Compare commits
32 Commits
3059c6cc77
...
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 | |||
| 0787525134 | |||
| f6ae86617c | |||
| e483bf73e7 | |||
| b12767e0fa | |||
| 588e18bbd2 | |||
| aede9a796e | |||
| 22577fe3fb | |||
| ec7ab8aadc | |||
| bd6d8d78fc | |||
| f8112f897e |
@@ -0,0 +1,5 @@
|
|||||||
|
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
-4
@@ -1,4 +1,4 @@
|
|||||||
bin
|
target/
|
||||||
obj
|
node_modules/
|
||||||
node_modules
|
.antigravitycli/
|
||||||
.env
|
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,98 +0,0 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stage 1 — npm install (Tailwind CLI)
|
|
||||||
# The .NET SDK stage needs node_modules present before running dotnet publish
|
|
||||||
# because the MSBuild Tailwind target calls `npx @tailwindcss/cli` during build.
|
|
||||||
#
|
|
||||||
# We use the official node:24-slim image here. This means the npm that ships
|
|
||||||
# with Node 24 (npm 10.x) is used as-is — we deliberately do NOT run
|
|
||||||
# `npm install -g npm@latest` anywhere. Running a global npm self-upgrade
|
|
||||||
# inside a Debian container is a known reliability hazard: npm replaces its
|
|
||||||
# own running binaries mid-flight, which can cause EBUSY / ENOENT failures
|
|
||||||
# that corrupt the install. The bundled npm is current enough.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
FROM node:24-slim AS npm-install
|
|
||||||
|
|
||||||
WORKDIR /npm
|
|
||||||
COPY Htmx.ApiDemo/package.json .
|
|
||||||
# npm ci requires package-lock.json; if it doesn't exist yet, run
|
|
||||||
# `npm install` locally first to generate it, then commit it to the repo.
|
|
||||||
COPY Htmx.ApiDemo/package-lock.json* ./
|
|
||||||
# ci is preferred over install in CI/Docker contexts: respects package-lock,
|
|
||||||
# clean installs, and is faster.
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stage 2 — AOT publish
|
|
||||||
# Uses the full .NET 10 SDK image. Node/npx must also be present here so the
|
|
||||||
# Tailwind MSBuild target can run. We install Node 24 from NodeSource using
|
|
||||||
# the official setup script and then immediately install nodejs via apt — no
|
|
||||||
# subsequent `npm install -g npm` step, for the same reason as above.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS publish
|
|
||||||
|
|
||||||
# Install Node.js 24 (required by the Tailwind MSBuild target at publish time).
|
|
||||||
# We download the NodeSource setup script to a file first so we can inspect it
|
|
||||||
# if needed, then run it. Using `| bash -` directly is convenient but hides
|
|
||||||
# the script from audit — the two-step form is safer in CI/CD contexts.
|
|
||||||
RUN apt-get update && \
|
|
||||||
apt-get install -y --no-install-recommends curl ca-certificates && \
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh && \
|
|
||||||
bash /tmp/nodesource_setup.sh && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm /tmp/nodesource_setup.sh && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
# Intentionally no `npm install -g npm` — see Stage 1 note above.
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Copy solution and project files first so NuGet restore is cached separately
|
|
||||||
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
|
|
||||||
|
|
||||||
# Bring in the pre-installed node_modules from Stage 1.
|
|
||||||
# These were installed with `npm ci` on Node 24 — no npm upgrade was performed.
|
|
||||||
COPY --from=npm-install /npm/node_modules Htmx.ApiDemo/node_modules
|
|
||||||
|
|
||||||
# Copy the rest of the source
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# AOT publish — output goes to /publish
|
|
||||||
RUN dotnet publish Htmx.ApiDemo/Htmx.ApiDemo.csproj \
|
|
||||||
-c Release \
|
|
||||||
--no-restore \
|
|
||||||
-o /publish
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Stage 3 — Runtime image
|
|
||||||
# runtime-deps provides the native library dependencies (libc, libssl, libicu)
|
|
||||||
# that the AOT binary links against at runtime — no .NET runtime needed.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
|
|
||||||
|
|
||||||
# Run as non-root for security hardening (recommended by Cloud Run docs)
|
|
||||||
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=publish /publish .
|
|
||||||
|
|
||||||
# Ensure the binary is executable
|
|
||||||
RUN chmod +x ./Htmx.ApiDemo
|
|
||||||
|
|
||||||
# Cloud Run injects PORT (default 8080).
|
|
||||||
# ASP.NET Core reads ASPNETCORE_HTTP_PORTS, not PORT directly, so we set it.
|
|
||||||
# The entrypoint script below maps $PORT → ASPNETCORE_HTTP_PORTS at startup.
|
|
||||||
COPY GCR/entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
# Transfer ownership so the app can write temp files if needed
|
|
||||||
RUN chown -R appuser:appgroup /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
-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 "$@"
|
|
||||||
-185
@@ -1,185 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
param(
|
|
||||||
[switch]$Yes
|
|
||||||
)
|
|
||||||
|
|
||||||
$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) {
|
|
||||||
$dockerOk = (Select-String -Path $dockerCfg -Pattern "\"$($Cfg['GCP_REGION'])-docker.pkg.dev\"" -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
|
|
||||||
}
|
|
||||||
|
|
||||||
$serviceAccount = "serviceAccount:$($Cfg['GCP_PROJECT_ID'])@appspot.gserviceaccount.com"
|
|
||||||
$binding = gcloud secrets get-iam-policy mongodb-connection-string `
|
|
||||||
--project=$Cfg['GCP_PROJECT_ID'] `
|
|
||||||
--flatten="bindings[].members" `
|
|
||||||
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" `
|
|
||||||
--format="value(bindings.members)" 2>$null
|
|
||||||
|
|
||||||
return ($binding -match [regex]::Escape($serviceAccount))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = $config['GCP_PROJECT_ID'] ?? ''
|
|
||||||
$GCP_REGION = $config['GCP_REGION'] ?? ''
|
|
||||||
|
|
||||||
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,126 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 02-setup-project.ps1 (Windows)
|
|
||||||
# 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.
|
|
||||||
# Linux users: run GCR/scripts/02-setup-project.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) {
|
|
||||||
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
|
|
||||||
if ($line -match '^([^=]+)=(.*)$') {
|
|
||||||
$config[$Matches[1].Trim()] = $Matches[2].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
|
|
||||||
$GCP_REGION = $config['GCP_REGION'] ?? ''
|
|
||||||
$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? ''
|
|
||||||
|
|
||||||
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 }
|
|
||||||
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 }
|
|
||||||
|
|
||||||
Write-Host ">>> Active project: $GCP_PROJECT_ID"
|
|
||||||
Write-Host ">>> Region: $GCP_REGION"
|
|
||||||
Write-Host ">>> AR repository: $GCP_REPOSITORY"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# ── Step 1: Link billing account ──────────────────────────────────────────────
|
|
||||||
Write-Host ">>> Checking billing status..."
|
|
||||||
$billingOutput = gcloud billing projects describe $GCP_PROJECT_ID --format="value(billingEnabled)" 2>$null
|
|
||||||
$billingEnabled = ($billingOutput -eq "True")
|
|
||||||
|
|
||||||
if ($billingEnabled) {
|
|
||||||
Write-Host " Billing is already enabled — skipping."
|
|
||||||
} else {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " Billing is NOT enabled on this project."
|
|
||||||
Write-Host " Listing available billing accounts..."
|
|
||||||
Write-Host ""
|
|
||||||
gcloud billing accounts list --format="table(name,displayName,open)"
|
|
||||||
Write-Host ""
|
|
||||||
$BILLING_ACCOUNT_ID = Read-Host " Enter the BILLING_ACCOUNT_ID from the list above (format: XXXXXX-XXXXXX-XXXXXX)"
|
|
||||||
gcloud billing projects link $GCP_PROJECT_ID --billing-account=$BILLING_ACCOUNT_ID
|
|
||||||
Write-Host " Billing linked."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 2: Enable required APIs ─────────────────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> 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
|
|
||||||
Write-Host " APIs enabled."
|
|
||||||
|
|
||||||
# ── Step 3: Create Artifact Registry Docker repository ───────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Creating Artifact Registry repository: $GCP_REPOSITORY ..."
|
|
||||||
$repoExists = $false
|
|
||||||
try {
|
|
||||||
gcloud artifacts repositories describe $GCP_REPOSITORY `
|
|
||||||
--location=$GCP_REGION `
|
|
||||||
--project=$GCP_PROJECT_ID 2>$null | Out-Null
|
|
||||||
$repoExists = $true
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
if ($repoExists) {
|
|
||||||
Write-Host " 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
|
|
||||||
Write-Host " Repository created."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 4: Grant current user the minimum required IAM roles ─────────────────
|
|
||||||
$CURRENT_USER = (gcloud config get-value account).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 " Adding role: $role"
|
|
||||||
gcloud projects add-iam-policy-binding $GCP_PROJECT_ID `
|
|
||||||
--member="user:$CURRENT_USER" `
|
|
||||||
--role=$role `
|
|
||||||
--quiet
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Project setup complete."
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Summary:"
|
|
||||||
Write-Host " Project ID: $GCP_PROJECT_ID"
|
|
||||||
Write-Host " Region: $GCP_REGION"
|
|
||||||
Write-Host " Artifact Registry: $GCP_REGION-docker.pkg.dev/$GCP_PROJECT_ID/$GCP_REPOSITORY"
|
|
||||||
Write-Host ""
|
|
||||||
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,122 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 03-create-secrets.ps1 (Windows)
|
|
||||||
# Creates and configures secrets in Google Cloud Secret Manager.
|
|
||||||
#
|
|
||||||
# Run this after 02-setup-project.ps1 to set up sensitive configuration
|
|
||||||
# values (e.g., MongoDB connection string).
|
|
||||||
#
|
|
||||||
# Linux users: run GCR/scripts/03-create-secrets.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) {
|
|
||||||
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
|
|
||||||
if ($line -match '^([^=]+)=(.*)$') {
|
|
||||||
$config[$Matches[1].Trim()] = $Matches[2].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
|
|
||||||
if (-not $GCP_PROJECT_ID) { Write-Error "GCP_PROJECT_ID is not set in .env"; exit 1 }
|
|
||||||
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host " Google Cloud Secret Manager setup"
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host " Project: $GCP_PROJECT_ID"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# ── Helper function to create or update a secret ──────────────────────────────
|
|
||||||
function New-OrUpdateSecret {
|
|
||||||
param(
|
|
||||||
[string]$SecretName,
|
|
||||||
[string]$SecretPrompt
|
|
||||||
)
|
|
||||||
|
|
||||||
Write-Host ">>> Setting up secret: $SecretName"
|
|
||||||
Write-Host " $SecretPrompt"
|
|
||||||
|
|
||||||
# Read secret without echo
|
|
||||||
$SecretValue = Read-Host " Enter value (will not be echoed)" -AsSecureString
|
|
||||||
$PlainValue = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
||||||
[System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUni($SecretValue)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Write to temp file without trailing newline to avoid contaminating the secret
|
|
||||||
$TempFile = [System.IO.Path]::GetTempFileName()
|
|
||||||
try {
|
|
||||||
[System.IO.File]::WriteAllText($TempFile, $PlainValue, [System.Text.Encoding]::UTF8)
|
|
||||||
|
|
||||||
$secretExists = $false
|
|
||||||
try {
|
|
||||||
gcloud secrets describe $SecretName --project=$GCP_PROJECT_ID 2>$null | Out-Null
|
|
||||||
$secretExists = $true
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
if ($secretExists) {
|
|
||||||
Write-Host " Secret already exists — creating new version..."
|
|
||||||
gcloud secrets versions add $SecretName `
|
|
||||||
--data-file=$TempFile `
|
|
||||||
--project=$GCP_PROJECT_ID
|
|
||||||
} else {
|
|
||||||
Write-Host " Creating new secret..."
|
|
||||||
gcloud secrets create $SecretName `
|
|
||||||
--data-file=$TempFile `
|
|
||||||
--replication-policy="automatic" `
|
|
||||||
--project=$GCP_PROJECT_ID
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host " ✓ Secret '$SecretName' ready."
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 1: Create MongoDB connection string secret ──────────────────────────
|
|
||||||
New-OrUpdateSecret `
|
|
||||||
"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 ─────────────────
|
|
||||||
Write-Host ">>> Granting Cloud Run service account access to secrets..."
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Get the default Cloud Run service account for this project
|
|
||||||
$SERVICE_ACCOUNT = "$GCP_PROJECT_ID@appspot.gserviceaccount.com"
|
|
||||||
|
|
||||||
foreach ($SECRET_NAME in @("mongodb-connection-string")) {
|
|
||||||
Write-Host " 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
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host " Secret Manager setup complete!"
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Summary:"
|
|
||||||
Write-Host " Secrets created:"
|
|
||||||
Write-Host " • mongodb-connection-string"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " Service account granted access:"
|
|
||||||
Write-Host " • $SERVICE_ACCOUNT"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Next step: run GCR\scripts\04-deploy.ps1"
|
|
||||||
Write-Host " (The deploy script will automatically inject secrets into"
|
|
||||||
Write-Host " the running container.)"
|
|
||||||
@@ -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,194 +0,0 @@
|
|||||||
# =============================================================================
|
|
||||||
# 04-deploy.ps1 (Windows)
|
|
||||||
# Builds the Docker image, pushes it to Artifact Registry, and deploys it
|
|
||||||
# to Cloud Run — all in one command.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# .\GCR\scripts\04-deploy.ps1 # deploy with tag = git short SHA
|
|
||||||
# .\GCR\scripts\04-deploy.ps1 -Tag my-tag # deploy with a custom tag
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# 1. GCR\.env exists and is filled in (copy from GCR\.env.example)
|
|
||||||
# 2. 01-login.ps1 has been run (gcloud auth + Docker configured)
|
|
||||||
# 3. 02-setup-project.ps1 has been run (APIs enabled, repo created)
|
|
||||||
# 4. 03-create-secrets.ps1 has been run (MongoDB secret created)
|
|
||||||
# 5. Docker Desktop is running
|
|
||||||
#
|
|
||||||
# Linux users: run GCR/scripts/04-deploy.sh instead.
|
|
||||||
# =============================================================================
|
|
||||||
#Requires -Version 5.1
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[string]$Tag = ""
|
|
||||||
)
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
$RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).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) {
|
|
||||||
if ($line -match '^\s*$' -or $line -match '^\s*#') { continue }
|
|
||||||
if ($line -match '^([^=]+)=(.*)$') {
|
|
||||||
$config[$Matches[1].Trim()] = $Matches[2].Trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$GCP_PROJECT_ID = $config['GCP_PROJECT_ID'] ?? ''
|
|
||||||
$GCP_REGION = $config['GCP_REGION'] ?? ''
|
|
||||||
$GCP_REPOSITORY = $config['GCP_REPOSITORY'] ?? ''
|
|
||||||
$SERVICE_NAME = $config['SERVICE_NAME'] ?? ''
|
|
||||||
$MONGODB_DATABASE_NAME = $config['MONGODB_DATABASE_NAME'] ?? 'HtmxAppDb'
|
|
||||||
|
|
||||||
# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager (mongodb-connection-string)
|
|
||||||
|
|
||||||
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 }
|
|
||||||
if (-not $GCP_REPOSITORY) { Write-Error "GCP_REPOSITORY is not set in .env"; exit 1 }
|
|
||||||
if (-not $SERVICE_NAME) { Write-Error "SERVICE_NAME is not set in .env"; exit 1 }
|
|
||||||
|
|
||||||
# Note: MONGODB_CONNECTION_STRING is stored in Secret Manager
|
|
||||||
|
|
||||||
function Test-SecretsReady {
|
|
||||||
$serviceAccount = "serviceAccount:$GCP_PROJECT_ID@appspot.gserviceaccount.com"
|
|
||||||
|
|
||||||
try {
|
|
||||||
gcloud secrets describe mongodb-connection-string --project=$GCP_PROJECT_ID 2>$null | Out-Null
|
|
||||||
} catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
|
|
||||||
$binding = gcloud secrets get-iam-policy mongodb-connection-string `
|
|
||||||
--project=$GCP_PROJECT_ID `
|
|
||||||
--flatten="bindings[].members" `
|
|
||||||
--filter="bindings.role=roles/secretmanager.secretAccessor AND bindings.members=$serviceAccount" `
|
|
||||||
--format="value(bindings.members)" 2>$null
|
|
||||||
|
|
||||||
return ($binding -match [regex]::Escape($serviceAccount))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-SecretsReady)) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Required secrets are not fully configured yet."
|
|
||||||
$runSecretSetup = Read-Host " Run GCR\scripts\03-create-secrets.ps1 now? [y/N]"
|
|
||||||
if ($runSecretSetup -match '^[Yy]$') {
|
|
||||||
& (Join-Path $ScriptDir "03-create-secrets.ps1")
|
|
||||||
} else {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Error "Deployment requires secret setup first. Run: .\GCR\scripts\03-create-secrets.ps1"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-SecretsReady)) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Error "Secret setup check still failing after running 03-create-secrets.ps1."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Determine image tag ────────────────────────────────────────────────────────
|
|
||||||
if (-not $Tag) {
|
|
||||||
# Default to git short SHA if inside a git repo; otherwise use timestamp
|
|
||||||
try {
|
|
||||||
$Tag = (git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim()
|
|
||||||
} catch { }
|
|
||||||
if (-not $Tag) {
|
|
||||||
$Tag = (Get-Date -Format "yyyyMMddHHmmss")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$REGISTRY = "$GCP_REGION-docker.pkg.dev"
|
|
||||||
$IMAGE_URI = "$REGISTRY/$GCP_PROJECT_ID/$GCP_REPOSITORY/${SERVICE_NAME}:$Tag"
|
|
||||||
|
|
||||||
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_URI"
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# ── Step 1: Ensure package-lock.json exists (required for `npm ci`) ───────────
|
|
||||||
$LockFile = Join-Path $RepoRoot "Htmx.ApiDemo\package-lock.json"
|
|
||||||
if (-not (Test-Path $LockFile)) {
|
|
||||||
Write-Host ">>> package-lock.json not found. Generating it now..."
|
|
||||||
Write-Host " (This requires node + npm to be installed locally)"
|
|
||||||
Push-Location (Join-Path $RepoRoot "Htmx.ApiDemo")
|
|
||||||
npm install --package-lock-only
|
|
||||||
Pop-Location
|
|
||||||
Write-Host " package-lock.json generated. Commit it to the repository."
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 2: Build the Docker image ────────────────────────────────────────────
|
|
||||||
Write-Host ">>> Building Docker image..."
|
|
||||||
Write-Host " Context: $RepoRoot"
|
|
||||||
Write-Host " Dockerfile: GCR\Dockerfile"
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Build from repo root so COPY instructions can reach both project directories.
|
|
||||||
# Docker on Windows accepts forward slashes in --file.
|
|
||||||
$DockerFile = Join-Path $RepoRoot "GCR\Dockerfile"
|
|
||||||
docker build --file $DockerFile --tag $IMAGE_URI $RepoRoot
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Image built: $IMAGE_URI"
|
|
||||||
|
|
||||||
# ── Step 3: Push image to Artifact Registry ───────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Pushing image to Artifact Registry..."
|
|
||||||
docker push $IMAGE_URI
|
|
||||||
Write-Host ">>> Push complete."
|
|
||||||
|
|
||||||
# ── Step 4: Deploy to Cloud Run via docker-compose.yml ───────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> Deploying to Cloud Run..."
|
|
||||||
|
|
||||||
# Set env vars consumed by docker-compose.yml variable substitution
|
|
||||||
$env:IMAGE_URI = $IMAGE_URI
|
|
||||||
$env:MONGODB_DATABASE_NAME = $MONGODB_DATABASE_NAME
|
|
||||||
|
|
||||||
$ComposeFile = Join-Path $RepoRoot "GCR\docker-compose.yml"
|
|
||||||
gcloud run services replace $ComposeFile `
|
|
||||||
--region=$GCP_REGION `
|
|
||||||
--project=$GCP_PROJECT_ID
|
|
||||||
|
|
||||||
# ── Step 4b: Inject MongoDB connection string from Secret Manager ────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> 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.
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ">>> 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 ─────────────────────────────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
$SERVICE_URL = (gcloud run services describe $SERVICE_NAME `
|
|
||||||
--region=$GCP_REGION `
|
|
||||||
--project=$GCP_PROJECT_ID `
|
|
||||||
--format="value(status.url)").Trim()
|
|
||||||
|
|
||||||
Write-Host "================================================================"
|
|
||||||
Write-Host " Deployment complete!"
|
|
||||||
Write-Host " 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,13 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using Htmx.ApiDemo.Templates;
|
|
||||||
|
|
||||||
namespace Htmx.ApiDemo;
|
|
||||||
|
|
||||||
[JsonSerializable(typeof(string))]
|
|
||||||
[JsonSerializable(typeof(PostLoginHandler.Command), TypeInfoPropertyName = "LoginCommand")]
|
|
||||||
[JsonSerializable(typeof(PostRegisterHandler.Command), TypeInfoPropertyName = "RegisterCommand")]
|
|
||||||
[JsonSerializable(typeof(PostLogoutHandler.Command), TypeInfoPropertyName = "LogoutCommand")]
|
|
||||||
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 AuthService(
|
|
||||||
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,46 +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>
|
|
||||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
|
||||||
<PublishAot>true</PublishAot>
|
|
||||||
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Immediate.Apis.Generators</InterceptorsPreviewNamespaces>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<CompilerVisibleProperty Include="RootNamespace" />
|
|
||||||
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Update="wwwroot\**" CopyToPublishDirectory="Always" />
|
|
||||||
<Content Remove="wwwroot\css\output.css" />
|
|
||||||
<AdditionalFiles Include="**/*.htmx" />
|
|
||||||
<None Remove="**/*.htmx" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Immediate.Apis" Version="4.2.0" />
|
|
||||||
<PackageReference Include="Immediate.Handlers" Version="3.5.0" />
|
|
||||||
<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,11 +0,0 @@
|
|||||||
@Htmx.ApiDemo_HostAddress = http://localhost:5120
|
|
||||||
|
|
||||||
GET {{Htmx.ApiDemo_HostAddress}}/todos/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
|
|
||||||
GET {{Htmx.ApiDemo_HostAddress}}/todos/1
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
@@ -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 = {B66FEAA2-59A2-4489-9AEB-ED875EEE5D3E}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Antiforgery;
|
|
||||||
|
|
||||||
namespace Htmx.ApiDemo;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders a full page or just the body component depending on whether
|
|
||||||
/// the request was made by HTMX (HX-Request header present).
|
|
||||||
///
|
|
||||||
/// Full request → wraps body in MainLayout (complete HTML page)
|
|
||||||
/// HTMX request → renders body only + sets HX-Title so the browser
|
|
||||||
/// tab title still updates
|
|
||||||
/// </summary>
|
|
||||||
public static class HtmxPageExtensions
|
|
||||||
{
|
|
||||||
public static void WriteHtmxPage(
|
|
||||||
this HttpContext ctx,
|
|
||||||
IHtmxComponent body,
|
|
||||||
string title = "App",
|
|
||||||
string appName = "HtmxApp",
|
|
||||||
string pageTitle = "")
|
|
||||||
{
|
|
||||||
if (ctx.Request.Headers.ContainsKey("HX-Request"))
|
|
||||||
{
|
|
||||||
// Partial swap: tell HTMX to update the browser <title> tag
|
|
||||||
ctx.Response.Headers["HX-Title"] = title;
|
|
||||||
ctx.WriteHtmxBody(body);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Resolve display name: prefer DisplayName claim, fall back to email/name
|
|
||||||
string? userName = ctx.User.Identity?.IsAuthenticated == true
|
|
||||||
? (ctx.User.FindFirst("DisplayName")?.Value
|
|
||||||
?? ctx.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Resolve antiforgery token for the logout form in the layout
|
|
||||||
var antiforgery = ctx.RequestServices.GetRequiredService<IAntiforgery>();
|
|
||||||
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
|
||||||
|
|
||||||
// Full page load: wrap in the shell layout
|
|
||||||
ctx.WriteHtmxBody(new Templates.MainLayout(body, title, appName, pageTitle, userName, afTokens.RequestToken));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using Htmx.ApiDemo;
|
|
||||||
using Htmx.ApiDemo.Data;
|
|
||||||
using Immediate.Apis;
|
|
||||||
using Immediate.Apis.Shared;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using MongoDB.Bson;
|
|
||||||
using MongoDB.Bson.Serialization;
|
|
||||||
using MongoDB.Bson.Serialization.Serializers;
|
|
||||||
using MongoDB.Driver;
|
|
||||||
|
|
||||||
// ── Explicit BsonClassMap — no AutoMap() reflection, fully AOT-safe ───────
|
|
||||||
BsonClassMap.RegisterClassMap<AppUser>(cm =>
|
|
||||||
{
|
|
||||||
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<AuthService>();
|
|
||||||
|
|
||||||
// ── App services ──────────────────────────────────────────────────────────
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
|
||||||
builder.Services
|
|
||||||
.AddHtmxApiDemoBehaviors()
|
|
||||||
.AddHtmxApiDemoHandlers();
|
|
||||||
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();
|
|
||||||
|
|
||||||
// ── Guard: redirect unauthenticated users to /login ───────────────────────
|
|
||||||
app.Use(async (context, next) =>
|
|
||||||
{
|
|
||||||
var path = context.Request.Path.Value ?? "";
|
|
||||||
bool isPublic = path.StartsWith("/login", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| path.StartsWith("/register", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| path.StartsWith("/logout", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| path.StartsWith("/css/", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| path.StartsWith("/js/", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (!isPublic && context.User.Identity?.IsAuthenticated != true)
|
|
||||||
{
|
|
||||||
context.Response.Redirect("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapHtmxApiDemoEndpoints();
|
|
||||||
|
|
||||||
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,6 +0,0 @@
|
|||||||
<div id="Greeting-$$GreetingId$$" class="greeting">
|
|
||||||
<h1>Hello, $$User$$!</h1>
|
|
||||||
<p>Welcome to high-performance htmx rendering.</p>
|
|
||||||
|
|
||||||
<button hx-get="/greet/$$User$$/$$Count$$/$$GreetingId$$" hx-target="#Greeting-$$GreetingId$$" hx-swap="outerHTML">Click to increase count $$Count$$</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using Immediate.Apis.Shared;
|
|
||||||
using Immediate.Handlers.Shared;
|
|
||||||
|
|
||||||
namespace Htmx.ApiDemo.Templates;
|
|
||||||
|
|
||||||
public sealed class Greeting : GreetingBase
|
|
||||||
{
|
|
||||||
private byte[] _userData = [];
|
|
||||||
private byte[] _countData = [];
|
|
||||||
private byte[] _greetingIdData = [];
|
|
||||||
public required string Username { init => _userData = value.ToUtf8Bytes(); }
|
|
||||||
public required int Count { init => _countData = $"{value}".ToUtf8Bytes(); }
|
|
||||||
public required Guid GreetingId { init => _greetingIdData = $"{value}".ToUtf8Bytes(); }
|
|
||||||
|
|
||||||
protected override void RenderCount(HtmxRenderContext context) => context.Writer.WriteUtf8(_countData);
|
|
||||||
protected override void RenderGreetingId(HtmxRenderContext context) => context.Writer.WriteUtf8(_greetingIdData);
|
|
||||||
protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Handler]
|
|
||||||
[MapGet("/greet/{username}/{count?}/{id?}")]
|
|
||||||
public static partial class GetGreetingHandler
|
|
||||||
{
|
|
||||||
public record Query(string Username, int? Count, Guid? Id);
|
|
||||||
|
|
||||||
private static ValueTask HandleAsync(
|
|
||||||
Query query,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var context = httpContextAccessor.HttpContext
|
|
||||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
|
||||||
|
|
||||||
var template = new Greeting { Username = query.Username, Count = query.Count + 1 ?? 0, GreetingId = query.Id ?? Guid.NewGuid() };
|
|
||||||
context.WriteHtmxBody(template);
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,88 +0,0 @@
|
|||||||
using Htmx.ApiDemo.Data;
|
|
||||||
using Immediate.Apis.Shared;
|
|
||||||
using Immediate.Handlers.Shared;
|
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[Handler]
|
|
||||||
[MapGet("/login")]
|
|
||||||
public static partial class GetLoginHandler
|
|
||||||
{
|
|
||||||
public record Query;
|
|
||||||
|
|
||||||
private static ValueTask HandleAsync(
|
|
||||||
Query _,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var ctx = httpContextAccessor.HttpContext
|
|
||||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
|
||||||
|
|
||||||
if (ctx.User.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
ctx.Response.Redirect("/");
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
|
||||||
ctx.WriteHtmxPage(new Login(afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in");
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[Handler]
|
|
||||||
[MapPost("/login")]
|
|
||||||
public static partial class PostLoginHandler
|
|
||||||
{
|
|
||||||
public record Command(
|
|
||||||
[property: FromForm] string Email,
|
|
||||||
[property: FromForm] string Password
|
|
||||||
);
|
|
||||||
|
|
||||||
private static async ValueTask HandleAsync(
|
|
||||||
[AsParameters] Command command,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
AuthService authService,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
var ctx = httpContextAccessor.HttpContext
|
|
||||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
|
||||||
|
|
||||||
var (success, error) = await authService.LoginAsync(command.Email, command.Password);
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
{
|
|
||||||
ctx.Response.Redirect("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var afTokens = antiforgery.GetAndStoreTokens(ctx);
|
|
||||||
ctx.WriteHtmxPage(new Login(error, afToken: afTokens.RequestToken), title: "Sign in", appName: "HtmxApp", pageTitle: "Sign in");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using Htmx.ApiDemo.Data;
|
|
||||||
using Immediate.Apis.Shared;
|
|
||||||
using Immediate.Handlers.Shared;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Htmx.ApiDemo.Templates;
|
|
||||||
|
|
||||||
[Handler]
|
|
||||||
[MapPost("/logout")]
|
|
||||||
public static partial class PostLogoutHandler
|
|
||||||
{
|
|
||||||
// Empty command — [AsParameters] ensures form content-type is accepted
|
|
||||||
// and antiforgery token in the form is validated by the middleware.
|
|
||||||
public record Command;
|
|
||||||
|
|
||||||
private static async ValueTask HandleAsync(
|
|
||||||
[AsParameters] Command _,
|
|
||||||
AuthService authService,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
|
||||||
await authService.SignOutAsync();
|
|
||||||
|
|
||||||
var ctx = httpContextAccessor.HttpContext
|
|
||||||
?? throw new InvalidOperationException("HttpContext is not available.");
|
|
||||||
ctx.Response.Redirect("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" class="dark">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>$$Title$$</title>
|
|
||||||
<link href="/css/output.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-background text-foreground antialiased">
|
|
||||||
|
|
||||||
<!-- Overlay for mobile sidebar -->
|
|
||||||
<div id="sidebar-overlay"
|
|
||||||
class="fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-300"
|
|
||||||
_="on click remove .open from #sidebar
|
|
||||||
then add .opacity-0 to me
|
|
||||||
then add .pointer-events-none to me"></div>
|
|
||||||
|
|
||||||
<div id="layout-container" class="flex min-h-dvh">
|
|
||||||
|
|
||||||
<!-- ── Sidebar ── -->
|
|
||||||
<aside id="sidebar"
|
|
||||||
class="fixed inset-y-0 left-0 z-30 flex w-64 -translate-x-full flex-col border-r border-border bg-card shadow-lg
|
|
||||||
transition-transform duration-300 ease-in-out
|
|
||||||
[&.open]:translate-x-0
|
|
||||||
md:relative md:translate-x-0 md:shadow-none">
|
|
||||||
|
|
||||||
<!-- Sidebar header -->
|
|
||||||
<div class="flex h-16 items-center gap-3 border-b border-border px-5">
|
|
||||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">A</span>
|
|
||||||
<span class="text-base font-semibold tracking-tight">$$AppName$$</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav items -->
|
|
||||||
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
|
||||||
<a href="/"
|
|
||||||
hx-get="/" hx-target="#main-view" hx-push-url="true"
|
|
||||||
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
|
|
||||||
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
|
|
||||||
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 9.75L12 3l9 6.75V21a.75.75 0 01-.75.75H15v-6H9v6H3.75A.75.75 0 013 21V9.75z"/>
|
|
||||||
</svg>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="/ui-demo"
|
|
||||||
hx-get="/ui-demo" hx-target="#main-view" hx-push-url="true"
|
|
||||||
class="sidebar-link group flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
|
|
||||||
text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground">
|
|
||||||
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/>
|
|
||||||
</svg>
|
|
||||||
UI Demo
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Sidebar footer -->
|
|
||||||
<div class="border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
|
||||||
© 2026 $$AppName$$
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<!-- ── /Sidebar ── -->
|
|
||||||
|
|
||||||
<!-- ── Main area ── -->
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
|
||||||
|
|
||||||
<!-- Top navbar -->
|
|
||||||
<header class="flex h-16 shrink-0 items-center gap-4 border-b border-border bg-card/80 px-4 backdrop-blur md:px-6">
|
|
||||||
|
|
||||||
<!-- Mobile hamburger -->
|
|
||||||
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
|
|
||||||
bg-transparent text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground
|
|
||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
|
|
||||||
aria-label="Toggle sidebar"
|
|
||||||
_="on click toggle .open on #sidebar
|
|
||||||
then toggle .opacity-0 on #sidebar-overlay
|
|
||||||
then toggle .pointer-events-none on #sidebar-overlay">
|
|
||||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Breadcrumb / title -->
|
|
||||||
<div class="flex-1 text-sm font-medium text-foreground">$$PageTitle$$</div>
|
|
||||||
|
|
||||||
<!-- Right-side actions -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Theme toggle -->
|
|
||||||
<button class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-input
|
|
||||||
bg-transparent transition-colors hover:bg-accent hover:text-accent-foreground
|
|
||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
_="on click toggle .dark on <html/>">
|
|
||||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10A5 5 0 0012 7z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
$$UserSection$$
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Page content -->
|
|
||||||
<main id="main-view" class="flex-1 overflow-y-auto p-6">
|
|
||||||
$$Body$$
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<!-- ── /Main area ── -->
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hyperscript.org@0.9.91/dist/_hyperscript.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
|
|
||||||
<script src="/js/components.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user