Compare commits
4 Commits
2c78112a4f
...
a5775930aa
| Author | SHA1 | Date | |
|---|---|---|---|
| a5775930aa | |||
| d9f980b640 | |||
| a6d1ade53c | |||
| cb99fec940 |
@ -6,10 +6,11 @@ services:
|
||||
# Baked into the client bundle at build time - see Dockerfile.
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}
|
||||
ports:
|
||||
- "3100:3000"
|
||||
- "3101:3100"
|
||||
environment:
|
||||
PORT: 3100
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://einvite:einvite@postgres:5432/einvite?schema=public}
|
||||
APP_URL: ${APP_URL:-http://localhost:3100}
|
||||
APP_URL: ${APP_URL:-http://localhost:3101}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
||||
@ -32,7 +33,7 @@ services:
|
||||
mailpit:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
@ -63,3 +64,9 @@ services:
|
||||
volumes:
|
||||
postgres-data:
|
||||
uploads-data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.89.0.0/24
|
||||
|
||||
1553
package-lock.json
generated
1553
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"archiver": "^8.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"heic-convert": "^2.1.0",
|
||||
"libphonenumber-js": "^1.13.8",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "^16.2.10",
|
||||
@ -26,11 +28,13 @@
|
||||
"prisma": "^5.20.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sharp": "^0.35.3",
|
||||
"twilio": "^6.0.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable: nullable host-chosen color overrides for the event's theme -
|
||||
-- NULL means "use the theme's own default colors" (see resolveTheme in
|
||||
-- src/lib/themes.ts).
|
||||
ALTER TABLE "Event" ADD COLUMN "themePrimaryColor" TEXT,
|
||||
ADD COLUMN "themeAccentColor" TEXT;
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable: nullable WebP thumbnail for photo formats browsers can't
|
||||
-- render inline (HEIC/HEIF) - see Photo.thumbnailFilename in schema.prisma.
|
||||
ALTER TABLE "Photo" ADD COLUMN "thumbnailFilename" TEXT;
|
||||
@ -71,6 +71,8 @@ model Event {
|
||||
description String @default("")
|
||||
contactInfo String @default("")
|
||||
theme String @default("classic-elegant")
|
||||
themePrimaryColor String?
|
||||
themeAccentColor String?
|
||||
timezone String @default("UTC")
|
||||
eventDate DateTime
|
||||
endDate DateTime?
|
||||
@ -99,12 +101,15 @@ model Event {
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
filename String
|
||||
uploaderName String @default("")
|
||||
hidden Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
eventId String
|
||||
filename String
|
||||
// WebP thumbnail filename for formats browsers can't render inline (HEIC/HEIF).
|
||||
// Null means `filename` itself is already displayable - use it directly.
|
||||
thumbnailFilename String?
|
||||
uploaderName String @default("")
|
||||
hidden Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
BIN
public/textures/cardboard.png
Normal file
BIN
public/textures/cardboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
BIN
public/textures/dark-leather.png
Normal file
BIN
public/textures/dark-leather.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
public/textures/exclusive-paper.png
Normal file
BIN
public/textures/exclusive-paper.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
public/textures/fresh-snow.png
Normal file
BIN
public/textures/fresh-snow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
public/textures/white-linen.png
Normal file
BIN
public/textures/white-linen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 B |
@ -19,6 +19,8 @@ function parseEventForm(formData: FormData) {
|
||||
description: formData.get('description') ?? '',
|
||||
contactInfo: formData.get('contactInfo') ?? '',
|
||||
theme: formData.get('theme'),
|
||||
themePrimaryColor: formData.get('themePrimaryColor') ?? '',
|
||||
themeAccentColor: formData.get('themeAccentColor') ?? '',
|
||||
timezone: formData.get('timezone'),
|
||||
eventDate: formData.get('eventDate'),
|
||||
endDate: formData.get('endDate') ?? '',
|
||||
@ -59,6 +61,8 @@ export async function createEventAction(_prev: ActionState, formData: FormData):
|
||||
description: data.description,
|
||||
contactInfo: data.contactInfo,
|
||||
theme: data.theme,
|
||||
themePrimaryColor: data.themePrimaryColor || null,
|
||||
themeAccentColor: data.themeAccentColor || null,
|
||||
timezone: tz,
|
||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||
@ -99,6 +103,8 @@ export async function updateEventAction(
|
||||
description: data.description,
|
||||
contactInfo: data.contactInfo,
|
||||
theme: data.theme,
|
||||
themePrimaryColor: data.themePrimaryColor || null,
|
||||
themeAccentColor: data.themeAccentColor || null,
|
||||
timezone: tz,
|
||||
eventDate: zonedDatetimeLocalToUtc(data.eventDate, tz),
|
||||
endDate: data.endDate ? zonedDatetimeLocalToUtc(data.endDate, tz) : null,
|
||||
|
||||
@ -4,7 +4,15 @@ import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { savePhotoFile, deletePhotoFile, MAX_PHOTO_BYTES, ALLOWED_PHOTO_TYPES } from '@/lib/photoStorage';
|
||||
import {
|
||||
savePhotoFile,
|
||||
saveThumbnailFile,
|
||||
deletePhotoFile,
|
||||
MAX_PHOTO_BYTES,
|
||||
resolvePhotoExtension,
|
||||
isHeicExtension,
|
||||
} from '@/lib/photoStorage';
|
||||
import { convertHeicToWebpThumbnail } from '@/lib/photoThumbnail';
|
||||
import { photoUploadSchema } from '@/lib/validation';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
@ -43,8 +51,9 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return { error: 'Choose a photo to upload' };
|
||||
}
|
||||
if (!ALLOWED_PHOTO_TYPES[file.type]) {
|
||||
return { error: 'Only JPEG, PNG, WebP, and GIF images are allowed' };
|
||||
const ext = resolvePhotoExtension(file);
|
||||
if (!ext) {
|
||||
return { error: 'Only JPEG, PNG, WebP, GIF, HEIC, and HEIF images are allowed' };
|
||||
}
|
||||
if (file.size > MAX_PHOTO_BYTES) {
|
||||
return { error: 'Photo is too large (8MB max)' };
|
||||
@ -53,7 +62,22 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
||||
// Write the file first (cheap to redo/discard), then enforce the cap with
|
||||
// a serializable transaction so a burst of concurrent uploads can't all
|
||||
// pass a stale count check and land past MAX_PHOTOS_PER_EVENT together.
|
||||
const filename = await savePhotoFile(eventId, file);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = await savePhotoFile(eventId, buffer, ext);
|
||||
|
||||
// Most browsers (everything but Safari) can't render HEIC/HEIF inline, so
|
||||
// generate a WebP thumbnail for gallery display; the original HEIC/HEIF
|
||||
// file is kept as-is and is what downloads (single or zip) hand back.
|
||||
let thumbnailFilename: string | null = null;
|
||||
if (isHeicExtension(ext)) {
|
||||
try {
|
||||
const thumbnailBuffer = await convertHeicToWebpThumbnail(buffer);
|
||||
thumbnailFilename = await saveThumbnailFile(eventId, thumbnailBuffer);
|
||||
} catch (err) {
|
||||
console.error(`Failed to generate thumbnail for HEIC/HEIF photo in event ${eventId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
@ -61,12 +85,13 @@ export async function uploadPhotoAction(_prev: ActionState, formData: FormData):
|
||||
if (photoCount >= MAX_PHOTOS_PER_EVENT) {
|
||||
throw new PhotoLimitReachedError();
|
||||
}
|
||||
await tx.photo.create({ data: { eventId, filename, uploaderName } });
|
||||
await tx.photo.create({ data: { eventId, filename, thumbnailFilename, uploaderName } });
|
||||
},
|
||||
{ isolationLevel: 'Serializable' }
|
||||
);
|
||||
} catch (err) {
|
||||
await deletePhotoFile(eventId, filename);
|
||||
if (thumbnailFilename) await deletePhotoFile(eventId, thumbnailFilename);
|
||||
if (err instanceof PhotoLimitReachedError) {
|
||||
return { error: 'This event has reached its photo limit' };
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { rsvpSchema } from '@/lib/validation';
|
||||
import { rsvpSchema, rsvpUpdateSchema, manualRsvpSchema } from '@/lib/validation';
|
||||
import { sendRsvpGuestConfirmation, sendRsvpHostNotification } from '@/lib/mail';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
import { sendPushToUser } from '@/lib/push';
|
||||
import { accessibleEventWhere, requireEventAccess } from '@/lib/eventAccess';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { looksLikeEmail, normalizePhoneNumber } from '@/lib/phone';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
@ -111,3 +115,110 @@ export async function submitRsvpAction(_prev: ActionState, formData: FormData):
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
return { success: SUCCESS_MESSAGE };
|
||||
}
|
||||
|
||||
async function requireOwnedRsvp(rsvpId: string) {
|
||||
const user = await requireUser();
|
||||
const rsvp = await prisma.rsvp.findUnique({ where: { id: rsvpId }, include: { event: true } });
|
||||
if (!rsvp) notFound();
|
||||
|
||||
const accessibleEvent = await prisma.event.findFirst({
|
||||
where: accessibleEventWhere(rsvp.eventId, user.id),
|
||||
});
|
||||
if (!accessibleEvent) notFound();
|
||||
|
||||
return rsvp;
|
||||
}
|
||||
|
||||
export type RsvpEditState = { error?: string };
|
||||
|
||||
export async function updateRsvpAction(
|
||||
rsvpId: string,
|
||||
_prev: RsvpEditState,
|
||||
formData: FormData
|
||||
): Promise<RsvpEditState> {
|
||||
const rsvp = await requireOwnedRsvp(rsvpId);
|
||||
|
||||
const parsed = rsvpUpdateSchema.safeParse({
|
||||
guestName: formData.get('guestName'),
|
||||
status: formData.get('status'),
|
||||
numGuests: formData.get('numGuests') || '1',
|
||||
note: formData.get('note') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
await prisma.rsvp.update({
|
||||
where: { id: rsvp.id },
|
||||
data: {
|
||||
guestName: data.guestName,
|
||||
status: data.status,
|
||||
numGuests: data.status === 'NO' ? 1 : data.numGuests,
|
||||
note: data.note,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
|
||||
revalidatePath(`/invite/${rsvp.event.slug}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an RSVP the host heard about outside the app (a text, a call, a
|
||||
* comment at the mailbox) rather than through the guest submitting the
|
||||
* form themselves. Upserts on the same email/phone key the guest-facing
|
||||
* form uses, so if that guest later does submit the form it just updates
|
||||
* this row instead of creating a duplicate.
|
||||
*/
|
||||
export async function addRsvpAction(
|
||||
eventId: string,
|
||||
_prev: RsvpEditState,
|
||||
formData: FormData
|
||||
): Promise<RsvpEditState> {
|
||||
const { event } = await requireEventAccess(eventId);
|
||||
|
||||
const parsed = manualRsvpSchema.safeParse({
|
||||
guestName: formData.get('guestName'),
|
||||
contact: formData.get('contact'),
|
||||
status: formData.get('status'),
|
||||
numGuests: formData.get('numGuests') || '1',
|
||||
note: formData.get('note') ?? '',
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
let guestEmail: string | null = null;
|
||||
let guestPhone: string | null = null;
|
||||
if (looksLikeEmail(data.contact)) {
|
||||
const emailResult = rsvpSchema.shape.guestEmail.safeParse(data.contact);
|
||||
if (!emailResult.success) return { error: 'Enter a valid email address' };
|
||||
guestEmail = emailResult.data.toLowerCase();
|
||||
} else {
|
||||
guestPhone = normalizePhoneNumber(data.contact);
|
||||
if (!guestPhone) return { error: 'Enter a valid email address or phone number' };
|
||||
}
|
||||
|
||||
const numGuests = data.status === 'NO' ? 1 : data.numGuests;
|
||||
|
||||
await prisma.rsvp.upsert({
|
||||
where: guestEmail
|
||||
? { eventId_guestEmail: { eventId: event.id, guestEmail } }
|
||||
: { eventId_guestPhone: { eventId: event.id, guestPhone: guestPhone as string } },
|
||||
create: { eventId: event.id, guestName: data.guestName, guestEmail, guestPhone, status: data.status, numGuests, note: data.note },
|
||||
update: { guestName: data.guestName, status: data.status, numGuests, note: data.note },
|
||||
});
|
||||
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
revalidatePath(`/invite/${event.slug}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function deleteRsvpAction(rsvpId: string) {
|
||||
const rsvp = await requireOwnedRsvp(rsvpId);
|
||||
await prisma.rsvp.delete({ where: { id: rsvp.id } });
|
||||
revalidatePath(`/dashboard/events/${rsvp.eventId}/guests`);
|
||||
revalidatePath(`/invite/${rsvp.event.slug}`);
|
||||
}
|
||||
|
||||
80
src/app/actions/thankyou.ts
Normal file
80
src/app/actions/thankyou.ts
Normal file
@ -0,0 +1,80 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { sendThankYouEmail } from '@/lib/mail';
|
||||
import { sendSms } from '@/lib/sms';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { sendThankYouSchema } from '@/lib/validation';
|
||||
import { checkRateLimit } from '@/lib/rateLimit';
|
||||
|
||||
export type ActionState = { error?: string; success?: string };
|
||||
|
||||
// Reaches everyone who RSVP'd yes, by whichever contact method they used to
|
||||
// RSVP (email form vs. SMS reply) - same "message the channel they gave us"
|
||||
// approach as sendInvitesAction, just for the guest list already on file
|
||||
// instead of a freshly pasted one.
|
||||
export async function sendThankYouAction(
|
||||
eventId: string,
|
||||
_prev: ActionState,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const { user, event } = await requireEventAccess(eventId);
|
||||
|
||||
const parsed = sendThankYouSchema.safeParse({
|
||||
eventId,
|
||||
message: formData.get('message'),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Invalid input' };
|
||||
}
|
||||
|
||||
const recipients = await prisma.rsvp.findMany({
|
||||
where: { eventId, status: 'YES' },
|
||||
select: { guestName: true, guestEmail: true, guestPhone: true },
|
||||
});
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return { error: "No guests have RSVP'd yes yet - nothing to send." };
|
||||
}
|
||||
|
||||
if (!checkRateLimit(`send-thankyou:${user.id}`, 200, 60 * 60 * 1000, recipients.length)) {
|
||||
return { error: 'Too many messages sent recently. Please try again later.' };
|
||||
}
|
||||
|
||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||
let emailed = 0;
|
||||
let emailFailed = 0;
|
||||
let texted = 0;
|
||||
let smsFailed = 0;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.guestEmail) {
|
||||
const delivered = await sendThankYouEmail({
|
||||
to: recipient.guestEmail,
|
||||
guestName: recipient.guestName,
|
||||
hostName: user.name,
|
||||
eventTitle: event.title,
|
||||
message: parsed.data.message,
|
||||
inviteUrl,
|
||||
});
|
||||
if (delivered) emailed++;
|
||||
else emailFailed++;
|
||||
} else if (recipient.guestPhone) {
|
||||
const delivered = await sendSms(recipient.guestPhone, `${user.name}: ${parsed.data.message}`);
|
||||
if (delivered) texted++;
|
||||
else smsFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/dashboard/events/${event.id}/guests`);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (emailed > 0) parts.push(`Emailed ${emailed}.`);
|
||||
if (texted > 0) parts.push(`Texted ${texted}.`);
|
||||
if (emailFailed > 0) parts.push(`${emailFailed} email(s) failed - check your SMTP settings.`);
|
||||
if (smsFailed > 0) parts.push(`${smsFailed} text(s) failed - check your Twilio settings.`);
|
||||
if (parts.length === 0) parts.push('No messages could be delivered.');
|
||||
return { success: parts.join(' ') };
|
||||
}
|
||||
36
src/app/api/events/[id]/photos/zip/route.ts
Normal file
36
src/app/api/events/[id]/photos/zip/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
import { buildPhotoZipStream } from '@/lib/photoZip';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { event } = await requireEventAccess(id);
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!checkRateLimit(`photo-zip:ip:${ip}`, 5, 5 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: 'Too many download requests. Please wait a bit and try again.' }, { status: 429 });
|
||||
}
|
||||
|
||||
// Hosts get everything, including hidden photos - they're the ones
|
||||
// managing them and may want a copy before permanently deleting one.
|
||||
const photos = await prisma.photo.findMany({ where: { eventId: id }, orderBy: { createdAt: 'asc' } });
|
||||
if (photos.length === 0) {
|
||||
return NextResponse.json({ error: 'No photos to download' }, { status: 404 });
|
||||
}
|
||||
|
||||
const stream = buildPhotoZipStream(
|
||||
event.slug,
|
||||
photos.map((p) => ({ eventId: id, filename: p.filename, uploaderName: p.uploaderName, createdAt: p.createdAt }))
|
||||
);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${event.slug}-photos.zip"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
40
src/app/api/events/[id]/rsvps/export/route.ts
Normal file
40
src/app/api/events/[id]/rsvps/export/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireEventAccess } from '@/lib/eventAccess';
|
||||
|
||||
// Quote every field per RFC 4180 and double up embedded quotes - simplest
|
||||
// rule that's correct for names/notes containing commas, quotes, or newlines.
|
||||
function csvField(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function csvRow(fields: string[]): string {
|
||||
return fields.map(csvField).join(',') + '\r\n';
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { event } = await requireEventAccess(id);
|
||||
|
||||
const rsvps = await prisma.rsvp.findMany({ where: { eventId: id }, orderBy: { createdAt: 'asc' } });
|
||||
|
||||
let csv = csvRow(['Name', 'Status', 'Guests', 'Email', 'Phone', 'Note', 'Responded at (UTC)']);
|
||||
for (const rsvp of rsvps) {
|
||||
csv += csvRow([
|
||||
rsvp.guestName,
|
||||
rsvp.status,
|
||||
String(rsvp.numGuests),
|
||||
rsvp.guestEmail ?? '',
|
||||
rsvp.guestPhone ?? '',
|
||||
rsvp.note,
|
||||
rsvp.createdAt.toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${event.slug}-rsvps.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -15,6 +15,8 @@ export default async function EditEventPage({ params }: { params: Promise<{ id:
|
||||
description: event.description,
|
||||
contactInfo: event.contactInfo,
|
||||
theme: event.theme,
|
||||
themePrimaryColor: event.themePrimaryColor ?? '',
|
||||
themeAccentColor: event.themeAccentColor ?? '',
|
||||
timezone: tz,
|
||||
eventDate: utcToZonedDatetimeLocal(event.eventDate, tz),
|
||||
endDate: event.endDate ? utcToZonedDatetimeLocal(event.endDate, tz) : '',
|
||||
|
||||
@ -4,19 +4,23 @@ import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { accessibleEventWhere } from '@/lib/eventAccess';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { resolveTheme } from '@/lib/themes';
|
||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||
import { ShareLinkBox } from '@/components/ShareLinkBox';
|
||||
import { SendInvitesForm } from '@/components/SendInvitesForm';
|
||||
import { InviteCollaboratorForm } from '@/components/InviteCollaboratorForm';
|
||||
import { regenerateSlugAction, deleteEventAction } from '@/app/actions/events';
|
||||
import { sendInvitesAction, removeGuestFromListAction } from '@/app/actions/invites';
|
||||
import { sendThankYouAction } from '@/app/actions/thankyou';
|
||||
import { ThankYouForm } from '@/components/ThankYouForm';
|
||||
import { toggleCommentHiddenAction, deleteCommentAction } from '@/app/actions/comments';
|
||||
import { inviteCollaboratorAction, removeCollaboratorAction } from '@/app/actions/collaborators';
|
||||
import { togglePhotoHiddenAction, deletePhotoAction, toggleForceCloseAction } from '@/app/actions/photos';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { photoUrl } from '@/lib/photoStorage';
|
||||
import { photoDisplayUrl } from '@/lib/photoStorage';
|
||||
import { RsvpRow } from '@/components/RsvpRow';
|
||||
import { AddRsvpForm } from '@/components/AddRsvpForm';
|
||||
|
||||
export default async function EventGuestsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@ -37,16 +41,18 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
const isPrimaryHost = event.hostId === user.id;
|
||||
const photosOpen = isPhotoUploadOpen(event);
|
||||
|
||||
const theme = getTheme(event.theme);
|
||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
||||
const inviteUrl = appUrl(`/invite/${event.slug}`);
|
||||
const tally = {
|
||||
YES: event.rsvps.filter((r) => r.status === 'YES').reduce((s, r) => s + r.numGuests, 0),
|
||||
NO: event.rsvps.filter((r) => r.status === 'NO').length,
|
||||
MAYBE: event.rsvps.filter((r) => r.status === 'MAYBE').reduce((s, r) => s + r.numGuests, 0),
|
||||
};
|
||||
const yesRsvpCount = event.rsvps.filter((r) => r.status === 'YES').length;
|
||||
|
||||
const boundRegenerate = regenerateSlugAction.bind(null, event.id);
|
||||
const boundSendInvites = sendInvitesAction.bind(null, event.id);
|
||||
const boundSendThankYou = sendThankYouAction.bind(null, event.id);
|
||||
const boundInviteCollaborator = inviteCollaboratorAction.bind(null, event.id);
|
||||
const boundToggleForceClose = toggleForceCloseAction.bind(null, event.id);
|
||||
|
||||
@ -185,14 +191,24 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
RSVPs
|
||||
{event.restrictRsvpToInvited && (
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
||||
Restricted to guest list
|
||||
</span>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="font-semibold text-gray-900">
|
||||
RSVPs
|
||||
{event.restrictRsvpToInvited && (
|
||||
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
|
||||
Restricted to guest list
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{event.rsvps.length > 0 && (
|
||||
<a
|
||||
href={`/api/events/${event.id}/rsvps/export`}
|
||||
className="shrink-0 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-6 text-sm">
|
||||
<span className="text-green-700">{tally.YES} attending</span>
|
||||
<span className="text-amber-700">{tally.MAYBE} maybe</span>
|
||||
@ -208,23 +224,34 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
<th className="py-2 font-medium">Guests</th>
|
||||
<th className="py-2 font-medium">Note</th>
|
||||
<th className="py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{event.rsvps.map((rsvp) => (
|
||||
<tr key={rsvp.id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<div>{rsvp.guestName}</div>
|
||||
<div className="text-xs text-gray-400">{rsvp.guestEmail}</div>
|
||||
</td>
|
||||
<td className="py-2">{rsvp.status}</td>
|
||||
<td className="py-2">{rsvp.numGuests}</td>
|
||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||
</tr>
|
||||
<RsvpRow key={rsvp.id} rsvp={rsvp} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<AddRsvpForm eventId={event.id} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<h2 className="font-semibold text-gray-900">Thank guests</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Send a message to everyone who RSVP'd yes, by whichever email or phone number they
|
||||
used to RSVP. Handy for a post-event thank-you, but you can send it any time.
|
||||
</p>
|
||||
{yesRsvpCount === 0 ? (
|
||||
<p className="mt-4 text-sm text-gray-500">No one has RSVP'd yes yet.</p>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<ThankYouForm action={boundSendThankYou} recipientCount={yesRsvpCount} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
@ -265,18 +292,28 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg p-5" style={adminCardStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-semibold text-gray-900">Photos</h2>
|
||||
{event.photosEnabled && (
|
||||
<form action={boundToggleForceClose}>
|
||||
<button
|
||||
type="submit"
|
||||
<div className="flex items-center gap-2">
|
||||
{event.photos.length > 0 && (
|
||||
<a
|
||||
href={`/api/events/${event.id}/photos/zip`}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
Download all ({event.photos.length})
|
||||
</a>
|
||||
)}
|
||||
{event.photosEnabled && (
|
||||
<form action={boundToggleForceClose}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{event.photosClosed ? 'Reopen uploads' : 'Close uploads now'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{!event.photosEnabled
|
||||
@ -297,11 +334,16 @@ export default async function EventGuestsPage({ params }: { params: Promise<{ id
|
||||
<div key={photo.id} className={`relative aspect-square overflow-hidden rounded-md ${photo.hidden ? 'opacity-40' : ''}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl(event.id, photo.filename)}
|
||||
src={photoDisplayUrl(event.id, photo)}
|
||||
alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{photo.uploaderName && (
|
||||
<span className="absolute inset-x-0 top-0 truncate bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{photo.uploaderName}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between bg-black/60 px-2 py-1 text-xs text-white">
|
||||
<form action={togglePhotoHiddenAction.bind(null, photo.id)}>
|
||||
<button type="submit" className="hover:underline">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { resolveTheme } from '@/lib/themes';
|
||||
import { PageContainer } from '@/components/PageContainer';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
@ -41,7 +41,7 @@ export default async function DashboardPage() {
|
||||
) : (
|
||||
<ul className="mt-6 flex flex-col gap-3">
|
||||
{events.map((event) => {
|
||||
const theme = getTheme(event.theme);
|
||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
||||
const yesCount = event.rsvps
|
||||
.filter((r) => r.status === 'YES')
|
||||
.reduce((sum, r) => sum + r.numGuests, 0);
|
||||
|
||||
28
src/app/invite/[slug]/ics/route.ts
Normal file
28
src/app/invite/[slug]/ics/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { buildIcs } from '@/lib/ics';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const event = await prisma.event.findUnique({ where: { slug } });
|
||||
if (!event) notFound();
|
||||
|
||||
const ics = buildIcs({
|
||||
uid: `${event.id}@e-invite`,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: event.eventDate,
|
||||
end: event.endDate,
|
||||
url: appUrl(`/invite/${event.slug}`),
|
||||
});
|
||||
|
||||
return new NextResponse(ics, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${event.slug}.ics"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getTheme } from '@/lib/themes';
|
||||
import { resolveTheme } from '@/lib/themes';
|
||||
import { ThemedInviteLayout } from '@/components/ThemedInviteLayout';
|
||||
import { ThemeDivider } from '@/components/ThemeDivider';
|
||||
import { themeCardStyle as cardStyle } from '@/components/ThemeBackground';
|
||||
@ -10,7 +10,10 @@ import { PhotoUploadForm } from '@/components/PhotoUploadForm';
|
||||
import { PhotoGallery } from '@/components/PhotoGallery';
|
||||
import { formatEventDateRange } from '@/lib/format';
|
||||
import { isPhotoUploadOpen } from '@/lib/photoWindow';
|
||||
import { photoUrl } from '@/lib/photoStorage';
|
||||
import { photoDisplayUrl } from '@/lib/photoStorage';
|
||||
import { googleCalendarUrl, mapsUrl } from '@/lib/ics';
|
||||
import { appUrl } from '@/lib/slug';
|
||||
import { EventCountdown } from '@/components/EventCountdown';
|
||||
|
||||
export default async function PublicInvitePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
@ -25,7 +28,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
||||
});
|
||||
if (!event) notFound();
|
||||
|
||||
const theme = getTheme(event.theme);
|
||||
const theme = resolveTheme(event.theme, event.themePrimaryColor, event.themeAccentColor);
|
||||
const rsvpClosed = Boolean(event.rsvpDeadline && event.rsvpDeadline < new Date());
|
||||
const photosOpen = isPhotoUploadOpen(event);
|
||||
const showPhotos = event.photosEnabled && (photosOpen || event.photos.length > 0);
|
||||
@ -43,7 +46,40 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
||||
<h1 className="mt-2 break-words text-3xl font-bold sm:text-4xl">{event.title}</h1>
|
||||
{event.occasion && <p className="mt-1 italic opacity-80">{event.occasion}</p>}
|
||||
<p className="mt-3 text-lg">{formatEventDateRange(event.eventDate, event.endDate, event.timezone)}</p>
|
||||
{event.location && <p className="mt-1 opacity-80">{event.location}</p>}
|
||||
<EventCountdown eventDate={event.eventDate.toISOString()} endDate={event.endDate?.toISOString() ?? null} />
|
||||
{event.location && (
|
||||
<p className="mt-1 opacity-80">
|
||||
<a href={mapsUrl(event.location)} target="_blank" rel="noopener noreferrer" className="underline decoration-dotted hover:opacity-80">
|
||||
{event.location}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-2 text-xs">
|
||||
<a
|
||||
href={`/invite/${event.slug}/ics`}
|
||||
className="rounded-full px-3 py-1 font-medium"
|
||||
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
|
||||
>
|
||||
📅 Add to calendar
|
||||
</a>
|
||||
<a
|
||||
href={googleCalendarUrl({
|
||||
uid: event.id,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
start: event.eventDate,
|
||||
end: event.endDate,
|
||||
url: appUrl(`/invite/${event.slug}`),
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full px-3 py-1 font-medium"
|
||||
style={{ background: 'var(--theme-bg)', border: '1px solid var(--theme-accent)' }}
|
||||
>
|
||||
Add to Google Calendar
|
||||
</a>
|
||||
</div>
|
||||
{(event.foodProvided || event.drinksProvided) && (
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
||||
{event.foodProvided && (
|
||||
@ -118,7 +154,17 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
||||
<>
|
||||
<ThemeDivider theme={theme} />
|
||||
<div className="reveal rounded-lg p-6" style={{ ...cardStyle, animationDelay: '0.4s' }}>
|
||||
<h2 className="text-xl font-semibold">Photos</h2>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-xl font-semibold">Photos</h2>
|
||||
{event.photos.length > 0 && (
|
||||
<a
|
||||
href={`/invite/${event.slug}/photos/zip`}
|
||||
className="shrink-0 rounded-full px-3 py-1 text-xs font-medium underline decoration-dotted opacity-80 hover:opacity-100"
|
||||
>
|
||||
Download all ({event.photos.length})
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{photosOpen ? (
|
||||
<div className="mt-4">
|
||||
<PhotoUploadForm eventId={event.id} />
|
||||
@ -129,7 +175,7 @@ export default async function PublicInvitePage({ params }: { params: Promise<{ s
|
||||
<PhotoGallery
|
||||
photos={event.photos.map((p) => ({
|
||||
id: p.id,
|
||||
url: photoUrl(event.id, p.filename),
|
||||
url: photoDisplayUrl(event.id, p),
|
||||
uploaderName: p.uploaderName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
36
src/app/invite/[slug]/photos/zip/route.ts
Normal file
36
src/app/invite/[slug]/photos/zip/route.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { buildPhotoZipStream } from '@/lib/photoZip';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/rateLimit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const event = await prisma.event.findUnique({ where: { slug } });
|
||||
if (!event || !event.photosEnabled) notFound();
|
||||
|
||||
const ip = await getClientIp();
|
||||
const rateLimited =
|
||||
!checkRateLimit(`photo-zip:ip:${ip}`, 5, 5 * 60 * 1000) || !checkRateLimit(`photo-zip:event:${event.id}`, 20, 5 * 60 * 1000);
|
||||
if (rateLimited) {
|
||||
return NextResponse.json({ error: 'Too many download requests. Please wait a bit and try again.' }, { status: 429 });
|
||||
}
|
||||
|
||||
// Guests only ever see (and can only download) photos the host hasn't hidden.
|
||||
const photos = await prisma.photo.findMany({ where: { eventId: event.id, hidden: false }, orderBy: { createdAt: 'asc' } });
|
||||
if (photos.length === 0) notFound();
|
||||
|
||||
const stream = buildPhotoZipStream(
|
||||
event.slug,
|
||||
photos.map((p) => ({ eventId: event.id, filename: p.filename, uploaderName: p.uploaderName, createdAt: p.createdAt }))
|
||||
);
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${event.slug}-photos.zip"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
85
src/components/AddRsvpForm.tsx
Normal file
85
src/components/AddRsvpForm.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef, useState } from 'react';
|
||||
import { addRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: RsvpEditState = {};
|
||||
|
||||
const inputClass =
|
||||
'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none';
|
||||
|
||||
export function AddRsvpForm({ eventId }: { eventId: string }) {
|
||||
const action = addRsvpAction.bind(null, eventId);
|
||||
const [state, formAction, isPending] = useActionState(action, initialState);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// See EditRsvpForm for why we track isPending instead of awaiting
|
||||
// formAction directly: its dispatch doesn't return a promise.
|
||||
const wasPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasPending.current && !isPending && !state.error) {
|
||||
setOpen(false);
|
||||
}
|
||||
wasPending.current = isPending;
|
||||
}, [isPending, state.error]);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Add an RSVP
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3 rounded-md border border-gray-200 p-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
For guests who let you know their response directly instead of using the invite link.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Name</label>
|
||||
<input name="guestName" required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Email or phone</label>
|
||||
<input name="contact" required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Status</label>
|
||||
<select name="status" defaultValue="YES" className={inputClass}>
|
||||
<option value="YES">Yes</option>
|
||||
<option value="MAYBE">Maybe</option>
|
||||
<option value="NO">Can't make it</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Guests</label>
|
||||
<input name="numGuests" type="number" min={1} max={50} defaultValue={1} className={inputClass} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500">Note (optional)</label>
|
||||
<input name="note" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
{state.error && <p className="text-sm text-red-700">{state.error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<SubmitButton className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
|
||||
Add RSVP
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
88
src/components/EditRsvpForm.tsx
Normal file
88
src/components/EditRsvpForm.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef } from 'react';
|
||||
import { updateRsvpAction, type RsvpEditState } from '@/app/actions/rsvp';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
|
||||
const initialState: RsvpEditState = {};
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-gray-300 px-2 py-1 text-sm focus:border-indigo-500 focus:outline-none';
|
||||
|
||||
export function EditRsvpForm({
|
||||
rsvpId,
|
||||
guestName,
|
||||
status,
|
||||
numGuests,
|
||||
note,
|
||||
onDone,
|
||||
}: {
|
||||
rsvpId: string;
|
||||
guestName: string;
|
||||
status: 'YES' | 'NO' | 'MAYBE';
|
||||
numGuests: number;
|
||||
note: string;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const action = updateRsvpAction.bind(null, rsvpId);
|
||||
const [state, formAction, isPending] = useActionState(action, initialState);
|
||||
|
||||
// useActionState's dispatch doesn't return a promise we can await, so we
|
||||
// can't just chain onDone() after calling it - instead watch isPending
|
||||
// fall back to false (the submission finished) and only close if it
|
||||
// succeeded, so a validation error stays visible.
|
||||
const wasPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasPending.current && !isPending && !state.error) {
|
||||
onDone();
|
||||
}
|
||||
wasPending.current = isPending;
|
||||
}, [isPending, state.error, onDone]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-2 rounded-md bg-gray-50 p-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-xs font-medium text-gray-500">Name</label>
|
||||
<input name="guestName" defaultValue={guestName} required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Status</label>
|
||||
<select name="status" defaultValue={status} className={inputClass}>
|
||||
<option value="YES">Yes</option>
|
||||
<option value="MAYBE">Maybe</option>
|
||||
<option value="NO">Can't make it</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500">Guests</label>
|
||||
<input
|
||||
name="numGuests"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
defaultValue={numGuests}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label className="block text-xs font-medium text-gray-500">Note</label>
|
||||
<input name="note" defaultValue={note} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
{state.error && <p className="text-xs text-red-700">{state.error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<SubmitButton className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
|
||||
Save
|
||||
</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
46
src/components/EventCountdown.tsx
Normal file
46
src/components/EventCountdown.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function timeLeft(target: Date) {
|
||||
const ms = target.getTime() - Date.now();
|
||||
const totalMinutes = Math.floor(ms / 60000);
|
||||
const days = Math.floor(totalMinutes / (60 * 24));
|
||||
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return { days, hours, minutes };
|
||||
}
|
||||
|
||||
function formatCountdown({ days, hours, minutes }: { days: number; hours: number; minutes: number }) {
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days} day${days === 1 ? '' : 's'}`);
|
||||
if (days > 0 || hours > 0) parts.push(`${hours} hour${hours === 1 ? '' : 's'}`);
|
||||
parts.push(`${minutes} minute${minutes === 1 ? '' : 's'}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
export function EventCountdown({ eventDate, endDate }: { eventDate: string; endDate: string | null }) {
|
||||
const start = new Date(eventDate);
|
||||
const end = endDate ? new Date(endDate) : null;
|
||||
|
||||
// Server-rendered markup can't know "now", so render nothing until the
|
||||
// client mounts and computes a real countdown - avoids a hydration
|
||||
// mismatch from comparing against two different clocks.
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const interval = setInterval(() => setNow(Date.now()), 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (now === null) return null;
|
||||
|
||||
if (now < start.getTime()) {
|
||||
return <p className="mt-2 text-sm font-medium opacity-80">Starts in {formatCountdown(timeLeft(start))}</p>;
|
||||
}
|
||||
if (end && now < end.getTime()) {
|
||||
return <p className="mt-2 text-sm font-medium opacity-80">Happening now!</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { resolveTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { ThemeBackground, adminCardStyle } from '@/components/ThemeBackground';
|
||||
import { EventForm, type EventActionState, type EventFormDefaults } from '@/components/EventForm';
|
||||
|
||||
@ -17,14 +17,25 @@ export function EventEditor({
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const [themeId, setThemeId] = useState(defaults?.theme ?? DEFAULT_THEME_ID);
|
||||
const theme = getTheme(themeId);
|
||||
const [primaryColor, setPrimaryColor] = useState<string | null>(defaults?.themePrimaryColor || null);
|
||||
const [accentColor, setAccentColor] = useState<string | null>(defaults?.themeAccentColor || null);
|
||||
const theme = resolveTheme(themeId, primaryColor, accentColor);
|
||||
|
||||
return (
|
||||
<ThemeBackground theme={theme} applyTextStyle={false} className="flex-1 p-6 sm:p-10">
|
||||
<div className="mx-auto max-w-2xl rounded-lg p-6" style={adminCardStyle}>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
||||
<div className="mt-6">
|
||||
<EventForm action={action} defaults={defaults} submitLabel={submitLabel} onThemeChange={setThemeId} />
|
||||
<EventForm
|
||||
action={action}
|
||||
defaults={defaults}
|
||||
submitLabel={submitLabel}
|
||||
onThemeChange={setThemeId}
|
||||
onColorsChange={(primary, accent) => {
|
||||
setPrimaryColor(primary);
|
||||
setAccentColor(accent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeBackground>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import { DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { getTheme, DEFAULT_THEME_ID } from '@/lib/themes';
|
||||
import { TIMEZONES } from '@/lib/timezone';
|
||||
|
||||
export type EventActionState = { error?: string };
|
||||
@ -14,6 +14,9 @@ export type EventFormDefaults = {
|
||||
description: string;
|
||||
contactInfo: string;
|
||||
theme: string;
|
||||
/** '' means "use the theme's own default color". */
|
||||
themePrimaryColor: string;
|
||||
themeAccentColor: string;
|
||||
timezone: string;
|
||||
eventDate: string;
|
||||
endDate: string;
|
||||
@ -34,6 +37,8 @@ const emptyDefaults: EventFormDefaults = {
|
||||
description: '',
|
||||
contactInfo: '',
|
||||
theme: DEFAULT_THEME_ID,
|
||||
themePrimaryColor: '',
|
||||
themeAccentColor: '',
|
||||
timezone: '',
|
||||
eventDate: '',
|
||||
endDate: '',
|
||||
@ -53,11 +58,13 @@ export function EventForm({
|
||||
defaults = emptyDefaults,
|
||||
submitLabel,
|
||||
onThemeChange,
|
||||
onColorsChange,
|
||||
}: {
|
||||
action: (state: EventActionState, formData: FormData) => Promise<EventActionState>;
|
||||
defaults?: EventFormDefaults;
|
||||
submitLabel: string;
|
||||
onThemeChange?: (themeId: string) => void;
|
||||
onColorsChange?: (primary: string | null, accent: string | null) => void;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, {});
|
||||
// New events (empty default) auto-detect the browser's zone; edits keep
|
||||
@ -69,6 +76,32 @@ export function EventForm({
|
||||
}
|
||||
}, [defaults.timezone]);
|
||||
|
||||
// Mirrors ThemePicker's own selection state so the color pickers below
|
||||
// know which theme's default colors to fall back to / preview against.
|
||||
const [themeId, setThemeId] = useState(defaults.theme);
|
||||
// null = "use the selected theme's own default color", not yet customized.
|
||||
const [primaryColor, setPrimaryColor] = useState<string | null>(defaults.themePrimaryColor || null);
|
||||
const [accentColor, setAccentColor] = useState<string | null>(defaults.themeAccentColor || null);
|
||||
const selectedTheme = getTheme(themeId);
|
||||
|
||||
function handleThemeChange(id: string) {
|
||||
setThemeId(id);
|
||||
setPrimaryColor(null);
|
||||
setAccentColor(null);
|
||||
onThemeChange?.(id);
|
||||
onColorsChange?.(null, null);
|
||||
}
|
||||
|
||||
function handlePrimaryChange(hex: string | null) {
|
||||
setPrimaryColor(hex);
|
||||
onColorsChange?.(hex, accentColor);
|
||||
}
|
||||
|
||||
function handleAccentChange(hex: string | null) {
|
||||
setAccentColor(hex);
|
||||
onColorsChange?.(primaryColor, hex);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-6">
|
||||
<div>
|
||||
@ -224,10 +257,57 @@ export function EventForm({
|
||||
<div>
|
||||
<span className="block text-sm font-medium text-gray-700">Theme</span>
|
||||
<div className="mt-2">
|
||||
<ThemePicker defaultThemeId={defaults.theme} onChange={onThemeChange} />
|
||||
<ThemePicker defaultThemeId={defaults.theme} onChange={handleThemeChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:max-w-xs">
|
||||
<div>
|
||||
<label htmlFor="themePrimaryColorInput" className="block text-sm font-medium text-gray-700">
|
||||
Primary color
|
||||
</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="themePrimaryColorInput"
|
||||
type="color"
|
||||
value={primaryColor ?? selectedTheme.colors.primary}
|
||||
onChange={(e) => handlePrimaryChange(e.target.value)}
|
||||
className="h-9 w-14 cursor-pointer rounded border border-gray-300 p-0.5"
|
||||
/>
|
||||
{primaryColor && (
|
||||
<button type="button" onClick={() => handlePrimaryChange(null)} className="text-xs text-gray-500 hover:text-gray-800">
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input type="hidden" name="themePrimaryColor" value={primaryColor ?? ''} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="themeAccentColorInput" className="block text-sm font-medium text-gray-700">
|
||||
Accent color
|
||||
</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="themeAccentColorInput"
|
||||
type="color"
|
||||
value={accentColor ?? selectedTheme.colors.accent}
|
||||
onChange={(e) => handleAccentChange(e.target.value)}
|
||||
className="h-9 w-14 cursor-pointer rounded border border-gray-300 p-0.5"
|
||||
/>
|
||||
{accentColor && (
|
||||
<button type="button" onClick={() => handleAccentChange(null)} className="text-xs text-gray-500 hover:text-gray-800">
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input type="hidden" name="themeAccentColor" value={accentColor ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="-mt-4 text-xs text-gray-500">
|
||||
Optional - override the theme's own colors, e.g. if the default palette doesn't
|
||||
fit your event. The background and text stay auto-adjusted for readability.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
|
||||
@ -17,7 +17,8 @@ export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photo.url} alt={photo.uploaderName ? `Photo by ${photo.uploaderName}` : 'Guest photo'} className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" loading="lazy" />
|
||||
{photo.uploaderName && (
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
// Always visible, not hover-only - most guests view this on a phone.
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/50 px-2 py-1 text-xs text-white">
|
||||
{photo.uploaderName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -20,7 +20,7 @@ export function PhotoUploadForm({ eventId }: { eventId: string }) {
|
||||
id="photo"
|
||||
name="photo"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif,.heic,.heif"
|
||||
required
|
||||
className="mt-1 w-full rounded-md border border-black/10 bg-white/90 px-3 py-2 text-sm text-gray-900 focus:outline-none"
|
||||
/>
|
||||
|
||||
75
src/components/RsvpRow.tsx
Normal file
75
src/components/RsvpRow.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { deleteRsvpAction } from '@/app/actions/rsvp';
|
||||
import { EditRsvpForm } from '@/components/EditRsvpForm';
|
||||
import { RsvpStatusPill } from '@/components/RsvpStatusPill';
|
||||
|
||||
type Status = 'YES' | 'NO' | 'MAYBE';
|
||||
|
||||
export function RsvpRow({
|
||||
rsvp,
|
||||
}: {
|
||||
rsvp: {
|
||||
id: string;
|
||||
guestName: string;
|
||||
guestEmail: string | null;
|
||||
guestPhone: string | null;
|
||||
status: Status;
|
||||
numGuests: number;
|
||||
note: string;
|
||||
};
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td colSpan={5} className="py-2">
|
||||
<EditRsvpForm
|
||||
rsvpId={rsvp.id}
|
||||
guestName={rsvp.guestName}
|
||||
status={rsvp.status}
|
||||
numGuests={rsvp.numGuests}
|
||||
note={rsvp.note}
|
||||
onDone={() => setEditing(false)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<div>{rsvp.guestName}</div>
|
||||
<div className="text-xs text-gray-400">{rsvp.guestEmail ?? rsvp.guestPhone}</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<RsvpStatusPill status={rsvp.status} />
|
||||
</td>
|
||||
<td className="py-2">{rsvp.numGuests}</td>
|
||||
<td className="py-2 text-gray-500">{rsvp.note}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="flex justify-end gap-3 text-xs">
|
||||
<button type="button" onClick={() => setEditing(true)} className="text-gray-500 hover:text-gray-800">
|
||||
Edit
|
||||
</button>
|
||||
<form action={deleteRsvpAction.bind(null, rsvp.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (!confirm(`Remove ${rsvp.guestName}'s RSVP? This can't be undone.`)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
19
src/components/RsvpStatusPill.tsx
Normal file
19
src/components/RsvpStatusPill.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
const STYLES: Record<'YES' | 'NO' | 'MAYBE', string> = {
|
||||
YES: 'bg-green-100 text-green-800',
|
||||
MAYBE: 'bg-amber-100 text-amber-800',
|
||||
NO: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
const LABELS: Record<'YES' | 'NO' | 'MAYBE', string> = {
|
||||
YES: 'Yes',
|
||||
MAYBE: 'Maybe',
|
||||
NO: "Can't make it",
|
||||
};
|
||||
|
||||
export function RsvpStatusPill({ status }: { status: 'YES' | 'NO' | 'MAYBE' }) {
|
||||
return (
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[status]}`}>
|
||||
{LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
src/components/ThankYouForm.tsx
Normal file
36
src/components/ThankYouForm.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { SubmitButton } from '@/components/SubmitButton';
|
||||
import type { ActionState } from '@/app/actions/thankyou';
|
||||
|
||||
export function ThankYouForm({
|
||||
action,
|
||||
recipientCount,
|
||||
}: {
|
||||
action: (state: ActionState, formData: FormData) => Promise<ActionState>;
|
||||
recipientCount: number;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, {});
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-3">
|
||||
<label htmlFor="thankYouMessage" className="text-sm font-medium text-gray-700">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="thankYouMessage"
|
||||
name="message"
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
placeholder="Thanks so much for celebrating with us!"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state.success && <p className="text-sm text-green-700">{state.success}</p>}
|
||||
<SubmitButton className="self-start rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-60">
|
||||
Send to {recipientCount} guest{recipientCount === 1 ? '' : 's'} who RSVP'd yes
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
49
src/lib/color.test.ts
Normal file
49
src/lib/color.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidHexColor, mix, deriveThemeColors } from '@/lib/color';
|
||||
|
||||
describe('isValidHexColor', () => {
|
||||
it('accepts a well-formed 6-digit hex color', () => {
|
||||
expect(isValidHexColor('#a1b2c3')).toBe(true);
|
||||
expect(isValidHexColor('#FFFFFF')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed values', () => {
|
||||
expect(isValidHexColor('#abc')).toBe(false);
|
||||
expect(isValidHexColor('abcdef')).toBe(false);
|
||||
expect(isValidHexColor('#gggggg')).toBe(false);
|
||||
expect(isValidHexColor('red')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mix', () => {
|
||||
it('returns the source color at t=0 and the target at t=1', () => {
|
||||
expect(mix('#112233', '#ffffff', 0)).toBe('#112233');
|
||||
expect(mix('#112233', '#ffffff', 1)).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('blends proportionally at t=0.5', () => {
|
||||
expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveThemeColors', () => {
|
||||
it('produces a near-white background and near-black text for light mode', () => {
|
||||
const colors = deriveThemeColors('light', '#2563eb', '#f59e0b');
|
||||
expect(colors.primary).toBe('#2563eb');
|
||||
expect(colors.accent).toBe('#f59e0b');
|
||||
// bg should be very light (close to white) regardless of the chosen hue
|
||||
const bgLuminance = parseInt(colors.bg.slice(1, 3), 16);
|
||||
expect(bgLuminance).toBeGreaterThan(200);
|
||||
// text should be very dark for contrast against that light background
|
||||
const textLuminance = parseInt(colors.text.slice(1, 3), 16);
|
||||
expect(textLuminance).toBeLessThan(80);
|
||||
});
|
||||
|
||||
it('produces a near-black background and near-white text for dark mode', () => {
|
||||
const colors = deriveThemeColors('dark', '#e63946', '#f1c40f');
|
||||
const bgLuminance = parseInt(colors.bg.slice(1, 3), 16);
|
||||
expect(bgLuminance).toBeLessThan(60);
|
||||
const textLuminance = parseInt(colors.text.slice(1, 3), 16);
|
||||
expect(textLuminance).toBeGreaterThan(200);
|
||||
});
|
||||
});
|
||||
54
src/lib/color.ts
Normal file
54
src/lib/color.ts
Normal file
@ -0,0 +1,54 @@
|
||||
// Small hex-color math for deriving a full theme palette from just the two
|
||||
// colors a host actually chooses (primary + accent) - see
|
||||
// deriveThemeColors. No external color library; these are simple enough to
|
||||
// hand-roll and keep the app dependency-free.
|
||||
|
||||
export function isValidHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value);
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
||||
return '#' + [r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Blends `hex` toward `target` by `t` (0 = hex, 1 = target). */
|
||||
export function mix(hex: string, target: string, t: number): string {
|
||||
const [r1, g1, b1] = hexToRgb(hex);
|
||||
const [r2, g2, b2] = hexToRgb(target);
|
||||
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
|
||||
}
|
||||
|
||||
export type ThemeColors = { bg: string; surface: string; primary: string; accent: string; text: string };
|
||||
|
||||
/**
|
||||
* Builds a full 5-color palette from just the two colors a host actually
|
||||
* picks (primary + accent), so a custom color choice always stays legible
|
||||
* instead of requiring the host to also get background/text contrast right
|
||||
* themselves. `mode` mirrors the theme's own default palette (light card vs.
|
||||
* dark card) so recoloring "Holiday Festive" still reads as a dark theme,
|
||||
* not an inverted one.
|
||||
*/
|
||||
export function deriveThemeColors(mode: 'light' | 'dark', primary: string, accent: string): ThemeColors {
|
||||
if (mode === 'light') {
|
||||
return {
|
||||
bg: mix(primary, '#ffffff', 0.93),
|
||||
surface: mix(primary, '#ffffff', 0.99),
|
||||
primary,
|
||||
accent,
|
||||
text: mix(primary, '#000000', 0.75),
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: mix(primary, '#000000', 0.88),
|
||||
surface: mix(primary, '#000000', 0.8),
|
||||
primary,
|
||||
accent,
|
||||
text: mix(primary, '#ffffff', 0.9),
|
||||
};
|
||||
}
|
||||
@ -5,7 +5,11 @@ import {
|
||||
Inter,
|
||||
Mountains_of_Christmas,
|
||||
Special_Elite,
|
||||
Press_Start_2P,
|
||||
VT323,
|
||||
Cormorant_Garamond,
|
||||
Fredoka,
|
||||
Zilla_Slab,
|
||||
Montserrat,
|
||||
} from 'next/font/google';
|
||||
|
||||
const playfairDisplay = Playfair_Display({ subsets: ['latin'], variable: '--font-classic-elegant' });
|
||||
@ -19,7 +23,19 @@ const mountainsOfChristmas = Mountains_of_Christmas({
|
||||
});
|
||||
const quicksandBaby = Quicksand({ subsets: ['latin'], variable: '--font-baby-shower-pastel' });
|
||||
const specialElite = Special_Elite({ subsets: ['latin'], weight: '400', variable: '--font-goth-grunge' });
|
||||
const pressStart2p = Press_Start_2P({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
|
||||
// Press Start 2P (an actual pixel-block font) reads fine for a short
|
||||
// logo-sized heading but is close to illegible for paragraphs of body
|
||||
// copy - VT323 keeps the retro-terminal/arcade feel while staying
|
||||
// readable at normal text sizes.
|
||||
const vt323 = VT323({ subsets: ['latin'], weight: '400', variable: '--font-retro-arcade' });
|
||||
const cormorantGaramond = Cormorant_Garamond({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '600'],
|
||||
variable: '--font-wedding-romance',
|
||||
});
|
||||
const fredoka = Fredoka({ subsets: ['latin'], variable: '--font-tropical-luau' });
|
||||
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['400', '600'], variable: '--font-rustic-harvest' });
|
||||
const montserrat = Montserrat({ subsets: ['latin'], variable: '--font-graduation-grad' });
|
||||
|
||||
// All font variable classes need to be present in the document for
|
||||
// next/font's generated CSS variables to be available, so we export one
|
||||
@ -32,7 +48,11 @@ export const allFontVariables = [
|
||||
mountainsOfChristmas.variable,
|
||||
quicksandBaby.variable,
|
||||
specialElite.variable,
|
||||
pressStart2p.variable,
|
||||
vt323.variable,
|
||||
cormorantGaramond.variable,
|
||||
fredoka.variable,
|
||||
zillaSlab.variable,
|
||||
montserrat.variable,
|
||||
].join(' ');
|
||||
|
||||
const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
||||
@ -44,6 +64,10 @@ const FONT_FAMILY_BY_THEME: Record<string, string> = {
|
||||
'baby-shower-pastel': 'var(--font-baby-shower-pastel)',
|
||||
'goth-grunge': 'var(--font-goth-grunge)',
|
||||
'retro-arcade': 'var(--font-retro-arcade)',
|
||||
'wedding-romance': 'var(--font-wedding-romance)',
|
||||
'tropical-luau': 'var(--font-tropical-luau)',
|
||||
'rustic-harvest': 'var(--font-rustic-harvest)',
|
||||
'graduation-grad': 'var(--font-graduation-grad)',
|
||||
};
|
||||
|
||||
export function fontFamilyForTheme(themeId: string) {
|
||||
|
||||
54
src/lib/ics.test.ts
Normal file
54
src/lib/ics.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildIcs, googleCalendarUrl, mapsUrl } from '@/lib/ics';
|
||||
|
||||
const base = {
|
||||
uid: 'evt123@e-invite',
|
||||
title: 'Backyard BBQ',
|
||||
description: 'Bring a side dish',
|
||||
location: '123 Main St',
|
||||
start: new Date('2027-07-18T16:00:00Z'),
|
||||
end: null as Date | null,
|
||||
url: 'https://example.com/invite/abc123',
|
||||
};
|
||||
|
||||
describe('buildIcs', () => {
|
||||
it('includes required VEVENT fields with UTC-formatted dates', () => {
|
||||
const ics = buildIcs(base);
|
||||
expect(ics).toContain('BEGIN:VEVENT');
|
||||
expect(ics).toContain('UID:evt123@e-invite');
|
||||
expect(ics).toContain('DTSTART:20270718T160000Z');
|
||||
expect(ics).toContain('SUMMARY:Backyard BBQ');
|
||||
expect(ics).toContain('LOCATION:123 Main St');
|
||||
expect(ics).toContain('END:VEVENT');
|
||||
});
|
||||
|
||||
it('defaults to a 2-hour duration when no end time is given', () => {
|
||||
const ics = buildIcs(base);
|
||||
expect(ics).toContain('DTEND:20270718T180000Z');
|
||||
});
|
||||
|
||||
it('uses the real end time when provided', () => {
|
||||
const ics = buildIcs({ ...base, end: new Date('2027-07-18T20:00:00Z') });
|
||||
expect(ics).toContain('DTEND:20270718T200000Z');
|
||||
});
|
||||
|
||||
it('escapes commas, semicolons, and newlines in text fields', () => {
|
||||
const ics = buildIcs({ ...base, description: 'Line one\nBring, chips; snacks' });
|
||||
expect(ics).toContain('DESCRIPTION:Line one\\nBring\\, chips\\; snacks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('googleCalendarUrl', () => {
|
||||
it('builds a render URL with the event details and UTC date range', () => {
|
||||
const url = googleCalendarUrl(base);
|
||||
expect(url).toContain('https://calendar.google.com/calendar/render?');
|
||||
expect(url).toContain('dates=20270718T160000Z%2F20270718T180000Z');
|
||||
expect(url).toContain('text=Backyard+BBQ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsUrl', () => {
|
||||
it('builds a Google Maps search URL for the location', () => {
|
||||
expect(mapsUrl('123 Main St')).toBe('https://www.google.com/maps/search/?api=1&query=123+Main+St');
|
||||
});
|
||||
});
|
||||
84
src/lib/ics.ts
Normal file
84
src/lib/ics.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Minimal iCalendar (RFC 5545) VEVENT generation and Google Calendar
|
||||
* "render" link building, both driven off the same UTC instant fields
|
||||
* (`eventDate`/`endDate`) Prisma stores - the event's `timezone` field is
|
||||
* purely for *display* (see lib/format.ts), so it's irrelevant here: an
|
||||
* absolute instant expressed in UTC renders correctly in any calendar
|
||||
* client regardless of the viewer's zone.
|
||||
*/
|
||||
|
||||
const DEFAULT_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours, when no end time was set
|
||||
|
||||
type IcsEvent = {
|
||||
uid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
start: Date;
|
||||
end: Date | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function icsDateUtc(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
// RFC 5545 TEXT escaping: backslash, semicolon, comma, then newlines.
|
||||
function escapeIcsText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
|
||||
// Lines over 75 octets should be folded per RFC 5545; most clients tolerate
|
||||
// long lines, but folding costs little and avoids surprises with picky ones.
|
||||
function foldLine(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
let out = line.slice(0, 75);
|
||||
let rest = line.slice(75);
|
||||
while (rest.length > 0) {
|
||||
out += '\r\n ' + rest.slice(0, 74);
|
||||
rest = rest.slice(74);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildIcs(event: IcsEvent): string {
|
||||
const end = event.end ?? new Date(event.start.getTime() + DEFAULT_DURATION_MS);
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//E-Invite//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${event.uid}`,
|
||||
`DTSTAMP:${icsDateUtc(new Date())}`,
|
||||
`DTSTART:${icsDateUtc(event.start)}`,
|
||||
`DTEND:${icsDateUtc(end)}`,
|
||||
`SUMMARY:${escapeIcsText(event.title)}`,
|
||||
];
|
||||
if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`);
|
||||
if (event.location) lines.push(`LOCATION:${escapeIcsText(event.location)}`);
|
||||
lines.push(`URL:${escapeIcsText(event.url)}`, 'END:VEVENT', 'END:VCALENDAR');
|
||||
|
||||
return lines.map(foldLine).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
export function googleCalendarUrl(event: IcsEvent): string {
|
||||
const end = event.end ?? new Date(event.start.getTime() + DEFAULT_DURATION_MS);
|
||||
const params = new URLSearchParams({
|
||||
action: 'TEMPLATE',
|
||||
text: event.title,
|
||||
dates: `${icsDateUtc(event.start)}/${icsDateUtc(end)}`,
|
||||
details: event.description,
|
||||
location: event.location,
|
||||
});
|
||||
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function mapsUrl(location: string): string {
|
||||
return `https://www.google.com/maps/search/?${new URLSearchParams({ api: '1', query: location }).toString()}`;
|
||||
}
|
||||
@ -210,6 +210,34 @@ export async function sendCollaboratorInviteEmail(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendThankYouEmail(opts: {
|
||||
to: string;
|
||||
guestName?: string;
|
||||
hostName: string;
|
||||
eventTitle: string;
|
||||
message: string;
|
||||
inviteUrl: string;
|
||||
}): Promise<boolean> {
|
||||
const guestName = opts.guestName ? escapeHtml(stripNewlines(opts.guestName)) : '';
|
||||
const hostName = escapeHtml(stripNewlines(opts.hostName));
|
||||
const eventTitle = escapeHtml(opts.eventTitle);
|
||||
const greeting = guestName ? `Hi ${guestName},` : 'Hi,';
|
||||
const message = escapeHtml(opts.message).replace(/\r?\n/g, '<br>');
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
subject: `Thank you from ${stripNewlines(opts.hostName)} - ${opts.eventTitle}`,
|
||||
html: layout(
|
||||
`Thanks for being part of ${eventTitle}!`,
|
||||
`
|
||||
<p>${greeting}</p>
|
||||
<p>${message}</p>
|
||||
<p style="margin-top:20px;">- ${hostName}</p>
|
||||
<p><a href="${opts.inviteUrl}">View the invitation again</a></p>
|
||||
`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(opts: { to: string; verifyUrl: string }): Promise<boolean> {
|
||||
return sendMail({
|
||||
to: opts.to,
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
// here is generated as a data: URI so there are no external image assets to
|
||||
// ship - the Docker image stays fully self-contained.
|
||||
|
||||
export type Pattern = { backgroundImage: string; backgroundSize: string };
|
||||
|
||||
function svgBackground(svg: string, size: string) {
|
||||
const uri = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
return { backgroundImage: uri, backgroundSize: size };
|
||||
@ -48,52 +50,71 @@ export function polkaDotPattern(color: string, radius = 2.5) {
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered leaf/vine motifs for a garden theme. */
|
||||
/** Scattered pointed-oval leaves (with a center vein) alternating between
|
||||
* two colors, for a garden theme. */
|
||||
export function leafPattern(leaf: string, accent: string) {
|
||||
const size = 150;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.5" fill="none" stroke="${leaf}" stroke-width="2">
|
||||
<path d="M20 30 Q30 15 45 25 Q30 35 20 30 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M100 20 Q108 8 120 15 Q110 25 100 20 Z" fill="${accent}" stroke="none"/>
|
||||
<path d="M70 70 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M20 100 Q30 85 45 95 Q30 105 20 100 Z" fill="${accent}" stroke="none"/>
|
||||
<path d="M110 110 q15 -10 25 5 q-18 5 -25 -5 Z" fill="${leaf}" stroke="none"/>
|
||||
<path d="M60 10 C 60 40, 60 40, 60 55" stroke="${leaf}" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered 6-point snowflakes for a winter/holiday theme. */
|
||||
export function snowflakePattern(color: string) {
|
||||
const size = 120;
|
||||
const flake = (cx: number, cy: number, r: number) => `
|
||||
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.4" opacity="0.6">
|
||||
${[0, 60, 120].map((deg) => `<line x1="${-r}" y1="0" x2="${r}" y2="0" transform="rotate(${deg})"/>`).join('')}
|
||||
const leafShape = (cx: number, cy: number, rotate: number, scale: number, color: string) => `
|
||||
<g transform="translate(${cx} ${cy}) rotate(${rotate}) scale(${scale})" opacity="0.55">
|
||||
<path d="M0 -12 C 8 -8, 8 8, 0 12 C -8 8, -8 -8, 0 -12 Z" fill="${color}"/>
|
||||
<line x1="0" y1="-10" x2="0" y2="10" stroke="${color}" stroke-width="0.6" opacity="0.6"/>
|
||||
</g>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
${flake(20, 25, 7)}
|
||||
${flake(85, 15, 5)}
|
||||
${flake(60, 70, 9)}
|
||||
${flake(105, 95, 6)}
|
||||
${flake(15, 95, 5)}
|
||||
${leafShape(24, 30, -25, 1, leaf)}
|
||||
${leafShape(112, 22, 40, 0.8, accent)}
|
||||
${leafShape(78, 78, 100, 0.9, leaf)}
|
||||
${leafShape(28, 108, -50, 0.8, accent)}
|
||||
${leafShape(120, 118, 15, 1, leaf)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered soft 4-point sparkle stars for a pastel theme. */
|
||||
/** Scattered 6-branch snowflakes, each arm carrying a pair of side ticks so
|
||||
* they read as snowflakes rather than plain asterisks, for a winter/holiday
|
||||
* theme. */
|
||||
export function snowflakePattern(color: string) {
|
||||
const size = 120;
|
||||
const flake = (cx: number, cy: number, r: number) => {
|
||||
const arm = (deg: number) => `
|
||||
<g transform="rotate(${deg})">
|
||||
<line x1="0" y1="0" x2="${r}" y2="0"/>
|
||||
<line x1="${r * 0.55}" y1="0" x2="${r * 0.38}" y2="${-r * 0.22}"/>
|
||||
<line x1="${r * 0.55}" y1="0" x2="${r * 0.38}" y2="${r * 0.22}"/>
|
||||
</g>`;
|
||||
return `
|
||||
<g transform="translate(${cx} ${cy})" stroke="${color}" stroke-width="1.1" stroke-linecap="round" opacity="0.65">
|
||||
${[0, 60, 120, 180, 240, 300].map(arm).join('')}
|
||||
<circle cx="0" cy="0" r="1.1" fill="${color}" stroke="none"/>
|
||||
</g>`;
|
||||
};
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
${flake(20, 25, 8)}
|
||||
${flake(85, 15, 6)}
|
||||
${flake(60, 70, 10)}
|
||||
${flake(105, 95, 7)}
|
||||
${flake(15, 95, 6)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Soft 4-point sparkle stars mixed with small twinkle dots, for a pastel
|
||||
* theme. */
|
||||
export function starPattern(colors: string[]) {
|
||||
const size = 130;
|
||||
const [a, b] = colors;
|
||||
const star = (cx: number, cy: number, r: number, fill: string) =>
|
||||
const sparkle = (cx: number, cy: number, r: number, fill: string) =>
|
||||
`<path d="M${cx} ${cy - r} Q${cx} ${cy} ${cx + r} ${cy} Q${cx} ${cy} ${cx} ${cy + r} Q${cx} ${cy} ${cx - r} ${cy} Q${cx} ${cy} ${cx} ${cy - r} Z" fill="${fill}"/>`;
|
||||
const dot = (cx: number, cy: number, r: number, fill: string) => `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.6">
|
||||
${star(25, 30, 7, a)}
|
||||
${star(95, 20, 5, b)}
|
||||
${star(60, 65, 6, a)}
|
||||
${star(110, 90, 8, b)}
|
||||
${star(20, 100, 5, a)}
|
||||
<g opacity="0.65">
|
||||
${sparkle(25, 30, 8, a)}
|
||||
${sparkle(95, 20, 6, b)}
|
||||
${sparkle(60, 65, 7, a)}
|
||||
${sparkle(110, 90, 9, b)}
|
||||
${sparkle(20, 100, 6, a)}
|
||||
${dot(70, 15, 2, b)}
|
||||
${dot(35, 78, 2, a)}
|
||||
${dot(100, 55, 2, b)}
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
@ -116,14 +137,133 @@ export function scratchPattern(color: string) {
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Subtle pixel-grid with scattered neon "pixels" for a retro arcade theme. */
|
||||
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
|
||||
const size = 40;
|
||||
const [a, b] = pixelColors;
|
||||
/** Delicate five-petal florets (rounded petals, not just dots) scattered on
|
||||
* their own, for a wedding/romance theme - restrained and lace-like rather
|
||||
* than busy. */
|
||||
export function floralLacePattern(color: string) {
|
||||
const size = 100;
|
||||
const flower = (cx: number, cy: number, r: number) => {
|
||||
const petal = (deg: number) =>
|
||||
`<ellipse cx="${cx}" cy="${cy - r}" rx="${r * 0.42}" ry="${r * 0.62}" fill="${color}" transform="rotate(${deg} ${cx} ${cy})"/>`;
|
||||
return `
|
||||
<g opacity="0.35">
|
||||
${[0, 72, 144, 216, 288].map(petal).join('')}
|
||||
<circle cx="${cx}" cy="${cy}" r="${r * 0.3}" fill="${color}"/>
|
||||
</g>`;
|
||||
};
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<rect width="${size}" height="${size}" fill="none" stroke="${gridColor}" stroke-width="1" opacity="0.25"/>
|
||||
<rect x="4" y="4" width="5" height="5" fill="${a}" opacity="0.6"/>
|
||||
<rect x="26" y="20" width="5" height="5" fill="${b}" opacity="0.6"/>
|
||||
${flower(20, 20, 7)}
|
||||
${flower(78, 55, 5)}
|
||||
${flower(45, 85, 7)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Fanned palm fronds with a small hibiscus accent, for a tropical/luau theme. */
|
||||
export function palmLeafPattern(leafColor: string, flowerColor: string) {
|
||||
const size = 140;
|
||||
const frond = (cx: number, cy: number, scale: number, rotate: number) => `
|
||||
<g transform="translate(${cx} ${cy}) rotate(${rotate}) scale(${scale})" fill="${leafColor}" opacity="0.4">
|
||||
<path d="M0 0 Q-14 -6 -20 -22 Q-6 -14 0 0 Z"/>
|
||||
<path d="M0 0 Q4 -16 -4 -30 Q10 -20 0 0 Z"/>
|
||||
<path d="M0 0 Q16 -8 24 -22 Q8 -16 0 0 Z"/>
|
||||
</g>`;
|
||||
const flower = (cx: number, cy: number) => `
|
||||
<g transform="translate(${cx} ${cy})" opacity="0.55">
|
||||
${[0, 72, 144, 216, 288]
|
||||
.map((deg) => `<ellipse cx="0" cy="-6" rx="2.5" ry="6" fill="${flowerColor}" transform="rotate(${deg})"/>`)
|
||||
.join('')}
|
||||
<circle r="2" fill="${flowerColor}"/>
|
||||
</g>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
${frond(24, 110, 1, -20)}
|
||||
${frond(110, 30, 0.8, 160)}
|
||||
${flower(95, 100)}
|
||||
${flower(30, 25)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Wheat spikes - kernels spaced in a clean herringbone up each stem rather
|
||||
* than overlapping - plus a couple of falling leaves, for a rustic/harvest
|
||||
* theme. */
|
||||
export function wheatPattern(color: string) {
|
||||
const size = 130;
|
||||
const stalk = (baseX: number, baseY: number, height: number, rows: number) => {
|
||||
const kernel = (x: number, y: number, angle: number) =>
|
||||
`<ellipse cx="${x}" cy="${y}" rx="2" ry="4.2" fill="${color}" transform="rotate(${angle} ${x} ${y})"/>`;
|
||||
const kernels = Array.from({ length: rows }, (_, i) => {
|
||||
const y = baseY - (i / (rows - 1)) * height;
|
||||
return `${kernel(baseX - 3.5, y, -30)}${kernel(baseX + 3.5, y, 30)}`;
|
||||
}).join('');
|
||||
return `
|
||||
<line x1="${baseX}" y1="${baseY}" x2="${baseX}" y2="${baseY - height}" stroke="${color}" stroke-width="1"/>
|
||||
${kernels}`;
|
||||
};
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g opacity="0.45">
|
||||
${stalk(20, 58, 42, 6)}
|
||||
${stalk(98, 44, 36, 5)}
|
||||
<path d="M95 90 q10 -14 24 -10 q-8 14 -24 10 Z" fill="${color}"/>
|
||||
<path d="M18 100 q8 -10 18 -6 q-6 10 -18 6 Z" fill="${color}"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** Scattered flat-silhouette mortarboard caps - diamond board over a
|
||||
* rounded band, with a button and a swinging tassel cord - for a graduation
|
||||
* theme. */
|
||||
export function gradCapPattern(color: string) {
|
||||
const size = 110;
|
||||
const cap = (cx: number, cy: number, scale: number) => `
|
||||
<g transform="translate(${cx} ${cy}) scale(${scale})" opacity="0.5">
|
||||
<ellipse cx="0" cy="7" rx="9" ry="5" fill="${color}"/>
|
||||
<path d="M0 -14 L20 -2 L0 9 L-20 -2 Z" fill="${color}"/>
|
||||
<circle cx="0" cy="-2" r="2" fill="${color}"/>
|
||||
<path d="M0 -1 L14 7 L11 15" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</g>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
${cap(28, 32, 0.85)}
|
||||
${cap(85, 78, 0.6)}
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
/** A bundled photographic texture (see public/textures/ - sourced from
|
||||
* transparenttextures.com and shipped in the Docker image rather than
|
||||
* fetched from that site at runtime, so a theme's background doesn't depend
|
||||
* on an external host being reachable). Meant to be layered under a theme's
|
||||
* accent-colored SVG motif via combinePatterns, not used alone - these are
|
||||
* flat/monochrome and rely on the theme's bg color showing through. */
|
||||
export function texturePattern(path: string, width: number, height: number): Pattern {
|
||||
return { backgroundImage: `url("${path}")`, backgroundSize: `${width}px ${height}px` };
|
||||
}
|
||||
|
||||
/** Layers multiple patterns into one CSS multi-background value - the first
|
||||
* pattern paints on top, later ones behind it. */
|
||||
export function combinePatterns(...patterns: Pattern[]): Pattern {
|
||||
return {
|
||||
backgroundImage: patterns.map((p) => p.backgroundImage).join(', '),
|
||||
backgroundSize: patterns.map((p) => p.backgroundSize).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
/** Sparse neon "pixels" over a faint grid for a retro arcade theme. Sized
|
||||
* and toned in line with the other patterns (large tile, lots of
|
||||
* negative space, low opacity) rather than a dense wall-to-wall grid,
|
||||
* which read as too busy behind a full page of text. */
|
||||
export function pixelGridPattern(gridColor: string, pixelColors: string[]) {
|
||||
const size = 120;
|
||||
const [a, b] = pixelColors;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
|
||||
<g stroke="${gridColor}" stroke-width="1" opacity="0.12">
|
||||
<line x1="0" y1="0.5" x2="${size}" y2="0.5"/>
|
||||
<line x1="0.5" y1="0" x2="0.5" y2="${size}"/>
|
||||
</g>
|
||||
<rect x="16" y="22" width="6" height="6" fill="${a}" opacity="0.35"/>
|
||||
<rect x="80" y="66" width="6" height="6" fill="${b}" opacity="0.35"/>
|
||||
<rect x="52" y="92" width="4" height="4" fill="${a}" opacity="0.25"/>
|
||||
</svg>`;
|
||||
return svgBackground(svg, `${size}px ${size}px`);
|
||||
}
|
||||
|
||||
34
src/lib/photoStorage.test.ts
Normal file
34
src/lib/photoStorage.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolvePhotoExtension } from '@/lib/photoStorage';
|
||||
|
||||
function fileWith(name: string, type: string): File {
|
||||
return new File(['data'], name, { type });
|
||||
}
|
||||
|
||||
describe('resolvePhotoExtension', () => {
|
||||
it('resolves standard image types by MIME type', () => {
|
||||
expect(resolvePhotoExtension(fileWith('photo.jpg', 'image/jpeg'))).toBe('jpg');
|
||||
expect(resolvePhotoExtension(fileWith('photo.png', 'image/png'))).toBe('png');
|
||||
expect(resolvePhotoExtension(fileWith('photo.webp', 'image/webp'))).toBe('webp');
|
||||
expect(resolvePhotoExtension(fileWith('photo.gif', 'image/gif'))).toBe('gif');
|
||||
});
|
||||
|
||||
it('resolves HEIC/HEIF by MIME type', () => {
|
||||
expect(resolvePhotoExtension(fileWith('IMG_0001.heic', 'image/heic'))).toBe('heic');
|
||||
expect(resolvePhotoExtension(fileWith('IMG_0001.heif', 'image/heif'))).toBe('heif');
|
||||
});
|
||||
|
||||
it('falls back to the filename extension for HEIC/HEIF when the browser sends a generic MIME type', () => {
|
||||
expect(resolvePhotoExtension(fileWith('IMG_0001.HEIC', 'application/octet-stream'))).toBe('heic');
|
||||
expect(resolvePhotoExtension(fileWith('IMG_0001.heif', ''))).toBe('heif');
|
||||
});
|
||||
|
||||
it('does not apply the generic-MIME fallback to non-HEIC extensions', () => {
|
||||
expect(resolvePhotoExtension(fileWith('photo.exe', 'application/octet-stream'))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects unsupported types', () => {
|
||||
expect(resolvePhotoExtension(fileWith('doc.pdf', 'application/pdf'))).toBeNull();
|
||||
expect(resolvePhotoExtension(fileWith('clip.mp4', 'video/mp4'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -9,8 +9,15 @@ export const ALLOWED_PHOTO_TYPES: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'image/heic': 'heic',
|
||||
'image/heif': 'heif',
|
||||
};
|
||||
|
||||
// iOS (and some Android browsers) often report a generic or empty MIME type
|
||||
// for HEIC/HEIF files instead of image/heic - fall back to the filename
|
||||
// extension for just those formats when that happens.
|
||||
const HEIC_EXTENSION_FALLBACK: Record<string, string> = { heic: 'heic', heif: 'heif' };
|
||||
|
||||
const UPLOADS_ROOT = path.join(process.cwd(), 'public', 'uploads');
|
||||
|
||||
function eventDir(eventId: string) {
|
||||
@ -22,19 +29,51 @@ export function photoUrl(eventId: string, filename: string): string {
|
||||
return `/uploads/${eventId}/${filename}`;
|
||||
}
|
||||
|
||||
/** Writes an uploaded file to disk under a per-event directory and returns
|
||||
* the generated filename (not the full path - callers store just this). */
|
||||
export async function savePhotoFile(eventId: string, file: File): Promise<string> {
|
||||
const ext = ALLOWED_PHOTO_TYPES[file.type];
|
||||
if (!ext) {
|
||||
throw new Error(`Unsupported file type: ${file.type}`);
|
||||
}
|
||||
/** URL to show in a gallery: the thumbnail if one was generated (HEIC/HEIF),
|
||||
* otherwise the original file, which every supported format can render inline. */
|
||||
export function photoDisplayUrl(eventId: string, photo: { filename: string; thumbnailFilename: string | null }): string {
|
||||
return photoUrl(eventId, photo.thumbnailFilename ?? photo.filename);
|
||||
}
|
||||
|
||||
/** Absolute on-disk path for a stored photo file. */
|
||||
export function photoFilePath(eventId: string, filename: string): string {
|
||||
return path.join(eventDir(eventId), filename);
|
||||
}
|
||||
|
||||
/** Resolves the on-disk extension for an uploaded file, or null if it's not
|
||||
* an allowed photo type. Trusts the declared MIME type first, then falls
|
||||
* back to the filename extension for HEIC/HEIF (see HEIC_EXTENSION_FALLBACK). */
|
||||
export function resolvePhotoExtension(file: File): string | null {
|
||||
if (ALLOWED_PHOTO_TYPES[file.type]) return ALLOWED_PHOTO_TYPES[file.type];
|
||||
if (!file.type || file.type === 'application/octet-stream') {
|
||||
const nameExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (nameExt && HEIC_EXTENSION_FALLBACK[nameExt]) return HEIC_EXTENSION_FALLBACK[nameExt];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isHeicExtension(ext: string): boolean {
|
||||
return ext === 'heic' || ext === 'heif';
|
||||
}
|
||||
|
||||
/** Writes a photo buffer to disk under a per-event directory and returns
|
||||
* the generated filename (not the full path - callers store just this). */
|
||||
export async function savePhotoFile(eventId: string, buffer: Buffer, ext: string): Promise<string> {
|
||||
const dir = eventDir(eventId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const filename = `${randomBytes(12).toString('hex')}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(path.join(dir, filename), buffer);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
/** Writes a generated WebP thumbnail to disk alongside the original photo. */
|
||||
export async function saveThumbnailFile(eventId: string, buffer: Buffer): Promise<string> {
|
||||
const dir = eventDir(eventId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const filename = `${randomBytes(12).toString('hex')}.webp`;
|
||||
await writeFile(path.join(dir, filename), buffer);
|
||||
|
||||
return filename;
|
||||
|
||||
15
src/lib/photoThumbnail.ts
Normal file
15
src/lib/photoThumbnail.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import 'server-only';
|
||||
import heicConvert from 'heic-convert';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const THUMBNAIL_MAX_DIMENSION = 1600;
|
||||
|
||||
/** Decodes a HEIC/HEIF buffer and re-encodes it as a resized WebP thumbnail,
|
||||
* for browsers (i.e. everything but Safari) that can't render HEIC/HEIF inline. */
|
||||
export async function convertHeicToWebpThumbnail(buffer: Buffer): Promise<Buffer> {
|
||||
const jpeg = await heicConvert({ buffer, format: 'JPEG', quality: 0.9 });
|
||||
return sharp(Buffer.from(jpeg))
|
||||
.resize({ width: THUMBNAIL_MAX_DIMENSION, height: THUMBNAIL_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
}
|
||||
38
src/lib/photoZip.ts
Normal file
38
src/lib/photoZip.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import 'server-only';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import { Readable } from 'node:stream';
|
||||
import { photoFilePath } from '@/lib/photoStorage';
|
||||
|
||||
type ZipPhoto = { eventId: string; filename: string; uploaderName: string; createdAt: Date };
|
||||
|
||||
function sanitizeForFilename(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/** Streams the *original* photo files (never the display thumbnail) into a
|
||||
* zip archive, oldest first. Each entry is named
|
||||
* `{event}-{upload date}-{uploader}-{index}.ext` so files stay identifiable
|
||||
* once extracted out of the zip; the index prefix guards against collisions
|
||||
* when several guests share a name, upload on the same day, or leave the
|
||||
* name blank. */
|
||||
export function buildPhotoZipStream(eventSlug: string, photos: ZipPhoto[]): ReadableStream<Uint8Array> {
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||
archive.on('warning', (err: Error) => console.error('Photo zip warning:', err));
|
||||
archive.on('error', (err: Error) => console.error('Photo zip error:', err));
|
||||
|
||||
for (const [index, photo] of photos.entries()) {
|
||||
const ext = photo.filename.split('.').pop() ?? 'jpg';
|
||||
const date = photo.createdAt.toISOString().slice(0, 10);
|
||||
const uploader = sanitizeForFilename(photo.uploaderName) || 'guest';
|
||||
const entryName = `${eventSlug}-${date}-${uploader}-${String(index + 1).padStart(3, '0')}.${ext}`;
|
||||
archive.file(photoFilePath(photo.eventId, photo.filename), { name: entryName });
|
||||
}
|
||||
archive.finalize();
|
||||
|
||||
return Readable.toWeb(archive) as ReadableStream<Uint8Array>;
|
||||
}
|
||||
@ -7,35 +7,48 @@ import {
|
||||
starPattern,
|
||||
scratchPattern,
|
||||
pixelGridPattern,
|
||||
floralLacePattern,
|
||||
palmLeafPattern,
|
||||
wheatPattern,
|
||||
gradCapPattern,
|
||||
texturePattern,
|
||||
combinePatterns,
|
||||
type Pattern,
|
||||
} from '@/lib/patterns';
|
||||
import { deriveThemeColors, type ThemeColors } from '@/lib/color';
|
||||
|
||||
export type Theme = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
colors: {
|
||||
bg: string;
|
||||
surface: string;
|
||||
primary: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
};
|
||||
pattern: {
|
||||
backgroundImage: string;
|
||||
backgroundSize: string;
|
||||
};
|
||||
/** Light card vs. dark card - drives how resolveTheme derives a full
|
||||
* palette from a host's custom primary/accent choice (see color.ts). */
|
||||
mode: 'light' | 'dark';
|
||||
colors: ThemeColors;
|
||||
pattern: Pattern;
|
||||
/** Rebuilds this theme's pattern for an arbitrary color set - used by
|
||||
* resolveTheme when a host overrides the default colors. */
|
||||
buildPattern: (colors: ThemeColors) => Pattern;
|
||||
};
|
||||
|
||||
type ThemeDefinition = Omit<Theme, 'pattern'>;
|
||||
|
||||
function defineTheme(def: ThemeDefinition): Theme {
|
||||
return { ...def, pattern: def.buildPattern(def.colors) };
|
||||
}
|
||||
|
||||
// Colors applied via CSS custom properties (see ThemedInviteLayout).
|
||||
// Fonts are mapped separately in src/lib/fonts.ts since next/font classes
|
||||
// must be static imports, not computed dynamically. `pattern` is a tileable
|
||||
// inline-SVG background (see src/lib/patterns.ts) that gives each theme a
|
||||
// distinct texture, not just a color/font swap.
|
||||
// distinct texture, not just a color/font swap. `buildPattern` regenerates
|
||||
// that same pattern for a different color set - see resolveTheme below.
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
defineTheme({
|
||||
id: 'classic-elegant',
|
||||
name: 'Classic Elegant',
|
||||
emoji: '🥂',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#f5f1ea',
|
||||
surface: '#ffffff',
|
||||
@ -43,12 +56,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#b08d57',
|
||||
text: '#2b2118',
|
||||
},
|
||||
pattern: damaskPattern('#b08d57'),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => combinePatterns(damaskPattern(c.accent), texturePattern('/textures/exclusive-paper.png', 560, 420)),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'birthday-confetti',
|
||||
name: 'Birthday Confetti',
|
||||
emoji: '🎉',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#fff3f8',
|
||||
surface: '#ffffff',
|
||||
@ -56,12 +70,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#ffb703',
|
||||
text: '#3a1030',
|
||||
},
|
||||
pattern: confettiPattern(['#e0218a', '#ffb703', '#3dd6c2']),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => confettiPattern([c.primary, c.accent, '#3dd6c2']),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'garden-party',
|
||||
name: 'Garden Party',
|
||||
emoji: '🌿',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#f1f7ee',
|
||||
surface: '#ffffff',
|
||||
@ -69,12 +84,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#e0a458',
|
||||
text: '#233720',
|
||||
},
|
||||
pattern: leafPattern('#3f6b3a', '#e0a458'),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => leafPattern(c.primary, c.accent),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'modern-minimal',
|
||||
name: 'Modern Minimal',
|
||||
emoji: '⬛',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#fafafa',
|
||||
surface: '#ffffff',
|
||||
@ -82,12 +98,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#2563eb',
|
||||
text: '#111827',
|
||||
},
|
||||
pattern: polkaDotPattern('#2563eb', 1.75),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => polkaDotPattern(c.accent, 1.75),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'holiday-festive',
|
||||
name: 'Holiday Festive',
|
||||
emoji: '❄️',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bg: '#0f2d24',
|
||||
surface: '#153a2e',
|
||||
@ -95,12 +112,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#f1c40f',
|
||||
text: '#fdf6ec',
|
||||
},
|
||||
pattern: snowflakePattern('#f1c40f'),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => combinePatterns(snowflakePattern(c.accent), texturePattern('/textures/fresh-snow.png', 500, 500)),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'baby-shower-pastel',
|
||||
name: 'Baby Shower Pastel',
|
||||
emoji: '🍼',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#f4f1fb',
|
||||
surface: '#ffffff',
|
||||
@ -108,12 +126,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#f6b6c1',
|
||||
text: '#3a3350',
|
||||
},
|
||||
pattern: starPattern(['#8e7cc3', '#f6b6c1']),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => starPattern([c.primary, c.accent]),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'goth-grunge',
|
||||
name: 'Goth Grunge',
|
||||
emoji: '💀',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bg: '#121212',
|
||||
surface: '#1c1c1c',
|
||||
@ -121,12 +140,13 @@ export const THEMES: Theme[] = [
|
||||
accent: '#9333ea',
|
||||
text: '#e5e5e5',
|
||||
},
|
||||
pattern: scratchPattern('#9333ea'),
|
||||
},
|
||||
{
|
||||
buildPattern: (c) => combinePatterns(scratchPattern(c.accent), texturePattern('/textures/dark-leather.png', 398, 484)),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'retro-arcade',
|
||||
name: 'Retro Arcade',
|
||||
emoji: '🕹️',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bg: '#0d0221',
|
||||
surface: '#1a0b3d',
|
||||
@ -134,8 +154,64 @@ export const THEMES: Theme[] = [
|
||||
accent: '#00e5ff',
|
||||
text: '#eafffb',
|
||||
},
|
||||
pattern: pixelGridPattern('#00e5ff', ['#ff2e88', '#00e5ff']),
|
||||
},
|
||||
buildPattern: (c) => pixelGridPattern(c.accent, [c.primary, c.accent]),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'wedding-romance',
|
||||
name: 'Wedding Romance',
|
||||
emoji: '💍',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#fdf5f2',
|
||||
surface: '#ffffff',
|
||||
primary: '#8c5b6b',
|
||||
accent: '#c9a45c',
|
||||
text: '#3a2a2e',
|
||||
},
|
||||
buildPattern: (c) => combinePatterns(floralLacePattern(c.accent), texturePattern('/textures/white-linen.png', 400, 300)),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'tropical-luau',
|
||||
name: 'Tropical Luau',
|
||||
emoji: '🌺',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#e6fbf7',
|
||||
surface: '#ffffff',
|
||||
primary: '#0f766e',
|
||||
accent: '#f59e0b',
|
||||
text: '#0b3d3a',
|
||||
},
|
||||
buildPattern: (c) => palmLeafPattern(c.primary, c.accent),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'rustic-harvest',
|
||||
name: 'Rustic Harvest',
|
||||
emoji: '🍂',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bg: '#f7ecd9',
|
||||
surface: '#fffaf1',
|
||||
primary: '#7a4a24',
|
||||
accent: '#c1622d',
|
||||
text: '#3d2812',
|
||||
},
|
||||
buildPattern: (c) => combinePatterns(wheatPattern(c.accent), texturePattern('/textures/cardboard.png', 600, 600)),
|
||||
}),
|
||||
defineTheme({
|
||||
id: 'graduation-grad',
|
||||
name: 'Graduation',
|
||||
emoji: '🎓',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bg: '#0b1f3a',
|
||||
surface: '#122a4d',
|
||||
primary: '#f2c14e',
|
||||
accent: '#f2c14e',
|
||||
text: '#f5f5f0',
|
||||
},
|
||||
buildPattern: (c) => gradCapPattern(c.accent),
|
||||
}),
|
||||
];
|
||||
|
||||
export const DEFAULT_THEME_ID = THEMES[0].id;
|
||||
@ -143,3 +219,27 @@ export const DEFAULT_THEME_ID = THEMES[0].id;
|
||||
export function getTheme(id: string): Theme {
|
||||
return THEMES.find((t) => t.id === id) ?? THEMES[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a theme for rendering, with optional host-chosen primary/accent
|
||||
* overrides (Event.themePrimaryColor / themeAccentColor). With no overrides,
|
||||
* returns the theme's own hand-tuned default palette and pattern completely
|
||||
* unchanged. With one or both overrides, derives a full contrast-safe
|
||||
* palette (see deriveThemeColors) and rebuilds the pattern to match, so a
|
||||
* customized theme never ends up illegible.
|
||||
*/
|
||||
export function resolveTheme(
|
||||
id: string,
|
||||
primaryOverride?: string | null,
|
||||
accentOverride?: string | null
|
||||
): Theme {
|
||||
const base = getTheme(id);
|
||||
if (!primaryOverride && !accentOverride) return base;
|
||||
|
||||
const colors = deriveThemeColors(
|
||||
base.mode,
|
||||
primaryOverride || base.colors.primary,
|
||||
accentOverride || base.colors.accent
|
||||
);
|
||||
return { ...base, colors, pattern: base.buildPattern(colors) };
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { THEMES } from '@/lib/themes';
|
||||
import { isValidDatetimeLocal } from '@/lib/timezone';
|
||||
import { isValidHexColor } from '@/lib/color';
|
||||
|
||||
const themeIds = THEMES.map((t) => t.id) as [string, ...string[]];
|
||||
|
||||
@ -11,6 +12,12 @@ const optionalDatetimeLocal = z
|
||||
.optional()
|
||||
.or(z.literal(''));
|
||||
|
||||
const optionalHexColor = z
|
||||
.string()
|
||||
.refine((v) => !v || isValidHexColor(v), 'Enter a valid color')
|
||||
.optional()
|
||||
.or(z.literal(''));
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
@ -57,6 +64,8 @@ export const eventFormSchema = z
|
||||
description: z.string().trim().max(5000).default(''),
|
||||
contactInfo: z.string().trim().max(300).default(''),
|
||||
theme: z.enum(themeIds),
|
||||
themePrimaryColor: optionalHexColor,
|
||||
themeAccentColor: optionalHexColor,
|
||||
timezone: z.string().trim().min(1, 'Timezone is required'),
|
||||
eventDate: datetimeLocal,
|
||||
endDate: optionalDatetimeLocal,
|
||||
@ -88,6 +97,21 @@ export const rsvpSchema = z.object({
|
||||
note: z.string().trim().max(1000).default(''),
|
||||
});
|
||||
|
||||
export const rsvpUpdateSchema = z.object({
|
||||
guestName: z.string().trim().min(1, 'Name is required').max(150),
|
||||
status: z.enum(['YES', 'NO', 'MAYBE']),
|
||||
numGuests: z.coerce.number().int().min(1).max(50),
|
||||
note: z.string().trim().max(1000).default(''),
|
||||
});
|
||||
|
||||
export const manualRsvpSchema = z.object({
|
||||
guestName: z.string().trim().min(1, 'Name is required').max(150),
|
||||
contact: z.string().trim().min(1, 'Enter an email or phone number'),
|
||||
status: z.enum(['YES', 'NO', 'MAYBE']),
|
||||
numGuests: z.coerce.number().int().min(1).max(50),
|
||||
note: z.string().trim().max(1000).default(''),
|
||||
});
|
||||
|
||||
export const commentSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
authorName: z.string().trim().min(1, 'Name is required').max(150),
|
||||
@ -105,6 +129,11 @@ export const inviteCollaboratorSchema = z.object({
|
||||
email: z.string().trim().email('Enter a valid email'),
|
||||
});
|
||||
|
||||
export const sendThankYouSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
message: z.string().trim().min(1, 'Enter a message').max(2000),
|
||||
});
|
||||
|
||||
export const photoUploadSchema = z.object({
|
||||
eventId: z.string().min(1),
|
||||
uploaderName: z.string().trim().max(100).default(''),
|
||||
|
||||
9
src/types/heic-convert.d.ts
vendored
Normal file
9
src/types/heic-convert.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
declare module 'heic-convert' {
|
||||
interface ConvertOptions {
|
||||
buffer: Buffer | ArrayBuffer | Uint8Array;
|
||||
format: 'JPEG' | 'PNG';
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export default function convert(options: ConvertOptions): Promise<ArrayBuffer>;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user