init
This commit is contained in:
commit
4fdb51f583
22
.env.example
Normal file
22
.env.example
Normal 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
39
.gitignore
vendored
Normal 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
42
Dockerfile
Normal 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
36
README.md
Normal 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
464
deploy.md
Normal 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
324
description.md
Normal 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
66
docker-compose.yml
Normal 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
18
eslint.config.mjs
Normal 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
208
local.md
Normal 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
19
next.config.ts
Normal 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
32
nginx/conf.d/default.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
53
nginx/conf.d/production.conf
Normal file
53
nginx/conf.d/production.conf
Normal 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
6979
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal 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
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
40
prisma/migrations/20260620151657_init/migration.sql
Normal file
40
prisma/migrations/20260620151657_init/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
35
prisma/schema.prisma
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
34
scripts/set-bucket-policy.js
Normal file
34
scripts/set-bucket-policy.js
Normal 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
8
scripts/start.sh
Normal 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
|
||||||
64
src/app/[subdomain]/page.tsx
Normal file
64
src/app/[subdomain]/page.tsx
Normal 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);
|
||||||
|
}
|
||||||
13
src/app/api/check-subdomain/route.ts
Normal file
13
src/app/api/check-subdomain/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
54
src/app/api/image/route.ts
Normal file
54
src/app/api/image/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/monument/[subdomain]/route.ts
Normal file
27
src/app/api/monument/[subdomain]/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
94
src/app/api/publish/route.ts
Normal file
94
src/app/api/publish/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/api/upload/route.ts
Normal file
66
src/app/api/upload/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/api/user/monument/image/[imageId]/route.ts
Normal file
52
src/app/api/user/monument/image/[imageId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/app/api/user/monument/route.ts
Normal file
83
src/app/api/user/monument/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/app/dashboard/edit/page.tsx
Normal file
184
src/app/dashboard/edit/page.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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
114
src/app/dashboard/page.tsx
Normal 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"} · 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
29
src/app/globals.css
Normal file
29
src/app/globals.css
Normal 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
33
src/app/layout.tsx
Normal 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
16
src/app/not-found.tsx
Normal 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
182
src/app/onboarding/page.tsx
Normal 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
54
src/app/page.tsx
Normal 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">🏛</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">✍</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">📷</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">📱</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/sign-in/[[...sign-in]]/page.tsx
Normal file
9
src/app/sign-in/[[...sign-in]]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/sign-up/[[...sign-up]]/page.tsx
Normal file
9
src/app/sign-up/[[...sign-up]]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/CopyButton.tsx
Normal file
12
src/components/CopyButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/DeleteImageButton.tsx
Normal file
41
src/components/DeleteImageButton.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/DeleteMonumentButton.tsx
Normal file
61
src/components/DeleteMonumentButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/components/ImageUploader.tsx
Normal file
137
src/components/ImageUploader.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/NavAuth.tsx
Normal file
58
src/components/NavAuth.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/SubdomainPicker.tsx
Normal file
76
src/components/SubdomainPicker.tsx
Normal 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">✓ {slug}.testbed.mk is available!</p>
|
||||||
|
)}
|
||||||
|
{!checking && available === false && (
|
||||||
|
<p className="text-red-600">✗ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/TemplatePicker.tsx
Normal file
86
src/components/TemplatePicker.tsx
Normal 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
9
src/lib/prisma.ts
Normal 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
16
src/lib/qrcode.ts
Normal 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
26
src/lib/s3.ts
Normal 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
162
src/lib/templates.tsx
Normal 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
36
src/lib/upload.ts
Normal 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
24
src/middleware.ts
Normal 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
24
src/types/index.ts
Normal 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
42
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user