commit 7d457b373dc6af3234192f92a11ef4e4b296ebdb Author: darwincereska Date: Thu Feb 12 19:16:31 2026 -0500 init: first init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddae705 --- /dev/null +++ b/.gitignore @@ -0,0 +1,204 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary build with `go test -c` +*.test + +# Output of go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Build output directory +dist/ +build/ +bin/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_STORE +.DS_STORE? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Environment variables +.env +.env.local +.env.*.local + +# Log files +*.log +logs/ + +# Temporary files +tmp/ +temp/ + +# Air (live reload tool) temporary files +tmp/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Configuration files with secrets +config.json +config.yaml +config.yml +secrets.json + +# SSL certificates +*.pem +*.key +*.crt +*.cert + +# Docker +.dockerignore + +# Kubernetes +*.kubeconfig + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# CSS build output (Tailwind) +web/static/css/style.css + +# Coverage reports +coverage.out +coverage.html +coverage.txt + +# Profiling data +*.prof +*.pprof + +# Memory dumps +*.dump + +# Local development files +.local/ +local/ + +# Documentation build +docs/_build/ + +# JetBrains IDEs +.idea/ +*.iml +*.ipr +*.iws + +# Visual Studio Code +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Vim +*.swp +*.swo +.vimrc.local + +# Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Backup files +*.bak +*.backup +*.old + +# Archive files +*.tar +*.tar.gz +*.zip +*.rar + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Taskfile +.task diff --git a/.templui.json b/.templui.json new file mode 100644 index 0000000..34e1a54 --- /dev/null +++ b/.templui.json @@ -0,0 +1,7 @@ +{ + "componentsDir": "web/components", + "utilsDir": "web/utils", + "moduleName": "blog", + "jsDir": "web/static/js", + "jsPublicPath": "/web/static/js" +} \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..072ff29 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,101 @@ +version: "3" + +method: checksum + +vars: + GO_CMD: + sh: command -v go >/dev/null 2>&1 && echo "go" + + GO_FLAGS: "-ldflags=-s -w -trimpath" + + TAILWIND_CMD: + sh: command -v tailwindcss >/dev/null 2>&1 && echo "tailwindcss" + +tasks: + # Development tasks + dev: + desc: Start development server with hot reload + cmds: + - task --parallel tailwind templ + + run: + desc: Build and run go server binary + deps: [build, build-css] + cmds: + - "./dist/server" + + # Build tasks + build: + desc: Build go server binary + deps: [dist] + sources: + - "**/*.go" + - "go.mod" + - "go.sum" + generates: + - dist/server + cmds: + - "{{ .GO_CMD }} build {{ .PROD_FLAGS }} -o dist/server cmd/main.go" + + build-css: + desc: Build CSS once + sources: + - web/static/css/input.css + - tailwind.config.js + generates: + - web/static/css/style.css + cmds: + - "{{ .TAILWIND_CMD }} -i ./web/static/css/input.css -o ./web/static/css/style.css" + + build-all: + desc: Build everything (Go binary + CSS) + deps: [build, build-css] + + # Watch tasks + templ: + desc: Run templ with integrated server and hot reload + cmds: + - 'templ generate --watch --proxy="http://localhost:8080" --cmd="go run ./cmd/web/main.go" --open-browser=false' + + tailwind: + desc: Watch TailwindCSS changes + cmds: + - "{{ .TAILWIND_CMD }} -i ./web/static/css/input.css -o ./web/static/css/style.css --watch" + + # Utility tasks + deps: + desc: Install dependencies + cmds: + - "{{ .GO_CMD }} mod tidy" + - "{{ .GO_CMD }} mod download" + + clean: + desc: Clean build artifacts + cmds: + - rm -rf dist/ + - rm -f web/static/css/style.css + + dist: + internal: true + silent: true + status: + - test -d dist/ + cmds: + - mkdir -p dist/ + + # Testing and linting + test: + desc: Run tests + cmds: + - "{{ .GO_CMD }} test ./..." + + fmt: + desc: Format Go code + cmds: + - "{{ .GO_CMD }} fmt ./..." + + vet: + desc: Run go vet + cmds: + - "{{ .GO_CMD }} vet ./..." + diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d7247ab --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "blog/internal/database" + "blog/internal/config" + "github.com/charmbracelet/log" +) + +func main() { + // Server config + server_config := config.NewServerConfig() + server_config.LoadConfig() + + // Database Config + db_config := config.NewDatabaseConfig() + db_config.LoadConfig() + + // Connect to database + db, err := database.Connect(db_config.GetDSN()) + if err != nil { + log.Fatal("Failed to connect to database: ", err) + } + defer db.Close() +} diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..7905807 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f11fb04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + # Postgres Database + postgres: + image: postgres:18-alpine + container_name: blog-postgres + restart: unless-stopped + environment: + POSTGRES_USER: blog + POSTGRES_PASSWORD: blog + POSTGRES_DB: blog + ports: + - "5432:5432" + volumes: + - pg_data:/var/lib/postgresql + networks: + - blog-network + + + # Redis cache + redis: + image: redis:8.4-alpine + container_name: blog-redis + restart: unless-stopped + command: redis-server --appendonly yes --databases 16 + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - blog-network + +volumes: + pg_data: + redis_data: + +networks: + blog-network: + driver: bridge + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51ff1d8 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module blog + +go 1.25.5 + +require ( + github.com/Oudwins/tailwind-merge-go v0.2.1 + github.com/a-h/templ v0.3.977 + github.com/charmbracelet/log v0.4.2 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6d88827 --- /dev/null +++ b/go.sum @@ -0,0 +1,76 @@ +github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE= +github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/cache/redis.go b/internal/cache/redis.go new file mode 100644 index 0000000..9cacc5f --- /dev/null +++ b/internal/cache/redis.go @@ -0,0 +1,2 @@ +package cache + diff --git a/internal/config/database.go b/internal/config/database.go new file mode 100644 index 0000000..cecfe76 --- /dev/null +++ b/internal/config/database.go @@ -0,0 +1,37 @@ +package config + +import ( + "github.com/charmbracelet/log" + "blog/internal/config/env" + "fmt" +) + +type DatabaseConfig struct { + Host string + Port int + Name string + User string + Password string + SSLMode string + TimeZone string +} + +func NewDatabaseConfig() *DatabaseConfig { + return &DatabaseConfig{} +} + +func (c *DatabaseConfig) LoadConfig() { + c.Host = env.GetString("DB_HOST", "localhost") + c.Port = env.GetInt("DB_PORT", 5432) + c.Name = env.GetString("DB_NAME", "blog") + c.User = env.GetString("DB_USER", "blog") + c.Password = env.GetString("DB_PASSWORD", "blog") + c.SSLMode = env.GetString("DB_SSL_MODE", "disable") + c.TimeZone = env.GetString("DB_TIME_ZONE", "America/New_York") + + log.Info("Successfully loaded database config") +} + +func (c *DatabaseConfig) GetDSN() string { + return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s", c.Host, c.User, c.Password, c.Name, c.Port, c.SSLMode, c.TimeZone) +} diff --git a/internal/config/env/env.go b/internal/config/env/env.go new file mode 100644 index 0000000..60b8457 --- /dev/null +++ b/internal/config/env/env.go @@ -0,0 +1,48 @@ +package env + +import ( + "os" + "strconv" +) + +// GetString returns the string value from an environment variable +func GetString(name, defaultValue string) string { + value := os.Getenv(name) + if value == "" { + return defaultValue + } + + return value +} + +// GetInt returns the int value from an environment variable +func GetInt(name string, defaultValue int) int { + value := os.Getenv(name) + if value == "" { + return defaultValue + } + + // Convert string to int + intValue, err := strconv.Atoi(value) + if err != nil { + return defaultValue + } + + return intValue +} + +// GetBool returns the bool value from an environment variable +func GetBool(name string, defaultValue bool) bool { + value := os.Getenv(name) + if value == "" { + return defaultValue + } + + // Convert string to bool + boolValue, err := strconv.ParseBool(value) + if err != nil { + return defaultValue + } + + return boolValue +} diff --git a/internal/config/frontend.go b/internal/config/frontend.go new file mode 100644 index 0000000..f3921b0 --- /dev/null +++ b/internal/config/frontend.go @@ -0,0 +1,2 @@ +package config + diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..3d59233 --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,40 @@ +package config + +import ( + "github.com/charmbracelet/log" + "blog/internal/config/env" +) + +type ServerConfig struct { + Host string + Port int + StrapiHost string + RedisHost string + RedisPort int + StrapiApiKey string + CacheTTL int + EchoMode string +} + +func NewServerConfig() *ServerConfig { + return &ServerConfig{} +} + +func (c *ServerConfig) LoadConfig() { + c.Host = env.GetString("HOST", "0.0.0.0") + c.Port = env.GetInt("PORT", 3000) + c.StrapiHost = env.GetString("STRAPI_HOST", "https://strapi.darwincereska.dev") + c.StrapiApiKey = env.GetString("STRAPI_API_KEY", "") + c.RedisHost = env.GetString("REDIS_HOST", "localhost") + c.RedisPort = env.GetInt("REDIS_PORT", 6379) + c.CacheTTL = env.GetInt("CACHE_TTL", 3600) + c.EchoMode = env.GetString("ECHO_MODE", "release") + + log.Info("Sucessfully loaded server config") + log.Info("Host", "host", c.Host) + log.Info("Port", "port", c.Port) + log.Info("Redis Host", "host", c.RedisHost) + log.Info("Strapi URL", "host", c.StrapiHost) + log.Info("Echo Mode", "mode", c.EchoMode) + log.Info("Cache TTL", "ttl", c.CacheTTL) +} diff --git a/internal/database/connection.go b/internal/database/connection.go new file mode 100644 index 0000000..b5854a7 --- /dev/null +++ b/internal/database/connection.go @@ -0,0 +1,93 @@ +package database + +import ( + "database/sql" + "fmt" + "time" + + "log/slog" + "os" + + "github.com/charmbracelet/log" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type DB struct { + *gorm.DB +} + + +// Connect connects to database and returns DB +func Connect(dsn string) (*DB, error) { + // Charmbracelet's "log" as a slog handler + charmHandler := log.NewWithOptions(os.Stdout, log.Options{ + ReportCaller: false, + ReportTimestamp: true, + Prefix: "GORM", + }) + + slogger := slog.New(charmHandler) + + // Create GORM logger with slog + gormLogger := logger.New( + slog.NewLogLogger(slogger.Handler(), slog.LevelDebug), + logger.Config{ + SlowThreshold: time.Millisecond * 200, + LogLevel: logger.Info, + IgnoreRecordNotFoundError: false, + Colorful: true, + }, + ) + + // Connect to database + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Get underlying sql.DB to configure connection pooling + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + // Maximum open connections + sqlDB.SetMaxOpenConns(25) + + // Maximum idle connections + sqlDB.SetMaxIdleConns(5) + + // Maximum lifetime for connections + sqlDB.SetConnMaxLifetime(time.Hour) + + // Maximum idle time for connections + sqlDB.SetConnMaxIdleTime(time.Minute * 10) + + // Test the connection + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Info("Successfully connected to database") + + return &DB{DB: db}, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + sqlDB, err := db.DB.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// GetStats returns database connection stats +func (db *DB) GetStats() sql.DBStats { + sqlDB, _ := db.DB.DB() + return sqlDB.Stats() +} diff --git a/internal/email/service.go b/internal/email/service.go new file mode 100644 index 0000000..1dfc537 --- /dev/null +++ b/internal/email/service.go @@ -0,0 +1,2 @@ +package email + diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go new file mode 100644 index 0000000..8a0943b --- /dev/null +++ b/internal/markdown/parser.go @@ -0,0 +1,2 @@ +package markdown + diff --git a/internal/models/analytics.go b/internal/models/analytics.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/internal/models/analytics.go @@ -0,0 +1 @@ +package models diff --git a/internal/models/api.go b/internal/models/api.go new file mode 100644 index 0000000..276f09d --- /dev/null +++ b/internal/models/api.go @@ -0,0 +1,3 @@ +package models + + diff --git a/internal/models/blog.go b/internal/models/blog.go new file mode 100644 index 0000000..3db3fc6 --- /dev/null +++ b/internal/models/blog.go @@ -0,0 +1,79 @@ +package models + +import "time" + +type Post struct { + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Content string `json:"content"` + Excerpt string `json:"excerpt"` + FeaturedImage *Media `json:"featured_image"` + Published bool `json:"published"` + PublishedAt *time.Time `json:"published_at"` + MetaTitle string `json:"meta_title"` + MetaDesc string `json:"meta_description"` + ReadingTime int `json:"reading_time"` + Author Author `json:"author"` + Tags []Tag `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Tag struct { + ID uint `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Color string `json:"color,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Author struct { + ID uint `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Bio string `json:"bio"` + Avatar *Media `json:"avatar"` + SocialLinks map[string]string `json:"social_links"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Media struct { + ID uint `json:"id"` + Name string `json:"name"` + AlternativeText string `json:"alternativeText"` + Caption string `json:"caption"` + Width int `json:"width"` + Height int `json:"height"` + Formats MediaFormats `json:"formats"` + Hash string `json:"hash"` + Ext string `json:"ext"` + Mime string `json:"mime"` + Size float64 `json:"size"` + URL string `json:"url"` + PreviewURL string `json:"previewUrl"` + Provider string `json:"provider"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type MediaFormats struct { + Large *MediaFormat `json:"large,omitempty"` + Medium *MediaFormat `json:"medium,omitempty"` + Small *MediaFormat `json:"small,omitempty"` + Thumbnail *MediaFormat `json:"thumbnail,omitempty"` +} + +type MediaFormat struct { + Name string `json:"name"` + Hash string `json:"hash"` + Ext string `json:"ext"` + Mime string `json:"mime"` + Width int `json:"width"` + Height int `json:"height"` + Size float64 `json:"size"` + Path string `json:"path"` + URL string `json:"url"` +} diff --git a/internal/models/newsletter.go b/internal/models/newsletter.go new file mode 100644 index 0000000..2640e7f --- /dev/null +++ b/internal/models/newsletter.go @@ -0,0 +1 @@ +package models diff --git a/internal/repositories/analytics.go b/internal/repositories/analytics.go new file mode 100644 index 0000000..2a952e3 --- /dev/null +++ b/internal/repositories/analytics.go @@ -0,0 +1,2 @@ +package repositories + diff --git a/internal/repositories/newsletter.go b/internal/repositories/newsletter.go new file mode 100644 index 0000000..2a952e3 --- /dev/null +++ b/internal/repositories/newsletter.go @@ -0,0 +1,2 @@ +package repositories + diff --git a/internal/rss/rss.go b/internal/rss/rss.go new file mode 100644 index 0000000..3b593b4 --- /dev/null +++ b/internal/rss/rss.go @@ -0,0 +1,6 @@ +package rss + +import ( + "encoding/xml" +) + diff --git a/internal/seo/embed.go b/internal/seo/embed.go new file mode 100644 index 0000000..3eb248a --- /dev/null +++ b/internal/seo/embed.go @@ -0,0 +1 @@ +package seo diff --git a/internal/services/analytics.go b/internal/services/analytics.go new file mode 100644 index 0000000..ec025b1 --- /dev/null +++ b/internal/services/analytics.go @@ -0,0 +1,2 @@ +package services + diff --git a/internal/services/newsletter.go b/internal/services/newsletter.go new file mode 100644 index 0000000..ec025b1 --- /dev/null +++ b/internal/services/newsletter.go @@ -0,0 +1,2 @@ +package services + diff --git a/internal/services/strapi.go b/internal/services/strapi.go new file mode 100644 index 0000000..ec025b1 --- /dev/null +++ b/internal/services/strapi.go @@ -0,0 +1,2 @@ +package services + diff --git a/internal/sitemap/sitemap.go b/internal/sitemap/sitemap.go new file mode 100644 index 0000000..f9272a5 --- /dev/null +++ b/internal/sitemap/sitemap.go @@ -0,0 +1,5 @@ +package sitemap + +import ( + "encoding/xml" +) diff --git a/internal/strapi/client.go b/internal/strapi/client.go new file mode 100644 index 0000000..2f909e8 --- /dev/null +++ b/internal/strapi/client.go @@ -0,0 +1,2 @@ +package strapi + diff --git a/web/components/navigation/navigation.templ b/web/components/navigation/navigation.templ new file mode 100644 index 0000000..81a409f --- /dev/null +++ b/web/components/navigation/navigation.templ @@ -0,0 +1,130 @@ +package navigation + + +import ( + "fmt" + // CHANGE TO WHATEVER DIRECTORY HAS YOUR TEMPLUI UTILS FOLDER + "blog/web/utils" +) + +type Variant string +type Size string + +const( + VariantDefault Variant = "default" + VariantOutline Variant = "outline" + VariantSecondary Variant = "secondary" + VariantGhost Variant = "ghost" +) + +const( + SizeDefault Size = "default" + SizeSm Size = "sm" + SizeLg Size = "lg" + SizeIcon Size = "icon" +) + +type Props struct { + ID string + Class string + Columns int + Attributes templ.Attributes + Variant Variant + Size Size +} + +templ Navigation(props ...Props) { + {{ var p Props }} + {{ var cols string }} + if len(props) > 0 { + {{ p = props[0] }} + } + + if p.Columns == 0 { + {{ cols = "grid-cols-1"}} + } else { + {{ cols = fmt.Sprintf("grid-cols-%d", p.Columns) }} + } + +
+ { children... } +
+} + +func (p Props) variantClasses() string { + switch p.Variant { + case VariantGhost: + return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent" + case VariantOutline: + return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50" + case VariantSecondary: + return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80" + default: + return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90" + } +} + +func (b Props) sizeClasses() string { + switch b.Size { + case SizeSm: + return "h-8 rounded-full aspect-square gap-1.5 px-3 has-[>svg]:px-2.5" + case SizeLg: + return "h-10 rounded-full aspect-square px-6 has-[>svg]:px-4" + case SizeIcon: + return "size-9" + default: // SizeDefault + return "h-9 px-4 py-2 has-[>svg]:px-3" + } +} + +// Theme change script +templ Script() { + +} + diff --git a/web/components/navigation/navigation_templ.go b/web/components/navigation/navigation_templ.go new file mode 100644 index 0000000..4dec6ed --- /dev/null +++ b/web/components/navigation/navigation_templ.go @@ -0,0 +1,176 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package navigation + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + // CHANGE TO WHATEVER DIRECTORY HAS YOUR TEMPLUI UTILS FOLDER + "blog/web/utils" +) + +type Variant string +type Size string + +const ( + VariantDefault Variant = "default" + VariantOutline Variant = "outline" + VariantSecondary Variant = "secondary" + VariantGhost Variant = "ghost" +) + +const ( + SizeDefault Size = "default" + SizeSm Size = "sm" + SizeLg Size = "lg" + SizeIcon Size = "icon" +) + +type Props struct { + ID string + Class string + Columns int + Attributes templ.Attributes + Variant Variant + Size Size +} + +func Navigation(props ...Props) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + var p Props + var cols string + if len(props) > 0 { + p = props[0] + } + if p.Columns == 0 { + cols = "grid-cols-1" + } else { + cols = fmt.Sprintf("grid-cols-%d", p.Columns) + } + var templ_7745c5c3_Var2 = []any{utils.TwMerge( + "w-full bg-card border-b border-b-secondary h-16 grid 2 text-lg items-center px-4 ", + cols, + ), + } + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func (p Props) variantClasses() string { + switch p.Variant { + case VariantGhost: + return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent" + case VariantOutline: + return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50" + case VariantSecondary: + return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80" + default: + return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90" + } +} + +func (b Props) sizeClasses() string { + switch b.Size { + case SizeSm: + return "h-8 rounded-full aspect-square gap-1.5 px-3 has-[>svg]:px-2.5" + case SizeLg: + return "h-10 rounded-full aspect-square px-6 has-[>svg]:px-4" + case SizeIcon: + return "size-9" + default: // SizeDefault + return "h-9 px-4 py-2 has-[>svg]:px-3" + } +} + +// Theme change script +func Script() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/static/css/input.css b/web/static/css/input.css new file mode 100644 index 0000000..0931ca7 --- /dev/null +++ b/web/static/css/input.css @@ -0,0 +1,195 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --breakpoint-3xl: 1600px; + --breakpoint-4xl: 2000px; + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + --font-lato: var(--font-lato); + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-normal: var(--tracking-normal); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); +} + +:root { + --radius: 0.75rem; + --background: oklch(0.9755 0.0067 97.3510); + --foreground: oklch(0.2178 0 0); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.2178 0 0); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.2178 0 0); + --primary: oklch(0.7414 0.0738 84.5946); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.9096 0.0167 91.5611); + --secondary-foreground: oklch(0.2178 0 0); + --muted: oklch(0.9459 0.0165 91.5544); + --muted-foreground: oklch(0.5022 0.0278 85.7741); + --accent: oklch(0.7414 0.0738 84.5946); + --accent-foreground: oklch(1.0000 0 0); + --destructive: oklch(0.5680 0.2002 26.4057); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.8986 0.0258 97.1423); + --input: oklch(0.8986 0.0258 97.1423); + --ring: oklch(0.7414 0.0738 84.5946); + --chart-1: oklch(0.7414 0.0738 84.5946); + --chart-2: oklch(0.3738 0.0116 258.3660); + --chart-3: oklch(0.9096 0.0167 91.5611); + --chart-4: oklch(0.5933 0.0472 87.9063); + --chart-5: oklch(0.2958 0.0084 255.5667); + --sidebar: oklch(0.9459 0.0165 91.5544); + --sidebar-foreground: oklch(0.2178 0 0); + --sidebar-primary: oklch(0.7414 0.0738 84.5946); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.8986 0.0258 97.1423); + --sidebar-accent-foreground: oklch(0.2178 0 0); + --sidebar-border: oklch(0.8986 0.0258 97.1423); + --sidebar-ring: oklch(0.7414 0.0738 84.5946); + --font-sans: Inter, -apple-system, sans-serif; + --font-serif: Georgia, serif; + --font-lato: Lato, sans-serif; + --font-mono: JetBrains Mono, monospace; + --shadow-x: 0px; + --shadow-y: 8px; + --shadow-blur: 15px; + --shadow-spread: 0px; + --shadow-opacity: 0.05; + --shadow-color: #000000; + --shadow-2xs: 0px 8px 15px 0px hsl(0 0% 0% / 0.03); + --shadow-xs: 0px 8px 15px 0px hsl(0 0% 0% / 0.03); + --shadow-sm: 0px 8px 15px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05); + --shadow: 0px 8px 15px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05); + --shadow-md: 0px 8px 15px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px hsl(0 0% 0% / 0.05); + --shadow-lg: 0px 8px 15px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px hsl(0 0% 0% / 0.05); + --shadow-xl: 0px 8px 15px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px hsl(0 0% 0% / 0.05); + --shadow-2xl: 0px 8px 15px 0px hsl(0 0% 0% / 0.13); + --tracking-normal: -0.02em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0.1904 0.0040 106.7692); + --foreground: oklch(0.9755 0.0067 97.3510); + --card: oklch(0.2343 0.0038 106.6863); + --card-foreground: oklch(0.9755 0.0067 97.3510); + --popover: oklch(0.2343 0.0038 106.6863); + --popover-foreground: oklch(0.9755 0.0067 97.3510); + --primary: oklch(0.8039 0.0702 84.7579); + --primary-foreground: oklch(0.1904 0.0040 106.7692); + --secondary: oklch(0.2901 0.0061 78.2320); + --secondary-foreground: oklch(0.9755 0.0067 97.3510); + --muted: oklch(0.2596 0.0037 106.6523); + --muted-foreground: oklch(0.7006 0.0154 84.5911); + --accent: oklch(0.8039 0.0702 84.7579); + --accent-foreground: oklch(0.1904 0.0040 106.7692); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(0.9755 0.0067 97.3510); + --border: oklch(0.2901 0.0061 78.2320); + --input: oklch(0.2901 0.0061 78.2320); + --ring: oklch(0.8039 0.0702 84.7579); + --chart-1: oklch(0.8039 0.0702 84.7579); + --chart-2: oklch(0.5498 0.0111 252.8780); + --chart-3: oklch(0.3142 0.0060 78.2414); + --chart-4: oklch(0.6926 0.0459 87.9527); + --chart-5: oklch(0.2170 0.0038 106.7146); + --sidebar: oklch(0.1904 0.0040 106.7692); + --sidebar-foreground: oklch(0.9755 0.0067 97.3510); + --sidebar-primary: oklch(0.8039 0.0702 84.7579); + --sidebar-primary-foreground: oklch(0.1904 0.0040 106.7692); + --sidebar-accent: oklch(0.2901 0.0061 78.2320); + --sidebar-accent-foreground: oklch(0.9755 0.0067 97.3510); + --sidebar-border: oklch(0.2901 0.0061 78.2320); + --sidebar-ring: oklch(0.8039 0.0702 84.7579); + --font-sans: Inter, -apple-system, sans-serif; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; + --shadow-x: 0px; + --shadow-y: 10px; + --shadow-blur: 25px; + --shadow-spread: 0px; + --shadow-opacity: 0.4; + --shadow-color: #000000; + --shadow-2xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20); + --shadow-xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20); + --shadow-sm: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40); + --shadow: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40); + --shadow-md: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 2px 4px -1px hsl(0 0% 0% / 0.40); + --shadow-lg: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 4px 6px -1px hsl(0 0% 0% / 0.40); + --shadow-xl: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 8px 10px -1px hsl(0 0% 0% / 0.40); + --shadow-2xl: 0px 10px 25px 0px hsl(0 0% 0% / 1.00); +} + +@layer base { + * { + @apply border-border; + scrollbar-width: thin; + scrollbar-color: var(--color-muted-foreground) transparent; + } + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-thumb { + background: var(--color-muted-foreground); + border-radius: 4px; + } + *::-webkit-scrollbar-thumb:hover { + background: var(--color-foreground); + } + body { + @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); + } +} + + diff --git a/web/utils/templui.go b/web/utils/templui.go new file mode 100644 index 0000000..1efaff7 --- /dev/null +++ b/web/utils/templui.go @@ -0,0 +1,74 @@ +// templui util templui.go - version: v1.5.0 installed by templui v1.5.0 +package utils + +import ( + "fmt" + "time" + + "crypto/rand" + + "github.com/a-h/templ" + + twmerge "github.com/Oudwins/tailwind-merge-go" +) + +// TwMerge combines Tailwind classes and resolves conflicts. +// Example: "bg-red-500 hover:bg-blue-500", "bg-green-500" → "hover:bg-blue-500 bg-green-500" +func TwMerge(classes ...string) string { + return twmerge.Merge(classes...) +} + +// TwIf returns value if condition is true, otherwise an empty value of type T. +// Example: true, "bg-red-500" → "bg-red-500" +func If[T comparable](condition bool, value T) T { + var empty T + if condition { + return value + } + return empty +} + +// TwIfElse returns trueValue if condition is true, otherwise falseValue. +// Example: true, "bg-red-500", "bg-gray-300" → "bg-red-500" +func IfElse[T any](condition bool, trueValue T, falseValue T) T { + if condition { + return trueValue + } + return falseValue +} + +// MergeAttributes combines multiple Attributes into one. +// Example: MergeAttributes(attr1, attr2) → combined attributes +func MergeAttributes(attrs ...templ.Attributes) templ.Attributes { + merged := templ.Attributes{} + for _, attr := range attrs { + for k, v := range attr { + merged[k] = v + } + } + return merged +} + +// RandomID generates a random ID string. +// Example: RandomID() → "id-1a2b3c" +func RandomID() string { + return fmt.Sprintf("id-%s", rand.Text()) +} + +// ScriptVersion is a timestamp generated at app start for cache busting. +// Used in Script() templates to append ?v= to script URLs. +var ScriptVersion = fmt.Sprintf("%d", time.Now().Unix()) + +// ScriptURL generates cache-busted script URLs. +// Override this to use custom cache busting (CDN, content hashing, etc.) +// +// Example override in your app: +// +// func init() { +// utils.ScriptURL = func(path string) string { +// return myAssetManifest.GetURL(path) +// } +// } +var ScriptURL = func(path string) string { + return path + "?v=" + ScriptVersion +}