feat(pwa): service worker, offline page, web push notifications
PWA manifest, sw.js with offline cache. VAPID web-push via lib/push.ts. Push subscribe button (client). Push fired on recipe comment creation. VAPID keys configurable via admin site settings.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, pushSubscriptions, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
keys: z.object({
|
||||
p256dh: z.string(),
|
||||
auth: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const unsubscribeSchema = z.object({
|
||||
endpoint: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const json = await request.json();
|
||||
const parsed = subscribeSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
|
||||
await db
|
||||
.insert(pushSubscriptions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: session!.user.id,
|
||||
endpoint: body.endpoint,
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: pushSubscriptions.endpoint,
|
||||
set: {
|
||||
userId: session!.user.id,
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const json = await request.json();
|
||||
const parsed = unsubscribeSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(pushSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(pushSubscriptions.endpoint, parsed.data.endpoint),
|
||||
eq(pushSubscriptions.userId, session!.user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold">You're offline</h1>
|
||||
<p className="max-w-sm text-muted-foreground">
|
||||
Your recently visited recipes are available. Connect to internet to load new content.
|
||||
</p>
|
||||
<Link
|
||||
href="/recipes"
|
||||
className="mt-2 underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Back to my recipes
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user