Compare commits

...

17 Commits

Author SHA1 Message Date
shaamilahmed 83fbb16b6b Merge branch 'main' of https://git.nciphered.com/shaamilahmed/htmx into refactored (complete rewrite) 2026-05-31 06:42:52 +05:00
shaamilahmed fd5a187258 chore: untrack and ignore compiled tailwind.css 2026-05-31 06:42:19 +05:00
shaamilahmed 2b7ba3c96b style: add whitespace-nowrap to standard table headers to prevent wrapping and lock header height to 49px 2026-05-31 06:26:06 +05:00
shaamilahmed c1a00d34ab style: adjust wrapper background gradient to 49px to match exact table header height 2026-05-31 06:23:54 +05:00
shaamilahmed 0afc51f576 style: apply gradient background on outer table wrapper to seamlessly cover scrollbar track 2026-05-31 06:20:47 +05:00
shaamilahmed 61fd356846 style: resolve table scrollbar gap by using transparent scroll tracks and linear background gradient on container 2026-05-31 06:19:14 +05:00
shaamilahmed 360c9edcef style: use overlay scrollbar styling for tables to eliminate background width gap on the right 2026-05-31 06:13:39 +05:00
shaamilahmed 42d6910ae7 feat: simplify tables container by using native scrollbar styling and remove obsolete top scroll sync 2026-05-31 06:08:40 +05:00
shaamilahmed 58c929dd38 feat: resolve linter warnings, optimize tables scroll behavior with top scroll sync, and extract reusable table macros 2026-05-31 06:03:54 +05:00
shaamilahmed 478d5f3c17 feat: implement responsive mobile navigation header and wiki sidebar drawer
- Refactored templates/base.html to hide navigation on mobile and expose a hamburger menu button.
- Built a right-aligned sliding mobile menu drawer sheet in base.html.
- Hidden the documentation sidebar in templates/docs/sidebar.html by default on mobile, placing it inside a left-aligned sliding drawer sheet.
- Added a floating 'Explore Guides' directory trigger bar at the top of doc pages on mobile.
- Updated static/js/components.js to apply active route highlighting globally to both desktop and mobile sidebars.
2026-05-30 19:03:27 +05:00
shaamilahmed bb35206fff feat: implement administrator-driven audit log purging with forced archival download
- Created find_older_than and delete_older_than database repository methods.
- Built a /auth/audit/purge POST endpoint that validates retention days (min 1 day).
- Serializes and streams matching logs as an attachment JSON download for archival before database deletion.
- Added a 'Purge Logs' button and modal interface in the administrator dashboard, automatically reloading the view after download.
2026-05-30 18:54:45 +05:00
shaamilahmed 4c98dd93ad feat: implement audit logs system, request extractor, admin log panel, and dedicated documentation
- Added an enterprise-grade, request-scoped AuditLogger extractor in Axum.
- Configured MongoDB persistence for structured, replayable audit logs (capturing timestamp, user, action, type, payload snapshot, client IP with proxy header support, and User-Agent).
- Created a live Administrator console at /auth/audit to filter and inspect log events.
- Re-architected documentation by moving Design Wiki pages out of /components into a dedicated /docs route.
- Published logging architecture documentation at /docs/logging.
2026-05-30 18:27:21 +05:00
shaamilahmed f6ea8a99d9 feat: refactor and refine authentication system with decoupled user management and admin console 2026-05-30 18:27:21 +05:00
shaamilahmed f35908095c feat: implement interactive calendar view grids and confirmation action patterns 2026-05-30 18:27:21 +05:00
shaamilahmed 1e705053f5 style: refine design system aesthetics to flat solid dark-mode theme 2026-05-30 18:27:21 +05:00
shaamilahmed 110fc61fa2 refactor: migrate and consolidate UI templates to compile-time Askama component macros 2026-05-30 18:27:21 +05:00
shaamilahmed f42a5f05b2 feat: initialize template shell and basic components 2026-05-30 18:27:21 +05:00
223 changed files with 12131 additions and 14593 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
**/obj/
**/bin/
**/.git/
**/.vs/
**/node_modules/
target
.git
.antigravitycli
cookie.txt
.env
+11
View File
@@ -0,0 +1,11 @@
# MongoDB Connection Options
DATABASE_URL=mongodb://localhost:27017
DATABASE_NAME=stick_db
# Cryptography Settings
# Ensure this secret is a strong, random character sequence in production
JWT_SECRET=super_secret_template_signing_key_that_is_at_least_32_characters_long
# Web Server Options
HOST=127.0.0.1
PORT=3000
+11
View File
@@ -0,0 +1,11 @@
# MongoDB Connection Options
DATABASE_URL=mongodb://localhost:27017
DATABASE_NAME=stick_db
# Cryptography Settings
# Ensure this secret is a strong, random character sequence in production
JWT_SECRET=super_secret_template_signing_key_that_is_at_least_32_characters_long
# Web Server Options
HOST=127.0.0.1
PORT=3000
+4 -5
View File
@@ -1,5 +1,4 @@
**/bin/
**/obj/
**/node_modules/
.env
**/Publish/
target/
node_modules/
.antigravitycli/
static/tailwind.css
Generated
+3549
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "stick"
version = "0.1.0"
edition = "2024"
[dependencies]
askama = "0.16.0"
async-trait = "0.1.89"
axum = { version = "0.8.9", features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["cookie"] }
bcrypt = "0.19.1"
bson = { version = "3.1.0", features = ["chrono-0_4", "serde", "serde_with-3"] }
chrono = { version = "0.4.44", features = ["serde"] }
dotenvy = "0.15.7"
futures = "0.3.32"
jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] }
mongodb = { version = "3.7.0", features = ["bson-3"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
serde_with = "3.20.0"
thiserror = "2.0.18"
time = "0.3.47"
tokio = { version = "1.52.3", features = ["full"] }
tower-http = { version = "0.6.11", features = ["trace", "cors"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
rand = "0.8.5"
+69
View File
@@ -0,0 +1,69 @@
# Stage 1: Build Tailwind CSS
FROM node:20-slim AS tailwind-builder
WORKDIR /app
# Copy dependency manifests and install dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source, templates, and static directories for Tailwind compile scan
COPY src ./src
COPY templates ./templates
COPY static ./static
# Run Tailwind compilation
RUN npx tailwindcss -i src/input.css -o static/tailwind.css
# Stage 2: Build Rust application
FROM rust:1.95-slim AS builder
WORKDIR /app
# Create a dummy project to cache dependencies
RUN cargo new --bin stick
WORKDIR /app/stick
# Copy dependency manifests
COPY Cargo.toml Cargo.lock ./
# Build dependencies (cached as a layer unless Cargo.toml/Cargo.lock changes)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/stick/target \
cargo build
# Copy tailwind.css compiled in the first stage
COPY --from=tailwind-builder /app/static/tailwind.css ./static/tailwind.css
# Copy the actual source code, templates, and rest of static files
COPY src ./src
COPY templates ./templates
COPY static ./static
# Ensure the compiled tailwind.css is definitely the one used
COPY --from=tailwind-builder /app/static/tailwind.css ./static/tailwind.css
# Build the real binary
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/stick/target \
touch src/main.rs && \
cargo build && \
cp target/debug/stick /app/stick-bin
# Stage 3: Runtime stage
FROM debian:bookworm-slim
WORKDIR /app
# Install runtime dependencies if needed
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy the binary and templates from the build stage
COPY --from=builder /app/stick-bin /app/stick
COPY --from=builder /app/stick/templates /app/templates
# Expose port 3007
EXPOSE 3007
# Set default HOST and PORT
ENV HOST=0.0.0.0
ENV PORT=3007
# Run the app
CMD ["/app/stick"]
-38
View File
@@ -1,38 +0,0 @@
# Environment variables for Cloud Run deployment
# Copy this file to GCR/.env and fill in your values.
# GCR/.env is gitignored — never commit real credentials.
#
# IMPORTANT: Sensitive values (MongoDB connection string, API keys, passwords)
# should NOT be stored here. Use Google Cloud Secret Manager instead.
# See GCR/README.md for the Secret Manager setup workflow.
# ═══════════════════════════════════════════════════════════════════════════
# NON-SENSITIVE CONFIG — safe to commit to version control
# ═══════════════════════════════════════════════════════════════════════════
# Your GCP project ID (lowercase letters, digits, hyphens; 630 chars)
GCP_PROJECT_ID=htmx-demo
# Region to deploy the Cloud Run service and Artifact Registry repository
# Recommended regions: us-central1, us-east1, europe-west1, asia-east1
GCP_REGION=asia-east1
# Name of the Artifact Registry Docker repository (created by 02-setup-project.sh)
GCP_REPOSITORY=htmx-demo
# Cloud Run service name
SERVICE_NAME=htmx-demo-app
# Name of the MongoDB database to use (not sensitive, just a name)
MONGODB_DATABASE_NAME=HtmxAppDb
# ═══════════════════════════════════════════════════════════════════════════
# SENSITIVE CONFIG — stored in Google Cloud Secret Manager, never here!
# ═══════════════════════════════════════════════════════════════════════════
#
# MongoDB Connection String
# Store in Secret Manager as: mongodb-connection-string
# Create with: printf '%s' "mongodb+srv://user:pass@..." | \
# gcloud secrets create mongodb-connection-string --data-file=-
#
# The deploy script will inject this automatically as ConnectionStrings__DefaultConnection
-2
View File
@@ -1,2 +0,0 @@
# Never commit real credentials
.env
-70
View File
@@ -1,70 +0,0 @@
# ─────────────────────────────────────────────────────────────────────────────
# Stage 1 — npm install (Tailwind CLI)
# Uses node:24-alpine for a small image. npm ci is used for reproducible,
# fast installs from the lockfile.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:24-alpine AS npm-install
WORKDIR /npm
COPY Htmx.ApiDemo/package.json Htmx.ApiDemo/package-lock.json* ./
RUN npm ci
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 — AOT publish
# Uses the Alpine SDK image so the binary is linked against musl libc,
# making it compatible with the Alpine runtime image in Stage 3.
#
# Alpine packages are cached via BuildKit mount cache — after the first build
# `apk add` is near-instant because the package index and downloads are
# reused from the local cache rather than re-fetched from the network.
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS publish
# Install AOT linker tools and Node.js (for the Tailwind MSBuild target).
# --mount=type=cache keeps the apk package cache between builds on this machine.
RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache clang lld musl-dev build-base nodejs npm
WORKDIR /src
# Copy project files first — NuGet restore layer is cached until these change.
COPY Htmx.slnx .
COPY Htmx.ApiDemo/Htmx.ApiDemo.csproj Htmx.ApiDemo/
COPY Htmx.SourceGenerator/Htmx.SourceGenerator.csproj Htmx.SourceGenerator/
RUN dotnet restore Htmx.ApiDemo/Htmx.ApiDemo.csproj -r linux-musl-x64
# Bring in pre-installed node_modules from Stage 1.
COPY --from=npm-install /npm/node_modules Htmx.ApiDemo/node_modules
# Copy the rest of the source
COPY . .
# AOT publish targeting musl so the binary runs on Alpine.
RUN dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj \
-c Release \
-r linux-musl-x64 \
--no-restore \
--self-contained true \
-o /publish
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — Runtime image (~12 MB base vs ~100 MB on Debian)
# runtime-deps:alpine provides only the native libs the AOT binary needs.
# No .NET runtime is included — the binary is fully self-contained.
# ─────────────────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS runtime
WORKDIR /app
COPY --from=publish /publish .
# Cloud Run injects PORT (default 8080).
# ASP.NET Core reads ASPNETCORE_HTTP_PORTS directly — no entrypoint script needed.
ENV ASPNETCORE_HTTP_PORTS=8080
# Use the built-in non-root user provided by the official .NET Alpine image.
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["./Htmx.ApiDemo"]
-384
View File
@@ -1,384 +0,0 @@
# Deploying to Google Cloud Run
This folder contains everything needed to deploy the Htmx app to Google Cloud Run — completely isolated from the application code.
## Folder structure
```
GCR/
├── .env.example ← copy to .env and fill in your values
├── Dockerfile ← multi-stage AOT build (Linux/amd64)
├── entrypoint.sh ← maps Cloud Run's PORT var to ASP.NET Core
├── docker-compose.yml ← Cloud Run service definition (used by gcloud)
├── run-all.sh ← smart Linux runner (checks + prompts)
├── run-all.ps1 ← smart Windows runner (checks + prompts)
└── scripts/
├── 00-install-gcloud.sh / .ps1 ← install Google Cloud CLI
├── 01-login.sh / .ps1 ← authenticate + configure Docker
├── 02-setup-project.sh / .ps1 ← one-time GCP project setup
├── 03-create-secrets.sh / .ps1 ← manage MongoDB secret
└── 04-deploy.sh / .ps1 ← build, push, and deploy
```
`.sh` scripts are for **Linux**. `.ps1` scripts are for **Windows** (PowerShell 5.1+).
### One-command flow (recommended)
Instead of running each step manually, use the root runner. It checks each step,
shows completed items, and prompts to run only missing steps.
**Linux:**
```bash
bash GCR/run-all.sh
# non-interactive (auto-run missing steps):
bash GCR/run-all.sh --yes
```
**Windows:**
```powershell
.\GCR\run-all.ps1
# non-interactive (auto-run missing steps):
.\GCR\run-all.ps1 -Yes
```
## Security on Untrusted Machines
Do **not** run these scripts on machines you don't control unless absolutely necessary.
Why this matters:
1. The scripts grant project-level IAM roles to your user, including `roles/secretmanager.admin`.
2. `gcloud auth login` stores local credentials/tokens that can be reused if the machine is compromised.
3. Docker auth is configured for Artifact Registry and may persist in local Docker config.
4. A local `GCR/.env` file contains project identifiers and deployment metadata.
Minimum cleanup if you ever used a shared/untrusted machine:
1. Revoke IAM roles from your user account in the GCP project.
2. Revoke local gcloud credentials and clear config.
3. Remove Docker credential entries for Artifact Registry.
4. Delete local `GCR/.env` and any temporary files.
Example role cleanup (Linux/macOS shell):
```bash
USER_EMAIL="your-user@company.com"
PROJECT_ID="your-project-id"
for ROLE in \
roles/run.developer \
roles/artifactregistry.writer \
roles/iam.serviceAccountUser \
roles/secretmanager.admin \
roles/secretmanager.secretAccessor \
roles/secretmanager.secretVersionAdder; do
gcloud projects remove-iam-policy-binding "$PROJECT_ID" \
--member="user:$USER_EMAIL" \
--role="$ROLE" \
--quiet
done
```
Credential cleanup:
```bash
gcloud auth revoke --all
gcloud config configurations delete default --quiet || true
```
Credential cleanup (Windows PowerShell):
```powershell
gcloud auth revoke --all
gcloud config configurations delete default --quiet
```
Prefer a dedicated personal/admin workstation, or use a tightly scoped CI service account instead of broad user credentials.
---
## Step 0 — Configure your .env file
Copy the example and fill it in:
```bash
# Linux
cp GCR/.env.example GCR/.env
```
```powershell
# Windows
Copy-Item GCR\.env.example GCR\.env
```
Open `GCR/.env` in any editor and set:
| Variable | Description | Example |
|---|---|---|
| `GCP_PROJECT_ID` | Your GCP project ID | `my-htmx-project` |
| `GCP_REGION` | Cloud Run region | `us-central1` |
| `GCP_REPOSITORY` | Artifact Registry repo name | `htmx` |
| `SERVICE_NAME` | Cloud Run service name | `htmx-app` |
| `MONGODB_DATABASE_NAME` | Database name | `HtmxAppDb` |
> **Security note:** `GCR/.env` is gitignored. Never commit it.
> **MongoDB note:** The app connects to MongoDB at startup. Cloud Run containers do not have access to `localhost:27017` — use MongoDB Atlas (cloud-hosted) or a MongoDB instance reachable over the internet/VPC.
> **Secrets note:** The MongoDB connection string is **not stored in .env**. It's stored securely in Google Cloud Secret Manager. See **Step 4** below.
---
## Step 1 — Install the Google Cloud CLI
Run **once** on a new machine.
**Linux:**
```bash
bash GCR/scripts/00-install-gcloud.sh
```
**Windows** (PowerShell, run as Administrator):
```powershell
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser # first time only
.\GCR\scripts\00-install-gcloud.ps1
```
After installation, open a new terminal and verify:
```
gcloud version
```
---
## Step 2 — Log in
Authenticates your machine to GCP and configures Docker to push to Artifact Registry.
**Linux:**
```bash
bash GCR/scripts/01-login.sh
```
**Windows:**
```powershell
.\GCR\scripts\01-login.ps1
```
A browser window opens for Google sign-in. Use the account that owns or has access to your GCP project.
---
## Step 3 — Set up the GCP project (one time)
Enables APIs, creates the Artifact Registry repository, and grants your account the required IAM roles. If billing is not yet linked, the script prompts you to choose a billing account.
**Linux:**
```bash
bash GCR/scripts/02-setup-project.sh
```
**Windows:**
```powershell
.\GCR\scripts\02-setup-project.ps1
```
This is **safe to re-run** — all operations are idempotent.
### What it enables
| API | Purpose |
|---|---|
| `run.googleapis.com` | Cloud Run service |
| `artifactregistry.googleapis.com` | Docker image storage |
| `secretmanager.googleapis.com` | Available for future use |
| `cloudresourcemanager.googleapis.com` | IAM and project management |
### What IAM roles it grants (to your account)
| Role | Purpose |
|---|---|
| `roles/run.developer` | Deploy and manage Cloud Run services |
| `roles/artifactregistry.writer` | Push container images |
| `roles/iam.serviceAccountUser` | Run the service under a service account |
| `roles/secretmanager.admin` | Create/manage secrets and IAM policies (includes `secretmanager.secrets.create`) |
| `roles/secretmanager.secretAccessor` | Read secret payloads (for validation/access workflows) |
| `roles/secretmanager.secretVersionAdder` | Add/set new secret versions (rotate values safely) |
---
## Step 4 — Create secrets in Google Cloud Secret Manager
Store the MongoDB connection string securely:
**Linux:**
```bash
bash GCR/scripts/03-create-secrets.sh
```
**Windows:**
```powershell
.\GCR\scripts\03-create-secrets.ps1
```
The script prompts for your MongoDB connection string, creates the secret in Secret Manager, and grants Cloud Run permission to access it. The secret is referenced by name (`mongodb-connection-string`) in the deploy script — never stored in .env.
This is a **one-time setup**. Re-run only if you need to **update** the MongoDB connection string.
---
## Step 5 — Deploy
Builds the Docker image, pushes it to Artifact Registry, and deploys to Cloud Run.
If secrets are missing, the deploy script now performs a pre-check and prompts to run
the secrets setup script before continuing.
**Linux:**
```bash
bash GCR/scripts/04-deploy.sh
# or with a custom image tag:
bash GCR/scripts/04-deploy.sh v1.0.0
```
**Windows:**
```powershell
.\GCR\scripts\04-deploy.ps1
# or with a custom image tag:
.\GCR\scripts\04-deploy.ps1 -Tag v1.0.0
```
The script:
1. Checks for (or generates) `Htmx.ApiDemo/package-lock.json`
2. Builds the Docker image from the repo root using `GCR/Dockerfile`
3. Pushes the image to Artifact Registry
4. Deploys to Cloud Run using `GCR/docker-compose.yml`
5. Opens the service to public access (no authentication required)
6. Prints the live service URL
By default the image tag is the short git commit SHA (e.g. `a3f4b7c`). A timestamp is used if the directory is not a git repo.
---
## How configuration reaches the app
The app reads configuration from environment variables. Cloud Run injects them at container startup — no config files needed in the image.
| Environment variable | Maps to | Set by |
|---|---|---|
| `ConnectionStrings__DefaultConnection` | `appsettings.json``ConnectionStrings.DefaultConnection` | Secret Manager (via `--set-secrets`) → deploy script |
| `MongoDbName` | `appsettings.json``MongoDbName` | `GCR/.env` → deploy script → docker-compose.yml |
| `ASPNETCORE_ENVIRONMENT` | ASP.NET Core environment | `docker-compose.yml` (hardcoded `Production`) |
| `PORT` | Listening port | Injected by Cloud Run (default `8080`) |
**Secret Manager workflow:**
- Step 4 stores the MongoDB connection string in Cloud Run Secret Manager
- Step 5 (deploy script) injects it via `gcloud run services update --set-secrets=...`
- The container never sees the raw connection string; Cloud Run mounts it as an env var at runtime
- Each time you update the secret, Cloud Run automatically uses the latest version
The `GCR/entrypoint.sh` script translates Cloud Run's `PORT` variable into `ASPNETCORE_HTTP_PORTS` at container startup, since ASP.NET Core does not read `PORT` directly.
---
## Re-deploying after code changes
Just run Step 5 again. Each deployment gets a new image tag (git SHA), and Cloud Run creates a new immutable revision. Traffic is shifted to the new revision automatically.
---
## Updating configuration
### Non-sensitive config (MONGODB_DATABASE_NAME, etc.)
To change a non-sensitive value without rebuilding:
**Linux:**
```bash
source GCR/.env
gcloud run services update $SERVICE_NAME \
--region=$GCP_REGION \
--update-env-vars "MongoDbName=NewDatabaseName"
```
**Windows:**
```powershell
gcloud run services update htmx-app `
--region=us-central1 `
--update-env-vars "MongoDbName=NewDatabaseName"
```
### Sensitive config (MongoDB connection string)
To update the MongoDB connection string:
**Linux:**
```bash
source GCR/.env
# Read from stdin (paste the connection string and press Ctrl+D):
gcloud secrets versions add mongodb-connection-string \
--data-file=- \
--project=$GCP_PROJECT_ID
```
**Windows:**
```powershell
# Use a temp file to avoid adding trailing newlines to the secret
$connectionString = Read-Host -AsSecureString "Enter MongoDB connection string"
$connectionStringPlainText = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($connectionString))
$TempFile = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($TempFile, $connectionStringPlainText, [System.Text.Encoding]::UTF8)
gcloud secrets versions add mongodb-connection-string --data-file=$TempFile --project=your-project-id
} finally {
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
}
```
Or re-run the creation script:
**Linux:**
```bash
bash GCR/scripts/03-create-secrets.sh
```
**Windows:**
```powershell
.\GCR\scripts\03-create-secrets.ps1
```
Cloud Run automatically uses the latest secret version on the next container start.
---
## Troubleshooting
### Docker build fails on `npm ci`
`npm ci` requires `Htmx.ApiDemo/package-lock.json` to exist. Generate it locally:
```bash
cd Htmx.ApiDemo && npm install
```
Then commit `package-lock.json` to the repository.
### `gcloud: command not found` after install
Close and reopen your terminal. The installer adds `gcloud` to `PATH`, but the current shell session won't see it until restarted.
### `PERMISSION_DENIED` errors during deploy
Run `02-setup-project` again — it grants the required IAM roles. It is safe to re-run.
### Cloud Run container crashes on startup
View logs in the GCP console (Cloud Run → your service → Logs tab), or:
```bash
gcloud run services logs read $SERVICE_NAME --region=$GCP_REGION --limit=50
```
The most common causes:
- MongoDB connection string is wrong or unreachable from Cloud Run
- `ASPNETCORE_ENVIRONMENT` is `Production` but `appsettings.Production.json` overrides something unexpectedly
### Service URL returns 404 for all routes
The service is running but no route matched. Confirm the app started correctly by checking logs for `Now listening on`.
-30
View File
@@ -1,30 +0,0 @@
# docker-compose.yml — Cloud Run Compose deployment
#
# Usage:
# gcloud run services replace docker-compose.yml --region=REGION
#
# Or via the deploy script:
# ./GCR/scripts/03-deploy.sh
#
# Environment variables are substituted from your shell or a .env file.
# Copy GCR/.env.example to GCR/.env and fill in your values before deploying.
#
# Cloud Run Compose reference:
# https://docs.cloud.google.com/run/docs/deploy-run-compose
services:
app:
# IMAGE_URI is set by the deploy script after pushing to Artifact Registry.
# Format: REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/SERVICE_NAME:TAG
image: ${IMAGE_URI}
environment:
# Non-sensitive config only — sensitive values (like MongoDB connection string)
# are injected via Secret Manager (--set-secrets) by the deploy script.
MongoDbName: ${MONGODB_DATABASE_NAME:-HtmxAppDb}
ASPNETCORE_ENVIRONMENT: Production
# Cloud Run only honours the first port entry; the container must listen on
# the port Cloud Run advertises via the PORT env var (default 8080).
ports:
- target: 8080
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# Translate Cloud Run's injected PORT env var into what ASP.NET Core reads.
# Cloud Run sets PORT (default 8080). ASP.NET Core reads ASPNETCORE_HTTP_PORTS.
export ASPNETCORE_HTTP_PORTS="${PORT:-8080}"
exec ./Htmx.ApiDemo "$@"
-187
View File
@@ -1,187 +0,0 @@
#Requires -Version 5.1
param(
[switch]$Yes
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $RootDir ".env"
function Confirm-Run {
param(
[string]$Label,
[string]$ScriptPath
)
if ($Yes) {
Write-Host "[x] $Label not done yet. Running $ScriptPath (-Yes enabled)..."
& $ScriptPath
return
}
$answer = Read-Host "[x] $Label not done yet. Run now? [y/N]"
if ($answer -match '^[Yy]$') {
& $ScriptPath
}
}
function Get-EnvConfig {
if (-not (Test-Path $EnvFile)) {
throw "GCR/.env not found. Copy GCR/.env.example to GCR/.env first."
}
$cfg = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') {
$cfg[$Matches[1].Trim()] = $Matches[2].Trim()
}
}
foreach ($key in @('GCP_PROJECT_ID', 'GCP_REGION', 'GCP_REPOSITORY', 'SERVICE_NAME')) {
if (-not $cfg[$key]) {
throw "$key is not set in GCR/.env"
}
}
return $cfg
}
function Test-GcloudInstalled {
return [bool](Get-Command gcloud -ErrorAction SilentlyContinue)
}
function Test-Login {
param([hashtable]$Cfg)
$activeAccount = (gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>$null | Select-Object -First 1)
$currentProject = (gcloud config get-value project 2>$null)
$currentRegion = (gcloud config get-value run/region 2>$null)
$dockerCfg = if ($env:DOCKER_CONFIG) { Join-Path $env:DOCKER_CONFIG "config.json" } else { Join-Path $HOME ".docker\config.json" }
$dockerOk = $false
if (Test-Path $dockerCfg) {
$dockerPattern = "`"$($Cfg['GCP_REGION'])-docker.pkg.dev`""
$dockerOk = (Select-String -Path $dockerCfg -Pattern $dockerPattern -SimpleMatch -Quiet)
}
return (-not [string]::IsNullOrWhiteSpace($activeAccount)) -and
($currentProject.Trim() -eq $Cfg['GCP_PROJECT_ID']) -and
($currentRegion.Trim() -eq $Cfg['GCP_REGION']) -and
$dockerOk
}
function Test-ProjectSetup {
param([hashtable]$Cfg)
$billingEnabled = (gcloud billing projects describe $Cfg['GCP_PROJECT_ID'] --format='value(billingEnabled)' 2>$null)
if ($billingEnabled -ne 'True') { return $false }
try {
gcloud artifacts repositories describe $Cfg['GCP_REPOSITORY'] --location=$Cfg['GCP_REGION'] --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null
} catch {
return $false
}
foreach ($api in @('run.googleapis.com', 'artifactregistry.googleapis.com', 'secretmanager.googleapis.com', 'cloudresourcemanager.googleapis.com')) {
$enabled = gcloud services list --enabled --project=$Cfg['GCP_PROJECT_ID'] --format='value(config.name)' 2>$null | Select-String -Pattern "^$([regex]::Escape($api))$"
if (-not $enabled) { return $false }
}
return $true
}
function Test-SecretsSetup {
param([hashtable]$Cfg)
try {
gcloud secrets describe mongodb-connection-string --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null
} catch {
return $false
}
# Check if any service account has secretAccessor access (not just @appspot)
$bindings = gcloud secrets get-iam-policy mongodb-connection-string `
--project=$Cfg['GCP_PROJECT_ID'] `
--flatten='bindings[].members' `
--filter='bindings.role=roles/secretmanager.secretAccessor' `
--format='value(bindings.members)' 2>$null
return (-not [string]::IsNullOrWhiteSpace($bindings))
}
function Test-DeployDone {
param([hashtable]$Cfg)
try {
gcloud run services describe $Cfg['SERVICE_NAME'] --region=$Cfg['GCP_REGION'] --project=$Cfg['GCP_PROJECT_ID'] 2>$null | Out-Null
return $true
} catch {
return $false
}
}
function Write-Done {
param([string]$Text)
Write-Host "[v] $Text"
}
Write-Host "================================================================"
Write-Host " Htmx deployment flow runner (Windows)"
Write-Host "================================================================"
if (-not (Test-Path $EnvFile)) {
Write-Host "[x] Step 0: GCR/.env is missing"
Write-Host " Copy GCR/.env.example to GCR/.env and fill required values."
exit 1
}
Write-Done "Step 0: .env exists"
$cfg = Get-EnvConfig
if (Test-GcloudInstalled) {
Write-Done "Step 1: gcloud installed"
} else {
Confirm-Run "Step 1: gcloud install" (Join-Path $RootDir "scripts\00-install-gcloud.ps1")
}
if (Test-Login $cfg) {
Write-Done "Step 2: login + docker auth configured"
} else {
Confirm-Run "Step 2: login" (Join-Path $RootDir "scripts\01-login.ps1")
}
if (Test-ProjectSetup $cfg) {
Write-Done "Step 3: project setup complete"
} else {
Confirm-Run "Step 3: project setup" (Join-Path $RootDir "scripts\02-setup-project.ps1")
}
if (Test-SecretsSetup $cfg) {
Write-Done "Step 4: secrets created and access granted"
} else {
Confirm-Run "Step 4: secrets setup" (Join-Path $RootDir "scripts\03-create-secrets.ps1")
}
if (Test-DeployDone $cfg) {
Write-Done "Step 5: service is already deployed"
} else {
Confirm-Run "Step 5: deploy" (Join-Path $RootDir "scripts\04-deploy.ps1")
}
Write-Host ""
Write-Host "================================================================"
Write-Host " Final verification"
Write-Host "================================================================"
if (Test-GcloudInstalled) { Write-Done "Step 1" } else { Write-Host "[x] Step 1" }
if (Test-Login $cfg) { Write-Done "Step 2" } else { Write-Host "[x] Step 2" }
if (Test-ProjectSetup $cfg) { Write-Done "Step 3" } else { Write-Host "[x] Step 3" }
if (Test-SecretsSetup $cfg) { Write-Done "Step 4" } else { Write-Host "[x] Step 4" }
if (Test-DeployDone $cfg) { Write-Done "Step 5" } else { Write-Host "[x] Step 5" }
Write-Host ""
Write-Host "Tip: run .\GCR\run-all.ps1 -Yes to auto-run missing steps without prompts."
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/run-all.ps1"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
AUTO_YES="${1:-}"
confirm_run() {
local label="$1"
local script_path="$2"
if [[ "$AUTO_YES" == "--yes" ]]; then
echo "[x] $label not done yet. Running $script_path (--yes enabled)..."
bash "$script_path"
return
fi
local answer
read -rp "[x] $label not done yet. Run now? [y/N]: " answer
if [[ "$answer" =~ ^[Yy]$ ]]; then
bash "$script_path"
fi
}
load_env() {
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}"
: "${GCP_REGION:?GCP_REGION is not set in .env}"
: "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}"
: "${SERVICE_NAME:?SERVICE_NAME is not set in .env}"
}
check_env() {
[[ -f "$ENV_FILE" ]]
}
check_gcloud_installed() {
command -v gcloud >/dev/null 2>&1
}
check_login() {
local active_account
local current_project
local current_region
local docker_cfg
active_account="$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | head -n1 || true)"
current_project="$(gcloud config get-value project 2>/dev/null || true)"
current_region="$(gcloud config get-value run/region 2>/dev/null || true)"
docker_cfg="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
[[ -n "$active_account" ]] &&
[[ "$current_project" == "$GCP_PROJECT_ID" ]] &&
[[ "$current_region" == "$GCP_REGION" ]] &&
[[ -f "$docker_cfg" ]] &&
grep -q "\"${GCP_REGION}-docker.pkg.dev\"" "$docker_cfg"
}
check_project_setup() {
local billing_enabled
billing_enabled="$(gcloud billing projects describe "$GCP_PROJECT_ID" --format="value(billingEnabled)" 2>/dev/null || true)"
[[ "$billing_enabled" == "True" ]] || return 1
gcloud artifacts repositories describe "$GCP_REPOSITORY" \
--location="$GCP_REGION" \
--project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1
local api
for api in run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com cloudresourcemanager.googleapis.com; do
gcloud services list --enabled --project="$GCP_PROJECT_ID" --format="value(config.name)" 2>/dev/null \
| grep -Fxq "$api" || return 1
done
}
check_secrets_setup() {
local service_account
service_account="serviceAccount:${GCP_PROJECT_ID}@appspot.gserviceaccount.com"
gcloud secrets describe mongodb-connection-string --project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1
gcloud secrets get-iam-policy mongodb-connection-string \
--project="$GCP_PROJECT_ID" \
--flatten="bindings[].members" \
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=${service_account}" \
--format="value(bindings.members)" 2>/dev/null \
| grep -Fxq "$service_account"
}
check_deploy_done() {
gcloud run services describe "$SERVICE_NAME" \
--region="$GCP_REGION" \
--project="$GCP_PROJECT_ID" >/dev/null 2>&1
}
print_done() {
echo "[v] $1"
}
echo "================================================================"
echo " Htmx deployment flow runner (Linux)"
echo "================================================================"
if check_env; then
print_done "Step 0: .env exists"
else
echo "[x] Step 0: GCR/.env is missing"
echo " Copy GCR/.env.example to GCR/.env and fill required values."
exit 1
fi
load_env
if check_gcloud_installed; then
print_done "Step 1: gcloud installed"
else
confirm_run "Step 1: gcloud install" "$SCRIPT_DIR/scripts/00-install-gcloud.sh"
fi
if check_login; then
print_done "Step 2: login + docker auth configured"
else
confirm_run "Step 2: login" "$SCRIPT_DIR/scripts/01-login.sh"
fi
if check_project_setup; then
print_done "Step 3: project setup complete"
else
confirm_run "Step 3: project setup" "$SCRIPT_DIR/scripts/02-setup-project.sh"
fi
if check_secrets_setup; then
print_done "Step 4: secrets created and access granted"
else
confirm_run "Step 4: secrets setup" "$SCRIPT_DIR/scripts/03-create-secrets.sh"
fi
if check_deploy_done; then
print_done "Step 5: service is already deployed"
else
confirm_run "Step 5: deploy" "$SCRIPT_DIR/scripts/04-deploy.sh"
fi
echo ""
echo "================================================================"
echo " Final verification"
echo "================================================================"
if check_gcloud_installed; then print_done "Step 1"; else echo "[x] Step 1"; fi
if check_login; then print_done "Step 2"; else echo "[x] Step 2"; fi
if check_project_setup; then print_done "Step 3"; else echo "[x] Step 3"; fi
if check_secrets_setup; then print_done "Step 4"; else echo "[x] Step 4"; fi
if check_deploy_done; then print_done "Step 5"; else echo "[x] Step 5"; fi
echo ""
echo "Tip: run 'bash GCR/run-all.sh --yes' to auto-run missing steps without prompts."
-54
View File
@@ -1,54 +0,0 @@
# =============================================================================
# 00-install-gcloud.ps1 (Windows)
# Installs the Google Cloud CLI (gcloud) on Windows.
# Run this once in an elevated PowerShell prompt before doing anything else.
#
# Linux users: run GCR/scripts/00-install-gcloud.sh instead.
# =============================================================================
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Write-Host ">>> Installing Google Cloud CLI on Windows..."
Write-Host ""
# ── Try winget first (available on Windows 10 1709+ / Windows 11) ────────────
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host ">>> winget found — installing Google Cloud SDK via winget..."
winget install --id Google.CloudSDK --accept-source-agreements --accept-package-agreements
Write-Host ""
Write-Host ">>> gcloud installed via winget."
Write-Host " Close and reopen PowerShell so PATH changes take effect."
}
# ── Fallback: download the official Windows installer ────────────────────────
else {
Write-Host ">>> winget not available — downloading the official installer..."
Write-Host ""
$InstallerUrl = "https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe"
$InstallerPath = "$env:TEMP\GoogleCloudSDKInstaller.exe"
Write-Host " Downloading from: $InstallerUrl"
Invoke-WebRequest -Uri $InstallerUrl -OutFile $InstallerPath -UseBasicParsing
Write-Host " Launching installer..."
Write-Host " Follow the on-screen prompts. Make sure 'Add gcloud to PATH' is checked."
Write-Host ""
Start-Process -FilePath $InstallerPath -Wait
Remove-Item $InstallerPath -Force
}
Write-Host ""
Write-Host ">>> Verifying installation..."
try {
$version = & gcloud version 2>&1 | Select-Object -First 1
Write-Host " $version"
Write-Host ""
Write-Host ">>> gcloud is installed."
} catch {
Write-Host " gcloud not found on PATH yet."
Write-Host " Close and reopen PowerShell, then run: gcloud version"
}
Write-Host ""
Write-Host ">>> Next step: run GCR\scripts\01-login.ps1"
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# 00-install-gcloud.sh (Linux)
# Installs the Google Cloud CLI (gcloud) on Debian/Ubuntu.
# Run this once on a new machine before doing anything else.
#
# Windows users: run GCR/scripts/00-install-gcloud.ps1 in PowerShell instead.
# =============================================================================
set -euo pipefail
OS="$(uname -s)"
if [[ "$OS" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/scripts/00-install-gcloud.ps1"
exit 1
fi
echo ">>> Installing Google Cloud CLI on Linux (Debian/Ubuntu)..."
apt_update_with_retry() {
local attempts=5
local i
for ((i=1; i<=attempts; i++)); do
if sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update; then
return 0
fi
echo " apt-get update failed (attempt ${i}/${attempts})."
if (( i < attempts )); then
echo " Retrying apt metadata refresh..."
fi
done
echo "ERROR: apt-get update failed after ${attempts} attempts."
echo "This is often a temporary mirror sync issue. Please try again in a few minutes."
exit 1
}
apt_install_with_retry() {
local attempts=3
local i
for ((i=1; i<=attempts; i++)); do
if sudo apt-get install -y --no-install-recommends "$@"; then
return 0
fi
echo " apt-get install failed (attempt ${i}/${attempts}) for: $*"
if (( i < attempts )); then
echo " Retrying package install..."
fi
done
echo "ERROR: apt-get install failed after ${attempts} attempts for: $*"
exit 1
}
# ── Install dependencies ───────────────────────────────────────────────────
apt_update_with_retry
apt_install_with_retry \
apt-transport-https \
ca-certificates \
curl \
gnupg
# ── Import the Google Cloud signing key ───────────────────────────────────
# Key is downloaded to a file rather than piped straight into gpg so it can
# be inspected or cached by CI systems if needed.
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
-o /tmp/cloud.google.gpg
sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg /tmp/cloud.google.gpg
rm /tmp/cloud.google.gpg
# ── Add the apt repository ────────────────────────────────────────────────
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] \
https://packages.cloud.google.com/apt cloud-sdk main" \
| sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list
# ── Install gcloud ────────────────────────────────────────────────────────
apt_update_with_retry
apt_install_with_retry google-cloud-cli
echo ""
echo ">>> gcloud installed successfully."
gcloud version
echo ""
echo ">>> Next step: run GCR/scripts/01-login.sh"
-62
View File
@@ -1,62 +0,0 @@
# =============================================================================
# 01-login.ps1 (Windows)
# Authenticates your local machine to Google Cloud and configures Docker
# to push images to Artifact Registry.
#
# Run this once per machine (or whenever your credentials expire).
# Linux users: run GCR/scripts/01-login.sh instead.
# =============================================================================
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
# ── Load .env ─────────────────────────────────────────────────────────────────
if (-not (Test-Path $EnvFile)) {
Write-Error "ERROR: $EnvFile not found.`nCopy GCR\.env.example to GCR\.env and fill in your values first."
exit 1
}
$config = @{}
foreach ($line in Get-Content $EnvFile) {
# Skip blank lines and comments
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') {
$config[$Matches[1].Trim()] = $Matches[2].Trim()
}
}
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION is not set in .env"; exit 1 }
# ── Step 1: Authenticate user account ────────────────────────────────────────
Write-Host ">>> Logging in to Google Cloud..."
Write-Host " A browser window will open. Sign in with the Google account"
Write-Host " that has access to project: $GCP_PROJECT_ID"
Write-Host ""
gcloud auth login
# ── Step 2: Set default project ──────────────────────────────────────────────
Write-Host ""
Write-Host ">>> Setting default project to: $GCP_PROJECT_ID"
gcloud config set project $GCP_PROJECT_ID
# ── Step 3: Set default region ───────────────────────────────────────────────
Write-Host ">>> Setting default region to: $GCP_REGION"
gcloud config set run/region $GCP_REGION
# ── Step 4: Configure Docker to authenticate against Artifact Registry ────────
Write-Host ""
Write-Host ">>> Configuring Docker to authenticate with Artifact Registry..."
gcloud auth configure-docker "$GCP_REGION-docker.pkg.dev" --quiet
Write-Host ""
Write-Host ">>> Login complete. You are now authenticated as:"
gcloud auth list --filter=status:ACTIVE --format='value(account)'
Write-Host ""
Write-Host ">>> Next step: run GCR\scripts\02-setup-project.ps1"
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# 01-login.sh (Linux)
# Authenticates your local machine to Google Cloud and configures Docker
# to push images to Artifact Registry.
#
# Run this once per machine (or whenever your credentials expire).
# Windows users: run GCR/scripts/01-login.ps1 in PowerShell instead.
# =============================================================================
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/scripts/01-login.ps1"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env ─────────────────────────────────────────────────────────────────
ENV_FILE="$SCRIPT_DIR/../.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE not found."
echo "Copy GCR/.env.example to GCR/.env and fill in your values first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}"
: "${GCP_REGION:?GCP_REGION is not set in .env}"
# ── Step 1: Authenticate user account ────────────────────────────────────────
echo ">>> Logging in to Google Cloud..."
echo " A browser window will open. Sign in with the Google account that has"
echo " access to project: $GCP_PROJECT_ID"
echo ""
gcloud auth login
# ── Step 2: Set default project ──────────────────────────────────────────────
echo ""
echo ">>> Setting default project to: $GCP_PROJECT_ID"
gcloud config set project "$GCP_PROJECT_ID"
# ── Step 3: Set default region ───────────────────────────────────────────────
echo ">>> Setting default region to: $GCP_REGION"
gcloud config set run/region "$GCP_REGION"
# ── Step 4: Configure Docker to authenticate against Artifact Registry ────────
echo ""
echo ">>> Configuring Docker to authenticate with Artifact Registry..."
gcloud auth configure-docker "${GCP_REGION}-docker.pkg.dev" --quiet
echo ""
echo ">>> Login complete. You are now authenticated as:"
gcloud auth list --filter=status:ACTIVE --format="value(account)"
echo ""
echo ">>> Next step: run GCR/scripts/02-setup-project.sh"
-71
View File
@@ -1,71 +0,0 @@
#Requires -Version 5.1
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
$GCP_REPOSITORY = if ($config['GCP_REPOSITORY']) { $config['GCP_REPOSITORY'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION not set"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY not set"; exit 1 }
Write-Host ">>> Active project: $GCP_PROJECT_ID"
Write-Host ">>> Region: $GCP_REGION"
Write-Host ">>> AR repository: $GCP_REPOSITORY"
Write-Host ""
Write-Host ">>> Checking billing..."
$billing = gcloud billing projects describe $GCP_PROJECT_ID --format='value(billingEnabled)' 2>$null
if ($billing -eq 'True') {
Write-Host " Billing already enabled."
} else {
Write-Host " Billing NOT enabled. Listing accounts..."
gcloud billing accounts list --format='table(name,displayName,open)' 2>$null
$BILLING_ACCOUNT_ID = Read-Host " Enter BILLING_ACCOUNT_ID"
gcloud billing projects link $GCP_PROJECT_ID --billing-account=$BILLING_ACCOUNT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to link billing."; exit 1 }
Write-Host " Billing linked."
}
Write-Host ""
Write-Host ">>> Enabling required APIs..."
gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com cloudresourcemanager.googleapis.com --project=$GCP_PROJECT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to enable APIs."; exit 1 }
Write-Host " APIs enabled."
Write-Host ""
Write-Host ">>> Checking Artifact Registry repository: $GCP_REPOSITORY ..."
gcloud artifacts repositories describe $GCP_REPOSITORY --location=$GCP_REGION --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " Repository already exists."
} else {
Write-Host " Creating repository..."
gcloud artifacts repositories create $GCP_REPOSITORY --repository-format=docker --location=$GCP_REGION --description="Container images for Htmx app" --project=$GCP_PROJECT_ID 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to create repository."; exit 1 }
Write-Host " Repository created."
}
$CURRENT_USER = (gcloud config get-value account 2>$null).Trim()
Write-Host ""
Write-Host ">>> Granting IAM roles to $CURRENT_USER ..."
foreach ($role in @("roles/run.developer","roles/artifactregistry.writer","roles/iam.serviceAccountUser","roles/secretmanager.admin","roles/secretmanager.secretAccessor","roles/secretmanager.secretVersionAdder")) {
Write-Host " $role"
gcloud projects add-iam-policy-binding $GCP_PROJECT_ID --member="user:$CURRENT_USER" --role=$role --quiet 2>$null | Out-Null
}
Write-Host ""
Write-Host ">>> Project setup complete."
Write-Host ">>> Next step: run GCR\scripts\03-create-secrets.ps1"
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# 02-setup-project.sh (Linux)
# One-time GCP project setup:
# - Links a billing account to the project
# - Enables required APIs (Cloud Run, Artifact Registry, Secret Manager)
# - Creates an Artifact Registry Docker repository
# - Grants the current user the minimum required IAM roles
#
# Safe to re-run — most operations are idempotent.
# Windows users: run GCR/scripts/02-setup-project.ps1 in PowerShell instead.
# =============================================================================
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/scripts/02-setup-project.ps1"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env ─────────────────────────────────────────────────────────────────
ENV_FILE="$SCRIPT_DIR/../.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE not found."
echo "Copy GCR/.env.example to GCR/.env and fill in your values first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}"
: "${GCP_REGION:?GCP_REGION is not set in .env}"
: "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}"
# ── Confirm active project ────────────────────────────────────────────────────
echo ">>> Active project: $GCP_PROJECT_ID"
echo ">>> Region: $GCP_REGION"
echo ">>> AR repository: $GCP_REPOSITORY"
echo ""
# ── Step 1: Link billing account ──────────────────────────────────────────────
echo ">>> Checking billing status..."
BILLING_ENABLED=$(gcloud billing projects describe "$GCP_PROJECT_ID" \
--format="value(billingEnabled)" 2>/dev/null || echo "false")
if [[ "$BILLING_ENABLED" == "True" ]]; then
echo " Billing is already enabled — skipping."
else
echo ""
echo " Billing is NOT enabled on this project."
echo " Listing available billing accounts..."
echo ""
gcloud billing accounts list --format="table(name,displayName,open)"
echo ""
read -rp " Enter the BILLING_ACCOUNT_ID from the list above (format: XXXXXX-XXXXXX-XXXXXX): " BILLING_ACCOUNT_ID
gcloud billing projects link "$GCP_PROJECT_ID" \
--billing-account="$BILLING_ACCOUNT_ID"
echo " Billing linked."
fi
# ── Step 2: Enable required APIs ─────────────────────────────────────────────
echo ""
echo ">>> Enabling required Google Cloud APIs (this may take a minute)..."
gcloud services enable \
run.googleapis.com \
artifactregistry.googleapis.com \
secretmanager.googleapis.com \
cloudresourcemanager.googleapis.com \
--project="$GCP_PROJECT_ID"
echo " APIs enabled."
# ── Step 3: Create Artifact Registry Docker repository ───────────────────────
echo ""
echo ">>> Creating Artifact Registry repository: $GCP_REPOSITORY ..."
if gcloud artifacts repositories describe "$GCP_REPOSITORY" \
--location="$GCP_REGION" \
--project="$GCP_PROJECT_ID" &>/dev/null; then
echo " Repository already exists — skipping."
else
gcloud artifacts repositories create "$GCP_REPOSITORY" \
--repository-format=docker \
--location="$GCP_REGION" \
--description="Container images for Htmx app" \
--project="$GCP_PROJECT_ID"
echo " Repository created."
fi
# ── Step 4: Grant current user the minimum required IAM roles ─────────────────
CURRENT_USER="$(gcloud config get-value account)"
echo ""
echo ">>> Granting IAM roles to $CURRENT_USER ..."
for ROLE in \
"roles/run.developer" \
"roles/artifactregistry.writer" \
"roles/iam.serviceAccountUser" \
"roles/secretmanager.admin" \
"roles/secretmanager.secretAccessor" \
"roles/secretmanager.secretVersionAdder"; do
echo " Adding role: $ROLE"
gcloud projects add-iam-policy-binding "$GCP_PROJECT_ID" \
--member="user:$CURRENT_USER" \
--role="$ROLE" \
--quiet
done
echo ""
echo ">>> Project setup complete."
echo ""
echo ">>> Summary:"
echo " Project ID: $GCP_PROJECT_ID"
echo " Region: $GCP_REGION"
echo " Artifact Registry: ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${GCP_REPOSITORY}"
echo ""
echo ">>> Next step: run GCR/scripts/03-create-secrets.sh"
-57
View File
@@ -1,57 +0,0 @@
#Requires -Version 5.1
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
Write-Host "================================================================"
Write-Host " Google Cloud Secret Manager setup - Project: $GCP_PROJECT_ID"
Write-Host "================================================================"
Write-Host ""
$SecretName = "mongodb-connection-string"
Write-Host ">>> Secret: $SecretName"
Write-Host " MongoDB connection URI (e.g. mongodb+srv://user:pass@cluster.mongodb.net)"
$val = Read-Host " Enter value" -AsSecureString
$plain = [System.Net.NetworkCredential]::new("", $val).Password
$tmp = [System.IO.Path]::GetTempFileName()
try {
[System.IO.File]::WriteAllText($tmp, $plain, [System.Text.Encoding]::UTF8)
gcloud secrets describe $SecretName --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
gcloud secrets versions add $SecretName --data-file=$tmp --project=$GCP_PROJECT_ID 2>$null | Out-Null
} else {
gcloud secrets create $SecretName --data-file=$tmp --replication-policy=automatic --project=$GCP_PROJECT_ID 2>$null | Out-Null
}
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to save secret."; exit 1 }
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
Write-Host " Secret saved."
Write-Host ""
Write-Host ">>> Granting Cloud Run service account access to secrets..."
$PROJECT_NUMBER = (gcloud projects describe $GCP_PROJECT_ID --format='value(projectNumber)' 2>$null).Trim()
$APPENGINE_SA = "$GCP_PROJECT_ID@appspot.gserviceaccount.com"
$COMPUTE_SA = "$PROJECT_NUMBER-compute@developer.gserviceaccount.com"
gcloud iam service-accounts describe $APPENGINE_SA --project=$GCP_PROJECT_ID 2>$null | Out-Null
$SA = if ($LASTEXITCODE -eq 0) { $APPENGINE_SA } else { $COMPUTE_SA }
Write-Host " Granting secretAccessor on '$SecretName' to: $SA"
gcloud secrets add-iam-policy-binding $SecretName --member="serviceAccount:$SA" --role=roles/secretmanager.secretAccessor --project=$GCP_PROJECT_ID --quiet 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Error "Failed to grant access."; exit 1 }
Write-Host ""
Write-Host ">>> Secrets setup complete."
Write-Host ">>> Next step: run GCR\scripts\04-deploy.ps1"
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# 03-create-secrets.sh (Linux)
# Creates and configures secrets in Google Cloud Secret Manager.
#
# Run this after 02-setup-project.sh to set up sensitive configuration
# values (e.g., MongoDB connection string).
#
# Windows users: run GCR/scripts/03-create-secrets.ps1 in PowerShell instead.
# =============================================================================
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/scripts/03-create-secrets.ps1"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env ─────────────────────────────────────────────────────────────────
ENV_FILE="$SCRIPT_DIR/../.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE not found."
echo "Copy GCR/.env.example to GCR/.env and fill in your values first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}"
echo "================================================================"
echo " Google Cloud Secret Manager setup"
echo "================================================================"
echo " Project: $GCP_PROJECT_ID"
echo ""
# ── Helper function to create or update a secret ──────────────────────────────
create_or_update_secret() {
local SECRET_NAME="$1"
local SECRET_PROMPT="$2"
echo ">>> Setting up secret: $SECRET_NAME"
echo " $SECRET_PROMPT"
read -rsp " Enter value (will not be echoed): " SECRET_VALUE
echo ""
if gcloud secrets describe "$SECRET_NAME" --project="$GCP_PROJECT_ID" &>/dev/null; then
echo " Secret already exists — creating new version..."
printf '%s' "$SECRET_VALUE" | gcloud secrets versions add "$SECRET_NAME" \
--data-file=- \
--project="$GCP_PROJECT_ID"
else
echo " Creating new secret..."
printf '%s' "$SECRET_VALUE" | gcloud secrets create "$SECRET_NAME" \
--data-file=- \
--replication-policy="automatic" \
--project="$GCP_PROJECT_ID"
fi
echo " ✓ Secret '$SECRET_NAME' ready."
echo ""
}
# ── Step 1: Create MongoDB connection string secret ──────────────────────────
create_or_update_secret \
"mongodb-connection-string" \
"MongoDB Atlas or self-hosted connection URI (e.g., mongodb+srv://user:pass@cluster.mongodb.net)"
# ── Step 2: Grant Cloud Run service account access to secrets ─────────────────
echo ">>> Granting Cloud Run service account access to secrets..."
echo ""
# Get the default Cloud Run service account for this project
SERVICE_ACCOUNT="$GCP_PROJECT_ID@appspot.gserviceaccount.com"
for SECRET_NAME in mongodb-connection-string; do
echo " Granting Secret Accessor role for '$SECRET_NAME' to $SERVICE_ACCOUNT"
gcloud secrets add-iam-policy-binding "$SECRET_NAME" \
--member="serviceAccount:$SERVICE_ACCOUNT" \
--role="roles/secretmanager.secretAccessor" \
--project="$GCP_PROJECT_ID" \
--quiet
done
echo ""
echo "================================================================"
echo " Secret Manager setup complete!"
echo "================================================================"
echo ""
echo ">>> Summary:"
echo " Secrets created:"
echo " • mongodb-connection-string"
echo ""
echo " Service account granted access:"
echo "$SERVICE_ACCOUNT"
echo ""
echo ">>> Next step: run GCR/scripts/04-deploy.sh"
echo " (The deploy script will automatically inject secrets into"
echo " the running container.)"
-95
View File
@@ -1,95 +0,0 @@
#Requires -Version 5.1
param([string]$Tag)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$EnvFile = Join-Path $ScriptDir "..\\.env"
if (-not (Test-Path $EnvFile)) { Write-Error "ERROR: $EnvFile not found."; exit 1 }
$config = @{}
foreach ($line in Get-Content $EnvFile) {
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
if ($line -match '^([^=]+)=(.*)$') { $config[$Matches[1].Trim()] = $Matches[2].Trim() }
}
$GCP_PROJECT_ID = if ($config['GCP_PROJECT_ID']) { $config['GCP_PROJECT_ID'] } else { '' }
$GCP_REGION = if ($config['GCP_REGION']) { $config['GCP_REGION'] } else { '' }
$GCP_REPOSITORY = if ($config['GCP_REPOSITORY']) { $config['GCP_REPOSITORY'] } else { '' }
$SERVICE_NAME = if ($config['SERVICE_NAME']) { $config['SERVICE_NAME'] } else { '' }
$MONGODB_DATABASE_NAME = if ($config['MONGODB_DATABASE_NAME']) { $config['MONGODB_DATABASE_NAME'] } else { 'HtmxAppDb' }
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID not set"; exit 1 }
if (-not $GCP_REGION) { Write-Error "GCP_REGION not set"; exit 1 }
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY not set"; exit 1 }
if (-not $SERVICE_NAME) { Write-Error "SERVICE_NAME not set"; exit 1 }
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host ">>> Required secrets not configured."
$ans = Read-Host " Run 03-create-secrets.ps1 now? [y/N]"
if ($ans -match '^[Yy]$') {
& (Join-Path $ScriptDir "03-create-secrets.ps1")
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Error "Secret setup failed."; exit 1 }
} else {
Write-Error "Run 03-create-secrets.ps1 first."; exit 1
}
}
$bindings = gcloud secrets get-iam-policy mongodb-connection-string --project=$GCP_PROJECT_ID --flatten='bindings[].members' --filter='bindings.role=roles/secretmanager.secretAccessor' --format='value(bindings.members)' 2>$null
if ([string]::IsNullOrWhiteSpace($bindings)) {
Write-Error "No service account has secretAccessor access. Run 03-create-secrets.ps1 first."
exit 1
}
if (-not $Tag) {
try { $Tag = (git rev-parse --short HEAD 2>$null).Trim() } catch { }
if ([string]::IsNullOrWhiteSpace($Tag)) { $Tag = (Get-Date -Format "yyyyMMdd-HHmmss") }
}
$IMAGE = "$GCP_REGION-docker.pkg.dev/$GCP_PROJECT_ID/$GCP_REPOSITORY/htmx-demo-app:$Tag"
$contextDir = Split-Path -Parent (Split-Path -Parent $ScriptDir)
Write-Host "================================================================"
Write-Host " Htmx -> Cloud Run deployment"
Write-Host "================================================================"
Write-Host " Project: $GCP_PROJECT_ID"
Write-Host " Region: $GCP_REGION"
Write-Host " Service: $SERVICE_NAME"
Write-Host " Image: $IMAGE"
Write-Host "================================================================"
Write-Host ""
docker info 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Docker is not running. Start Docker Desktop first."
exit 1
}
Write-Host ">>> Building Docker image..."
$env:DOCKER_BUILDKIT = "1"
docker build -t $IMAGE -f "$contextDir\GCR\Dockerfile" $contextDir
if ($LASTEXITCODE -ne 0) { Write-Error "Docker build failed."; exit 1 }
Write-Host ">>> Image built: $IMAGE"
Write-Host ""
Write-Host ">>> Pushing to Artifact Registry..."
docker push $IMAGE
if ($LASTEXITCODE -ne 0) { Write-Error "Docker push failed."; exit 1 }
Write-Host ">>> Push complete."
Write-Host ""
Write-Host ">>> Deploying to Cloud Run..."
gcloud run services update $SERVICE_NAME --project=$GCP_PROJECT_ID --region=$GCP_REGION --image=$IMAGE --set-env-vars=MONGODB_DATABASE_NAME=$MONGODB_DATABASE_NAME --set-secrets=ConnectionStrings__DefaultConnection=mongodb-connection-string:latest --allow-unauthenticated 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host " Service not found, creating..."
gcloud run deploy $SERVICE_NAME --project=$GCP_PROJECT_ID --region=$GCP_REGION --image=$IMAGE --set-env-vars=MONGODB_DATABASE_NAME=$MONGODB_DATABASE_NAME --set-secrets=ConnectionStrings__DefaultConnection=mongodb-connection-string:latest --allow-unauthenticated 2>$null
if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed."; exit 1 }
}
Write-Host ">>> Deployment complete."
$SERVICE_URL = (gcloud run services describe $SERVICE_NAME --region=$GCP_REGION --project=$GCP_PROJECT_ID --format='value(status.url)' 2>$null).Trim()
Write-Host ""
Write-Host "================================================================"
Write-Host " Done! Service URL: $SERVICE_URL"
Write-Host "================================================================"
-180
View File
@@ -1,180 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# 04-deploy.sh (Linux)
# Builds the Docker image, pushes it to Artifact Registry, and deploys it
# to Cloud Run — all in one command.
#
# Usage:
# ./GCR/scripts/04-deploy.sh # deploy with tag = git short SHA
# ./GCR/scripts/04-deploy.sh my-tag # deploy with a custom tag
#
# Prerequisites:
# 1. GCR/.env exists and is filled in (copy from GCR/.env.example)
# 2. 01-login.sh has been run (gcloud auth + Docker configured)
# 3. 02-setup-project.sh has been run (APIs enabled, repo created)
# 4. 03-create-secrets.sh has been run (MongoDB secret created)
# 5. Docker daemon is running locally
#
# Windows users: run GCR/scripts/04-deploy.ps1 in PowerShell instead.
# =============================================================================
set -euo pipefail
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: This script is for Linux only."
echo "Windows users: run GCR/scripts/04-deploy.ps1"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# ── Load .env ─────────────────────────────────────────────────────────────────
ENV_FILE="$SCRIPT_DIR/../.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "ERROR: $ENV_FILE not found."
echo "Copy GCR/.env.example to GCR/.env and fill in your values first."
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is not set in .env}"
: "${GCP_REGION:?GCP_REGION is not set in .env}"
: "${GCP_REPOSITORY:?GCP_REPOSITORY is not set in .env}"
: "${SERVICE_NAME:?SERVICE_NAME is not set in .env}"
: "${MONGODB_DATABASE_NAME:?MONGODB_DATABASE_NAME is not set in .env}"
# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager (mongodb-connection-string)
# See GCR/README.md for Secret Manager setup
secret_setup_ready() {
local service_account
service_account="serviceAccount:${GCP_PROJECT_ID}@appspot.gserviceaccount.com"
gcloud secrets describe "mongodb-connection-string" --project="$GCP_PROJECT_ID" >/dev/null 2>&1 || return 1
gcloud secrets get-iam-policy "mongodb-connection-string" \
--project="$GCP_PROJECT_ID" \
--flatten="bindings[].members" \
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=${service_account}" \
--format="value(bindings.members)" 2>/dev/null \
| grep -Fxq "$service_account"
}
if ! secret_setup_ready; then
echo ""
echo ">>> Required secrets are not fully configured yet."
read -rp " Run GCR/scripts/03-create-secrets.sh now? [y/N]: " RUN_SECRET_SETUP
if [[ "$RUN_SECRET_SETUP" =~ ^[Yy]$ ]]; then
bash "$SCRIPT_DIR/03-create-secrets.sh"
else
echo ""
echo "ERROR: Deployment requires secret setup first."
echo "Run: bash GCR/scripts/03-create-secrets.sh"
exit 1
fi
if ! secret_setup_ready; then
echo ""
echo "ERROR: Secret setup check still failing after running 03-create-secrets.sh."
exit 1
fi
fi
# ── Determine image tag ────────────────────────────────────────────────────────
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
# Default to git short SHA if inside a git repo; otherwise use timestamp
if git -C "$REPO_ROOT" rev-parse --short HEAD &>/dev/null; then
TAG="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
else
TAG="$(date +%Y%m%d%H%M%S)"
fi
fi
REGISTRY="${GCP_REGION}-docker.pkg.dev"
IMAGE_URI="${REGISTRY}/${GCP_PROJECT_ID}/${GCP_REPOSITORY}/${SERVICE_NAME}:${TAG}"
echo "================================================================"
echo " Htmx → Cloud Run deployment"
echo "================================================================"
echo " Project: $GCP_PROJECT_ID"
echo " Region: $GCP_REGION"
echo " Service: $SERVICE_NAME"
echo " Image: $IMAGE_URI"
echo "================================================================"
echo ""
# ── Step 1: Ensure package-lock.json exists (required for `npm ci`) ───────────
LOCKFILE="$REPO_ROOT/Htmx.ApiDemo/package-lock.json"
if [[ ! -f "$LOCKFILE" ]]; then
echo ">>> package-lock.json not found. Generating it now..."
echo " (This requires node + npm to be installed locally)"
(cd "$REPO_ROOT/Htmx.ApiDemo" && npm install --package-lock-only)
echo " package-lock.json generated. Commit it to the repository."
echo ""
fi
# ── Step 2: Build the Docker image ────────────────────────────────────────────
echo ">>> Building Docker image..."
echo " Context: $REPO_ROOT"
echo " Dockerfile: GCR/Dockerfile"
echo ""
# Build from repo root so the COPY instructions can reach both
# Htmx.ApiDemo/ and Htmx.SourceGenerator/ directories.
docker build \
--file "$REPO_ROOT/GCR/Dockerfile" \
--tag "$IMAGE_URI" \
"$REPO_ROOT"
echo ""
echo ">>> Image built: $IMAGE_URI"
# ── Step 3: Push image to Artifact Registry ───────────────────────────────────
echo ""
echo ">>> Pushing image to Artifact Registry..."
docker push "$IMAGE_URI"
echo ">>> Push complete."
# ── Step 4: Deploy to Cloud Run via docker-compose.yml ───────────────────────
echo ""
echo ">>> Deploying to Cloud Run..."
# Export variables consumed by docker-compose.yml substitution
export IMAGE_URI
export MONGODB_DATABASE_NAME
gcloud run services replace "$REPO_ROOT/GCR/docker-compose.yml" \
--region="$GCP_REGION" \
--project="$GCP_PROJECT_ID"
# ── Step 4b: Inject MongoDB connection string from Secret Manager ────────────
echo ""
echo ">>> Injecting MongoDB connection string from Secret Manager..."
gcloud run services update "$SERVICE_NAME" \
--region="$GCP_REGION" \
--project="$GCP_PROJECT_ID" \
--set-secrets="ConnectionStrings__DefaultConnection=mongodb-connection-string:latest"
# ── Step 5: Make the service publicly accessible ──────────────────────────────
# Remove this block if you want the service to require authentication.
echo ""
echo ">>> Allowing public (unauthenticated) access to the service..."
gcloud run services add-iam-policy-binding "$SERVICE_NAME" \
--region="$GCP_REGION" \
--project="$GCP_PROJECT_ID" \
--member="allUsers" \
--role="roles/run.invoker"
# ── Print service URL ─────────────────────────────────────────────────────────
echo ""
SERVICE_URL=$(gcloud run services describe "$SERVICE_NAME" \
--region="$GCP_REGION" \
--project="$GCP_PROJECT_ID" \
--format="value(status.url)")
echo "================================================================"
echo " Deployment complete!"
echo " Service URL: $SERVICE_URL"
echo "================================================================"
-10
View File
@@ -1,10 +0,0 @@
using System.Text.Json.Serialization;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Http;
namespace Htmx.ApiDemo;
[JsonSerializable(typeof(string))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
-31
View File
@@ -1,31 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Htmx.ApiDemo.Data;
/// <summary>
/// Simple user document stored in MongoDB.
/// All property→field name mappings are declared explicitly via [BsonElement]
/// and registered in Program.cs via BsonClassMap — no AutoMap() reflection.
/// </summary>
public sealed class AppUser
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("email")]
public string Email { get; set; } = "";
/// <summary>Email.ToUpperInvariant() — used for case-insensitive lookups.</summary>
[BsonElement("normalizedEmail")]
public string NormalizedEmail { get; set; } = "";
[BsonElement("passwordHash")]
public string PasswordHash { get; set; } = "";
[BsonElement("displayName")]
public string? DisplayName { get; set; }
[BsonElement("createdAtUtc")]
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
-83
View File
@@ -1,83 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
namespace Htmx.ApiDemo.Data;
/// <summary>
/// AOT-safe authentication service backed by MongoDB.
/// No EF Core, no LINQ-to-SQL, no RelationalModel fully NativeAOT safe.
/// IPasswordHasher is pure PBKDF2 crypto with no dynamic IL.
/// </summary>
public sealed class AppAuthService(
MongoDbService mongo,
IPasswordHasher<AppUser> passwordHasher,
IHttpContextAccessor httpContextAccessor)
{
public async Task<(bool Success, string? Error)> RegisterAsync(
string email, string password, string? displayName, CancellationToken ct = default)
{
var normalized = email.ToUpperInvariant();
if (await mongo.EmailExistsAsync(normalized, ct))
return (false, "That email address is already registered.");
var user = new AppUser
{
Email = email,
NormalizedEmail = normalized,
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
CreatedAtUtc = DateTime.UtcNow,
};
user.PasswordHash = passwordHasher.HashPassword(user, password);
await mongo.InsertAsync(user, ct);
await SignInUserAsync(user);
return (true, null);
}
public async Task<(bool Success, string? Error)> LoginAsync(
string email, string password, CancellationToken ct = default)
{
var normalized = email.ToUpperInvariant();
var user = await mongo.FindByNormalizedEmailAsync(normalized, ct);
if (user is null)
return (false, "Invalid email or password.");
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
return (false, "Invalid email or password.");
await SignInUserAsync(user);
return (true, null);
}
public async Task SignOutAsync()
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
private async Task SignInUserAsync(AppUser user)
{
var ctx = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Email),
new Claim(ClaimTypes.Email, user.Email),
];
if (!string.IsNullOrEmpty(user.DisplayName))
claims.Add(new Claim("DisplayName", user.DisplayName));
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
}
}
-47
View File
@@ -1,47 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
namespace Htmx.ApiDemo.Data;
/// <summary>
/// Scoped service wrapping the AppUser MongoDB collection.
/// All operations use MongoDB's native async API — no EF, no LINQ-to-SQL
/// translation, no RelationalModel, fully NativeAOT safe.
/// </summary>
public sealed class MongoDbService
{
private readonly IMongoCollection<AppUser> _users;
public MongoDbService(IMongoClient client, IConfiguration configuration)
{
var db = client.GetDatabase(configuration["MongoDbName"] ?? "HtmxAppDb");
_users = db.GetCollection<AppUser>("users");
}
/// <summary>Ensures the unique index on NormalizedEmail exists (idempotent).</summary>
public async Task EnsureIndexesAsync(CancellationToken ct = default)
{
var indexKeys = Builders<AppUser>.IndexKeys.Ascending(u => u.NormalizedEmail);
var indexOptions = new CreateIndexOptions { Unique = true, Name = "uq_normalizedEmail" };
var model = new CreateIndexModel<AppUser>(indexKeys, indexOptions);
await _users.Indexes.CreateOneAsync(model, cancellationToken: ct);
}
/// <summary>Returns true if a user with the given normalised email already exists.</summary>
public async Task<bool> EmailExistsAsync(string normalizedEmail, CancellationToken ct = default)
{
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).AnyAsync(ct);
}
/// <summary>Returns the user matching the normalised email, or null.</summary>
public async Task<AppUser?> FindByNormalizedEmailAsync(string normalizedEmail, CancellationToken ct = default)
{
var filter = Builders<AppUser>.Filter.Eq(u => u.NormalizedEmail, normalizedEmail);
return await _users.Find(filter).FirstOrDefaultAsync(ct);
}
/// <summary>Inserts a new user document.</summary>
public Task InsertAsync(AppUser user, CancellationToken ct = default) =>
_users.InsertOneAsync(user, cancellationToken: ct);
}
-23
View File
@@ -1,23 +0,0 @@
namespace Htmx.ApiDemo;
public static class HtmxExtensions
{
public static void HtmxAwareWriteToBody(
this IHtmxComponent component, HttpContext context, string title, string appName, string pageTitle)
{
// If not a HX-Request, render the component inside main layout
if (!context.Request.Headers.ContainsKey("HX-Request"))
{
var layout = new MainLayout(component, title: title, appName: appName, pageTitle: pageTitle,
userName: context.User.Identity?.IsAuthenticated == true ? context.User.Identity.Name : null);
context.Response.ContentType = "text/html; charset=utf-8";
var renderContext = new HtmxRenderContext(context.Response.BodyWriter);
layout.Render(renderContext);
return;
}
//Else only render the component
component.WriteToResponseBody(context);
}
}
-25
View File
@@ -1,25 +0,0 @@
using Htmx.ApiDemo.Data;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
public static void MapHtmxRoutes(this WebApplication app)
{
MapGetIndex(app);
MapGetGreet(app);
GetRegister(app);
PostRegister(app);
PostLogout(app);
GetLogin(app);
PostLogin(app);
GetUiDemo(app);
}
private static void PostLogout(WebApplication app)
=> app.MapPost("/logout", async (HttpContext context, AppAuthService authService) =>
{
await authService.SignOutAsync();
return Results.Redirect("/login");
});
}
-47
View File
@@ -1,47 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>obj/Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<TrimmerRootAssembly Include="MongoDB.Bson" />
<TrimmerRootAssembly Include="Htmx.ApiDemo" />
<TrimmerRootAssembly Include="Microsoft.Extensions.Primitives" />
</ItemGroup>
<ItemGroup>
<CompilerVisibleProperty Include="RootNamespace" />
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\**" CopyToPublishDirectory="Always" />
<AdditionalFiles Include="**/*.htmx" />
<None Remove="**/*.htmx" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<Target Name="Tailwind" BeforeTargets="Build" Inputs="./wwwroot/css/input.css;./**/*.htmx;./**/*.cshtml" Outputs="./wwwroot/css/output.css">
<PropertyGroup>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
</PropertyGroup>
<Exec Command="npx @tailwindcss/cli -i ./wwwroot/css/input.css -o ./wwwroot/css/output.css $(TailwindMinify)" />
</Target>
</Project>
-24
View File
@@ -1,24 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Htmx.ApiDemo", "Htmx.ApiDemo.csproj", "{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {61C4ACCC-FDDE-4F92-B2A9-A5744496122B}
EndGlobalSection
EndGlobal
-87
View File
@@ -1,87 +0,0 @@
using Htmx.ApiDemo;
using Htmx.ApiDemo.Data;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
// ── Explicit serializer registrations — force AOT to preserve constructors ─
BsonSerializer.RegisterSerializer(new ObjectIdSerializer());
BsonSerializer.RegisterSerializer(new StringSerializer());
BsonSerializer.RegisterSerializer(new DateTimeSerializer());
BsonSerializer.RegisterSerializer(new BooleanSerializer());
BsonSerializer.RegisterSerializer(new NullableSerializer<DateTime>(new DateTimeSerializer()));
// ── Explicit BsonClassMap — no AutoMap() reflection, fully AOT-safe ───────
BsonClassMap.RegisterClassMap<AppUser>(cm =>
{
cm.AutoMap();
cm.MapIdProperty(u => u.Id).SetSerializer(new ObjectIdSerializer());
cm.MapProperty(u => u.Email).SetElementName("email");
cm.MapProperty(u => u.NormalizedEmail).SetElementName("normalizedEmail");
cm.MapProperty(u => u.PasswordHash).SetElementName("passwordHash");
cm.MapProperty(u => u.DisplayName).SetElementName("displayName");
cm.MapProperty(u => u.CreatedAtUtc).SetElementName("createdAtUtc");
cm.SetIgnoreExtraElements(true);
});
var builder = WebApplication.CreateSlimBuilder(args);
// ── Antiforgery ───────────────────────────────────────────────────────────
builder.Services.AddAntiforgery();
// ── JSON ──────────────────────────────────────────────────────────────────
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
// ── MongoDB ───────────────────────────────────────────────────────────────
builder.Services.AddSingleton<IMongoClient>(
new MongoClient(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<MongoDbService>();
// ── Cookie Authentication ─────────────────────────────────────────────────
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
options.AccessDeniedPath = "/login";
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
});
builder.Services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
builder.Services.AddScoped<AppAuthService>();
// ── App services ──────────────────────────────────────────────────────────
builder.Services.AddHttpContextAccessor();
builder.Services.AddOpenApi();
builder.Services.AddAuthorization();
var app = builder.Build();
// Ensure the unique index on NormalizedEmail exists (runs once on startup, idempotent).
using (var scope = app.Services.CreateScope())
await scope.ServiceProvider.GetRequiredService<MongoDbService>().EnsureIndexesAsync();
if (app.Environment.IsDevelopment())
app.MapOpenApi();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapHtmxRoutes();
app.Run();
@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "/",
"applicationUrl": "http://localhost:5120",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -1,3 +0,0 @@
<div class="accordion-root w-full" id="$$Id$$">
$$Items$$
</div>
@@ -1,55 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Accordion. Items collapse/expand client-side via components.js.
/// Pass a list of (Title, Content) tuples; set openIndex to expand one by default (-1 = all closed).
/// </summary>
public sealed class Accordion : AccordionBase
{
private const string ChevronSvg =
"""<svg class="accordion-chevron h-4 w-4 shrink-0 transition-transform duration-200" """ +
"""xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" """ +
"""stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>""";
private readonly byte[] _idData;
private readonly byte[] _itemsData;
public Accordion(string id, IEnumerable<(string Title, string Content)> items, int openIndex = -1)
{
_idData = id.ToUtf8Bytes();
var list = items.ToList();
var sb = new System.Text.StringBuilder();
for (int i = 0; i < list.Count; i++)
{
var (title, content) = list[i];
var expanded = i == openIndex;
var height = expanded ? "auto" : "0";
var opacity = expanded ? "1" : "0";
sb.Append($"""
<div class="accordion-item border-b border-border">
<h3 class="flex">
<button type="button"
class="accordion-trigger flex flex-1 items-center justify-between py-4 font-medium
transition-all hover:underline text-left"
aria-expanded="{(expanded ? "true" : "false")}">
{title}
{ChevronSvg}
</button>
</h3>
<div class="accordion-panel overflow-hidden text-sm transition-all duration-200"
style="height:{height};opacity:{opacity}">
<div class="pb-4 pt-0">{content}</div>
</div>
</div>
""");
}
_itemsData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData);
}
@@ -1,7 +0,0 @@
<div role="alert" class="$$Classes$$">
$$Icon$$
<div>
$$Title$$
$$Description$$
</div>
</div>
@@ -1,40 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Alert component.
/// Variant: default | destructive
/// </summary>
public sealed class Alert : AlertBase
{
private static readonly Dictionary<string, string> VariantClasses = new()
{
["default"] = "relative w-full rounded-lg border border-border bg-background p-4 " +
"[&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
["destructive"] = "relative w-full rounded-lg border border-destructive/50 p-4 text-destructive " +
"[&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive",
};
private readonly byte[] _classesData;
private readonly byte[] _iconData;
private readonly byte[] _titleData;
private readonly byte[] _descriptionData;
public Alert(
string title,
string description = "",
string variant = "default",
string icon = "")
{
_classesData = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]).ToUtf8Bytes();
_iconData = icon.ToUtf8Bytes();
_titleData = $"""<h5 class="mb-1 font-medium leading-none tracking-tight">{title}</h5>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<div class="text-sm [&_p]:leading-relaxed">{description}</div>""".ToUtf8Bytes();
}
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
protected override void RenderIcon(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_iconData);
protected override void RenderTitle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_titleData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
}
@@ -1,3 +0,0 @@
<span class="relative flex $$SizeClasses$$ shrink-0 overflow-hidden rounded-full">
$$Content$$
</span>
@@ -1,31 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Avatar component. Shows an image or falls back to initials.
/// Size: sm (h-8 w-8) | default (h-10 w-10) | lg (h-14 w-14) | xl (h-20 w-20)
/// </summary>
public sealed class Avatar : AvatarBase
{
private static readonly Dictionary<string, string> Sizes = new()
{
["sm"] = "h-8 w-8",
["default"] = "h-10 w-10",
["lg"] = "h-14 w-14",
["xl"] = "h-20 w-20",
};
private readonly byte[] _sizeClassesData;
private readonly byte[] _contentData;
public Avatar(string fallback, string? src = null, string size = "default")
{
_sizeClassesData = Sizes.GetValueOrDefault(size, Sizes["default"]).ToUtf8Bytes();
_contentData = !string.IsNullOrEmpty(src)
? $"""<img src="{src}" alt="{fallback}" class="aspect-square h-full w-full object-cover" />""".ToUtf8Bytes()
: $"""<span class="flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-medium select-none">{fallback}</span>""".ToUtf8Bytes();
}
protected override void RenderSizeClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_sizeClassesData);
protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData);
}
@@ -1 +0,0 @@
<span class="$$Classes$$">$$Text$$</span>
@@ -1,33 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Badge component.
/// Variant: default | secondary | destructive | outline
/// </summary>
public sealed class Badge : BadgeBase
{
private static readonly Dictionary<string, string> VariantClasses = new()
{
["default"] = "bg-primary text-primary-foreground hover:bg-primary/80",
["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80",
["destructive"] = "bg-destructive text-destructive-foreground hover:bg-destructive/80",
["outline"] = "text-foreground border border-input hover:bg-accent",
};
private const string BaseClasses =
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors " +
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2";
private readonly byte[] _textData;
private readonly byte[] _classesData;
public Badge(string text, string variant = "default")
{
_textData = text.ToUtf8Bytes();
var v = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]);
_classesData = $"{BaseClasses} {v}".ToUtf8Bytes();
}
protected override void RenderText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_textData);
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
}
@@ -1,5 +0,0 @@
<nav aria-label="Breadcrumb">
<ol class="flex flex-wrap items-center gap-1.5 wrap-break-word text-sm text-muted-foreground">
$$Items$$
</ol>
</nav>
@@ -1,42 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Breadcrumb navigation.
/// Pass items as (Label, Href) tuples — empty Href renders a non-linked span.
/// The last item is always rendered as the current page (non-linked, foreground colour).
/// </summary>
public sealed class Breadcrumb : BreadcrumbBase
{
private const string ChevronSvg =
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5"><path d="m9 18 6-6-6-6"/></svg>""";
private readonly byte[] _itemsData;
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
{
var list = items.ToList();
var sb = new System.Text.StringBuilder();
for (int i = 0; i < list.Count; i++)
{
var (label, href) = list[i];
bool isLast = i == list.Count - 1;
sb.Append("""<li class="inline-flex items-center gap-1.5">""");
if (isLast || string.IsNullOrEmpty(href))
sb.Append($"""<span class="{(isLast ? "font-normal text-foreground" : "")}">{label}</span>""");
else
sb.Append($"""<a href="{href}" class="hover:text-foreground transition-colors">{label}</a>""");
if (!isLast)
sb.Append($"""<span role="presentation" aria-hidden="true">{ChevronSvg}</span>""");
sb.Append("</li>");
}
_itemsData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData);
}
@@ -1 +0,0 @@
<button type="$$Type$$" class="$$Classes$$" $$HxAttrs$$>$$Label$$</button>
@@ -1,59 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Button component.
/// Variant: default | destructive | outline | secondary | ghost | link
/// Size: default | sm | lg | icon
/// </summary>
public sealed class Button : ButtonBase
{
private static readonly Dictionary<string, string> VariantClasses = new()
{
["default"] = "bg-primary text-primary-foreground hover:bg-primary/90",
["destructive"] = "bg-destructive text-destructive-foreground hover:bg-destructive/90",
["outline"] = "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
["secondary"] = "bg-secondary text-secondary-foreground hover:bg-secondary/80",
["ghost"] = "hover:bg-accent hover:text-accent-foreground",
["link"] = "text-primary underline-offset-4 hover:underline",
};
private static readonly Dictionary<string, string> SizeClasses = new()
{
["default"] = "h-10 px-4 py-2 text-sm",
["sm"] = "h-9 rounded-md px-3 text-xs",
["lg"] = "h-11 rounded-md px-8 text-base",
["icon"] = "h-10 w-10",
};
private const string BaseClasses =
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium " +
"ring-offset-background transition-colors " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " +
"disabled:pointer-events-none disabled:opacity-50";
private readonly byte[] _labelData;
private readonly byte[] _classesData;
private readonly byte[] _typeData;
private readonly byte[] _hxAttrsData;
public Button(
string label,
string variant = "default",
string size = "default",
string type = "button",
string hxAttrs = "")
{
_labelData = label.ToUtf8Bytes();
_typeData = type.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
var v = VariantClasses.GetValueOrDefault(variant, VariantClasses["default"]);
var s = SizeClasses.GetValueOrDefault(size, SizeClasses["default"]);
_classesData = $"{BaseClasses} {s} {v}".ToUtf8Bytes();
}
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
protected override void RenderType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_typeData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1,38 +0,0 @@
<div id="cal-$$Id$$"
class="calendar-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm"
data-year="$$Year$$"
data-month="$$Month$$"
data-sel-day="$$SelectedDay$$"
data-sel-month="$$SelectedMonth$$"
data-sel-year="$$SelectedYear$$"
data-view="days">
<!-- Header row -->
<div class="mb-3 flex items-center justify-between">
<button type="button" class="cal-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input
bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base"
aria-label="Previous month">&#8249;</button>
<button type="button" class="cal-month-label text-sm font-semibold px-2 py-0.5 rounded-md
hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button>
<button type="button" class="cal-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input
bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base"
aria-label="Next month">&#8250;</button>
</div>
<!-- Day-of-week headers -->
<div class="cal-dow-row mb-1 grid grid-cols-7 text-center">
<span class="cal-dow">Su</span>
<span class="cal-dow">Mo</span>
<span class="cal-dow">Tu</span>
<span class="cal-dow">We</span>
<span class="cal-dow">Th</span>
<span class="cal-dow">Fr</span>
<span class="cal-dow">Sa</span>
</div>
<!-- Day grid (populated by JS below) -->
<div class="cal-grid grid grid-cols-7 gap-0.5 text-center"></div>
<!-- Hidden input -->
<input type="hidden" name="$$Name$$" class="cal-hidden-input" value="$$DefaultValue$$" />
</div>
@@ -1,43 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Calendar (date-picker) component driven entirely by HyperScript.
/// Pass a selected date to pre-highlight a day; defaults to today.
/// </summary>
public sealed class Calendar : CalendarBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _yearData;
private readonly byte[] _monthData; // 0-based JS month
private readonly byte[] _selectedDayData;
private readonly byte[] _selectedMonthData; // 0-based
private readonly byte[] _selectedYearData;
private readonly byte[] _defaultValueData;
public Calendar(
string id,
string name = "date",
DateOnly? selected = null)
{
var date = selected ?? DateOnly.FromDateTime(DateTime.Today);
_idData = id.ToUtf8Bytes();
_nameData = name.ToUtf8Bytes();
_yearData = date.Year.ToString().ToUtf8Bytes();
_monthData = (date.Month - 1).ToString().ToUtf8Bytes(); // JS months are 0-based
_selectedDayData = date.Day.ToString().ToUtf8Bytes();
_selectedMonthData= (date.Month - 1).ToString().ToUtf8Bytes();
_selectedYearData = date.Year.ToString().ToUtf8Bytes();
_defaultValueData = date.ToString("yyyy-MM-dd").ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData);
protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData);
protected override void RenderSelectedDay(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedDayData);
protected override void RenderSelectedMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedMonthData);
protected override void RenderSelectedYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_selectedYearData);
protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData);
}
@@ -1,41 +0,0 @@
<div id="calr-$$Id$$"
class="calr-root inline-block min-w-72 rounded-md border border-border bg-card p-4 shadow-sm"
data-year="$$Year$$"
data-month="$$Month$$"
data-start="$$DefaultStart$$"
data-end="$$DefaultEnd$$"
data-view="days">
<!-- Header row -->
<div class="mb-3 flex items-center justify-between">
<button type="button" class="calr-prev cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input
bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base"
aria-label="Previous month">&#8249;</button>
<button type="button" class="calr-month-label text-sm font-semibold px-2 py-0.5 rounded-md
hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"></button>
<button type="button" class="calr-next cal-nav inline-flex h-8 w-8 items-center justify-center rounded-md border border-input
bg-transparent hover:bg-accent hover:text-accent-foreground transition-colors text-base"
aria-label="Next month">&#8250;</button>
</div>
<!-- Day-of-week headers -->
<div class="cal-dow-row mb-1 grid grid-cols-7 text-center">
<span class="cal-dow">Su</span>
<span class="cal-dow">Mo</span>
<span class="cal-dow">Tu</span>
<span class="cal-dow">We</span>
<span class="cal-dow">Th</span>
<span class="cal-dow">Fr</span>
<span class="cal-dow">Sa</span>
</div>
<!-- Day grid (populated by JS) -->
<div class="calr-grid grid grid-cols-7 text-center"></div>
<!-- Range label -->
<div class="calr-label mt-3 text-xs text-muted-foreground min-h-4"></div>
<!-- Hidden inputs -->
<input type="hidden" name="$$NameStart$$" class="calr-hidden-start" value="$$DefaultStart$$" />
<input type="hidden" name="$$NameEnd$$" class="calr-hidden-end" value="$$DefaultEnd$$" />
</div>
@@ -1,49 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style range Calendar. Lets the user pick a start and end date.
/// State and rendering are handled by components.js (initCalendarRange).
/// Fires a <c>rangeChange</c> CustomEvent with <c>{ start, end }</c> detail.
/// </summary>
public sealed class CalendarRange : CalendarRangeBase
{
private readonly byte[] _idData;
private readonly byte[] _nameStartData;
private readonly byte[] _nameEndData;
private readonly byte[] _yearData;
private readonly byte[] _monthData;
private readonly byte[] _defaultStartData;
private readonly byte[] _defaultEndData;
public CalendarRange(
string id,
string name = "date",
DateOnly? selectedStart = null,
DateOnly? selectedEnd = null)
{
// Show the start month if provided, otherwise today
var viewDate = selectedStart ?? DateOnly.FromDateTime(DateTime.Today);
_idData = id.ToUtf8Bytes();
_nameStartData = (name + "-start").ToUtf8Bytes();
_nameEndData = (name + "-end").ToUtf8Bytes();
_yearData = viewDate.Year.ToString().ToUtf8Bytes();
_monthData = (viewDate.Month - 1).ToString().ToUtf8Bytes(); // 0-based
_defaultStartData = selectedStart.HasValue
? selectedStart.Value.ToString("yyyy-MM-dd").ToUtf8Bytes()
: [] ;
_defaultEndData = selectedEnd.HasValue
? selectedEnd.Value.ToString("yyyy-MM-dd").ToUtf8Bytes()
: [];
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderNameStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameStartData);
protected override void RenderNameEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameEndData);
protected override void RenderYear(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_yearData);
protected override void RenderMonth(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_monthData);
protected override void RenderDefaultStart(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultStartData);
protected override void RenderDefaultEnd(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultEndData);
}
@@ -1,5 +0,0 @@
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
$$Header$$
<div class="p-6 pt-0">$$Content$$</div>
$$Footer$$
</div>
@@ -1,48 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Card component with optional header (title + description) and footer.
/// </summary>
public sealed class Card : CardBase
{
private readonly byte[] _extraClassesData;
private readonly byte[] _headerData;
private readonly byte[] _contentData;
private readonly byte[] _footerData;
public Card(
string content,
string title = "",
string description = "",
string footer = "",
string extraClasses = "")
{
_extraClassesData = extraClasses.ToUtf8Bytes();
_contentData = content.ToUtf8Bytes();
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
? []
: BuildHeader(title, description);
_footerData = string.IsNullOrEmpty(footer)
? []
: $"""<div class="flex items-center p-6 pt-0">{footer}</div>""".ToUtf8Bytes();
}
private static byte[] BuildHeader(string title, string description)
{
var sb = new System.Text.StringBuilder();
sb.Append("""<div class="flex flex-col space-y-1.5 p-6">""");
if (!string.IsNullOrEmpty(title))
sb.Append($"""<h3 class="text-2xl font-semibold leading-none tracking-tight">{title}</h3>""");
if (!string.IsNullOrEmpty(description))
sb.Append($"""<p class="text-sm text-muted-foreground">{description}</p>""");
sb.Append("</div>");
return sb.ToString().ToUtf8Bytes();
}
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData);
protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData);
protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData);
}
@@ -1,13 +0,0 @@
<div class="flex items-center gap-2">
<input
type="checkbox"
id="$$Id$$"
name="$$Name$$"
value="$$Value$$"
$$Checked$$
class="h-4 w-4 shrink-0 rounded-sm border border-input ring-offset-background
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
accent-primary cursor-pointer" />
$$Label$$
</div>
@@ -1,35 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Checkbox with an optional label.
/// </summary>
public sealed class Checkbox : CheckboxBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _valueData;
private readonly byte[] _checkedData;
private readonly byte[] _labelData;
public Checkbox(
string id,
string label = "",
string name = "",
string value = "true",
bool @checked = false)
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_valueData = value.ToUtf8Bytes();
_checkedData = (@checked ? "checked" : "").ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueData);
protected override void RenderChecked(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_checkedData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
}
@@ -1,11 +0,0 @@
<dialog id="dlg-$$Id$$"
class="dialog-root rounded-lg border border-border bg-background p-0 shadow-lg
fixed left-1/2 top-1/2 m-0 w-full max-w-lg -translate-x-1/2 -translate-y-1/2
backdrop:bg-black/50 backdrop:backdrop-blur-sm
open:block">
<div class="flex flex-col gap-4 p-6">
$$Header$$
<div class="text-sm text-muted-foreground">$$Content$$</div>
$$Footer$$
</div>
</dialog>
@@ -1,55 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Dialog using the native HTML &lt;dialog&gt; element.
/// Open with data-dialog-open="id" on any button; close with data-dialog-close or .dialog-close.
/// JS wiring is in components.js.
/// </summary>
public sealed class Dialog : DialogBase
{
private readonly byte[] _idData;
private readonly byte[] _headerData;
private readonly byte[] _contentData;
private readonly byte[] _footerData;
public Dialog(
string id,
string content,
string title = "",
string description = "",
string footer = "")
{
_idData = id.ToUtf8Bytes();
_contentData = content.ToUtf8Bytes();
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
? []
: BuildHeader(id, title, description);
_footerData = string.IsNullOrEmpty(footer)
? []
: $"""<div class="flex justify-end gap-2">{footer}</div>""".ToUtf8Bytes();
}
private static byte[] BuildHeader(string id, string title, string description)
{
var sb = new System.Text.StringBuilder();
sb.Append("""<div class="flex items-start justify-between gap-4">""");
sb.Append("""<div class="flex flex-col gap-1.5">""");
if (!string.IsNullOrEmpty(title))
sb.Append($"""<h2 class="text-lg font-semibold leading-none tracking-tight">{title}</h2>""");
if (!string.IsNullOrEmpty(description))
sb.Append($"""<p class="text-sm text-muted-foreground">{description}</p>""");
sb.Append("</div>");
sb.Append("""<button type="button" data-dialog-close class="dialog-close 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" aria-label="Close">""");
sb.Append("""<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>""");
sb.Append("</button>");
sb.Append("</div>");
return sb.ToString().ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData);
protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData);
protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData);
}
@@ -1,9 +0,0 @@
<div class="dropdown-root relative inline-block">
<div class="dropdown-trigger $$TriggerClasses$$" role="button" tabindex="0" aria-haspopup="menu" aria-expanded="false">
$$Trigger$$
</div>
<div class="dropdown-content hidden absolute $$Position$$ z-50 min-w-40 rounded-md border border-border
bg-popover p-1 text-popover-foreground shadow-md" role="menu">
$$Items$$
</div>
</div>
@@ -1,55 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// CSS-native DropdownMenu using &lt;details&gt;/&lt;summary&gt;.
/// Position: "left-0 top-full mt-1" (default) | "right-0 top-full mt-1" | etc.
/// Items: pre-built HTML — use BuildItem() helper for consistent styling.
/// </summary>
public sealed class DropdownMenu : DropdownMenuBase
{
private const string ItemClasses =
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm " +
"outline-none transition-colors hover:bg-accent hover:text-accent-foreground " +
"focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50";
private readonly byte[] _triggerClassesData;
private readonly byte[] _triggerData;
private readonly byte[] _positionData;
private readonly byte[] _itemsData;
public DropdownMenu(
IHtmxComponent trigger,
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
string position = "left-0 top-full mt-1")
{
// Render trigger to bytes
var writer = new System.Buffers.ArrayBufferWriter<byte>();
trigger.Render(new HtmxRenderContext(writer));
_triggerData = writer.WrittenSpan.ToArray();
_triggerClassesData = []; // trigger already supplies its own classes
_positionData = position.ToUtf8Bytes();
var sb = new System.Text.StringBuilder();
foreach (var (label, href, isSeparator) in items)
{
if (isSeparator)
{
sb.Append("""<div class="-mx-1 my-1 h-px bg-border"></div>""");
}
else if (string.IsNullOrEmpty(href))
{
sb.Append($"""<button type="button" class="{ItemClasses}">{label}</button>""");
}
else
{
sb.Append($"""<a href="{href}" class="{ItemClasses}">{label}</a>""");
}
}
_itemsData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderTriggerClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerClassesData);
protected override void RenderTrigger(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerData);
protected override void RenderPosition(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_positionData);
protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData);
}
@@ -1,18 +0,0 @@
<div class="flex flex-col gap-1.5">
$$Label$$
<input
id="$$Id$$"
name="$$Name$$"
type="file"
$$Accept$$
$$Multiple$$
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background
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-2 focus-visible:ring-ring
focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 $$ExtraClasses$$"
$$HxAttrs$$
/>
$$Description$$
</div>
@@ -1,51 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style FileInput component with optional label and description.
/// </summary>
public sealed class FileInput : FileInputBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _acceptData;
private readonly byte[] _multipleData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _extraClassesData;
private readonly byte[] _hxAttrsData;
public FileInput(
string id,
string name = "",
string accept = "",
bool multiple = false,
string label = "",
string description = "",
string extraClasses = "",
string hxAttrs = "")
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_acceptData = string.IsNullOrEmpty(accept) ? [] : $"""accept="{accept}" """.ToUtf8Bytes();
_multipleData = multiple ? "multiple".ToUtf8Bytes() : [];
_extraClassesData = extraClasses.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none">{label}</label>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderAccept(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_acceptData);
protected override void RenderMultiple(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_multipleData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1,15 +0,0 @@
<div class="flex flex-col gap-1.5">
$$Label$$
<input
id="$$Id$$"
name="$$Name$$"
type="$$InputType$$"
placeholder="$$Placeholder$$"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50 $$ExtraClasses$$"
$$HxAttrs$$
/>
$$Description$$
</div>
@@ -1,52 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Input component with optional label and description.
/// InputType: text | email | password | number | search | tel | url | date | time
/// </summary>
public sealed class Input : InputBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _inputTypeData;
private readonly byte[] _placeholderData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _extraClassesData;
private readonly byte[] _hxAttrsData;
public Input(
string id,
string name = "",
string inputType = "text",
string placeholder = "",
string label = "",
string description = "",
string extraClasses = "",
string hxAttrs = "")
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_inputTypeData = inputType.ToUtf8Bytes();
_placeholderData = placeholder.ToUtf8Bytes();
_extraClassesData = extraClasses.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderInputType(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_inputTypeData);
protected override void RenderPlaceholder(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_placeholderData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1,5 +0,0 @@
<nav aria-label="Pagination" class="flex items-center gap-1">
$$Prev$$
$$Pages$$
$$Next$$
</nav>
@@ -1,49 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Pagination. Generates prev/next and page-number buttons.
/// urlPattern: format string where {0} is replaced by the page number, e.g. "/items?page={0}"
/// </summary>
public sealed class Pagination : PaginationBase
{
private const string BtnBase =
"inline-flex items-center justify-center rounded-md border border-input bg-background " +
"px-3 h-9 text-sm font-medium ring-offset-background transition-colors " +
"hover:bg-accent hover:text-accent-foreground " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " +
"disabled:pointer-events-none disabled:opacity-50";
private const string ActiveBtn =
"inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground " +
"px-3 h-9 text-sm font-medium ring-offset-background " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
private readonly byte[] _prevData;
private readonly byte[] _pagesData;
private readonly byte[] _nextData;
public Pagination(int current, int total, string urlPattern = "?page={0}")
{
_prevData = current <= 1
? $"""<button type="button" class="{BtnBase}" disabled aria-label="Previous page">&#8249;</button>""".ToUtf8Bytes()
: $"""<a href="{string.Format(urlPattern, current - 1)}" class="{BtnBase}" aria-label="Previous page">&#8249;</a>""".ToUtf8Bytes();
_nextData = current >= total
? $"""<button type="button" class="{BtnBase}" disabled aria-label="Next page">&#8250;</button>""".ToUtf8Bytes()
: $"""<a href="{string.Format(urlPattern, current + 1)}" class="{BtnBase}" aria-label="Next page">&#8250;</a>""".ToUtf8Bytes();
var sb = new System.Text.StringBuilder();
for (int p = 1; p <= total; p++)
{
if (p == current)
sb.Append($"""<span class="{ActiveBtn}" aria-current="page">{p}</span>""");
else
sb.Append($"""<a href="{string.Format(urlPattern, p)}" class="{BtnBase}">{p}</a>""");
}
_pagesData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderPrev(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_prevData);
protected override void RenderPages(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_pagesData);
protected override void RenderNext(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nextData);
}
@@ -1,4 +0,0 @@
<div class="relative $$HeightClass$$ w-full overflow-hidden rounded-full bg-secondary"
role="progressbar" aria-valuenow="$$ValueNow$$" aria-valuemin="0" aria-valuemax="100">
<div class="h-full bg-primary transition-all duration-300" style="width:$$ValueNow$$%"></div>
</div>
@@ -1,26 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Progress bar. Value is clamped to 0100.
/// Size: sm (h-2) | default (h-4) | lg (h-6)
/// </summary>
public sealed class Progress : ProgressBase
{
private readonly byte[] _valueNowData;
private readonly byte[] _heightClassData;
public Progress(int value = 0, string size = "default")
{
var clamped = Math.Clamp(value, 0, 100);
_valueNowData = clamped.ToString().ToUtf8Bytes();
_heightClassData = size switch
{
"sm" => "h-2".ToUtf8Bytes(),
"lg" => "h-6".ToUtf8Bytes(),
_ => "h-4".ToUtf8Bytes(),
};
}
protected override void RenderValueNow(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueNowData);
protected override void RenderHeightClass(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_heightClassData);
}
@@ -1,6 +0,0 @@
<div class="flex flex-col gap-2">
$$GroupLabel$$
<div class="flex $$Direction$$ gap-3" role="radiogroup">
$$Items$$
</div>
</div>
@@ -1,46 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style RadioGroup.
/// Direction: flex-col | flex-row
/// </summary>
public sealed class RadioGroup : RadioGroupBase
{
private readonly byte[] _groupLabelData;
private readonly byte[] _directionData;
private readonly byte[] _itemsData;
public RadioGroup(
string name,
IEnumerable<(string Value, string Label, bool Selected)> options,
string label = "",
string direction = "flex-col")
{
_groupLabelData = string.IsNullOrEmpty(label)
? []
: $"""<span class="text-sm font-medium leading-none">{label}</span>""".ToUtf8Bytes();
_directionData = direction.ToUtf8Bytes();
var sb = new System.Text.StringBuilder();
foreach (var (value, optLabel, selected) in options)
{
var optId = $"{name}-{value}";
var sel = selected ? " checked" : "";
sb.Append($"""
<label class="flex items-center gap-2 cursor-pointer text-sm">
<input type="radio" id="{optId}" name="{name}" value="{value}"{sel}
class="h-4 w-4 border border-input accent-primary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50" />
{optLabel}
</label>
""");
}
_itemsData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderGroupLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_groupLabelData);
protected override void RenderDirection(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_directionData);
protected override void RenderItems(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_itemsData);
}
@@ -1,14 +0,0 @@
<div class="flex flex-col gap-1.5">
$$Label$$
<select
id="$$Id$$"
name="$$Name$$"
class="flex h-10 w-full items-center justify-between rounded-md border border-input
bg-background px-3 py-2 text-sm ring-offset-background
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50 appearance-none $$ExtraClasses$$"
$$HxAttrs$$>
$$Options$$
</select>
$$Description$$
</div>
@@ -1,56 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Select (native HTML select) component.
/// </summary>
public sealed class Select : SelectBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _optionsData;
private readonly byte[] _extraClassesData;
private readonly byte[] _hxAttrsData;
/// <param name="options">Collection of (value, display) tuples. Mark selected with selectedValue.</param>
public Select(
string id,
IEnumerable<(string Value, string Display)> options,
string selectedValue = "",
string name = "",
string label = "",
string description = "",
string extraClasses = "",
string hxAttrs = "")
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_extraClassesData = extraClasses.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none">{label}</label>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
var sb = new System.Text.StringBuilder();
foreach (var (value, display) in options)
{
var selected = value == selectedValue ? " selected" : "";
sb.Append($"""<option value="{value}"{selected}>{display}</option>""");
}
_optionsData = sb.ToString().ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderOptions(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_optionsData);
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1 +0,0 @@
<div class="$$Classes$$" role="separator" aria-orientation="$$Orientation$$"></div>
@@ -1,24 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Separator component.
/// Orientation: horizontal | vertical
/// </summary>
public sealed class Separator : SeparatorBase
{
private readonly byte[] _classesData;
private readonly byte[] _orientationData;
public Separator(string orientation = "horizontal", string extraClasses = "")
{
var cls = orientation == "vertical"
? $"inline-block h-full w-px bg-border {extraClasses}"
: $"block h-px w-full bg-border {extraClasses}";
_classesData = cls.Trim().ToUtf8Bytes();
_orientationData = orientation.ToUtf8Bytes();
}
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
protected override void RenderOrientation(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_orientationData);
}
@@ -1 +0,0 @@
<div class="animate-pulse rounded-md bg-muted $$Classes$$"></div>
@@ -1,17 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Skeleton loading placeholder.
/// Pass size classes via the classes parameter, e.g. "h-4 w-48" or "h-10 w-full".
/// </summary>
public sealed class Skeleton : SkeletonBase
{
private readonly byte[] _classesData;
public Skeleton(string classes = "h-4 w-full")
{
_classesData = classes.ToUtf8Bytes();
}
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
}
@@ -1,17 +0,0 @@
<div class="flex flex-col gap-2">
$$Label$$
<input
type="range"
id="$$Id$$"
name="$$Name$$"
min="$$Min$$"
max="$$Max$$"
step="$$Step$$"
value="$$Value$$"
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-secondary accent-primary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 $$ExtraClasses$$"
$$HxAttrs$$
/>
$$Description$$
</div>
@@ -1,59 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Slider (range input) with optional label and description.
/// </summary>
public sealed class Slider : SliderBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _minData;
private readonly byte[] _maxData;
private readonly byte[] _stepData;
private readonly byte[] _valueData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _extraClassesData;
private readonly byte[] _hxAttrsData;
public Slider(
string id,
string name = "",
int min = 0,
int max = 100,
int step = 1,
int value = 50,
string label = "",
string description = "",
string extraClasses = "",
string hxAttrs = "")
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_minData = min.ToString().ToUtf8Bytes();
_maxData = max.ToString().ToUtf8Bytes();
_stepData = step.ToString().ToUtf8Bytes();
_valueData = value.ToString().ToUtf8Bytes();
_extraClassesData = extraClasses.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none">{label}</label>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderMin(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_minData);
protected override void RenderMax(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_maxData);
protected override void RenderStep(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_stepData);
protected override void RenderValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_valueData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1,10 +0,0 @@
<label class="inline-flex items-center gap-3 cursor-pointer">
<input type="checkbox" id="$$Id$$" name="$$Name$$" $$Checked$$
value="true" class="sr-only switch-checkbox" />
<span class="switch-track relative inline-flex h-6 w-11 shrink-0 items-center rounded-full
bg-input transition-colors duration-200">
<span class="switch-thumb pointer-events-none block h-5 w-5 rounded-full bg-white
shadow-lg ring-0 transition-transform duration-200 translate-x-0.5"></span>
</span>
$$Label$$
</label>
@@ -1,32 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Switch (toggle). Rendered as a styled checkbox.
/// JS in components.js handles the visual on/off state.
/// </summary>
public sealed class Switch : SwitchBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _checkedData;
private readonly byte[] _labelData;
public Switch(
string id,
string label = "",
string name = "",
bool isChecked = false)
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_checkedData = (isChecked ? "checked" : "").ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<span class="text-sm font-medium leading-none">{label}</span>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderChecked(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_checkedData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
}
@@ -1,14 +0,0 @@
<div class="w-full overflow-auto rounded-md border border-border">
<table class="w-full caption-bottom text-sm">
$$Caption$$
<thead>
<tr class="border-b border-border bg-muted/50 transition-colors">
$$Headers$$
</tr>
</thead>
<tbody class="divide-y divide-border">
$$Rows$$
</tbody>
$$Footer$$
</table>
</div>
@@ -1,50 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Table component.
/// Headers: column header strings.
/// Rows: each row is an IEnumerable of cell strings.
/// Caption and Footer are optional.
/// </summary>
public sealed class Table : TableBase
{
private readonly byte[] _captionData;
private readonly byte[] _headersData;
private readonly byte[] _rowsData;
private readonly byte[] _footerData;
public Table(
IEnumerable<string> headers,
IEnumerable<IEnumerable<string>> rows,
string caption = "",
string footer = "")
{
_captionData = string.IsNullOrEmpty(caption)
? []
: $"""<caption class="mt-4 text-sm text-muted-foreground">{caption}</caption>""".ToUtf8Bytes();
var hSb = new System.Text.StringBuilder();
foreach (var h in headers)
hSb.Append($"""<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0">{h}</th>""");
_headersData = hSb.ToString().ToUtf8Bytes();
var rSb = new System.Text.StringBuilder();
foreach (var row in rows)
{
rSb.Append("""<tr class="border-b border-border transition-colors hover:bg-muted/50">""");
foreach (var cell in row)
rSb.Append($"""<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0">{cell}</td>""");
rSb.Append("</tr>");
}
_rowsData = rSb.ToString().ToUtf8Bytes();
_footerData = string.IsNullOrEmpty(footer)
? []
: $"""<tfoot><tr class="border-t border-border font-medium"><td colspan="99" class="p-4">{footer}</td></tr></tfoot>""".ToUtf8Bytes();
}
protected override void RenderCaption(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_captionData);
protected override void RenderHeaders(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headersData);
protected override void RenderRows(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_rowsData);
protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData);
}
@@ -1,7 +0,0 @@
<div class="tabs-root w-full" id="tabs-$$Id$$">
<div class="inline-flex h-10 w-full items-center justify-start rounded-md bg-muted p-1 text-muted-foreground"
role="tablist">
$$TabsList$$
</div>
$$TabsPanels$$
</div>
@@ -1,54 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Tabs component. Tabs are activated client-side via components.js.
/// Pass a list of (Id, Label, Content) tuples.
/// </summary>
public sealed class Tabs : TabsBase
{
private readonly byte[] _idData;
private readonly byte[] _tabsListData;
private readonly byte[] _tabsPanelsData;
private const string TriggerBase =
"tabs-trigger inline-flex items-center justify-center whitespace-nowrap rounded-sm " +
"px-3 py-1.5 text-sm font-medium ring-offset-background transition-all " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " +
"focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
private const string PanelBase =
"tabs-panel mt-2 ring-offset-background " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
public Tabs(string id, IEnumerable<(string Id, string Label, string Content)> tabs)
{
_idData = id.ToUtf8Bytes();
var tabList = tabs.ToList();
var triggerSb = new System.Text.StringBuilder();
var panelSb = new System.Text.StringBuilder();
foreach (var (tabId, label, content) in tabList)
{
triggerSb.Append($"""
<button type="button" role="tab" aria-selected="false" aria-controls="tabpanel-{tabId}"
class="{TriggerBase}">
{label}
</button>
""");
panelSb.Append($"""
<div id="tabpanel-{tabId}" role="tabpanel" class="{PanelBase}">
{content}
</div>
""");
}
_tabsListData = triggerSb.ToString().ToUtf8Bytes();
_tabsPanelsData = panelSb.ToString().ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderTabsList(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tabsListData);
protected override void RenderTabsPanels(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tabsPanelsData);
}
@@ -1,15 +0,0 @@
<div class="flex flex-col gap-1.5">
$$Label$$
<textarea
id="$$Id$$"
name="$$Name$$"
rows="$$Rows$$"
placeholder="$$Placeholder$$"
class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
resize-y min-h-20 $$ExtraClasses$$"
$$HxAttrs$$>$$DefaultValue$$</textarea>
$$Description$$
</div>
@@ -1,55 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Textarea component with optional label and description.
/// </summary>
public sealed class Textarea : TextareaBase
{
private readonly byte[] _idData;
private readonly byte[] _nameData;
private readonly byte[] _rowsData;
private readonly byte[] _placeholderData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _defaultValueData;
private readonly byte[] _extraClassesData;
private readonly byte[] _hxAttrsData;
public Textarea(
string id,
string name = "",
string placeholder = "",
string label = "",
string description = "",
string defaultValue = "",
string extraClasses = "",
string hxAttrs = "",
int rows = 4)
{
_idData = id.ToUtf8Bytes();
_nameData = (string.IsNullOrEmpty(name) ? id : name).ToUtf8Bytes();
_rowsData = rows.ToString().ToUtf8Bytes();
_placeholderData = placeholder.ToUtf8Bytes();
_extraClassesData = extraClasses.ToUtf8Bytes();
_hxAttrsData = hxAttrs.ToUtf8Bytes();
_defaultValueData = defaultValue.ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<label for="{id}" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{label}</label>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderRows(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_rowsData);
protected override void RenderPlaceholder(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_placeholderData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData);
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
protected override void RenderHxAttrs(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hxAttrsData);
}
@@ -1,28 +0,0 @@
<div class="timepicker-root flex flex-col gap-1.5" data-use12h="$$Use12h$$" id="tp-$$UniqueId$$">
$$Label$$
<div class="flex items-center gap-1">
<!-- Hour -->
<input type="number" min="$$HourMin$$" max="$$HourMax$$" step="1"
class="timepicker-hour h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm
ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
focus-visible:ring-offset-2"
value="$$DefaultHour$$" />
<span class="text-sm font-bold text-foreground">:</span>
<!-- Minute -->
<input type="number" min="0" max="59" step="1"
class="timepicker-minute h-10 w-16 rounded-md border border-input bg-background px-2 text-center text-sm
ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
focus-visible:ring-offset-2"
value="$$DefaultMinute$$" />
<!-- AM/PM toggle (only rendered when use12h=true) -->
$$AmPmToggle$$
<!-- Hidden input that stores HH:MM value -->
<input type="hidden" name="$$Name$$" class="timepicker-hidden" value="$$DefaultValue$$" />
</div>
$$Description$$
</div>
@@ -1,89 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style TimePicker. Syncs hour+minute inputs to a hidden HH:MM field via inline JS.
/// </summary>
public sealed class TimePicker : TimePickerBase
{
private readonly byte[] _uniqueIdData;
private readonly byte[] _nameData;
private readonly byte[] _use12hData;
private readonly byte[] _labelData;
private readonly byte[] _descriptionData;
private readonly byte[] _defaultHourData;
private readonly byte[] _defaultMinuteData;
private readonly byte[] _defaultValueData;
private readonly byte[] _hourMinData;
private readonly byte[] _hourMaxData;
private readonly byte[] _amPmToggleData;
public TimePicker(
string name = "time",
TimeOnly? selected = null,
string label = "",
string description = "",
bool use12h = false)
{
var time = selected ?? TimeOnly.FromDateTime(DateTime.Now);
var uid = Guid.NewGuid().ToString("N")[..8]; // short unique suffix
_uniqueIdData = uid.ToUtf8Bytes();
_nameData = name.ToUtf8Bytes();
_use12hData = (use12h ? "true" : "false").ToUtf8Bytes();
_defaultValueData = time.ToString("HH:mm").ToUtf8Bytes();
_labelData = string.IsNullOrEmpty(label)
? []
: $"""<span class="text-sm font-medium leading-none">{label}</span>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<p class="text-xs text-muted-foreground">{description}</p>""".ToUtf8Bytes();
if (use12h)
{
int hour12 = time.Hour % 12;
if (hour12 == 0) hour12 = 12;
bool isPm = time.Hour >= 12;
_defaultHourData = hour12.ToString().ToUtf8Bytes();
_defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes();
_hourMinData = "1".ToUtf8Bytes();
_hourMaxData = "12".ToUtf8Bytes();
_amPmToggleData = BuildAmPmToggle(isPm);
}
else
{
_defaultHourData = time.Hour.ToString("D2").ToUtf8Bytes();
_defaultMinuteData = time.Minute.ToString("D2").ToUtf8Bytes();
_hourMinData = "0".ToUtf8Bytes();
_hourMaxData = "23".ToUtf8Bytes();
_amPmToggleData = [];
}
}
private static byte[] BuildAmPmToggle(bool isPm)
{
var amSel = isPm ? "" : " selected";
var pmSel = isPm ? " selected" : "";
return $"""
<select class="timepicker-ampm h-10 rounded-md border border-input bg-background px-2 text-sm
focus:outline-none focus:ring-2 focus:ring-ring">
<option value="AM"{amSel}>AM</option>
<option value="PM"{pmSel}>PM</option>
</select>
""".ToUtf8Bytes();
}
protected override void RenderUniqueId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_uniqueIdData);
protected override void RenderName(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_nameData);
protected override void RenderUse12h(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_use12hData);
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderDefaultHour(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultHourData);
protected override void RenderDefaultMinute(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultMinuteData);
protected override void RenderDefaultValue(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_defaultValueData);
protected override void RenderHourMin(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMinData);
protected override void RenderHourMax(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_hourMaxData);
protected override void RenderAmPmToggle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_amPmToggleData);
}
@@ -1,18 +0,0 @@
<div class="toast-item pointer-events-auto relative flex w-full items-center justify-between
space-x-4 overflow-hidden rounded-md border border-border bg-background p-4
shadow-lg transition-all $$ExtraClasses$$"
role="alert">
<div class="grid gap-1">
$$Title$$
$$Description$$
</div>
<button type="button"
class="toast-close inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md
text-muted-foreground hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
aria-label="Dismiss">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
@@ -1,34 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// shadcn-style Toast notification. Typically created dynamically via window.showToast(),
/// but can also be server-rendered and injected via htmx.
/// </summary>
public sealed class Toast : ToastBase
{
private static readonly Dictionary<string, string> VariantClasses = new()
{
["default"] = "",
["destructive"] = "border-destructive text-destructive",
};
private readonly byte[] _titleData;
private readonly byte[] _descriptionData;
private readonly byte[] _extraClassesData;
public Toast(
string title,
string description = "",
string variant = "default")
{
_titleData = $"""<div class="text-sm font-semibold">{title}</div>""".ToUtf8Bytes();
_descriptionData = string.IsNullOrEmpty(description)
? []
: $"""<div class="text-sm opacity-90">{description}</div>""".ToUtf8Bytes();
_extraClassesData = VariantClasses.GetValueOrDefault(variant, "").ToUtf8Bytes();
}
protected override void RenderTitle(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_titleData);
protected override void RenderDescription(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_descriptionData);
protected override void RenderExtraClasses(HtmxRenderContext ctx)=> ctx.Writer.WriteUtf8(_extraClassesData);
}
@@ -1,4 +0,0 @@
<div class="fixed bottom-4 right-4 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:flex-col md:max-w-[420px] pointer-events-none toast-viewport"
id="$$Id$$">
$$Toasts$$
</div>
@@ -1,20 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// Fixed viewport container for toast notifications.
/// Place once in the page (or layout). Toasts appear via window.showToast() from components.js.
/// </summary>
public sealed class ToastViewport : ToastViewportBase
{
private readonly byte[] _idData;
private readonly byte[] _toastsData;
public ToastViewport(string id = "toast-viewport")
{
_idData = id.ToUtf8Bytes();
_toastsData = [];
}
protected override void RenderId(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_idData);
protected override void RenderToasts(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_toastsData);
}
@@ -1,9 +0,0 @@
<span class="group relative inline-flex items-center">
$$Trigger$$
<span class="pointer-events-none absolute $$Position$$ z-50 w-max max-w-xs
rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground
shadow-md border border-border whitespace-nowrap
opacity-0 group-hover:opacity-100 transition-opacity duration-150">
$$Text$$
</span>
</span>
@@ -1,36 +0,0 @@
namespace Htmx.ApiDemo.Templates.Components;
/// <summary>
/// CSS-only Tooltip using group-hover. Wraps a trigger element.
/// Position: "top" | "bottom" | "left" | "right" (default: top)
/// </summary>
public sealed class Tooltip : TooltipBase
{
private static readonly Dictionary<string, string> PositionClasses = new()
{
["top"] = "bottom-full left-1/2 -translate-x-1/2 mb-2",
["bottom"] = "top-full left-1/2 -translate-x-1/2 mt-2",
["left"] = "right-full top-1/2 -translate-y-1/2 mr-2",
["right"] = "left-full top-1/2 -translate-y-1/2 ml-2",
};
private readonly byte[] _triggerData;
private readonly byte[] _textData;
private readonly byte[] _positionData;
public Tooltip(string text, IHtmxComponent trigger, string position = "top")
{
_textData = text.ToUtf8Bytes();
_positionData = PositionClasses.GetValueOrDefault(position, PositionClasses["top"]).ToUtf8Bytes();
var bufferWriter = new System.IO.Pipelines.Pipe().Writer;
// Render trigger to bytes via a simple ArrayBufferWriter
var writer = new System.Buffers.ArrayBufferWriter<byte>();
trigger.Render(new HtmxRenderContext(writer));
_triggerData = writer.WrittenSpan.ToArray();
}
protected override void RenderTrigger(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_triggerData);
protected override void RenderText(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_textData);
protected override void RenderPosition(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_positionData);
}
-8
View File
@@ -1,8 +0,0 @@
<div id="Greeting-$$GreetingId$$" class="greeting">
<h1>Hello, $$User$$!</h1>
<p class="pb-2">Welcome to high-performance htmx rendering.</p>
$$Separator$$
<div class="m-3">
$$CountButton$$
</div>
</div>
-30
View File
@@ -1,30 +0,0 @@
using Htmx.ApiDemo.Templates.Components;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
public sealed class Greeting : GreetingBase
{
public required int Count { get; init; }
public required string Username { get; init; }
public required Guid GreetingId { get; init; }
protected override void RenderCountButton(HtmxRenderContext context)
{
var button = new Button(
$"Click to increase count {Count}",
"outline",
hxAttrs: $"hx-get=\"/greet/{Username}/{Count}/{GreetingId}\"" +
$" hx-target=\"#Greeting-{GreetingId}\" hx-swap=\"outerHTML\""
);
button.Render(context);
}
protected override void RenderUser(HtmxRenderContext context)
=> context.Writer.WriteUtf8(Username.ToUtf8Bytes());
protected override void RenderGreetingId(HtmxRenderContext context)
=> context.Writer.WriteUtf8(GreetingId.ToString().ToUtf8Bytes());
protected override void RenderSeparator(HtmxRenderContext context)
=> new Separator().Render(context);
}
@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Htmx.ApiDemo.Templates;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
public static void MapGetIndex(WebApplication app)
=> app.MapGet("/", (IHttpContextAccessor contextAccessor) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var isAuthenticate = context.User.Identity?.IsAuthenticated ?? false;
var JohnDoe = "John Doe";
var claimedName = context.User.Claims.FirstOrDefault(c => c.Type == "DisplayName")?.Value ?? JohnDoe;
string name =
isAuthenticate && claimedName is not null ? claimedName : JohnDoe;
var greet = new Greeting { Username = name, Count = 0, GreetingId = Guid.NewGuid() };
greet.HtmxAwareWriteToBody(
context: context,
title: "Home",
appName: "HtmxApp",
pageTitle: "Home"
);
});
private static void MapGetGreet(WebApplication app)
=> app.MapGet("/greet/{name}/{count}/{greetid}",
(
[FromRoute] string name,
[FromRoute] int count,
[FromRoute] string greetid,
[FromServices] IHttpContextAccessor contextAccessor
) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var id = Guid.TryParse(greetid, out var parsedId) ? parsedId : Guid.NewGuid();
var greet = new Greeting { Username = name, Count = ++count, GreetingId = id };
greet.HtmxAwareWriteToBody(
context: context,
title: "Greet",
appName: "HtmxApp",
pageTitle: "Greet"
);
});
}
-45
View File
@@ -1,45 +0,0 @@
<div class="flex min-h-full items-center justify-center py-12">
<div class="w-full max-w-sm space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold tracking-tight text-foreground">Sign in</h1>
<p class="mt-1 text-sm text-muted-foreground">Enter your credentials to access your account</p>
</div>
$$ErrorMessage$$
<form method="post" action="/login" class="space-y-4">
$$AntiforgeryToken$$
<div class="space-y-2">
<label class="text-sm font-medium leading-none text-foreground" for="login-email">Email</label>
<input id="login-email" name="email" type="email" required autocomplete="email"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1
focus-visible:ring-ring"
placeholder="you@example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium leading-none text-foreground" for="login-password">Password</label>
<input id="login-password" name="password" type="password" required autocomplete="current-password"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm
placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1
focus-visible:ring-ring"
placeholder="••••••••" />
</div>
<button type="submit"
class="inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 py-2
text-sm font-medium text-primary-foreground shadow transition-colors
hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
Sign in
</button>
</form>
<p class="text-center text-sm text-muted-foreground">
Don't have an account?
<a href="/register" class="font-medium text-primary hover:underline">Sign up</a>
</p>
</div>
</div>
-26
View File
@@ -1,26 +0,0 @@
using Htmx.ApiDemo.Data;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo.Templates;
public sealed class Login : LoginBase
{
private readonly byte[] _errorData;
private readonly byte[] _afTokenData;
public Login(string? errorMessage = null, string? afToken = null)
{
_errorData = string.IsNullOrEmpty(errorMessage)
? []
: $"""<div class="rounded-md bg-destructive/15 px-4 py-3 text-sm text-destructive border border-destructive/30">{System.Web.HttpUtility.HtmlEncode(errorMessage)}</div>""".ToUtf8Bytes();
_afTokenData = string.IsNullOrEmpty(afToken)
? []
: $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(afToken)}" />""".ToUtf8Bytes();
}
protected override void RenderErrorMessage(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_errorData);
protected override void RenderAntiforgeryToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_afTokenData);
}
@@ -1,65 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Antiforgery;
using Htmx.ApiDemo.Templates;
using Htmx.ApiDemo.Data;
using Microsoft.AspNetCore.Mvc;
namespace Htmx.ApiDemo;
public static partial class RouteMap
{
private static void GetLogin(WebApplication app)
=> app.MapGet("/login", (IHttpContextAccessor contextAccessor, IAntiforgery antiforgery) =>
{
var context = contextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
if (context.User.Identity?.IsAuthenticated == true)
{
context.Response.Redirect("/");
return;
}
var afToken = antiforgery.GetAndStoreTokens(context).RequestToken;
var loginComponent = new Login(afToken: afToken);
loginComponent.HtmxAwareWriteToBody(
context: context,
title: "Login",
appName: "HtmxApp",
pageTitle: "Welcome back"
);
});
private static void PostLogin(WebApplication app)
=> app.MapPost("/login", async ValueTask
(
[FromForm] string email,
[FromForm] string password,
[FromServices] IHttpContextAccessor httpContextAccessor,
[FromServices] IAntiforgery antiforgery,
[FromServices] AppAuthService authService
) =>
{
var context = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HttpContext is not available.");
var afToken = antiforgery.GetAndStoreTokens(context).RequestToken;
var (success, error) = await authService.LoginAsync(email, password);
if (success)
{
context.Response.Redirect("/");
return;
}
var loginComponent = new Login(error, afToken: afToken);
loginComponent.HtmxAwareWriteToBody(
context: context,
title: "Login",
appName: "HtmxApp",
pageTitle: "Welcome back"
);
});
}
-116
View File
@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$$Title$$</title>
<link href="/css/output.css" rel="stylesheet">
</head>
<body class="bg-background text-foreground antialiased">
<!-- Overlay for mobile sidebar -->
<div id="sidebar-overlay"
class="fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-300"
_="on click remove .open from #sidebar
then add .opacity-0 to me
then add .pointer-events-none to me"></div>
<div id="layout-container" class="flex min-h-dvh">
<!-- ── Sidebar ── -->
<aside id="sidebar"
class="fixed inset-y-0 left-0 z-30 flex w-64 -translate-x-full flex-col border-r border-border bg-card shadow-lg
transition-transform duration-300 ease-in-out
[&.open]:translate-x-0
md:relative md:translate-x-0 md:shadow-none">
<!-- Sidebar header -->
<div class="flex h-16 items-center gap-3 border-b border-border px-5">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">A</span>
<span class="text-base font-semibold tracking-tight">$$AppName$$</span>
</div>
<!-- Nav items -->
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
<a href="/"
hx-get="/" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 9.75L12 3l9 6.75V21a.75.75 0 01-.75.75H15v-6H9v6H3.75A.75.75 0 013 21V9.75z"/>
</svg>
Home
</a>
<a href="/ui-demo"
hx-get="/ui-demo" hx-target="#main-view" hx-push-url="true"
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
UI Demo
</a>
</nav>
<!-- Sidebar footer -->
<div class="border-t border-border px-5 py-3 text-xs text-muted-foreground">
© 2026 $$AppName$$ - Enciphered
</div>
</aside>
<!-- ── /Sidebar ── -->
<!-- ── Main area ── -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top navbar -->
<header class="flex h-16 shrink-0 items-center gap-4 border-b border-border bg-card/80 px-4 backdrop-blur md:px-6">
<!-- Mobile hamburger -->
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
aria-label="Toggle sidebar"
_="on click toggle .open on #sidebar
then toggle .opacity-0 on #sidebar-overlay
then toggle .pointer-events-none on #sidebar-overlay">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<!-- Breadcrumb / title -->
<div class="flex-1 text-sm font-medium text-foreground">$$PageTitle$$</div>
<!-- Right-side actions -->
<div class="flex items-center gap-2">
<!-- Theme toggle -->
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
bg-transparent transition-colors hover:bg-accent hover:text-accent-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Toggle theme"
_="on click toggle .dark on <html/>">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10A5 5 0 0012 7z"/>
</svg>
</button>
$$UserSection$$
</div>
</header>
<!-- Page content -->
<main id="main-view" class="flex-1 overflow-y-auto p-6">
$$Body$$
</main>
</div>
<!-- ── /Main area ── -->
</div>
<script src="https://cdn.jsdelivr.net/npm/hyperscript.org@0.9.91/dist/_hyperscript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
<script src="/js/components.js"></script>
</body>
</html>

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