template refinement
This commit is contained in:
parent
4fdb51f583
commit
ff93e8c5be
334
coolify.md
Normal file
334
coolify.md
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
# Deploying SpomeniQR on Coolify
|
||||||
|
|
||||||
|
This guide covers deploying SpomeniQR to a VPS using [Coolify](https://coolify.io/) — a self-hosting platform similar to Heroku/Vercel that manages Docker deployments, SSL, and databases.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A VPS with Docker installed (Coolify handles this)
|
||||||
|
- Coolify installed on your VPS (see [coolify.io/docs](https://coolify.io/docs/installation))
|
||||||
|
- Domain `testbed.mk` with DNS configured:
|
||||||
|
- **A record** `@` → your VPS IP
|
||||||
|
- **A record** `*` → your VPS IP (wildcard for subdomains)
|
||||||
|
- A Contabo S3 bucket set up with public read policy
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Coolify will run two containers:
|
||||||
|
1. **app** — Next.js standalone build (Dockerfile)
|
||||||
|
2. **db** — PostgreSQL 16 (Coolify managed database)
|
||||||
|
|
||||||
|
We do **not** deploy Nginx via Docker — Coolify has its own reverse proxy (Traefik/Caddy) that handles SSL, subdomain routing, and the `X-Subdomain` header.
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
│
|
||||||
|
├── *.testbed.mk ──► Coolify Proxy (:80/:443)
|
||||||
|
│ ├── Extracts subdomain → sets X-Subdomain header
|
||||||
|
│ └── Proxies to app:3000
|
||||||
|
│
|
||||||
|
└── testbed.mk ──► Coolify Proxy ──► app:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: Add a New Project in Coolify
|
||||||
|
|
||||||
|
1. Open your Coolify dashboard
|
||||||
|
2. Click **+ Add New Project**
|
||||||
|
3. Name it `SpomeniQR`
|
||||||
|
|
||||||
|
## Step 2: Create the Database
|
||||||
|
|
||||||
|
1. Inside the project, click **+ Add New Resource** → **Database**
|
||||||
|
2. Select **PostgreSQL**
|
||||||
|
3. Configure:
|
||||||
|
- **Name:** `spomeniqr-db`
|
||||||
|
- **PostgreSQL Version:** 16
|
||||||
|
- **Database Name:** `monuments`
|
||||||
|
- **Username:** `postgres`
|
||||||
|
- **Password:** generate a strong password or set your own
|
||||||
|
4. Click **Deploy**
|
||||||
|
5. After deployment, note the **Internal Connection String** — it looks like:
|
||||||
|
```
|
||||||
|
postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments
|
||||||
|
```
|
||||||
|
You'll need this for `DATABASE_URL`.
|
||||||
|
|
||||||
|
## Step 3: Add the Application
|
||||||
|
|
||||||
|
1. Inside the project, click **+ Add New Resource** → **Application**
|
||||||
|
2. Select **Public Repository** (or **Private** if your repo is private)
|
||||||
|
3. Configure:
|
||||||
|
- **Name:** `spomeniqr`
|
||||||
|
- **Repository URL:** your Git repo URL
|
||||||
|
- **Branch:** `main`
|
||||||
|
- **Build Pack:** **Nixpacks** (auto-detected) or **Docker** (uses the Dockerfile)
|
||||||
|
|
||||||
|
### Option A: Nixpacks (Recommended — simpler)
|
||||||
|
|
||||||
|
Leave the build pack as **Nixpacks**. Coolify will auto-detect Next.js and build it.
|
||||||
|
|
||||||
|
Add these **Build Commands:**
|
||||||
|
```
|
||||||
|
npx prisma generate && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this **Start Command:**
|
||||||
|
```
|
||||||
|
npx prisma migrate deploy && node .next/standalone/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Docker (uses the project's Dockerfile)
|
||||||
|
|
||||||
|
Set **Build Pack** to **Docker**. Coolify will use the `Dockerfile` in the repo root. No additional configuration needed — it already includes Prisma migration and standalone server startup.
|
||||||
|
|
||||||
|
**Note:** If using Docker build pack, the `DATABASE_URL` must use `spomeniqr-db` as the host (Coolify internal network), not `localhost`.
|
||||||
|
|
||||||
|
## Step 4: Configure Environment Variables
|
||||||
|
|
||||||
|
In the application settings, go to **Environment Variables** and add:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database — use the Coolify internal connection string
|
||||||
|
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@spomeniqr-db:5432/monuments
|
||||||
|
|
||||||
|
# Clerk
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
|
||||||
|
CLERK_SECRET_KEY=sk_live_...
|
||||||
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||||
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||||
|
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
|
||||||
|
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/
|
||||||
|
|
||||||
|
# Contabo S3
|
||||||
|
S3_ENDPOINT=https://eu2.contabostorage.com
|
||||||
|
S3_REGION=eu-2
|
||||||
|
S3_ACCESS_KEY_ID=your-access-key
|
||||||
|
S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
S3_BUCKET_NAME=monuments-images
|
||||||
|
|
||||||
|
# App
|
||||||
|
NEXT_PUBLIC_APP_URL=https://testbed.mk
|
||||||
|
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
|
||||||
|
|
||||||
|
# Node
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`.
|
||||||
|
- Use your **production** Clerk keys (`pk_live_` / `sk_live_`), not the test ones.
|
||||||
|
|
||||||
|
## Step 5: Configure Domain & Subdomain Routing
|
||||||
|
|
||||||
|
### Set the Main Domain
|
||||||
|
|
||||||
|
1. In the application settings, go to **Configuration** → **Domains**
|
||||||
|
2. Add: `testbed.mk`
|
||||||
|
3. Enable **HTTPS** — Coolify will auto-provision a Let's Encrypt certificate
|
||||||
|
|
||||||
|
### Enable Wildcard Subdomain Routing
|
||||||
|
|
||||||
|
This is the critical part. Coolify's proxy needs to:
|
||||||
|
|
||||||
|
1. Accept requests for `*.testbed.mk`
|
||||||
|
2. Extract the subdomain and pass it as the `X-Subdomain` header to the Next.js app
|
||||||
|
|
||||||
|
#### Option A: Coolify Proxy Configuration (Recommended)
|
||||||
|
|
||||||
|
1. In your application, go to **Configuration** → **Domains**
|
||||||
|
2. Add both domains:
|
||||||
|
- `testbed.mk`
|
||||||
|
- `*.testbed.mk`
|
||||||
|
3. Coolify will request a wildcard SSL certificate. If your DNS provider supports DNS-01 challenges (Cloudflare, Route53, etc.), this works automatically. Otherwise, you may need to add each subdomain manually.
|
||||||
|
|
||||||
|
#### Option B: Custom Proxy Configuration
|
||||||
|
|
||||||
|
If Coolify doesn't support wildcard domains easily, add a **custom Caddy/Traefik configuration** in the Coolify settings:
|
||||||
|
|
||||||
|
For **Caddy** (Coolify's default proxy):
|
||||||
|
|
||||||
|
Create a file at `/data/coolify/proxy/caddy/custom/testbed.mk`:
|
||||||
|
|
||||||
|
```
|
||||||
|
*.testbed.mk {
|
||||||
|
reverse_proxy app:3000 {
|
||||||
|
header_up X-Subdomain {http.request.host.labels.2}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testbed.mk {
|
||||||
|
reverse_proxy app:3000 {
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For **Traefik** (alternative proxy), you'd add labels to the container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
traefik.http.routers.spomeniqr.rule: HostRegexp(`{subdomain:[a-z0-9-]+}.testbed.mk`) || Host(`testbed.mk`)
|
||||||
|
traefik.http.middlewares.spomeniqr-subdomain.headers.customrequestheaders.X-Subdomain:
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The proxy configuration varies based on your Coolify version and proxy choice. Check the Coolify docs for the latest instructions on wildcard domains.
|
||||||
|
|
||||||
|
## Step 6: Configure Clerk
|
||||||
|
|
||||||
|
1. Go to [Clerk Dashboard](https://dashboard.clerk.com)
|
||||||
|
2. Switch to your **Production** instance
|
||||||
|
3. Under **Paths**, set:
|
||||||
|
- Sign-in: `/sign-in`
|
||||||
|
- Sign-up: `/sign-up`
|
||||||
|
4. Under **Domains**, add:
|
||||||
|
- `testbed.mk`
|
||||||
|
- `*.testbed.mk`
|
||||||
|
|
||||||
|
## Step 7: Configure Contabo S3
|
||||||
|
|
||||||
|
1. Log into Contabo Object Storage
|
||||||
|
2. Create a bucket named `monuments-images` in region `eu-2`
|
||||||
|
3. Set the **bucket policy** for public read:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PublicReadGetObject",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "AWS": ["*"] },
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::monuments-images/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Set **CORS** to allow uploads:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST"],
|
||||||
|
"AllowedOrigins": ["https://testbed.mk", "https://*.testbed.mk"],
|
||||||
|
"ExposeHeaders": ["ETag"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Create an API key with read/write permissions
|
||||||
|
|
||||||
|
## Step 8: Deploy
|
||||||
|
|
||||||
|
1. Click **Deploy** in the Coolify dashboard
|
||||||
|
2. Watch the build logs — it should:
|
||||||
|
- Install dependencies
|
||||||
|
- Run `npx prisma generate`
|
||||||
|
- Build the Next.js app
|
||||||
|
- Run `npx prisma migrate deploy`
|
||||||
|
- Start the server on port 3000
|
||||||
|
3. Once deployed, visit `https://testbed.mk` to verify
|
||||||
|
|
||||||
|
## Step 9: Verify Subdomain Routing
|
||||||
|
|
||||||
|
Test that wildcard subdomains work:
|
||||||
|
|
||||||
|
1. Create a memorial page with subdomain `test-memorial`
|
||||||
|
2. Visit `https://test-memorial.testbed.mk`
|
||||||
|
3. Check browser DevTools network tab — the `X-Subdomain` header should be set by the proxy
|
||||||
|
4. The Next.js middleware reads `X-Subdomain` and rewrites to the correct page
|
||||||
|
|
||||||
|
If subdomains aren't working:
|
||||||
|
- Check DNS: `dig *.testbed.mk +short` should return your VPS IP
|
||||||
|
- Check Coolify proxy logs for the wildcard domain configuration
|
||||||
|
- Verify the `X-Subdomain` header is being set in the proxy config
|
||||||
|
|
||||||
|
## Step 10: Verify Everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# App health
|
||||||
|
curl -s https://testbed.mk | head -5
|
||||||
|
|
||||||
|
# Subdomain routing
|
||||||
|
curl -sI https://test-memorial.testbed.mk | head -5
|
||||||
|
|
||||||
|
# API health
|
||||||
|
curl -s https://testbed.mk/api/check-subdomain?slug=test | python3 -m json.tool
|
||||||
|
|
||||||
|
# Database connection (from Coolify terminal)
|
||||||
|
docker exec spomeniqr-app npx prisma db push --accept-data-loss
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build fails with Prisma errors
|
||||||
|
|
||||||
|
Make sure `DATABASE_URL` points to the Coolify internal hostname (e.g., `spomeniqr-db:5432`), not `localhost`. The app and database must be on the same Coolify network.
|
||||||
|
|
||||||
|
### Images return 401 from S3
|
||||||
|
|
||||||
|
Make sure you've applied the bucket policy for public read. See Step 7. If Contabo doesn't serve public objects via URL, the app uses an `/api/image?key=...` proxy route as a fallback.
|
||||||
|
|
||||||
|
### Subdomain routing not working
|
||||||
|
|
||||||
|
- Verify DNS wildcard `*.testbed.mk` points to your VPS
|
||||||
|
- Check that Coolify's proxy config includes both `testbed.mk` and `*.testbed.mk`
|
||||||
|
- Check the proxy access logs — the `X-Subdomain` header should appear
|
||||||
|
- If using a custom Caddyfile, make sure it's in the right directory and reload the proxy
|
||||||
|
|
||||||
|
### Clerk authentication issues
|
||||||
|
|
||||||
|
- Ensure production Clerk keys are set (not `pk_test_` / `sk_test_`)
|
||||||
|
- Verify `testbed.mk` and `*.testbed.mk` are added in Clerk dashboard domains
|
||||||
|
- Check that `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_APP_DOMAIN` are set correctly
|
||||||
|
|
||||||
|
### Database migration issues
|
||||||
|
|
||||||
|
If migrations fail on deploy, you can run them manually from the Coolify terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into the app container
|
||||||
|
docker exec -it spomeniqr-app sh
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Or push schema changes directly
|
||||||
|
npx prisma db push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `DATABASE_URL` | Yes | PostgreSQL connection string (Coolify internal) |
|
||||||
|
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Yes | Clerk publishable key (`pk_live_...`) |
|
||||||
|
| `CLERK_SECRET_KEY` | Yes | Clerk secret key (`sk_live_...`) |
|
||||||
|
| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | Yes | `/sign-in` |
|
||||||
|
| `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | Yes | `/sign-up` |
|
||||||
|
| `S3_ENDPOINT` | Yes | Contabo S3 endpoint URL |
|
||||||
|
| `S3_REGION` | Yes | Contabo S3 region (e.g. `eu-2`) |
|
||||||
|
| `S3_ACCESS_KEY_ID` | Yes | S3 access key |
|
||||||
|
| `S3_SECRET_ACCESS_KEY` | Yes | S3 secret key |
|
||||||
|
| `S3_BUCKET_NAME` | Yes | S3 bucket name |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Yes | `https://testbed.mk` |
|
||||||
|
| `NEXT_PUBLIC_APP_DOMAIN` | Yes | `testbed.mk` |
|
||||||
|
| `NODE_ENV` | Yes | `production` |
|
||||||
|
|
||||||
|
## Useful Coolify Commands
|
||||||
|
|
||||||
|
- **Redeploy:** Project → Application → Deploy
|
||||||
|
- **View Logs:** Project → Application → Logs
|
||||||
|
- **SSH into container:** Project → Application → Terminal
|
||||||
|
- **Database Management:** Project → Database → Admin (pgAdmin or Prisma Studio)
|
||||||
|
- **SSL Certificates:** Managed automatically by Coolify for configured domains
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
To update the application:
|
||||||
|
|
||||||
|
1. Push changes to your Git repository
|
||||||
|
2. Coolify will auto-deploy if **Auto Deploy** is enabled, or click **Deploy** manually
|
||||||
|
3. The `start.sh` script runs `npx prisma migrate deploy` on every startup, so schema changes are applied automatically
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "bornDate" TEXT,
|
||||||
|
ADD COLUMN "passedDate" TEXT;
|
||||||
@ -16,6 +16,8 @@ model User {
|
|||||||
templateId Int @default(1)
|
templateId Int @default(1)
|
||||||
title String?
|
title String?
|
||||||
description String?
|
description String?
|
||||||
|
bornDate String?
|
||||||
|
passedDate String?
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@ -15,11 +15,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.published) {
|
if (!user || !user.published) {
|
||||||
return { title: "Monument Not Found" };
|
return { title: "Memorial Not Found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = user.title || "City Monument";
|
const title = user.title || "Memorial";
|
||||||
const description = user.description?.slice(0, 160) || `Visit ${title} — a monument page on SpomeniQR.`;
|
const description = user.description?.slice(0, 160) || `In Loving Memory of ${title}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -33,7 +33,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MonumentPage({ params }: Props) {
|
export default async function MemorialPage({ params }: Props) {
|
||||||
const { subdomain } = await params;
|
const { subdomain } = await params;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@ -49,6 +49,8 @@ export default async function MonumentPage({ params }: Props) {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
title: user.title,
|
title: user.title,
|
||||||
description: user.description,
|
description: user.description,
|
||||||
|
bornDate: user.bornDate,
|
||||||
|
passedDate: user.passedDate,
|
||||||
subdomain: user.subdomain,
|
subdomain: user.subdomain,
|
||||||
templateId: user.templateId,
|
templateId: user.templateId,
|
||||||
published: user.published,
|
published: user.published,
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, description, subdomain, templateId, images } = body;
|
const { title, description, bornDate, passedDate, subdomain, templateId, images } = body;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: "Title is required" }, { status: 400 });
|
return NextResponse.json({ error: "Title is required" }, { status: 400 });
|
||||||
@ -45,6 +45,8 @@ export async function POST(req: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
bornDate: bornDate || null,
|
||||||
|
passedDate: passedDate || null,
|
||||||
subdomain,
|
subdomain,
|
||||||
templateId,
|
templateId,
|
||||||
published: true,
|
published: true,
|
||||||
@ -64,6 +66,8 @@ export async function POST(req: NextRequest) {
|
|||||||
clerkId: userId,
|
clerkId: userId,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
bornDate: bornDate || null,
|
||||||
|
passedDate: passedDate || null,
|
||||||
subdomain,
|
subdomain,
|
||||||
templateId,
|
templateId,
|
||||||
published: true,
|
published: true,
|
||||||
|
|||||||
@ -62,13 +62,15 @@ export async function PUT(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { title, description, templateId, published } = body;
|
const { title, description, bornDate, passedDate, templateId, published } = body;
|
||||||
|
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { clerkId: userId },
|
where: { clerkId: userId },
|
||||||
data: {
|
data: {
|
||||||
...(title !== undefined && { title }),
|
...(title !== undefined && { title }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
|
...(bornDate !== undefined && { bornDate }),
|
||||||
|
...(passedDate !== undefined && { passedDate }),
|
||||||
...(templateId !== undefined && { templateId }),
|
...(templateId !== undefined && { templateId }),
|
||||||
...(published !== undefined && { published }),
|
...(published !== undefined && { published }),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import ImageUploader from "@/components/ImageUploader";
|
import ImageUploader from "@/components/ImageUploader";
|
||||||
|
import TemplatePicker from "@/components/TemplatePicker";
|
||||||
|
|
||||||
interface MonumentImage {
|
interface MonumentImage {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,14 +16,17 @@ interface EditData {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
|
bornDate: string;
|
||||||
|
passedDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditPage() {
|
export default function EditPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [data, setData] = useState<EditData>({ title: "", description: "", templateId: 1 });
|
const [data, setData] = useState<EditData>({ title: "", description: "", templateId: 1, bornDate: "", passedDate: "" });
|
||||||
const [images, setImages] = useState<MonumentImage[]>([]);
|
const [images, setImages] = useState<MonumentImage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/user/monument")
|
fetch("/api/user/monument")
|
||||||
@ -32,6 +36,8 @@ export default function EditPage() {
|
|||||||
title: d.user.title || "",
|
title: d.user.title || "",
|
||||||
description: d.user.description || "",
|
description: d.user.description || "",
|
||||||
templateId: d.user.templateId || 1,
|
templateId: d.user.templateId || 1,
|
||||||
|
bornDate: d.user.bornDate || "",
|
||||||
|
passedDate: d.user.passedDate || "",
|
||||||
});
|
});
|
||||||
setImages(d.user.images || []);
|
setImages(d.user.images || []);
|
||||||
})
|
})
|
||||||
@ -69,20 +75,29 @@ export default function EditPage() {
|
|||||||
return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
|
return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imagePreviews = images.map((img) => ({ url: img.url, order: img.order }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50">
|
<div className="min-h-screen bg-stone-50">
|
||||||
<header className="border-b border-stone-200 bg-white">
|
<header className="border-b border-stone-200 bg-white">
|
||||||
<div className="mx-auto max-w-2xl px-6 py-4">
|
<div className="mx-auto max-w-6xl px-6 py-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold text-stone-900">Edit Monument</h1>
|
<h1 className="text-xl font-semibold text-stone-900">Edit Memorial</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
|
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 lg:hidden"
|
||||||
|
>
|
||||||
|
{showPreview ? "Edit" : "Preview"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-2xl px-6 py-8">
|
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{/* Form - hidden on mobile when preview is shown */}
|
||||||
|
<div className={`flex-1 min-w-0 ${showPreview ? "hidden lg:block" : ""}`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
|
<label htmlFor="title" className="block text-sm font-medium text-stone-700">Name</label>
|
||||||
Monument Name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
type="text"
|
type="text"
|
||||||
@ -93,10 +108,35 @@ export default function EditPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
|
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">Born</label>
|
||||||
Description
|
<input
|
||||||
</label>
|
id="bornDate"
|
||||||
|
type="text"
|
||||||
|
value={data.bornDate}
|
||||||
|
onChange={(e) => setData({ ...data, bornDate: e.target.value })}
|
||||||
|
placeholder="e.g. 1960"
|
||||||
|
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={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">Passed Away</label>
|
||||||
|
<input
|
||||||
|
id="passedDate"
|
||||||
|
type="text"
|
||||||
|
value={data.passedDate}
|
||||||
|
onChange={(e) => setData({ ...data, passedDate: e.target.value })}
|
||||||
|
placeholder="e.g. 2024"
|
||||||
|
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={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-stone-700">Epitaph / Life Story</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={data.description}
|
value={data.description}
|
||||||
@ -111,9 +151,9 @@ export default function EditPage() {
|
|||||||
<label className="block text-sm font-medium text-stone-700 mb-2">Template</label>
|
<label className="block text-sm font-medium text-stone-700 mb-2">Template</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{[
|
{[
|
||||||
{ id: 1, name: "Classic" },
|
{ id: 1, name: "Elegance" },
|
||||||
{ id: 2, name: "Modern" },
|
{ id: 2, name: "Cinematic" },
|
||||||
{ id: 3, name: "Minimal" },
|
{ id: 3, name: "Serene" },
|
||||||
].map((t) => (
|
].map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
@ -131,7 +171,7 @@ export default function EditPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-stone-700 mb-2">Current Images</label>
|
<label className="block text-sm font-medium text-stone-700 mb-2">Photos</label>
|
||||||
{images.length > 0 ? (
|
{images.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
@ -147,38 +187,50 @@ export default function EditPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-stone-400">No images yet.</p>
|
<p className="text-sm text-stone-400">No photos yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{images.length < 3 && (
|
{images.length < 3 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
images={images.map((img) => ({ key: img.key, order: img.order }))}
|
images={images.map((img) => ({ key: img.key, order: img.order }))}
|
||||||
onImagesChange={(newImages) => {
|
onImagesChange={() => router.refresh()}
|
||||||
router.refresh();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<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">
|
||||||
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"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
<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">
|
||||||
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
|
{/* Live preview - always visible on desktop, toggle on mobile */}
|
||||||
|
<div className={`w-[420px] flex-shrink-0 ${showPreview ? "" : "hidden lg:block"}`}>
|
||||||
|
<div className="sticky top-8">
|
||||||
|
<p className="mb-2 text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
|
||||||
|
<div className="h-[600px] overflow-y-auto">
|
||||||
|
<TemplatePicker
|
||||||
|
value={data.templateId}
|
||||||
|
onChange={(id) => setData({ ...data, templateId: id })}
|
||||||
|
title={data.title}
|
||||||
|
description={data.description}
|
||||||
|
bornDate={data.bornDate}
|
||||||
|
passedDate={data.passedDate}
|
||||||
|
images={imagePreviews}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -33,7 +33,12 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
<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>
|
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Untitled Memorial"}</h2>
|
||||||
|
{(user.bornDate || user.passedDate) && (
|
||||||
|
<p className="mt-1 text-sm tracking-wider text-stone-400">
|
||||||
|
{user.bornDate}{user.bornDate && user.passedDate ? " — " : ""}{user.passedDate}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-2 text-sm text-stone-500">
|
<p className="mt-2 text-sm text-stone-500">
|
||||||
{user.published ? "Published" : "Draft"} · Subdomain: <span className="font-mono">{user.subdomain}</span>
|
{user.published ? "Published" : "Draft"} · Subdomain: <span className="font-mono">{user.subdomain}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
--color-surface: #f9fafb;
|
--color-surface: #f9fafb;
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-serif: Georgia, "Times New Roman", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SpomeniQR — City Monuments Memories",
|
title: "SpomeniQR — In Loving Memory",
|
||||||
description: "Create beautiful monument pages with QR codes. Share memories of city monuments with the world.",
|
description: "Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import Link from "next/link";
|
|||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
<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>
|
<h1 className="text-4xl font-bold text-stone-900">Memorial Not Found</h1>
|
||||||
<p className="mt-4 text-stone-600">This monument page does not exist or has not been published yet.</p>
|
<p className="mt-4 text-stone-600">This memorial page does not exist or has not been published yet.</p>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader";
|
|||||||
import SubdomainPicker from "@/components/SubdomainPicker";
|
import SubdomainPicker from "@/components/SubdomainPicker";
|
||||||
import TemplatePicker from "@/components/TemplatePicker";
|
import TemplatePicker from "@/components/TemplatePicker";
|
||||||
|
|
||||||
const STEPS = ["Details", "Photos", "Subdomain", "Template"] as const;
|
const STEPS = ["Details", "Dates", "Photos", "Subdomain", "Template"] as const;
|
||||||
|
|
||||||
export default function OnboardingWizard() {
|
export default function OnboardingWizard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -16,22 +16,20 @@ export default function OnboardingWizard() {
|
|||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [images, setImages] = useState<{ key: string; order: number }[]>([]);
|
const [bornDate, setBornDate] = useState("");
|
||||||
|
const [passedDate, setPassedDate] = useState("");
|
||||||
|
const [images, setImages] = useState<{ key: string; order: number; url?: string }[]>([]);
|
||||||
const [subdomain, setSubdomain] = useState("");
|
const [subdomain, setSubdomain] = useState("");
|
||||||
const [templateId, setTemplateId] = useState(1);
|
const [templateId, setTemplateId] = useState(1);
|
||||||
|
|
||||||
const canProceed = () => {
|
const canProceed = () => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0:
|
case 0: return title.trim().length > 0;
|
||||||
return title.trim().length > 0;
|
case 1: return true;
|
||||||
case 1:
|
case 2: return images.length > 0;
|
||||||
return images.length > 0;
|
case 3: return subdomain.length >= 3;
|
||||||
case 2:
|
case 4: return templateId >= 1 && templateId <= 3;
|
||||||
return subdomain.length >= 3;
|
default: return false;
|
||||||
case 3:
|
|
||||||
return templateId >= 1 && templateId <= 3;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,9 +43,11 @@ export default function OnboardingWizard() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
bornDate: bornDate || undefined,
|
||||||
|
passedDate: passedDate || undefined,
|
||||||
subdomain,
|
subdomain,
|
||||||
templateId,
|
templateId,
|
||||||
images,
|
images: images.map(({ key, order }) => ({ key, order })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -62,34 +62,34 @@ export default function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const imagePreviews = images
|
||||||
|
.filter((img) => img.url)
|
||||||
|
.map((img) => ({ url: img.url!, order: img.order }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50">
|
<div className="min-h-screen bg-stone-50">
|
||||||
<header className="border-b border-stone-200 bg-white">
|
<header className="border-b border-stone-200 bg-white">
|
||||||
<div className="mx-auto max-w-2xl px-6 py-4">
|
<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>
|
<h1 className="text-xl font-semibold text-stone-900">Create a Memorial Page</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto max-w-2xl px-6 py-8">
|
<div className="mx-auto max-w-2xl px-6 py-8">
|
||||||
<div className="mb-8 flex gap-2">
|
<div className="mb-8 flex gap-1">
|
||||||
{STEPS.map((label, i) => (
|
{STEPS.map((label, i) => (
|
||||||
<div key={label} className="flex flex-1 items-center">
|
<div key={label} className="flex flex-1 items-center">
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
|
||||||
i <= step ? "bg-primary text-white" : "bg-stone-200 text-stone-500"
|
i <= step ? "bg-primary text-white" : "bg-stone-200 text-stone-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`ml-1.5 hidden text-xs sm:inline ${i <= step ? "text-stone-900" : "text-stone-400"}`}>
|
||||||
className={`ml-2 hidden text-sm sm:inline ${
|
|
||||||
i <= step ? "text-stone-900" : "text-stone-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{i < STEPS.length - 1 && (
|
{i < STEPS.length - 1 && (
|
||||||
<div className={`mx-2 h-px flex-1 ${i < step ? "bg-primary" : "bg-stone-200"}`} />
|
<div className={`mx-1 h-px flex-1 ${i < step ? "bg-primary" : "bg-stone-200"}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -103,29 +103,29 @@ export default function OnboardingWizard() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
|
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
|
||||||
Monument Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g. Eiffel Tower"
|
placeholder="e.g. Maria Novakova"
|
||||||
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"
|
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
|
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
|
||||||
Description
|
Epitaph / Life Story
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Tell the story of this monument..."
|
placeholder="A few words, a poem, or a biography to honor their memory..."
|
||||||
rows={5}
|
rows={6}
|
||||||
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"
|
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -133,18 +133,62 @@ export default function OnboardingWizard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<ImageUploader
|
<div className="space-y-4">
|
||||||
images={images}
|
<p className="text-sm text-stone-500">
|
||||||
onImagesChange={setImages}
|
These are optional. You can enter exact dates, approximate years, or leave them blank.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">
|
||||||
|
Born
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bornDate"
|
||||||
|
type="text"
|
||||||
|
value={bornDate}
|
||||||
|
onChange={(e) => setBornDate(e.target.value)}
|
||||||
|
placeholder="e.g. 1960 or March 15, 1960"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">
|
||||||
|
Passed Away
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passedDate"
|
||||||
|
type="text"
|
||||||
|
value={passedDate}
|
||||||
|
onChange={(e) => setPassedDate(e.target.value)}
|
||||||
|
placeholder="e.g. 2024 or November 20, 2024"
|
||||||
|
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
|
<ImageUploader
|
||||||
|
images={images}
|
||||||
|
onImagesChange={(newImages) => setImages(newImages as typeof images)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<TemplatePicker value={templateId} onChange={setTemplateId} />
|
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<TemplatePicker
|
||||||
|
value={templateId}
|
||||||
|
onChange={setTemplateId}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
bornDate={bornDate}
|
||||||
|
passedDate={passedDate}
|
||||||
|
images={imagePreviews}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 flex justify-between">
|
<div className="mt-8 flex justify-between">
|
||||||
@ -170,9 +214,9 @@ export default function OnboardingWizard() {
|
|||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
disabled={!canProceed() || loading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? "Publishing..." : "Publish Monument"}
|
{loading ? "Publishing..." : "Publish Memorial"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,12 +15,12 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
<main className="flex flex-1 items-center justify-center px-6">
|
<main className="flex flex-1 items-center justify-center px-6">
|
||||||
<div className="mx-auto max-w-3xl text-center">
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
<div className="mb-6 text-6xl">🏛</div>
|
<div className="mb-6 text-5xl">💎</div>
|
||||||
<h2 className="text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
|
<h2 className="text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
|
||||||
City Monuments Memories
|
In Loving Memory
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-lg text-stone-600">
|
<p className="mt-4 text-lg text-stone-600">
|
||||||
Create beautiful monument pages with QR codes. Share memories of city monuments with the world.
|
Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<LandingAuth />
|
<LandingAuth />
|
||||||
@ -29,25 +29,25 @@ export default async function HomePage() {
|
|||||||
<div className="mt-16 grid grid-cols-1 gap-8 text-left sm:grid-cols-3">
|
<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="rounded-lg border border-stone-200 bg-white p-6">
|
||||||
<div className="mb-3 text-2xl">✍</div>
|
<div className="mb-3 text-2xl">✍</div>
|
||||||
<h3 className="font-semibold text-stone-900">Describe Your Monument</h3>
|
<h3 className="font-semibold text-stone-900">Tell Their Story</h3>
|
||||||
<p className="mt-1 text-sm text-stone-500">Write the story of any city monument and share it with visitors.</p>
|
<p className="mt-1 text-sm text-stone-500">Write an epitaph, biography, or poem to honor their memory.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||||
<div className="mb-3 text-2xl">📷</div>
|
<div className="mb-3 text-2xl">📷</div>
|
||||||
<h3 className="font-semibold text-stone-900">Upload Photos</h3>
|
<h3 className="font-semibold text-stone-900">Share Photos</h3>
|
||||||
<p className="mt-1 text-sm text-stone-500">Add up to 3 beautiful photos to showcase the monument.</p>
|
<p className="mt-1 text-sm text-stone-500">Add up to 3 photos that capture their spirit and legacy.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||||
<div className="mb-3 text-2xl">📱</div>
|
<div className="mb-3 text-2xl">📱</div>
|
||||||
<h3 className="font-semibold text-stone-900">Get a QR Code</h3>
|
<h3 className="font-semibold text-stone-900">QR Code at the Grave</h3>
|
||||||
<p className="mt-1 text-sm text-stone-500">Each monument gets a unique subdomain and QR code for easy access.</p>
|
<p className="mt-1 text-sm text-stone-500">Visitors scan the QR code to read their story, right at the memorial site.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-stone-200 py-6 text-center text-sm text-stone-400">
|
<footer className="border-t border-stone-200 py-6 text-center text-sm text-stone-400">
|
||||||
SpomeniQR — City Monuments Memories
|
SpomeniQR — In Loving Memory
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
49
src/components/MemorialPreview.tsx
Normal file
49
src/components/MemorialPreview.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TemplateElegance, TemplateCinematic, TemplateSerene } from "@/lib/templates";
|
||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
|
||||||
|
interface MemorialPreviewProps {
|
||||||
|
templateId: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
bornDate: string;
|
||||||
|
passedDate: string;
|
||||||
|
images: { url: string; order: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_IMAGES = [
|
||||||
|
{ id: "p1", url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23d4d4d8' width='800' height='600'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%2371717a'%3EPhoto%3C/text%3E%3C/svg%3E", key: "p1", order: 1 },
|
||||||
|
{ id: "p2", url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Crect fill='%23a1a1aa' width='800' height='600'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%23ffffff'%3EPhoto%3C/text%3E%3C/svg%3E", key: "p2", order: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MemorialPreview({
|
||||||
|
templateId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
bornDate,
|
||||||
|
passedDate,
|
||||||
|
images,
|
||||||
|
}: MemorialPreviewProps) {
|
||||||
|
const data: MemorialData = {
|
||||||
|
id: "preview",
|
||||||
|
title: title || "Name Surname",
|
||||||
|
description: description || "A beloved soul who touched the lives of many and will forever be remembered.",
|
||||||
|
bornDate: bornDate || null,
|
||||||
|
passedDate: passedDate || null,
|
||||||
|
subdomain: "preview",
|
||||||
|
templateId,
|
||||||
|
published: true,
|
||||||
|
images: images.length > 0
|
||||||
|
? images.map((img, i) => ({ id: `img-${i}`, url: img.url, key: `key-${i}`, order: img.order }))
|
||||||
|
: PLACEHOLDER_IMAGES,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none select-none">
|
||||||
|
{templateId === 1 && <TemplateElegance data={data} />}
|
||||||
|
{templateId === 2 && <TemplateCinematic data={data} />}
|
||||||
|
{templateId === 3 && <TemplateSerene data={data} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,86 +1,62 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import MemorialPreview from "./MemorialPreview";
|
||||||
|
|
||||||
interface TemplatePickerProps {
|
interface TemplatePickerProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
bornDate: string;
|
||||||
|
passedDate: string;
|
||||||
|
images: { url: string; order: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEMPLATES = [
|
const TEMPLATES = [
|
||||||
{
|
{ id: 1, name: "Elegance", tagline: "Serif warmth, dignified tribute" },
|
||||||
id: 1,
|
{ id: 2, name: "Cinematic", tagline: "Bold, immersive, full-screen" },
|
||||||
name: "Classic",
|
{ id: 3, name: "Serene", tagline: "Peaceful, centered, spiritual" },
|
||||||
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) {
|
export default function TemplatePicker({ value, onChange, title, description, bornDate, passedDate, images }: TemplatePickerProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-stone-600">Choose how your monument page will look.</p>
|
<p className="text-sm text-stone-600">Choose how the memorial page will look. Scroll the preview to see the full page.</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{TEMPLATES.map((t) => (
|
{TEMPLATES.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => onChange(t.id)}
|
onClick={() => onChange(t.id)}
|
||||||
className={`rounded-lg border-2 p-1 text-left transition-colors ${
|
className={`rounded-lg border-2 px-3 py-2 text-left transition-colors ${
|
||||||
value === t.id
|
value === t.id
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-stone-200 hover:border-stone-300"
|
: "border-stone-200 hover:border-stone-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2 rounded bg-white">{t.preview}</div>
|
<p className="text-sm font-semibold text-stone-900">{t.name}</p>
|
||||||
<p className="px-2 text-sm font-medium text-stone-900">{t.name}</p>
|
<p className="mt-0.5 text-xs text-stone-500">{t.tagline}</p>
|
||||||
<p className="mt-0.5 px-2 text-xs text-stone-500">{t.description}</p>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
|
||||||
|
<div className="h-[500px] overflow-y-auto">
|
||||||
|
<MemorialPreview
|
||||||
|
templateId={value}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
bornDate={bornDate}
|
||||||
|
passedDate={passedDate}
|
||||||
|
images={images}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,161 +1,215 @@
|
|||||||
import type { MonumentData } from "@/types";
|
import type { MemorialData } from "@/types";
|
||||||
|
|
||||||
export function renderTemplate(templateId: number, data: MonumentData) {
|
function formatDates(born: string | null, passed: string | null): string {
|
||||||
|
if (born && passed) return `${born} \u2014 ${passed}`;
|
||||||
|
if (passed) return passed;
|
||||||
|
if (born) return born;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemorialFooter({ name }: { name: string | null }) {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-stone-200 py-10 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-amber-600">
|
||||||
|
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm italic text-stone-400">In Loving Memory</p>
|
||||||
|
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTemplate(templateId: number, data: MemorialData) {
|
||||||
switch (templateId) {
|
switch (templateId) {
|
||||||
case 1:
|
case 1:
|
||||||
return <TemplateClassic data={data} />;
|
return <TemplateElegance data={data} />;
|
||||||
case 2:
|
case 2:
|
||||||
return <TemplateModern data={data} />;
|
return <TemplateCinematic data={data} />;
|
||||||
case 3:
|
case 3:
|
||||||
return <TemplateMinimal data={data} />;
|
return <TemplateSerene data={data} />;
|
||||||
default:
|
default:
|
||||||
return <TemplateClassic data={data} />;
|
return <TemplateElegance data={data} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateClassic({ data }: { data: MonumentData }) {
|
export function TemplateElegance({ data }: { data: MemorialData }) {
|
||||||
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
const heroImage = sortedImages[0];
|
const heroImage = sortedImages[0];
|
||||||
const gridImages = sortedImages.slice(1);
|
const gridImages = sortedImages.slice(1);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50">
|
<div className="min-h-screen bg-stone-50 font-serif">
|
||||||
{heroImage && (
|
{heroImage && (
|
||||||
<div className="relative h-[50vh] w-full overflow-hidden">
|
<div className="relative h-[55vh] w-full overflow-hidden">
|
||||||
<img
|
<img src={heroImage.url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
|
||||||
src={heroImage.url}
|
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/70 via-stone-900/20 to-transparent" />
|
||||||
alt={data.title || "Monument"}
|
<div className="absolute bottom-10 left-0 right-0 text-center">
|
||||||
className="h-full w-full object-cover"
|
<h1 className="text-4xl font-bold tracking-wide text-white md:text-6xl drop-shadow-lg">
|
||||||
/>
|
{data.title || "In Loving Memory"}
|
||||||
<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>
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
|
||||||
|
{dates}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
{!heroImage && (
|
||||||
<div className="prose prose-lg prose-stone mx-auto">
|
<div className="mx-auto max-w-2xl px-6 pt-24 text-center">
|
||||||
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap">
|
<h1 className="text-4xl font-bold tracking-wide text-stone-900 md:text-6xl">
|
||||||
|
{data.title || "In Loving Memory"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
<div className="mx-auto mt-6 h-px w-16 bg-amber-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-14">
|
||||||
|
{data.description && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap md:text-xl">
|
||||||
{data.description}
|
{data.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{gridImages.length > 0 && (
|
{gridImages.length > 0 && (
|
||||||
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{gridImages.map((img) => (
|
{gridImages.map((img) => (
|
||||||
<div key={img.id} className="overflow-hidden rounded-lg shadow-lg">
|
<div key={img.id} className="overflow-hidden rounded-lg shadow-md">
|
||||||
<img
|
<img src={img.url} alt="" className="h-64 w-full object-cover transition-transform duration-500 hover:scale-105" />
|
||||||
src={img.url}
|
|
||||||
alt={data.title || "Monument"}
|
|
||||||
className="h-64 w-full object-cover transition-transform duration-300 hover:scale-105"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mt-16 border-t border-stone-200 pt-8 text-center text-sm text-stone-400">
|
<MemorialFooter name={data.title} />
|
||||||
<p>Scan the QR code to visit this monument page</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateModern({ data }: { data: MonumentData }) {
|
export function TemplateCinematic({ data }: { data: MemorialData }) {
|
||||||
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-screen bg-zinc-950 text-white">
|
||||||
{sortedImages.length > 0 && (
|
{sortedImages.length > 0 && (
|
||||||
|
<>
|
||||||
<div className="relative h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
<img
|
<img src={sortedImages[0].url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
|
||||||
src={sortedImages[0].url}
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
|
||||||
alt={data.title || "Monument"}
|
<div className="absolute bottom-16 left-8 right-8 md:left-16">
|
||||||
className="h-full w-full object-cover"
|
<h1 className="text-5xl font-black tracking-tight md:text-8xl drop-shadow-2xl">
|
||||||
/>
|
{data.title || "In Loving Memory"}
|
||||||
<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>
|
</h1>
|
||||||
<p className="mt-4 max-w-xl text-lg text-zinc-300 line-clamp-3">
|
{dates && (
|
||||||
|
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
|
||||||
|
{dates}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.description && (
|
||||||
|
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-300 md:text-lg line-clamp-4">
|
||||||
{data.description}
|
{data.description}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sortedImages.length > 1 && (
|
||||||
|
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||||
|
{sortedImages.slice(1).map((img) => (
|
||||||
|
<div key={img.id} className="relative aspect-square overflow-hidden">
|
||||||
|
<img src={img.url} alt="" 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 text-center">
|
||||||
|
<h1 className="text-5xl font-black tracking-tight md:text-8xl">
|
||||||
|
{data.title || "In Loving Memory"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
{data.description && (
|
||||||
|
<p className="mt-8 text-base leading-relaxed text-zinc-400 whitespace-pre-wrap md:text-lg">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-zinc-500">
|
||||||
|
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm italic text-zinc-500">In Loving Memory</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateSerene({ data }: { data: MemorialData }) {
|
||||||
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<div className="mx-auto max-w-xl px-6 py-20 md:py-28">
|
||||||
|
<div className="text-center">
|
||||||
|
{sortedImages.length > 0 && (
|
||||||
|
<div className="mx-auto mb-8 h-40 w-40 overflow-hidden rounded-full shadow-lg">
|
||||||
|
<img src={sortedImages[0].url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
|
||||||
|
{data.title || "In Loving Memory"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-3 text-sm tracking-[0.2em] text-zinc-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto mt-6 h-px w-12 bg-blue-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.description && (
|
||||||
|
<div className="mt-10 border-l-2 border-blue-100 pl-6">
|
||||||
|
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap md:text-lg">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedImages.length > 1 && (
|
{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">
|
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
|
||||||
{sortedImages.map((img) => (
|
{sortedImages.slice(1).map((img) => (
|
||||||
<div key={img.id} className="flex-shrink-0">
|
<div key={img.id} className="flex-shrink-0">
|
||||||
<img
|
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
|
||||||
src={img.url}
|
|
||||||
alt={data.title || "Monument"}
|
|
||||||
className="h-72 w-auto rounded-sm object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer className="mt-24 border-t border-zinc-100 pt-8 text-center text-xs text-zinc-400">
|
<MemorialFooter name={data.title} />
|
||||||
<p>Scan the QR code to visit this monument page</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
export interface MonumentData {
|
export interface MemorialData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
bornDate: string | null;
|
||||||
|
passedDate: string | null;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
@ -18,6 +20,8 @@ export interface ImageData {
|
|||||||
export interface PublishPayload {
|
export interface PublishPayload {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
bornDate?: string;
|
||||||
|
passedDate?: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
images: { key: string; order: number }[];
|
images: { key: string; order: number }[];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user