Compare commits
22 Commits
0787525134
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ad77f7ef6 | |||
| 3c46cb7c5b | |||
| 6c5c85ec4c | |||
| d7de80e7e0 | |||
| 9d88936b4a | |||
| 83fbb16b6b | |||
| fd5a187258 | |||
| 2b7ba3c96b | |||
| c1a00d34ab | |||
| 0afc51f576 | |||
| 61fd356846 | |||
| 360c9edcef | |||
| 42d6910ae7 | |||
| 58c929dd38 | |||
| 478d5f3c17 | |||
| bb35206fff | |||
| 4c98dd93ad | |||
| f6ea8a99d9 | |||
| f35908095c | |||
| 1e705053f5 | |||
| 110fc61fa2 | |||
| f42a5f05b2 |
+5
-5
@@ -1,5 +1,5 @@
|
||||
**/obj/
|
||||
**/bin/
|
||||
**/.git/
|
||||
**/.vs/
|
||||
**/node_modules/
|
||||
target
|
||||
.git
|
||||
.antigravitycli
|
||||
cookie.txt
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Production Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- refactored
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.nciphered.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.nciphered.com/shaamilahmed/htmx:latest
|
||||
git.nciphered.com/shaamilahmed/htmx:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy-droplet:
|
||||
name: Deploy to DigitalOcean Droplet
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ secrets.DEPLOY_TARGET == 'droplet' }}
|
||||
steps:
|
||||
- name: Executing remote SSH commands to deploy
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DROPLET_HOST }}
|
||||
username: ${{ secrets.DROPLET_USER }}
|
||||
key: ${{ secrets.DROPLET_SSH_KEY }}
|
||||
script: |
|
||||
# Authenticate with Gitea registry on Droplet
|
||||
docker login -u "${{ github.actor }}" -p "${{ secrets.REGISTRY_TOKEN }}" git.nciphered.com
|
||||
|
||||
# Pull latest image
|
||||
docker pull git.nciphered.com/shaamilahmed/htmx:latest
|
||||
|
||||
# Stop existing container
|
||||
docker rm -f ${{ secrets.APP_CONTAINER_NAME }} || true
|
||||
|
||||
# Run container with user-defined docker flags (e.g., --network, -p)
|
||||
docker run -d \
|
||||
--name ${{ secrets.APP_CONTAINER_NAME }} \
|
||||
${{ secrets.DOCKER_RUN_FLAGS }} \
|
||||
-e DATABASE_URL="${{ secrets.DATABASE_URL_DROPLET }}" \
|
||||
-e DATABASE_NAME="${{ secrets.DATABASE_NAME }}" \
|
||||
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
|
||||
-e HOST="0.0.0.0" \
|
||||
-e PORT="${{ secrets.APP_PORT }}" \
|
||||
--restart unless-stopped \
|
||||
git.nciphered.com/shaamilahmed/htmx:latest
|
||||
|
||||
|
||||
deploy-cloudrun:
|
||||
name: Deploy to Google Cloud Run
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ secrets.DEPLOY_TARGET == 'cloudrun' }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Authenticate with Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Configure Docker Authentication
|
||||
run: |
|
||||
gcloud auth configure-docker asia-southeast1-docker.pkg.dev --quiet
|
||||
|
||||
- name: Tag and Push Image to Artifact Registry
|
||||
run: |
|
||||
# Build/Tag for Google Artifact Registry
|
||||
docker tag git.nciphered.com/shaamilahmed/htmx:latest asia-southeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/stick/app:latest
|
||||
docker push asia-southeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/stick/app:latest
|
||||
|
||||
- name: Deploy to Google Cloud Run
|
||||
uses: google-github-actions/deploy-cloudrun@v2
|
||||
with:
|
||||
service: stick-app
|
||||
image: asia-southeast1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/stick/app:latest
|
||||
region: asia-southeast1
|
||||
env_vars: |
|
||||
DATABASE_URL=${{ secrets.DATABASE_URL_CLOUDRUN }}
|
||||
DATABASE_NAME=${{ secrets.DATABASE_NAME }}
|
||||
JWT_SECRET=${{ secrets.JWT_SECRET }}
|
||||
HOST=0.0.0.0
|
||||
PORT=3007
|
||||
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
.env
|
||||
**/Publish/
|
||||
target/
|
||||
node_modules/
|
||||
.antigravitycli/
|
||||
static/tailwind.css
|
||||
|
||||
Generated
+3549
File diff suppressed because it is too large
Load Diff
+28
@@ -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
@@ -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"]
|
||||
@@ -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; 6–30 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
|
||||
@@ -1,2 +0,0 @@
|
||||
# Never commit real credentials
|
||||
.env
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.)"
|
||||
@@ -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 "================================================================"
|
||||
@@ -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 "================================================================"
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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">‹</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">›</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">‹</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">›</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 <dialog> 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 <details>/<summary>.
|
||||
/// 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">‹</button>""".ToUtf8Bytes()
|
||||
: $"""<a href="{string.Format(urlPattern, current - 1)}" class="{BtnBase}" aria-label="Previous page">‹</a>""".ToUtf8Bytes();
|
||||
|
||||
_nextData = current >= total
|
||||
? $"""<button type="button" class="{BtnBase}" disabled aria-label="Next page">›</button>""".ToUtf8Bytes()
|
||||
: $"""<a href="{string.Format(urlPattern, current + 1)}" class="{BtnBase}" aria-label="Next page">›</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 0–100.
|
||||
/// 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user