This commit is contained in:
echo 2026-06-20 18:17:30 +02:00
commit 4fdb51f583
58 changed files with 10367 additions and 0 deletions

22
.env.example Normal file
View File

@ -0,0 +1,22 @@
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
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=/
# Database
DATABASE_URL=postgresql://postgres:postgres@db:5432/monuments
POSTGRES_PASSWORD=postgres
# 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

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# docker
certbot/

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY scripts/start.sh /app/start.sh
RUN chmod +x /app/start.sh
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["/bin/sh", "/app/start.sh"]

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

464
deploy.md Normal file
View File

@ -0,0 +1,464 @@
# Deployment Guide — testbed.mk on VPS
## Architecture Overview
```
Internet
├── *.testbed.mk ──► Nginx (:80/:443)
│ ├── Extracts subdomain → sets X-Subdomain header
│ └── Proxies to app:3000
└── testbed.mk ──► Nginx ──► Next.js app
├── Clerk (auth)
├── PostgreSQL (db:5432)
└── Contabo S3 (images)
```
**Stack**: Docker Compose with 3 containers — `app` (Next.js), `db` (PostgreSQL 16), `nginx` (Nginx)
## 1. VPS Preparation
### System Requirements
- Ubuntu 22.04+ or similar Linux
- Minimum 2GB RAM, 1 vCPU
- Open ports: 22 (SSH), 80 (HTTP), 443 (HTTPS)
### Install Docker
```bash
# Update packages
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install Docker Compose (if not included)
sudo apt install -y docker-compose-plugin
# Add your user to docker group (optional, avoids sudo)
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker --version
docker compose version
```
## 2. DNS Configuration
In your DNS provider, create these records for `testbed.mk`:
| Type | Host | Value | TTL |
|-------|------------------|----------------|------|
| A | `@` | `YOUR_VPS_IP` | 300 |
| A | `*` | `YOUR_VPS_IP` | 300 |
This means:
- `testbed.mk` → your VPS
- `anything.testbed.mk` → your VPS (wildcard)
**Verify DNS propagation:**
```bash
dig testbed.mk +short
dig random.testbed.mk +short
# Both should return your VPS IP
```
## 3. Deploy the Application
### Clone the Repository
```bash
git clone <your-repo-url> /opt/spomeniQR
cd /opt/spomeniQR
```
### Create Environment File
```bash
cp .env.example .env
nano .env
```
Fill in all values:
```env
# Clerk (PRODUCTION keys from https://dashboard.clerk.com)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
# Database (strong password!)
DATABASE_URL=postgresql://postgres:STRONG_PASSWORD_HERE@db:5432/monuments
POSTGRES_PASSWORD=STRONG_PASSWORD_HERE
# Contabo S3
S3_ENDPOINT=https://eu2.contabostorage.com
S3_REGION=eu-2
S3_ACCESS_KEY_ID=your-contabo-access-key
S3_SECRET_ACCESS_KEY=your-contabo-secret-key
S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
```
**Important**: Use a strong, unique password for `POSTGRES_PASSWORD`.
### Create the Prisma Migration
Before the first deployment, create the initial migration locally or on the server:
```bash
# Start only the database first
docker compose up db -d
# Wait for it to be ready (~5 seconds)
sleep 5
# Run the migration
docker compose exec db psql -U postgres -c "CREATE DATABASE monuments;" 2>/dev/null || true
# Run Prisma migration using a temporary container
docker compose run --rm app npx prisma migrate deploy
```
Alternatively, you can generate a migration file locally first:
```bash
# On your local machine (with DATABASE_URL pointing to any postgres)
npx prisma migrate dev --name init
```
Then commit the generated migration files. The `scripts/start.sh` entrypoint will run `npx prisma migrate deploy` automatically on every container start.
## 4. Configure Contabo S3
### Create the Bucket
1. Log into Contabo Object Storage at https://contabostorage.com
2. Create a bucket named `monuments-images`
3. Choose the same region as your `S3_REGION` (e.g. `eu-2`)
### Set Public Read Access
The monument images need to be publicly viewable. Set this bucket policy:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::monuments-images/*"]
}
]
}
```
### Set CORS (for browser uploads)
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT"],
"AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
### Create API Keys
1. In the Contabo Object Storage panel, create a new access key
2. Grant it read/write permissions on the `monuments-images` bucket
3. Copy the Access Key ID and Secret Access Key to your `.env`
## 5. Configure Clerk (Production)
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` (if supported — otherwise just the apex domain)
5. Copy the **Production** publishable key and secret key to your `.env` (the ones starting with `pk_live_` and `sk_live_`)
## 6. SSL / HTTPS Setup
### Option A: Let's Encrypt with Certbot (Recommended)
The project includes two Nginx configs:
- `nginx/conf.d/default.conf` — HTTP only (for initial setup / local dev)
- `nginx/conf.d/production.conf` — HTTPS with Let's Encrypt
#### Step 1: Start with HTTP first
Make sure `default.conf` is active (it is by default):
```bash
docker compose up -d
```
Verify the app is reachable: `http://testbed.mk`
#### Step 2: Get the SSL certificate
```bash
# Install certbot on the host
sudo apt install -y certbot
# Get a wildcard certificate (requires DNS-01 challenge for *.testbed.mk)
# OR get a single-domain cert (simpler, no wildcard):
# For a single-domain cert (covers testbed.mk only, NOT subdomains):
sudo certbot certonly --webroot \
-w /opt/spomeniQR/certbot/www \
-d testbed.mk
# For a wildcard cert (covers testbed.mk AND *.testbed.mk):
# You MUST use DNS-01 challenge. Example with Cloudflare DNS plugin:
sudo apt install -y python3-certbot-dns-cloudflare
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d testbed.mk \
-d '*.testbed.mk'
```
**For the wildcard certificate**, you need to use a DNS-01 challenge. This requires:
- Your DNS provider's API credentials
- The appropriate certbot DNS plugin
If your DNS provider doesn't have a certbot plugin, you can use [acme.sh](https://github.com/acmesh-official/acme.sh) with DNS manual mode.
**Alternative: Use a certificate for each subdomain on-the-fly.** This requires a more complex Nginx setup (not covered here — wildcard cert is recommended for simplicity).
#### Step 3: Switch to HTTPS config
```bash
# Replace the HTTP config with the HTTPS config
cd /opt/spomeniQR/nginx/conf.d/
mv default.conf default.conf.bak
cp production.conf default.conf
# Restart nginx
docker compose restart nginx
```
#### Step 4: Auto-renewal
Let's Encrypt certificates expire every 90 days. Set up auto-renewal:
```bash
# Test renewal
sudo certbot renew --dry-run
# Add a cron job for auto-renewal
sudo crontab -e
# Add this line:
0 3 * * * certbot renew --quiet --deploy-hook "docker restart spomeniqr-nginx"
```
### Option B: No SSL (Development / Staging)
If you're just testing, keep `default.conf` as-is. The app works over HTTP. Just make sure:
- Clerk dashboard allows `http://testbed.mk` as a domain
- `NEXT_PUBLIC_APP_URL=http://testbed.mk` in your `.env`
## 7. Build and Run
```bash
cd /opt/spomeniQR
# Build and start all services
docker compose up -d --build
# View logs
docker compose logs -f app
# Check status
docker compose ps
```
The app should now be live at **https://testbed.mk** (or **http://testbed.mk** if no SSL).
## 8. Verify Everything Works
### Check the Services
```bash
# All containers should be "Up"
docker compose ps
# Check app logs
docker compose logs app | tail -20
# Check nginx logs
docker compose logs nginx | tail -20
```
### Test the Endpoints
```bash
# Landing page
curl -I https://testbed.mk
# Subdomain routing (should pass X-Subdomain header)
curl -I https://eiffel-tower.testbed.mk
# API health check
curl https://testbed.mk/api/check-subdomain?slug=test
```
### Test in Browser
1. Visit `https://testbed.mk` — should show the landing page
2. Click Sign Up — should create a Clerk account
3. Go through the onboarding wizard
4. After publishing, visit `https://{your-subdomain}.testbed.mk`
5. Verify photos upload correctly (check Contabo S3 bucket)
## 9. Maintenance
### View Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f app
docker compose logs -f db
docker compose logs -f nginx
```
### Database Access
```bash
# Connect to PostgreSQL
docker compose exec db psql -U postgres -d monuments
# Or use Prisma Studio (opens a web GUI)
docker compose run --rm app npx prisma studio
```
### Update the Application
```bash
cd /opt/spomeniQR
git pull origin main
docker compose up -d --build
```
The `start.sh` script runs `npx prisma migrate deploy` automatically, so any schema changes will be applied on startup.
### Database Backup
```bash
# Create a backup
docker compose exec db pg_dump -U postgres monuments > backup_$(date +%Y%m%d).sql
# Restore from backup
cat backup_20240101.sql | docker compose exec -T db psql -U postgres monuments
```
### Restart Services
```bash
# Restart everything
docker compose restart
# Restart only the app (e.g., after env change)
docker compose restart app
# Restart nginx (e.g., after config change)
docker compose restart nginx
```
## 10. Troubleshooting
### App won't start
```bash
docker compose logs app
```
Common issues:
- **DATABASE_URL is wrong**: Ensure it matches your `POSTGRES_PASSWORD` and uses `db` as hostname (not `localhost`) inside Docker
- **Clerk keys are wrong**: Verify `pk_live_` / `sk_live_` keys
- **S3 credentials wrong**: Check your Contabo access key
### 502 Bad Gateway
The app isn't running or not ready yet:
```bash
docker compose ps # Check if app is running
docker compose logs app # Check for startup errors
docker compose restart app # Try restarting
```
### Subdomain routing not working
1. Check DNS: `dig random.testbed.mk +short` should return your VPS IP
2. Check Nginx config contains the `X-Subdomain` header logic:
```bash
docker compose exec nginx cat /etc/nginx/conf.d/default.conf
```
3. Check the middleware: subdomains rely on the `X-Subdomain` header set by Nginx
### SSL certificate errors
```bash
# Check if certificate files exist
ls -la /opt/spomeniQR/certbot/conf/live/testbed.mk/
# Renew manually
sudo certbot renew --force-renewal
```
### S3 uploads failing
1. Check CORS configuration includes `https://testbed.mk`
2. Check bucket name matches `S3_BUCKET_NAME` in `.env`
3. Check access key has read+write permissions
4. Test with: `curl -I https://eu2.contabostorage.com/monuments-images/`
### Can't connect to PostgreSQL
```bash
# Check if db is healthy
docker compose ps db
# Try connecting
docker compose exec db psql -U postgres -d monuments -c "SELECT 1;"
# Check the connection string
docker compose exec app printenv DATABASE_URL
```
## 11. Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (pk_live_...) |
| `CLERK_SECRET_KEY` | Yes | Clerk secret key (sk_live_...) |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `POSTGRES_PASSWORD` | Yes | PostgreSQL password (used by db container) |
| `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 (monuments-images) |
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL (https://testbed.mk) |
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | Domain only (testbed.mk) |

324
description.md Normal file
View File

@ -0,0 +1,324 @@
# 🏛️ City Monuments Memories — Platform Architecture & Implementation Plan
## 1. Tech Stack
| Layer | Technology | Why |
|-------|-----------|-----|
| **Framework** | **Next.js 15 (App Router)** | Full-stack React framework — server components, API routes, middleware for subdomain routing |
| **Auth** | **Clerk** | Required by spec; handles sign-up/sign-in, user profiles, session management |
| **Database** | **PostgreSQL + Prisma ORM** | Relational data model (users, monuments, memories); Prisma for type-safe queries & migrations |
| **File Upload** | **UploadThing** | Built for Next.js, handles image resizing, CDN delivery, max 3 files validation |
| **QR Code** | **`qrcode` (npm)** | Pure JS, generates PNG/SVG from server, 1M+ weekly downloads |
| **Subdomain** | **Next.js Middleware + Vercel Wildcard Domains** | `*.monuments.app` rewrites to dynamic route `[subdomain]/page.tsx` |
| **Templates** | **React Server Components + Tailwind CSS** | 3 pre-designed HTML landing page templates as React components; server-rendered for SEO |
| **Deployment** | **Vercel** | Native Next.js support, wildcard domains, serverless functions, Edge middleware |
| **Storage** | **Vercel Blob / AWS S3** | For uploaded monument images |
---
## 2. Data Model (Prisma Schema)
```prisma
model User {
id String @id @default(cuid())
clerkId String @unique
email String?
name String?
subdomain String @unique // e.g. "eiffel-tower"
templateId Int // 1, 2, or 3
title String? // monument page title
description String? // user-written text
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images Image[]
}
model Image {
id String @id @default(cuid())
url String // CDN URL from UploadThing
key String // UploadThing file key
order Int // display order (1-3)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
```
---
## 3. Application Flow
```
┌─────────────────────────┐
│ monuments.app (landing) │
└────────┬────────────────┘
│ Sign Up via Clerk
┌─────────────────────────────┐
│ Dashboard / Profile Setup │
│ - Pick template (1/2/3) │
│ - Enter monument name/text │
│ - Upload up to 3 photos │
│ - Choose custom subdomain │
└─────────────┬───────────────┘
│ Publish
┌────────────────────────────────────────┐
│ 1. Generate subdomain + HTML landing │
│ 2. Generate QR code (→ subdomain URL) │
│ 3. Store everything in DB │
└────────────────┬───────────────────────┘
┌────────────────────────────────────────┐
│ eiffel-tower.monuments.app │
│ → SSR landing page from template │
│ → Displays text + 3 photos │
└────────────────────────────────────────┘
```
---
## 4. Implementation Plan — Phase by Phase
### Phase 0: Project Setup (Day 1)
```bash
npx create-next-app@latest city-monuments --typescript --tailwind --app
npm install @clerk/nextjs prisma @prisma/client @uploadthing/react uploadthing qrcode qrcode @types/qrcode
```
- [ ] Create Next.js 15 project with TypeScript + Tailwind
- [ ] Configure Clerk (`.env.local` with publishable + secret keys)
- [ ] Set up `app/layout.tsx` with `<ClerkProvider>`
- [ ] Initialize Prisma with PostgreSQL
- [ ] Set up UploadThing with file router (max 3 images, 5MB each, image only)
### Phase 1: Authentication & Onboarding (Days 2-3)
- [ ] Configure Clerk middleware (`src/middleware.ts`)
- [ ] Create `/sign-in` and `/sign-up` pages using Clerk components
- [ ] Create `/dashboard` — protected page showing user's monument (or "create one" CTA)
- [ ] Create `/onboarding` wizard page:
- **Step 1:** Enter monument name and description (text area)
- **Step 2:** Upload up to 3 photos (UploadThing dropzone, drag-to-reorder)
- **Step 3:** Choose subdomain slug (check availability via API)
- **Step 4:** Pick template (3 visual card selections)
- [ ] On submit: call `/api/publish` to save everything
### Phase 2: API Routes (Days 3-4)
| Route | Method | Purpose |
|-------|--------|---------|
| `/api/publish` | POST | Saves user data, generates subdomain, returns QR code |
| `/api/check-subdomain` | GET | `?slug=xyz` — returns `{available: bool}` |
| `/api/monument/[subdomain]` | GET | Public JSON data for a monument page |
| `/api/upload` | POST | UploadThing endpoint (auto-generated) |
| `/api/user/monument` | GET/PUT | Get/update current user's monument data |
**Key logic in `/api/publish`:**
1. Validate input (text length, image count, template ID)
2. Check subdomain availability
3. Store in DB (User + Image records)
4. Generate QR code via `qrcode.toBuffer(subdomainUrl, { type: 'png' })`
5. Return QR code as base64 data URL + public monument URL
6. (Optional) Store QR code image in blob storage
### Phase 3: Subdomain Routing & Middleware (Day 4)
```typescript
// src/middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export default clerkMiddleware(async (auth, req: NextRequest) => {
const url = req.nextUrl
const hostname = req.headers.get('host') || ''
// Extract subdomain (e.g. "eiffel-tower" from "eiffel-tower.monuments.app")
const subdomain = hostname
.replace('www.', '')
.replace('.monuments.app', '')
// If it's a subdomain (not apex), rewrite to the dynamic page
if (subdomain && !subdomain.includes('.') && hostname.includes('.monuments.app')) {
url.pathname = `/${subdomain}`
return NextResponse.rewrite(url)
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!_next|api|static|.*\\..*).*)']
}
```
- [ ] Create `app/[subdomain]/page.tsx` — fetches monument data, renders template
- [ ] Handle 404 for unknown subdomains
- [ ] Configure Vercel: add `*.monuments.app` as wildcard domain
### Phase 4: Template System (Day 5)
Create 3 React Server Components that render pure HTML landing pages:
| Template | Vibe | Layout |
|----------|------|--------|
| **Template 1 — "Classic"** | Clean, serif font, historical archive feel | Header image hero → description → 3-column photo grid |
| **Template 2 — "Modern"** | Bold, full-bleed images, sans-serif | Full-screen photo carousel → floating text overlay → gallery |
| **Template 3 — "Minimal"** | Whitespace-heavy, single-column, journal style | Title → paragraph → horizontal image strip → footer |
All templates:
- Are **React Server Components** (no JS shipped for visitors!)
- Use **Tailwind CSS** for styling
- Are **SEO-friendly** with proper `<head>` meta tags
- Render **optimized images** via Next.js `<Image>` component
- Include the **QR code badge** in the footer ("Scan to visit this monument")
- Support **Open Graph** metadata for social sharing
Example template rendering function:
```typescript
// src/lib/templates.tsx
export function renderTemplate(templateId: number, data: MonumentData) {
switch (templateId) {
case 1: return <TemplateClassic data={data} />
case 2: return <TemplateModern data={data} />
case 3: return <TemplateMinimal data={data} />
}
}
```
### Phase 5: QR Code Generation (Day 5)
```typescript
// src/lib/qrcode.ts
import QRCode from 'qrcode'
export async function generateMonumentQR(subdomain: string): Promise<string> {
const url = `https://${subdomain}.monuments.app`
const qrBuffer = await QRCode.toBuffer(url, {
type: 'png',
width: 400,
margin: 2,
color: {
dark: '#1a1a2e',
light: '#ffffff'
}
})
// Store buffer to Vercel Blob and return public URL
// OR return as data URL for download
return `data:image/png;base64,${qrBuffer.toString('base64')}`
}
```
- [ ] Generate QR on publish
- [ ] Show QR in dashboard for download (PNG)
- [ ] Optionally embed QR on the monument page footer
### Phase 6: Dashboard & User Experience (Days 6-7)
- [ ] Dashboard: view/edit monument details
- [ ] Template preview (live switching)
- [ ] Image reorder/delete
- [ ] QR download button
- [ ] Share link + copy-to-clipboard
- [ ] "Unpublish" button
- [ ] Loading skeletons, error states, empty states
### Phase 7: Polish & Production (Day 8+)
- [ ] SEO — dynamic metadata per monument page (generateMetadata)
- [ ] Analytics — Vercel Analytics or Plausible
- [ ] Rate limiting on API routes
- [ ] Image optimization (UploadThing does auto-resize/webp)
- [ ] Custom 404 page for unknown subdomains
- [ ] Proper error boundaries
- [ ] Loading UI (`loading.tsx` per route)
---
## 5. Folder Structure
```
src/
├── app/
│ ├── layout.tsx # Root layout with ClerkProvider
│ ├── page.tsx # Landing page (monuments.app)
│ ├── [subdomain]/
│ │ └── page.tsx # Dynamic monument landing page (SSR)
│ ├── sign-in/[[...sign-in]]/page.tsx
│ ├── sign-up/[[...sign-up]]/page.tsx
│ ├── dashboard/
│ │ ├── page.tsx # Dashboard home
│ │ └── preview/
│ │ └── page.tsx # Live template preview
│ └── api/
│ ├── publish/route.ts
│ ├── check-subdomain/route.ts
│ ├── user/monument/route.ts
│ └── uploadthing/route.ts
├── components/
│ ├── templates/
│ │ ├── TemplateClassic.tsx
│ │ ├── TemplateModern.tsx
│ │ └── TemplateMinimal.tsx
│ ├── OnboardingWizard.tsx
│ ├── ImageUploader.tsx
│ ├── SubdomainPicker.tsx
│ ├── TemplatePicker.tsx
│ └── QRDisplay.tsx
├── lib/
│ ├── prisma.ts # Prisma client singleton
│ ├── templates.tsx # Template registry
│ ├── qrcode.ts # QR generation logic
│ └── uploadthing.ts # UploadThing config
├── middleware.ts # Clerk + subdomain routing
├── types/
│ └── index.ts # Shared TypeScript types
└── styles/
└── globals.css
```
---
## 6. Deployment Checklist (Vercel)
- [ ] Set environment variables in Vercel dashboard
- [ ] Configure `*.monuments.app` in Vercel project → Domains
- [ ] Add `monuments.app` apex domain
- [ ] Run `npx prisma migrate deploy` on production DB
- [ ] Configure Clerk production URLs (from localhost → monuments.app)
- [ ] Set up UploadThing production env vars
- [ ] Enable Vercel Analytics
---
## 7. Future Enhancements (v2)
- **Multi-language** — i18n support for monument descriptions
- **Audio guide** — embed audio narration per monument
- **Map integration** — pin monuments on a city map
- **Social sharing** — share to Instagram/TikTok with QR
- **Analytics per monument** — visit counter, scan counter
- **Custom domain** — allow users to bring their own domain (e.g., `eiffeltower.com`)
- **Admin panel** — moderate content, flagged monuments
---
## 8. Key Technical Decisions Summary
| Decision | Choice | Reasoning |
|----------|--------|-----------|
| Auth | Clerk | Specified requirement; great DX with Next.js |
| Subdomain routing | Middleware rewrite | Clean URLs, no path-based routing needed |
| Image storage | UploadThing | Built-in Next.js integration, CDN, auto-optimization |
| QR generation | `qrcode` npm | Pure JS, works server-side, PNG/SVG output |
| DB ORM | Prisma | Type-safe, auto-generated types, easy migrations |
| Templates | React Server Components | Zero client JS for public pages, fast SSR |
| Styling | Tailwind CSS | Rapid prototyping, consistent design system |
| Hosting | Vercel | One-command deploy, wildcard domains, Edge middleware |

66
docker-compose.yml Normal file
View File

@ -0,0 +1,66 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: spomeniqr-app
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-postgres}@db:5432/monuments
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
- CLERK_SECRET_KEY=${CLERK_SECRET_KEY}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://testbed.mk}
- NEXT_PUBLIC_APP_DOMAIN=${NEXT_PUBLIC_APP_DOMAIN:-testbed.mk}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_REGION=${S3_REGION:-eu-2}
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-monuments-images}
depends_on:
db:
condition: service_healthy
networks:
- spomeniqr
db:
image: postgres:16-alpine
container_name: spomeniqr-db
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: monuments
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- spomeniqr
nginx:
image: nginx:alpine
container_name: spomeniqr-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
depends_on:
- app
networks:
- spomeniqr
networks:
spomeniqr:
driver: bridge
volumes:
pgdata:

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

208
local.md Normal file
View File

@ -0,0 +1,208 @@
# Local Development Guide
## Prerequisites
- Node.js 20+
- npm
- PostgreSQL (running locally or via Docker)
- A Contabo S3 bucket (or any S3-compatible storage)
## 1. Clone and Install
```bash
git clone <repo-url> && cd spomeniQR
npm install
```
## 2. Set Up Environment Variables
```bash
cp .env.example .env
```
Edit `.env` with your local values:
```env
# Clerk - get from https://dashboard.clerk.com
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Database - use localhost for local Postgres
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/monuments
# 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=http://localhost:3000
NEXT_PUBLIC_APP_DOMAIN=localhost:3000
```
## 3. Set Up the Database
You need a PostgreSQL database. Options:
### Option A: Docker (recommended)
Start only the database:
```bash
docker compose up db -d
```
This starts PostgreSQL on port 5432 with user `postgres`, password `postgres`, database `monuments`.
### Option B: Local PostgreSQL
Create the database manually:
```bash
createdb monuments
```
Update `DATABASE_URL` in `.env` to match your local PostgreSQL credentials.
## 4. Run Prisma Migrations
```bash
npx prisma migrate dev --name init
```
This creates the tables and generates the Prisma client.
## 5. Set Up Contabo S3
1. Log into your Contabo Object Storage dashboard
2. Create a bucket named `monuments-images`
3. Set bucket policy to **public read** (monument images must be publicly accessible)
4. Create an API key (access key + secret key)
5. Note your endpoint and region (e.g. `https://eu2.contabostorage.com`, region `eu-2`)
Set the CORS policy on the bucket to allow browser uploads:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::monuments-images/*"]
}
]
}
```
Also set a CORS configuration:
```json
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT"],
"AllowedOrigins": ["http://localhost:3000"],
"ExposeHeaders": [],
"MaxAgeSeconds": 3600
}
]
```
## 6. Configure Clerk
1. Go to [Clerk Dashboard](https://dashboard.clerk.com)
2. Create a new application
3. Under **Paths**, set:
- Sign-in: `/sign-in`
- Sign-up: `/sign-up`
4. Under **API Keys**, copy the publishable key and secret key to your `.env`
5. Under **Domains**, add `localhost:3000`
## 7. Start Development Server
```bash
npm run dev
```
The app runs at **http://localhost:3000**.
## 8. Test Subdomain Routing Locally
Subdomain routing (`eiffel-tower.testbed.mk`) doesn't work on `localhost`. For local testing of subdomain pages:
1. **Direct URL**: Visit `http://localhost:3000/eiffel-tower` (this simulates subdomain routing via the `[subdomain]` page route)
2. **Using /etc/hosts** (optional, for realistic testing):
```bash
sudo nano /etc/hosts
# Add:
127.0.0.1 eiffel-tower.testbed.mk
127.0.0.1 testbed.mk
```
Then add this to your `.env`:
```
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
```
And modify `middleware.ts` temporarily to also check `localhost` in development.
3. **Using the X-Subdomain header directly** (for curl testing):
```bash
curl -H "X-Subdomain: eiffel-tower" http://localhost:3000/
```
## 9. Useful Commands
```bash
# Start dev server with hot reload
npm run dev
# Generate Prisma client (after schema changes)
npx prisma generate
# Create a new migration
npx prisma migrate dev --name your-migration-name
# Open Prisma Studio (DB GUI)
npx prisma studio
# Run linter
npm run lint
# Type check
npx tsc --noEmit
# Build for production
npm run build
```
## 10. Troubleshooting
### "Prisma Client could not be generated"
```bash
npx prisma generate
```
### Database connection refused
- Make sure PostgreSQL is running: `docker compose up db -d`
- Check `DATABASE_URL` in `.env` matches your DB credentials
### Clerk authentication errors
- Verify your Clerk keys in `.env` are correct
- Ensure `localhost:3000` is added as a domain in the Clerk dashboard
### S3 upload fails
- Check that the bucket exists and permissions are set
- Verify the CORS configuration allows `http://localhost:3000`
- Ensure the access key has both read and write permissions
### Port 3000 already in use
```bash
# Kill whatever is on port 3000
lsof -ti:3000 | xargs kill -9
# Or use a different port
PORT=3001 npm run dev
```

19
next.config.ts Normal file
View File

@ -0,0 +1,19 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: process.env.S3_ENDPOINT?.replace("https://", "") || "",
},
{
protocol: "http",
hostname: process.env.S3_ENDPOINT?.replace("http://", "") || "",
},
],
},
};
export default nextConfig;

32
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,32 @@
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name testbed.mk *.testbed.mk;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
set $subdomain "";
if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") {
set $subdomain $1;
}
proxy_set_header X-Subdomain $subdomain;
}
location /_next/static/ {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,53 @@
upstream nextjs {
server app:3000;
}
server {
listen 80;
server_name testbed.mk *.testbed.mk;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name testbed.mk *.testbed.mk;
ssl_certificate /etc/letsencrypt/live/testbed.mk/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/testbed.mk/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
set $subdomain "";
if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") {
set $subdomain $1;
}
proxy_set_header X-Subdomain $subdomain;
}
location /_next/static/ {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable";
}
}

6979
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "spomeniqr",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:generate": "prisma generate",
"postinstall": "prisma generate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1073.0",
"@aws-sdk/s3-request-presigner": "^3.1073.0",
"@clerk/nextjs": "^7.5.7",
"@prisma/client": "^5.22.0",
"next": "^15.5.19",
"qrcode": "^1.5.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"uuid": "^14.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "^15.5.19",
"prisma": "^5.22.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"clerkId" TEXT NOT NULL,
"email" TEXT,
"name" TEXT,
"subdomain" TEXT NOT NULL,
"templateId" INTEGER NOT NULL DEFAULT 1,
"title" TEXT,
"description" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Image" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"key" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Image_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_clerkId_key" ON "User"("clerkId");
-- CreateIndex
CREATE UNIQUE INDEX "User_subdomain_key" ON "User"("subdomain");
-- CreateIndex
CREATE INDEX "Image_userId_idx" ON "Image"("userId");
-- AddForeignKey
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

35
prisma/schema.prisma Normal file
View File

@ -0,0 +1,35 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
clerkId String @unique
email String?
name String?
subdomain String @unique
templateId Int @default(1)
title String?
description String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
images Image[]
}
model Image {
id String @id @default(cuid())
url String
key String
order Int
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId])
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,34 @@
const { S3Client, PutBucketPolicyCommand } = require("@aws-sdk/client-s3");
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || "eu-2",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
});
const bucket = process.env.S3_BUCKET_NAME || "monuments-images";
const policy = {
Version: "2012-10-17",
Statement: [
{
Sid: "PublicReadGetObject",
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${bucket}/*`],
},
],
};
client
.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }))
.then(() => console.log("Bucket policy set successfully:", bucket))
.catch((err) => {
console.error("Failed to set bucket policy:", err.message);
process.exit(1);
});

8
scripts/start.sh Normal file
View File

@ -0,0 +1,8 @@
#!/bin/bash
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy
echo "Starting Next.js server..."
exec node server.js

View File

@ -0,0 +1,64 @@
import { prisma } from "@/lib/prisma";
import { renderTemplate } from "@/lib/templates";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ subdomain: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { subdomain } = await params;
const user = await prisma.user.findUnique({
where: { subdomain },
include: { images: { orderBy: { order: "asc" } } },
});
if (!user || !user.published) {
return { title: "Monument Not Found" };
}
const title = user.title || "City Monument";
const description = user.description?.slice(0, 160) || `Visit ${title} — a monument page on SpomeniQR.`;
return {
title,
description,
openGraph: {
title,
description,
images: user.images[0]?.url ? [{ url: user.images[0].url }] : undefined,
type: "article",
},
};
}
export default async function MonumentPage({ params }: Props) {
const { subdomain } = await params;
const user = await prisma.user.findUnique({
where: { subdomain },
include: { images: { orderBy: { order: "asc" } } },
});
if (!user || !user.published) {
notFound();
}
const data = {
id: user.id,
title: user.title,
description: user.description,
subdomain: user.subdomain,
templateId: user.templateId,
published: user.published,
images: user.images.map((img) => ({
id: img.id,
url: img.url,
key: img.key,
order: img.order,
})),
};
return renderTemplate(user.templateId, data);
}

View File

@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: NextRequest) {
const slug = req.nextUrl.searchParams.get("slug");
if (!slug || slug.length < 3) {
return NextResponse.json({ available: false });
}
const existing = await prisma.user.findUnique({ where: { subdomain: slug } });
return NextResponse.json({ available: !existing });
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || "eu-2",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true,
});
const S3_BUCKET = process.env.S3_BUCKET_NAME || "monuments-images";
export async function GET(req: NextRequest) {
const key = req.nextUrl.searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Missing key parameter" }, { status: 400 });
}
if (!key.startsWith("uploads/")) {
return NextResponse.json({ error: "Invalid key" }, { status: 400 });
}
try {
const result = await s3Client.send(
new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key,
})
);
const body = await result.Body!.transformToByteArray();
const contentType = result.ContentType || "application/octet-stream";
return new NextResponse(Buffer.from(body), {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
console.error("Image proxy error:", message);
if (message.includes("NoSuchKey") || message.includes("404")) {
return NextResponse.json({ error: "Image not found" }, { status: 404 });
}
return NextResponse.json({ error: "Failed to fetch image" }, { status: 500 });
}
}

View File

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ subdomain: string }> }
) {
const { subdomain } = await params;
const user = await prisma.user.findUnique({
where: { subdomain },
include: { images: { orderBy: { order: "asc" } } },
});
if (!user || !user.published) {
return NextResponse.json({ error: "Monument not found" }, { status: 404 });
}
return NextResponse.json({
id: user.id,
title: user.title,
description: user.description,
subdomain: user.subdomain,
templateId: user.templateId,
images: user.images,
});
}

View File

@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { generateMonumentQR } from "@/lib/qrcode";
import { getPublicUrl } from "@/lib/upload";
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { title, description, subdomain, templateId, images } = body;
if (!title?.trim()) {
return NextResponse.json({ error: "Title is required" }, { status: 400 });
}
if (!subdomain || subdomain.length < 3) {
return NextResponse.json({ error: "Subdomain must be at least 3 characters" }, { status: 400 });
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(subdomain)) {
return NextResponse.json({ error: "Subdomain can only contain lowercase letters, numbers, and hyphens" }, { status: 400 });
}
if (templateId < 1 || templateId > 3) {
return NextResponse.json({ error: "Invalid template" }, { status: 400 });
}
if (!images || images.length === 0 || images.length > 3) {
return NextResponse.json({ error: "1-3 images required" }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { subdomain } });
if (existing && existing.clerkId !== userId) {
return NextResponse.json({ error: "Subdomain already taken" }, { status: 409 });
}
const existingUser = await prisma.user.findUnique({ where: { clerkId: userId } });
let user;
if (existingUser) {
await prisma.image.deleteMany({ where: { userId: existingUser.id } });
user = await prisma.user.update({
where: { clerkId: userId },
data: {
title,
description,
subdomain,
templateId,
published: true,
images: {
create: images.map((img: { key: string }, i: number) => ({
url: getPublicUrl(img.key),
key: img.key,
order: i + 1,
})),
},
},
include: { images: true },
});
} else {
user = await prisma.user.create({
data: {
clerkId: userId,
title,
description,
subdomain,
templateId,
published: true,
images: {
create: images.map((img: { key: string }, i: number) => ({
url: getPublicUrl(img.key),
key: img.key,
order: i + 1,
})),
},
},
include: { images: true },
});
}
const qrCode = await generateMonumentQR(subdomain);
return NextResponse.json({
success: true,
monumentUrl: `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`,
qrCode,
user,
});
} catch (error) {
console.error("Publish error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from "uuid";
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || "eu-2",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true,
});
const S3_BUCKET = process.env.S3_BUCKET_NAME || "monuments-images";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
function getPublicUrl(key: string): string {
return `/api/image?key=${encodeURIComponent(key)}`;
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
}
const ext = file.type.split("/")[1];
const key = `uploads/${userId}/${uuidv4()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3Client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
const publicUrl = getPublicUrl(key);
return NextResponse.json({ key, url: publicUrl });
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { deleteS3Object } from "@/lib/s3";
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ imageId: string }> }
) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { imageId } = await params;
try {
const image = await prisma.image.findUnique({
where: { id: imageId },
include: { user: true },
});
if (!image || image.user.clerkId !== userId) {
return NextResponse.json({ error: "Image not found" }, { status: 404 });
}
try {
await deleteS3Object(image.key);
} catch (e) {
console.error("Failed to delete S3 object:", image.key, e);
}
await prisma.image.delete({ where: { id: imageId } });
const remainingImages = await prisma.image.findMany({
where: { userId: image.userId },
orderBy: { order: "asc" },
});
for (let i = 0; i < remainingImages.length; i++) {
await prisma.image.update({
where: { id: remainingImages[i].id },
data: { order: i + 1 },
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Delete image error:", error);
return NextResponse.json({ error: "Failed to delete image" }, { status: 500 });
}
}

View File

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { deleteS3Object } from "@/lib/s3";
export async function DELETE(req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const user = await prisma.user.findUnique({
where: { clerkId: userId },
include: { images: true },
});
if (!user) {
return NextResponse.json({ error: "Monument not found" }, { status: 404 });
}
for (const image of user.images) {
try {
await deleteS3Object(image.key);
} catch (e) {
console.error("Failed to delete S3 object:", image.key, e);
}
}
await prisma.user.delete({ where: { id: user.id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error("Delete monument error:", error);
return NextResponse.json({ error: "Failed to delete monument" }, { status: 500 });
}
}
export async function GET() {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { clerkId: userId },
include: { images: { orderBy: { order: "asc" } } },
});
if (!user) {
return NextResponse.json({ error: "Monument not found" }, { status: 404 });
}
return NextResponse.json({ user });
}
export async function PUT(req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { title, description, templateId, published } = body;
const user = await prisma.user.update({
where: { clerkId: userId },
data: {
...(title !== undefined && { title }),
...(description !== undefined && { description }),
...(templateId !== undefined && { templateId }),
...(published !== undefined && { published }),
},
include: { images: { orderBy: { order: "asc" } } },
});
return NextResponse.json({ user });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Failed to update monument" }, { status: 500 });
}
}

View File

@ -0,0 +1,184 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import ImageUploader from "@/components/ImageUploader";
interface MonumentImage {
id: string;
key: string;
url: string;
order: number;
}
interface EditData {
title: string;
description: string;
templateId: number;
}
export default function EditPage() {
const router = useRouter();
const [data, setData] = useState<EditData>({ title: "", description: "", templateId: 1 });
const [images, setImages] = useState<MonumentImage[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch("/api/user/monument")
.then((r) => r.json())
.then((d) => {
setData({
title: d.user.title || "",
description: d.user.description || "",
templateId: d.user.templateId || 1,
});
setImages(d.user.images || []);
})
.finally(() => setLoading(false));
}, []);
const handleDeleteImage = async (imageId: string) => {
try {
const res = await fetch(`/api/user/monument/image/${imageId}`, { method: "DELETE" });
if (res.ok) {
setImages(images.filter((img) => img.id !== imageId));
}
} catch {
// ignore
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/user/monument", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
router.push("/dashboard");
}
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
}
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-2xl px-6 py-4">
<h1 className="text-xl font-semibold text-stone-900">Edit Monument</h1>
</div>
</header>
<main className="mx-auto max-w-2xl px-6 py-8">
<div className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
Monument Name
</label>
<input
id="title"
type="text"
value={data.title}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
Description
</label>
<textarea
id="description"
value={data.description}
onChange={(e) => setData({ ...data, description: e.target.value })}
rows={5}
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={2000}
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">Template</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 1, name: "Classic" },
{ id: 2, name: "Modern" },
{ id: 3, name: "Minimal" },
].map((t) => (
<button
key={t.id}
onClick={() => setData({ ...data, templateId: t.id })}
className={`rounded-lg border-2 px-4 py-2 text-sm font-medium transition-colors ${
data.templateId === t.id
? "border-primary bg-primary/5 text-primary"
: "border-stone-200 text-stone-700 hover:border-stone-300"
}`}
>
{t.name}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">Current Images</label>
{images.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{images.map((img) => (
<div key={img.id} className="group relative overflow-hidden rounded-lg border border-stone-200">
<img src={img.url} alt="" className="h-32 w-full object-cover" />
<button
onClick={() => handleDeleteImage(img.id)}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
>
&times;
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-stone-400">No images yet.</p>
)}
{images.length < 3 && (
<div className="mt-4">
<ImageUploader
images={images.map((img) => ({ key: img.key, order: img.order }))}
onImagesChange={(newImages) => {
router.refresh();
}}
/>
</div>
)}
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<button
onClick={() => router.push("/dashboard")}
className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
Cancel
</button>
</div>
</div>
</main>
</div>
);
}

114
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,114 @@
import Link from "next/link";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { UserButton } from "@clerk/nextjs";
import CopyButton from "@/components/CopyButton";
import DeleteMonumentButton from "@/components/DeleteMonumentButton";
import DeleteImageButton from "@/components/DeleteImageButton";
export default async function DashboardPage() {
const { userId } = await auth();
if (!userId) redirect("/sign-in");
const user = await prisma.user.findUnique({
where: { clerkId: userId },
include: { images: { orderBy: { order: "asc" } } },
});
if (!user) {
redirect("/onboarding");
}
const monumentUrl = `https://${user.subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`;
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<h1 className="text-xl font-semibold text-stone-900">SpomeniQR</h1>
<UserButton />
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-8">
<div className="rounded-lg border border-stone-200 bg-white p-6">
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Untitled Monument"}</h2>
<p className="mt-2 text-sm text-stone-500">
{user.published ? "Published" : "Draft"} &middot; Subdomain: <span className="font-mono">{user.subdomain}</span>
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href={monumentUrl}
target="_blank"
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
View Monument
</Link>
<Link
href="/dashboard/edit"
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
Edit
</Link>
</div>
</div>
{user.images.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-medium text-stone-900">Images</h3>
<div className="mt-3 grid grid-cols-3 gap-4">
{user.images.map((img) => (
<div key={img.id} className="group relative overflow-hidden rounded-lg border border-stone-200">
<img src={img.url} alt="" className="h-40 w-full object-cover" />
<DeleteImageButton imageId={img.id} />
</div>
))}
</div>
</div>
)}
<div className="mt-6">
<h3 className="text-lg font-medium text-stone-900">Monument QR Code</h3>
<p className="mt-1 text-sm text-stone-500">Download and display this QR code at your monument location.</p>
<div className="mt-4 rounded-lg border border-stone-200 bg-white p-4">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(monumentUrl)}`}
alt="QR Code"
className="h-48 w-48"
/>
<a
href={`https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(monumentUrl)}`}
download
className="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
Download QR
</a>
</div>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-stone-900">Share Link</h3>
<div className="mt-2 flex items-center gap-2">
<input
type="text"
readOnly
value={monumentUrl}
className="flex-1 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 font-mono text-sm text-stone-700"
/>
<CopyButton text={monumentUrl} />
</div>
</div>
<div className="mt-10 border-t border-stone-200 pt-6">
<h3 className="text-sm font-medium text-red-600">Danger Zone</h3>
<p className="mt-1 text-xs text-stone-500">Permanently delete your monument and all associated images.</p>
<div className="mt-3">
<DeleteMonumentButton />
</div>
</div>
</main>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

29
src/app/globals.css Normal file
View File

@ -0,0 +1,29 @@
@import "tailwindcss";
@theme inline {
--color-background: #ffffff;
--color-foreground: #171717;
--color-primary: #1a1a2e;
--color-primary-light: #2d2d4e;
--color-accent: #c9a84c;
--color-accent-light: #e0c96e;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-surface: #f9fafb;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
}

33
src/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "SpomeniQR — City Monuments Memories",
description: "Create beautiful monument pages with QR codes. Share memories of city monuments with the world.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
</ClerkProvider>
);
}

16
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,16 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6">
<h1 className="text-4xl font-bold text-stone-900">Monument Not Found</h1>
<p className="mt-4 text-stone-600">This monument page does not exist or has not been published yet.</p>
<Link
href="/"
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
Go Home
</Link>
</div>
);
}

182
src/app/onboarding/page.tsx Normal file
View File

@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import ImageUploader from "@/components/ImageUploader";
import SubdomainPicker from "@/components/SubdomainPicker";
import TemplatePicker from "@/components/TemplatePicker";
const STEPS = ["Details", "Photos", "Subdomain", "Template"] as const;
export default function OnboardingWizard() {
const router = useRouter();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<{ key: string; order: number }[]>([]);
const [subdomain, setSubdomain] = useState("");
const [templateId, setTemplateId] = useState(1);
const canProceed = () => {
switch (step) {
case 0:
return title.trim().length > 0;
case 1:
return images.length > 0;
case 2:
return subdomain.length >= 3;
case 3:
return templateId >= 1 && templateId <= 3;
default:
return false;
}
};
const handlePublish = async () => {
setLoading(true);
setError("");
try {
const res = await fetch("/api/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description,
subdomain,
templateId,
images,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to publish");
}
router.push("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-stone-50">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-2xl px-6 py-4">
<h1 className="text-xl font-semibold text-stone-900">Create Your Monument Page</h1>
</div>
</header>
<div className="mx-auto max-w-2xl px-6 py-8">
<div className="mb-8 flex gap-2">
{STEPS.map((label, i) => (
<div key={label} className="flex flex-1 items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
i <= step ? "bg-primary text-white" : "bg-stone-200 text-stone-500"
}`}
>
{i + 1}
</div>
<span
className={`ml-2 hidden text-sm sm:inline ${
i <= step ? "text-stone-900" : "text-stone-400"
}`}
>
{label}
</span>
{i < STEPS.length - 1 && (
<div className={`mx-2 h-px flex-1 ${i < step ? "bg-primary" : "bg-stone-200"}`} />
)}
</div>
))}
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</div>
)}
{step === 0 && (
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
Monument Name
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Eiffel Tower"
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={100}
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Tell the story of this monument..."
rows={5}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={2000}
/>
</div>
</div>
)}
{step === 1 && (
<ImageUploader
images={images}
onImagesChange={setImages}
/>
)}
{step === 2 && (
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
)}
{step === 3 && (
<TemplatePicker value={templateId} onChange={setTemplateId} />
)}
<div className="mt-8 flex justify-between">
{step > 0 ? (
<button
onClick={() => setStep(step - 1)}
className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
Back
</button>
) : (
<div />
)}
{step < STEPS.length - 1 ? (
<button
onClick={() => setStep(step + 1)}
disabled={!canProceed()}
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
Continue
</button>
) : (
<button
onClick={handlePublish}
disabled={!canProceed() || loading}
className="rounded-lg bg-accent px-6 py-2 text-sm font-medium text-primary transition-colors hover:bg-accent-light disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Publishing..." : "Publish Monument"}
</button>
)}
</div>
</div>
</div>
);
}

54
src/app/page.tsx Normal file
View File

@ -0,0 +1,54 @@
import Link from "next/link";
import NavAuth, { LandingAuth } from "@/components/NavAuth";
export default async function HomePage() {
return (
<div className="flex min-h-screen flex-col">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<Link href="/" className="text-xl font-bold text-primary">
SpomeniQR
</Link>
<NavAuth />
</div>
</header>
<main className="flex flex-1 items-center justify-center px-6">
<div className="mx-auto max-w-3xl text-center">
<div className="mb-6 text-6xl">&#127963;</div>
<h2 className="text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
City Monuments Memories
</h2>
<p className="mt-4 text-lg text-stone-600">
Create beautiful monument pages with QR codes. Share memories of city monuments with the world.
</p>
<div className="mt-8">
<LandingAuth />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 text-left sm:grid-cols-3">
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#9997;</div>
<h3 className="font-semibold text-stone-900">Describe Your Monument</h3>
<p className="mt-1 text-sm text-stone-500">Write the story of any city monument and share it with visitors.</p>
</div>
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#128247;</div>
<h3 className="font-semibold text-stone-900">Upload Photos</h3>
<p className="mt-1 text-sm text-stone-500">Add up to 3 beautiful photos to showcase the monument.</p>
</div>
<div className="rounded-lg border border-stone-200 bg-white p-6">
<div className="mb-3 text-2xl">&#128241;</div>
<h3 className="font-semibold text-stone-900">Get a QR Code</h3>
<p className="mt-1 text-sm text-stone-500">Each monument gets a unique subdomain and QR code for easy access.</p>
</div>
</div>
</div>
</main>
<footer className="border-t border-stone-200 py-6 text-center text-sm text-stone-400">
SpomeniQR City Monuments Memories
</footer>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn />
</div>
);
}

View File

@ -0,0 +1,9 @@
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp />
</div>
);
}

View File

@ -0,0 +1,12 @@
"use client";
export default function CopyButton({ text }: { text: string }) {
return (
<button
onClick={() => navigator.clipboard.writeText(text)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
Copy
</button>
);
}

View File

@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface DeleteImageButtonProps {
imageId: string;
}
export default function DeleteImageButton({ imageId }: DeleteImageButtonProps) {
const [deleting, setDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
setDeleting(true);
try {
const res = await fetch(`/api/user/monument/image/${imageId}`, { method: "DELETE" });
if (res.ok) {
router.refresh();
} else {
const data = await res.json();
alert(data.error || "Failed to delete image");
}
} catch {
alert("Something went wrong");
} finally {
setDeleting(false);
}
};
return (
<button
onClick={handleDelete}
disabled={deleting}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-50"
title="Delete image"
>
&times;
</button>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function DeleteMonumentButton() {
const [showConfirm, setShowConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
setDeleting(true);
try {
const res = await fetch("/api/user/monument", { method: "DELETE" });
if (res.ok) {
router.push("/dashboard");
router.refresh();
} else {
const data = await res.json();
alert(data.error || "Failed to delete monument");
}
} catch {
alert("Something went wrong");
} finally {
setDeleting(false);
setShowConfirm(false);
}
};
if (showConfirm) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-800">Are you sure? This will permanently delete your monument and all its images.</p>
<div className="mt-3 flex gap-2">
<button
onClick={handleDelete}
disabled={deleting}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Yes, delete forever"}
</button>
<button
onClick={() => setShowConfirm(false)}
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
>
Cancel
</button>
</div>
</div>
);
}
return (
<button
onClick={() => setShowConfirm(true)}
className="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
>
Delete Monument
</button>
);
}

View File

@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useUser } from "@clerk/nextjs";
const MAX_FILES = 3;
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
interface ImageData {
key: string;
url: string;
order: number;
}
interface ImageUploaderProps {
images: { key: string; order: number }[];
onImagesChange: (images: { key: string; order: number }[]) => void;
}
export default function ImageUploader({ images, onImagesChange }: ImageUploaderProps) {
const { user } = useUser();
const [uploading, setUploading] = useState(false);
const [previews, setPreviews] = useState<ImageData[]>([]);
const [error, setError] = useState("");
const handleFiles = async (files: FileList) => {
const remaining = MAX_FILES - images.length;
if (remaining <= 0) return;
const validFiles = Array.from(files)
.filter((f) => ALLOWED_TYPES.includes(f.type))
.filter((f) => f.size <= MAX_FILE_SIZE)
.slice(0, remaining);
if (validFiles.length === 0) {
setError("Invalid file type or size. Accepted: JPEG, PNG, WebP, GIF up to 5MB.");
return;
}
setError("");
setUploading(true);
try {
const newImages = [...images];
const newPreviews = [...previews];
for (const file of validFiles) {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Upload failed");
}
const { key, url } = await res.json();
const order = newImages.length + 1;
newImages.push({ key, order });
newPreviews.push({ key, url, order });
}
onImagesChange(newImages);
setPreviews(newPreviews);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
};
const removeImage = (key: string) => {
const idx = images.findIndex((img) => img.key === key);
if (idx === -1) return;
const updated = images
.filter((img) => img.key !== key)
.map((img, i) => ({ ...img, order: i + 1 }));
onImagesChange(updated);
setPreviews(previews.filter((p) => p.key !== key));
};
return (
<div className="space-y-4">
<div>
<p className="text-sm text-stone-600">
Upload up to {MAX_FILES} photos (max {MAX_FILE_SIZE / 1024 / 1024}MB each, JPEG/PNG/WebP/GIF)
</p>
<label className="mt-3 flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-stone-300 bg-stone-50 px-6 py-8 transition-colors hover:border-primary hover:bg-stone-100">
<div className="text-center">
<p className="text-sm font-medium text-stone-700">
{uploading ? "Uploading..." : "Click to upload or drag and drop"}
</p>
</div>
<input
type="file"
accept={ALLOWED_TYPES.join(",")}
multiple
className="hidden"
disabled={uploading || images.length >= MAX_FILES}
onChange={(e) => e.target.files && handleFiles(e.target.files)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
e.dataTransfer.files && handleFiles(e.dataTransfer.files);
}}
/>
</label>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
{previews.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{previews.map((preview) => (
<div key={preview.key} className="group relative overflow-hidden rounded-lg border border-stone-200">
<img src={preview.url} alt="" className="h-32 w-full object-cover" />
<button
onClick={() => removeImage(preview.key)}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
>
&times;
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { SignInButton, SignUpButton, UserButton } from "@clerk/nextjs";
import { useAuth } from "@clerk/nextjs";
import Link from "next/link";
export default function NavAuth() {
const { isSignedIn } = useAuth();
if (isSignedIn) {
return <UserButton />;
}
return (
<div className="flex items-center gap-3">
<SignInButton mode="redirect" fallbackRedirectUrl="/dashboard">
<button className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50">
Sign In
</button>
</SignInButton>
<SignUpButton mode="redirect" fallbackRedirectUrl="/onboarding">
<button className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light">
Sign Up
</button>
</SignUpButton>
</div>
);
}
export function LandingAuth() {
const { isSignedIn } = useAuth();
if (isSignedIn) {
return (
<Link
href="/dashboard"
className="rounded-lg bg-primary px-8 py-3 text-base font-medium text-white transition-colors hover:bg-primary-light"
>
Go to Dashboard
</Link>
);
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<SignInButton mode="redirect" fallbackRedirectUrl="/dashboard">
<button className="rounded-lg border border-stone-200 px-8 py-3 text-base font-medium text-stone-700 transition-colors hover:bg-stone-50">
Sign In
</button>
</SignInButton>
<SignUpButton mode="redirect" fallbackRedirectUrl="/onboarding">
<button className="rounded-lg bg-primary px-8 py-3 text-base font-medium text-white transition-colors hover:bg-primary-light">
Get Started
</button>
</SignUpButton>
</div>
);
}

View File

@ -0,0 +1,76 @@
"use client";
import { useState, useEffect, useCallback } from "react";
interface SubdomainPickerProps {
value: string;
onChange: (value: string) => void;
}
export default function SubdomainPicker({ value, onChange }: SubdomainPickerProps) {
const [checking, setChecking] = useState(false);
const [available, setAvailable] = useState<boolean | null>(null);
const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
const checkAvailability = useCallback(async (s: string) => {
if (s.length < 3) {
setAvailable(null);
return;
}
setChecking(true);
try {
const res = await fetch(`/api/check-subdomain?slug=${encodeURIComponent(s)}`);
const data = await res.json();
setAvailable(data.available);
} catch {
setAvailable(null);
} finally {
setChecking(false);
}
}, []);
useEffect(() => {
const timer = setTimeout(() => {
checkAvailability(slug);
}, 500);
return () => clearTimeout(timer);
}, [slug, checkAvailability]);
return (
<div className="space-y-4">
<div>
<label htmlFor="subdomain" className="block text-sm font-medium text-stone-700">
Choose your subdomain
</label>
<div className="mt-1 flex items-center rounded-lg border border-stone-200 bg-white">
<input
id="subdomain"
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))}
placeholder="e.g. eiffel-tower"
className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={63}
/>
<span className="rounded-r-lg bg-stone-50 px-3 py-2 text-sm text-stone-500 border-l border-stone-200">
.{process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"}
</span>
</div>
</div>
<div className="text-sm">
{checking && <p className="text-stone-500">Checking availability...</p>}
{!checking && available === true && (
<p className="text-green-600">&#10003; {slug}.testbed.mk is available!</p>
)}
{!checking && available === false && (
<p className="text-red-600">&#10007; This subdomain is already taken.</p>
)}
{!checking && available === null && slug.length > 0 && slug.length < 3 && (
<p className="text-stone-400">At least 3 characters required.</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,86 @@
"use client";
interface TemplatePickerProps {
value: number;
onChange: (value: number) => void;
}
const TEMPLATES = [
{
id: 1,
name: "Classic",
description: "Clean serif typography with a historical archive feel. Hero image with description and photo grid.",
preview: (
<div className="space-y-2 p-3">
<div className="h-12 w-full rounded bg-stone-200" />
<div className="space-y-1">
<div className="h-1.5 w-3/4 rounded bg-stone-300" />
<div className="h-1.5 w-full rounded bg-stone-200" />
<div className="h-1.5 w-full rounded bg-stone-200" />
</div>
<div className="flex gap-1.5">
<div className="h-8 w-8 rounded bg-stone-200" />
<div className="h-8 w-8 rounded bg-stone-200" />
</div>
</div>
),
},
{
id: 2,
name: "Modern",
description: "Bold, full-bleed images with dark overlay. Cinematic and immersive feel.",
preview: (
<div className="space-y-2 p-3">
<div className="h-16 w-full rounded bg-zinc-800" />
<div className="flex gap-1.5">
<div className="h-6 w-6 rounded bg-zinc-700" />
<div className="h-6 w-6 rounded bg-zinc-700" />
</div>
</div>
),
},
{
id: 3,
name: "Minimal",
description: "Whitespace-heavy, single-column journal style. Clean and elegant.",
preview: (
<div className="space-y-2 p-3">
<div className="h-2 w-1/2 rounded bg-stone-300" />
<div className="space-y-1">
<div className="h-1.5 w-full rounded bg-stone-100" />
<div className="h-1.5 w-2/3 rounded bg-stone-100" />
</div>
<div className="flex gap-1">
<div className="h-4 w-8 rounded bg-stone-100" />
<div className="h-4 w-8 rounded bg-stone-100" />
<div className="h-4 w-8 rounded bg-stone-100" />
</div>
</div>
),
},
];
export default function TemplatePicker({ value, onChange }: TemplatePickerProps) {
return (
<div className="space-y-3">
<p className="text-sm text-stone-600">Choose how your monument page will look.</p>
<div className="grid grid-cols-3 gap-3">
{TEMPLATES.map((t) => (
<button
key={t.id}
onClick={() => onChange(t.id)}
className={`rounded-lg border-2 p-1 text-left transition-colors ${
value === t.id
? "border-primary bg-primary/5"
: "border-stone-200 hover:border-stone-300"
}`}
>
<div className="mb-2 rounded bg-white">{t.preview}</div>
<p className="px-2 text-sm font-medium text-stone-900">{t.name}</p>
<p className="mt-0.5 px-2 text-xs text-stone-500">{t.description}</p>
</button>
))}
</div>
</div>
);
}

9
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

16
src/lib/qrcode.ts Normal file
View File

@ -0,0 +1,16 @@
import QRCode from "qrcode";
export async function generateMonumentQR(subdomain: string): Promise<string> {
const url = `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`;
const qrBuffer = await QRCode.toBuffer(url, {
type: "png",
width: 400,
margin: 2,
color: {
dark: "#1a1a2e",
light: "#ffffff",
},
});
return `data:image/png;base64,${qrBuffer.toString("base64")}`;
}

26
src/lib/s3.ts Normal file
View File

@ -0,0 +1,26 @@
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";
export const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || "eu-2",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true,
});
export const S3_BUCKET = process.env.S3_BUCKET_NAME || "monuments-images";
export function getPublicUrl(key: string): string {
return `/api/image?key=${encodeURIComponent(key)}`;
}
export async function deleteS3Object(key: string): Promise<void> {
await s3Client.send(
new DeleteObjectCommand({
Bucket: S3_BUCKET,
Key: key,
})
);
}

162
src/lib/templates.tsx Normal file
View File

@ -0,0 +1,162 @@
import type { MonumentData } from "@/types";
export function renderTemplate(templateId: number, data: MonumentData) {
switch (templateId) {
case 1:
return <TemplateClassic data={data} />;
case 2:
return <TemplateModern data={data} />;
case 3:
return <TemplateMinimal data={data} />;
default:
return <TemplateClassic data={data} />;
}
}
function TemplateClassic({ data }: { data: MonumentData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
const heroImage = sortedImages[0];
const gridImages = sortedImages.slice(1);
return (
<div className="min-h-screen bg-stone-50">
{heroImage && (
<div className="relative h-[50vh] w-full overflow-hidden">
<img
src={heroImage.url}
alt={data.title || "Monument"}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/60 to-transparent" />
<div className="absolute bottom-8 left-8">
<h1 className="font-serif text-4xl font-bold text-white md:text-5xl">
{data.title}
</h1>
</div>
</div>
)}
<div className="mx-auto max-w-4xl px-6 py-12">
<div className="prose prose-lg prose-stone mx-auto">
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap">
{data.description}
</p>
</div>
{gridImages.length > 0 && (
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2">
{gridImages.map((img) => (
<div key={img.id} className="overflow-hidden rounded-lg shadow-lg">
<img
src={img.url}
alt={data.title || "Monument"}
className="h-64 w-full object-cover transition-transform duration-300 hover:scale-105"
/>
</div>
))}
</div>
)}
<footer className="mt-16 border-t border-stone-200 pt-8 text-center text-sm text-stone-400">
<p>Scan the QR code to visit this monument page</p>
</footer>
</div>
</div>
);
}
function TemplateModern({ data }: { data: MonumentData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
return (
<div className="min-h-screen bg-zinc-950 text-white">
{sortedImages.length > 0 && (
<div className="relative h-screen w-full">
<img
src={sortedImages[0].url}
alt={data.title || "Monument"}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
<div className="absolute bottom-12 left-8 right-8">
<h1 className="text-5xl font-black tracking-tight md:text-7xl">
{data.title}
</h1>
<p className="mt-4 max-w-xl text-lg text-zinc-300 line-clamp-3">
{data.description}
</p>
</div>
</div>
)}
{sortedImages.length > 1 && (
<div className="grid grid-cols-2 gap-1 md:grid-cols-3">
{sortedImages.slice(1).map((img) => (
<div key={img.id} className="relative aspect-square overflow-hidden">
<img
src={img.url}
alt={data.title || "Monument"}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
)}
{sortedImages.length <= 0 && (
<div className="flex min-h-screen items-center justify-center px-8">
<div className="max-w-2xl">
<h1 className="text-5xl font-black tracking-tight md:text-7xl">
{data.title}
</h1>
<p className="mt-6 text-lg text-zinc-400 whitespace-pre-wrap">
{data.description}
</p>
</div>
</div>
)}
<footer className="px-8 py-12 text-center text-sm text-zinc-600">
<p>Scan the QR code to visit this monument page</p>
</footer>
</div>
);
}
function TemplateMinimal({ data }: { data: MonumentData }) {
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
return (
<div className="min-h-screen bg-white">
<div className="mx-auto max-w-2xl px-6 py-24">
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-4xl">
{data.title}
</h1>
<div className="mt-8 border-t border-zinc-100 pt-8">
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap">
{data.description}
</p>
</div>
{sortedImages.length > 0 && (
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
{sortedImages.map((img) => (
<div key={img.id} className="flex-shrink-0">
<img
src={img.url}
alt={data.title || "Monument"}
className="h-72 w-auto rounded-sm object-cover"
/>
</div>
))}
</div>
)}
<footer className="mt-24 border-t border-zinc-100 pt-8 text-center text-xs text-zinc-400">
<p>Scan the QR code to visit this monument page</p>
</footer>
</div>
</div>
);
}

36
src/lib/upload.ts Normal file
View File

@ -0,0 +1,36 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { v4 as uuidv4 } from "uuid";
import { s3Client, S3_BUCKET, getPublicUrl } from "./s3";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_FILES = 3;
export { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES, getPublicUrl };
export async function generatePresignedUrl(
contentType: string,
userId: string
): Promise<{ url: string; key: string; publicUrl: string }> {
if (!ALLOWED_TYPES.includes(contentType)) {
throw new Error(`Invalid content type: ${contentType}`);
}
const ext = contentType.split("/")[1];
const key = `uploads/${userId}/${uuidv4()}.${ext}`;
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return {
url,
key,
publicUrl: getPublicUrl(key),
};
}

24
src/middleware.ts Normal file
View File

@ -0,0 +1,24 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]);
export default clerkMiddleware(async (auth, req: NextRequest) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
const subdomain = req.headers.get("x-subdomain");
if (subdomain) {
const url = req.nextUrl.clone();
url.pathname = `/${subdomain}${url.pathname === "/" ? "" : url.pathname}`;
return NextResponse.rewrite(url);
}
return NextResponse.next();
});
export const config = {
matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"],
};

24
src/types/index.ts Normal file
View File

@ -0,0 +1,24 @@
export interface MonumentData {
id: string;
title: string | null;
description: string | null;
subdomain: string;
templateId: number;
published: boolean;
images: ImageData[];
}
export interface ImageData {
id: string;
url: string;
key: string;
order: number;
}
export interface PublishPayload {
title: string;
description: string;
subdomain: string;
templateId: number;
images: { key: string; order: number }[];
}

42
tsconfig.json Normal file
View File

@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}