From ff93e8c5befca75cd57955d4fea4dbffa45751ec Mon Sep 17 00:00:00 2001 From: echo Date: Sat, 20 Jun 2026 19:28:05 +0200 Subject: [PATCH] template refinement --- coolify.md | 334 ++++++++++++++++++ .../migration.sql | 3 + prisma/schema.prisma | 2 + src/app/[subdomain]/page.tsx | 10 +- src/app/api/publish/route.ts | 6 +- src/app/api/user/monument/route.ts | 4 +- src/app/dashboard/edit/page.tsx | 244 ++++++++----- src/app/dashboard/page.tsx | 7 +- src/app/globals.css | 1 + src/app/layout.tsx | 4 +- src/app/not-found.tsx | 4 +- src/app/onboarding/page.tsx | 118 +++++-- src/app/page.tsx | 20 +- src/components/MemorialPreview.tsx | 49 +++ src/components/TemplatePicker.tsx | 94 ++--- src/lib/templates.tsx | 236 ++++++++----- src/types/index.ts | 6 +- 17 files changed, 837 insertions(+), 305 deletions(-) create mode 100644 coolify.md create mode 100644 prisma/migrations/20260620163243_add_memorial_dates/migration.sql create mode 100644 src/components/MemorialPreview.tsx diff --git a/coolify.md b/coolify.md new file mode 100644 index 0000000..cb400f0 --- /dev/null +++ b/coolify.md @@ -0,0 +1,334 @@ +# Deploying SpomeniQR on Coolify + +This guide covers deploying SpomeniQR to a VPS using [Coolify](https://coolify.io/) — a self-hosting platform similar to Heroku/Vercel that manages Docker deployments, SSL, and databases. + +## Prerequisites + +- A VPS with Docker installed (Coolify handles this) +- Coolify installed on your VPS (see [coolify.io/docs](https://coolify.io/docs/installation)) +- Domain `testbed.mk` with DNS configured: + - **A record** `@` → your VPS IP + - **A record** `*` → your VPS IP (wildcard for subdomains) +- A Contabo S3 bucket set up with public read policy + +## Architecture Overview + +Coolify will run two containers: +1. **app** — Next.js standalone build (Dockerfile) +2. **db** — PostgreSQL 16 (Coolify managed database) + +We do **not** deploy Nginx via Docker — Coolify has its own reverse proxy (Traefik/Caddy) that handles SSL, subdomain routing, and the `X-Subdomain` header. + +``` +Internet + │ + ├── *.testbed.mk ──► Coolify Proxy (:80/:443) + │ ├── Extracts subdomain → sets X-Subdomain header + │ └── Proxies to app:3000 + │ + └── testbed.mk ──► Coolify Proxy ──► app:3000 +``` + +## Step 1: Add a New Project in Coolify + +1. Open your Coolify dashboard +2. Click **+ Add New Project** +3. Name it `SpomeniQR` + +## Step 2: Create the Database + +1. Inside the project, click **+ Add New Resource** → **Database** +2. Select **PostgreSQL** +3. Configure: + - **Name:** `spomeniqr-db` + - **PostgreSQL Version:** 16 + - **Database Name:** `monuments` + - **Username:** `postgres` + - **Password:** generate a strong password or set your own +4. Click **Deploy** +5. After deployment, note the **Internal Connection String** — it looks like: + ``` + postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments + ``` + You'll need this for `DATABASE_URL`. + +## Step 3: Add the Application + +1. Inside the project, click **+ Add New Resource** → **Application** +2. Select **Public Repository** (or **Private** if your repo is private) +3. Configure: + - **Name:** `spomeniqr` + - **Repository URL:** your Git repo URL + - **Branch:** `main` + - **Build Pack:** **Nixpacks** (auto-detected) or **Docker** (uses the Dockerfile) + +### Option A: Nixpacks (Recommended — simpler) + +Leave the build pack as **Nixpacks**. Coolify will auto-detect Next.js and build it. + +Add these **Build Commands:** +``` +npx prisma generate && npm run build +``` + +Add this **Start Command:** +``` +npx prisma migrate deploy && node .next/standalone/server.js +``` + +### Option B: Docker (uses the project's Dockerfile) + +Set **Build Pack** to **Docker**. Coolify will use the `Dockerfile` in the repo root. No additional configuration needed — it already includes Prisma migration and standalone server startup. + +**Note:** If using Docker build pack, the `DATABASE_URL` must use `spomeniqr-db` as the host (Coolify internal network), not `localhost`. + +## Step 4: Configure Environment Variables + +In the application settings, go to **Environment Variables** and add: + +```env +# Database — use the Coolify internal connection string +DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments + +# Clerk +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_... +CLERK_SECRET_KEY=sk_live_... +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/ +NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/ + +# Contabo S3 +S3_ENDPOINT=https://eu2.contabostorage.com +S3_REGION=eu-2 +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +S3_BUCKET_NAME=monuments-images + +# App +NEXT_PUBLIC_APP_URL=https://testbed.mk +NEXT_PUBLIC_APP_DOMAIN=testbed.mk + +# Node +NODE_ENV=production +``` + +**Important:** +- `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`. +- Use your **production** Clerk keys (`pk_live_` / `sk_live_`), not the test ones. + +## Step 5: Configure Domain & Subdomain Routing + +### Set the Main Domain + +1. In the application settings, go to **Configuration** → **Domains** +2. Add: `testbed.mk` +3. Enable **HTTPS** — Coolify will auto-provision a Let's Encrypt certificate + +### Enable Wildcard Subdomain Routing + +This is the critical part. Coolify's proxy needs to: + +1. Accept requests for `*.testbed.mk` +2. Extract the subdomain and pass it as the `X-Subdomain` header to the Next.js app + +#### Option A: Coolify Proxy Configuration (Recommended) + +1. In your application, go to **Configuration** → **Domains** +2. Add both domains: + - `testbed.mk` + - `*.testbed.mk` +3. Coolify will request a wildcard SSL certificate. If your DNS provider supports DNS-01 challenges (Cloudflare, Route53, etc.), this works automatically. Otherwise, you may need to add each subdomain manually. + +#### Option B: Custom Proxy Configuration + +If Coolify doesn't support wildcard domains easily, add a **custom Caddy/Traefik configuration** in the Coolify settings: + +For **Caddy** (Coolify's default proxy): + +Create a file at `/data/coolify/proxy/caddy/custom/testbed.mk`: + +``` +*.testbed.mk { + reverse_proxy app:3000 { + header_up X-Subdomain {http.request.host.labels.2} + header_up X-Forwarded-Proto {scheme} + } +} + +testbed.mk { + reverse_proxy app:3000 { + header_up X-Forwarded-Proto {scheme} + } +} +``` + +For **Traefik** (alternative proxy), you'd add labels to the container: + +```yaml +traefik.http.routers.spomeniqr.rule: HostRegexp(`{subdomain:[a-z0-9-]+}.testbed.mk`) || Host(`testbed.mk`) +traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subdomain: +``` + +> **Note:** The proxy configuration varies based on your Coolify version and proxy choice. Check the Coolify docs for the latest instructions on wildcard domains. + +## Step 6: Configure Clerk + +1. Go to [Clerk Dashboard](https://dashboard.clerk.com) +2. Switch to your **Production** instance +3. Under **Paths**, set: + - Sign-in: `/sign-in` + - Sign-up: `/sign-up` +4. Under **Domains**, add: + - `testbed.mk` + - `*.testbed.mk` + +## Step 7: Configure Contabo S3 + +1. Log into Contabo Object Storage +2. Create a bucket named `monuments-images` in region `eu-2` +3. Set the **bucket policy** for public read: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": { "AWS": ["*"] }, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::monuments-images/*"] + } + ] +} +``` + +4. Set **CORS** to allow uploads: + +```json +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "PUT", "POST"], + "AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] +``` + +5. Create an API key with read/write permissions + +## Step 8: Deploy + +1. Click **Deploy** in the Coolify dashboard +2. Watch the build logs — it should: + - Install dependencies + - Run `npx prisma generate` + - Build the Next.js app + - Run `npx prisma migrate deploy` + - Start the server on port 3000 +3. Once deployed, visit `https://testbed.mk` to verify + +## Step 9: Verify Subdomain Routing + +Test that wildcard subdomains work: + +1. Create a memorial page with subdomain `test-memorial` +2. Visit `https://test-memorial.testbed.mk` +3. Check browser DevTools network tab — the `X-Subdomain` header should be set by the proxy +4. The Next.js middleware reads `X-Subdomain` and rewrites to the correct page + +If subdomains aren't working: +- Check DNS: `dig *.testbed.mk +short` should return your VPS IP +- Check Coolify proxy logs for the wildcard domain configuration +- Verify the `X-Subdomain` header is being set in the proxy config + +## Step 10: Verify Everything + +```bash +# App health +curl -s https://testbed.mk | head -5 + +# Subdomain routing +curl -sI https://test-memorial.testbed.mk | head -5 + +# API health +curl -s https://testbed.mk/api/check-subdomain?slug=test | python3 -m json.tool + +# Database connection (from Coolify terminal) +docker exec spomeniqr-app npx prisma db push --accept-data-loss +``` + +## Troubleshooting + +### Build fails with Prisma errors + +Make sure `DATABASE_URL` points to the Coolify internal hostname (e.g., `spomeniqr-db:5432`), not `localhost`. The app and database must be on the same Coolify network. + +### Images return 401 from S3 + +Make sure you've applied the bucket policy for public read. See Step 7. If Contabo doesn't serve public objects via URL, the app uses an `/api/image?key=...` proxy route as a fallback. + +### Subdomain routing not working + +- Verify DNS wildcard `*.testbed.mk` points to your VPS +- Check that Coolify's proxy config includes both `testbed.mk` and `*.testbed.mk` +- Check the proxy access logs — the `X-Subdomain` header should appear +- If using a custom Caddyfile, make sure it's in the right directory and reload the proxy + +### Clerk authentication issues + +- Ensure production Clerk keys are set (not `pk_test_` / `sk_test_`) +- Verify `testbed.mk` and `*.testbed.mk` are added in Clerk dashboard domains +- Check that `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_DOMAIN` are set correctly + +### Database migration issues + +If migrations fail on deploy, you can run them manually from the Coolify terminal: + +```bash +# SSH into the app container +docker exec -it spomeniqr-app sh + +# Run migrations +npx prisma migrate deploy + +# Or push schema changes directly +npx prisma db push +``` + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string (Coolify internal) | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (`pk_live_...`) | +| `CLERK_SECRET_KEY` | Yes | Clerk secret key (`sk_live_...`) | +| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | Yes | `/sign-in` | +| `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | Yes | `/sign-up` | +| `S3_ENDPOINT` | Yes | Contabo S3 endpoint URL | +| `S3_REGION` | Yes | Contabo S3 region (e.g. `eu-2`) | +| `S3_ACCESS_KEY_ID` | Yes | S3 access key | +| `S3_SECRET_ACCESS_KEY` | Yes | S3 secret key | +| `S3_BUCKET_NAME` | Yes | S3 bucket name | +| `NEXT_PUBLIC_APP_URL` | Yes | `https://testbed.mk` | +| `NEXT_PUBLIC_APP_DOMAIN` | Yes | `testbed.mk` | +| `NODE_ENV` | Yes | `production` | + +## Useful Coolify Commands + +- **Redeploy:** Project → Application → Deploy +- **View Logs:** Project → Application → Logs +- **SSH into container:** Project → Application → Terminal +- **Database Management:** Project → Database → Admin (pgAdmin or Prisma Studio) +- **SSL Certificates:** Managed automatically by Coolify for configured domains + +## Updates + +To update the application: + +1. Push changes to your Git repository +2. Coolify will auto-deploy if **Auto Deploy** is enabled, or click **Deploy** manually +3. The `start.sh` script runs `npx prisma migrate deploy` on every startup, so schema changes are applied automatically \ No newline at end of file diff --git a/prisma/migrations/20260620163243_add_memorial_dates/migration.sql b/prisma/migrations/20260620163243_add_memorial_dates/migration.sql new file mode 100644 index 0000000..2284ad8 --- /dev/null +++ b/prisma/migrations/20260620163243_add_memorial_dates/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "bornDate" TEXT, +ADD COLUMN "passedDate" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e91d4d1..d654f85 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,8 @@ model User { templateId Int @default(1) title String? description String? + bornDate String? + passedDate String? published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/[subdomain]/page.tsx b/src/app/[subdomain]/page.tsx index 3e6fd0c..09665e3 100644 --- a/src/app/[subdomain]/page.tsx +++ b/src/app/[subdomain]/page.tsx @@ -15,11 +15,11 @@ export async function generateMetadata({ params }: Props): Promise { }); if (!user || !user.published) { - return { title: "Monument Not Found" }; + return { title: "Memorial Not Found" }; } - const title = user.title || "City Monument"; - const description = user.description?.slice(0, 160) || `Visit ${title} — a monument page on SpomeniQR.`; + const title = user.title || "Memorial"; + const description = user.description?.slice(0, 160) || `In Loving Memory of ${title}`; return { title, @@ -33,7 +33,7 @@ export async function generateMetadata({ params }: Props): Promise { }; } -export default async function MonumentPage({ params }: Props) { +export default async function MemorialPage({ params }: Props) { const { subdomain } = await params; const user = await prisma.user.findUnique({ @@ -49,6 +49,8 @@ export default async function MonumentPage({ params }: Props) { id: user.id, title: user.title, description: user.description, + bornDate: user.bornDate, + passedDate: user.passedDate, subdomain: user.subdomain, templateId: user.templateId, published: user.published, diff --git a/src/app/api/publish/route.ts b/src/app/api/publish/route.ts index 0ade08b..e0a4893 100644 --- a/src/app/api/publish/route.ts +++ b/src/app/api/publish/route.ts @@ -12,7 +12,7 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); - const { title, description, subdomain, templateId, images } = body; + const { title, description, bornDate, passedDate, subdomain, templateId, images } = body; if (!title?.trim()) { return NextResponse.json({ error: "Title is required" }, { status: 400 }); @@ -45,6 +45,8 @@ export async function POST(req: NextRequest) { data: { title, description, + bornDate: bornDate || null, + passedDate: passedDate || null, subdomain, templateId, published: true, @@ -64,6 +66,8 @@ export async function POST(req: NextRequest) { clerkId: userId, title, description, + bornDate: bornDate || null, + passedDate: passedDate || null, subdomain, templateId, published: true, diff --git a/src/app/api/user/monument/route.ts b/src/app/api/user/monument/route.ts index 9821d0e..fb379b8 100644 --- a/src/app/api/user/monument/route.ts +++ b/src/app/api/user/monument/route.ts @@ -62,13 +62,15 @@ export async function PUT(req: NextRequest) { try { const body = await req.json(); - const { title, description, templateId, published } = body; + const { title, description, bornDate, passedDate, templateId, published } = body; const user = await prisma.user.update({ where: { clerkId: userId }, data: { ...(title !== undefined && { title }), ...(description !== undefined && { description }), + ...(bornDate !== undefined && { bornDate }), + ...(passedDate !== undefined && { passedDate }), ...(templateId !== undefined && { templateId }), ...(published !== undefined && { published }), }, diff --git a/src/app/dashboard/edit/page.tsx b/src/app/dashboard/edit/page.tsx index d9ab148..11af2ae 100644 --- a/src/app/dashboard/edit/page.tsx +++ b/src/app/dashboard/edit/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import ImageUploader from "@/components/ImageUploader"; +import TemplatePicker from "@/components/TemplatePicker"; interface MonumentImage { id: string; @@ -15,14 +16,17 @@ interface EditData { title: string; description: string; templateId: number; + bornDate: string; + passedDate: string; } export default function EditPage() { const router = useRouter(); - const [data, setData] = useState({ title: "", description: "", templateId: 1 }); + const [data, setData] = useState({ title: "", description: "", templateId: 1, bornDate: "", passedDate: "" }); const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [showPreview, setShowPreview] = useState(false); useEffect(() => { fetch("/api/user/monument") @@ -32,6 +36,8 @@ export default function EditPage() { title: d.user.title || "", description: d.user.description || "", templateId: d.user.templateId || 1, + bornDate: d.user.bornDate || "", + passedDate: d.user.passedDate || "", }); setImages(d.user.images || []); }) @@ -69,116 +75,162 @@ export default function EditPage() { return
Loading...
; } + const imagePreviews = images.map((img) => ({ url: img.url, order: img.order })); + return (
-
-

Edit Monument

+
+

Edit Memorial

+
-
-
-
- - setData({ ...data, title: e.target.value })} - className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" - maxLength={100} - /> -
+
+
+ {/* Form - hidden on mobile when preview is shown */} +
+
+
+ + setData({ ...data, title: e.target.value })} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + maxLength={100} + /> +
-
- -