diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..bb21eb4 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/public/icon-16.png b/public/icon-16.png new file mode 100644 index 0000000..7fe28ed Binary files /dev/null and b/public/icon-16.png differ diff --git a/public/icon-180.png b/public/icon-180.png index c2ddc72..f05509c 100644 Binary files a/public/icon-180.png and b/public/icon-180.png differ diff --git a/public/icon-192.png b/public/icon-192.png index e755168..3d956e6 100644 Binary files a/public/icon-192.png and b/public/icon-192.png differ diff --git a/public/icon-32.png b/public/icon-32.png new file mode 100644 index 0000000..71d727b Binary files /dev/null and b/public/icon-32.png differ diff --git a/public/icon-384.png b/public/icon-384.png index 1498832..38f4c7b 100644 Binary files a/public/icon-384.png and b/public/icon-384.png differ diff --git a/public/icon-512-maskable.png b/public/icon-512-maskable.png index 745c0b9..3dfc3ce 100644 Binary files a/public/icon-512-maskable.png and b/public/icon-512-maskable.png differ diff --git a/public/icon-512.png b/public/icon-512.png index 745c0b9..99613a1 100644 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg index b495eef..0723161 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,12 +1,6 @@ - - - - - - - - - - + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index b470957..c21203b 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -4,23 +4,26 @@ "description": "Family coordination app", "start_url": "/", "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#4F46E5", + "background_color": "#4338CA", + "theme_color": "#4338CA", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/icon-384.png", "sizes": "384x384", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" }, { "src": "/icon-512-maskable.png", diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index bc4e57e..a0eff59 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -1,100 +1,48 @@ /** - * Generates placeholder PNG icons for the famapp PWA. + * Rasterizes public/icon.svg into all required PNG icon sizes. * * Usage: node scripts/generate-icons.mjs * * Produces: + * public/icon-16.png + * public/icon-32.png + * public/icon-180.png (apple-touch-icon) * public/icon-192.png * public/icon-384.png * public/icon-512.png - * public/icon-512-maskable.png (same image; OS applies its own mask) - * public/icon-180.png (apple-touch-icon) - * - * All images are solid indigo (#4F46E5) squares — replace with a real - * branded export from icon.svg when assets are finalised. + * public/icon-512-maskable.png (square bg, content in safe zone) */ -import { deflateSync } from "zlib"; -import { writeFileSync } from "fs"; -import { resolve, dirname } from "path"; +import sharp from "sharp"; +import { readFileSync } from "fs"; import { fileURLToPath } from "url"; +import { dirname, join } from "path"; -const __dir = dirname(fileURLToPath(import.meta.url)); -const publicDir = resolve(__dir, "../public"); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pub = join(__dirname, "..", "public"); -// Build CRC-32 lookup table once. -const CRC_TABLE = new Uint32Array(256); -for (let i = 0; i < 256; i++) { - let c = i; - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - CRC_TABLE[i] = c; -} +const svg = readFileSync(join(pub, "icon.svg")); -function crc32(buf) { - let crc = 0xffffffff; - for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); - return (crc ^ 0xffffffff) >>> 0; -} - -function u32(n) { - const b = Buffer.alloc(4); - b.writeUInt32BE(n, 0); - return b; -} - -function pngChunk(type, data) { - const typeBytes = Buffer.from(type, "ascii"); - const crc = u32(crc32(Buffer.concat([typeBytes, data]))); - return Buffer.concat([u32(data.length), typeBytes, data, crc]); -} - -/** - * Returns a Buffer containing a valid PNG of size×size filled with (r, g, b). - */ -function solidPNG(size, r, g, b) { - const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - - const ihdr = pngChunk( - "IHDR", - Buffer.concat([ - u32(size), - u32(size), - Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace - ]), - ); - - // One filter byte (0 = None) followed by size×3 RGB bytes per row. - const rowLen = 1 + size * 3; - const raw = Buffer.alloc(size * rowLen); - for (let y = 0; y < size; y++) { - const base = y * rowLen; - raw[base] = 0; - for (let x = 0; x < size; x++) { - raw[base + 1 + x * 3] = r; - raw[base + 2 + x * 3] = g; - raw[base + 3 + x * 3] = b; - } - } - - const idat = pngChunk("IDAT", deflateSync(raw)); - const iend = pngChunk("IEND", Buffer.alloc(0)); - - return Buffer.concat([PNG_SIG, ihdr, idat, iend]); -} - -// Indigo-600 (#4F46E5) -const [R, G, B] = [0x4f, 0x46, 0xe5]; - -const icons = [ +const sizes = [ + { name: "icon-16.png", size: 16 }, + { name: "icon-32.png", size: 32 }, + { name: "icon-180.png", size: 180 }, { name: "icon-192.png", size: 192 }, { name: "icon-384.png", size: 384 }, { name: "icon-512.png", size: 512 }, - { name: "icon-512-maskable.png", size: 512 }, - { name: "icon-180.png", size: 180 }, ]; -for (const { name, size } of icons) { - const out = resolve(publicDir, name); - writeFileSync(out, solidPNG(size, R, G, B)); +for (const { name, size } of sizes) { + await sharp(svg).resize(size, size).png().toFile(join(pub, name)); console.log(` ✓ public/${name} (${size}×${size})`); } + +// Maskable: remove rounded corners so the background fills the full canvas, +// allowing the OS to apply any mask shape without clipping the icon corners. +const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', ""); + +await sharp(Buffer.from(maskableSvg)) + .resize(512, 512) + .png() + .toFile(join(pub, "icon-512-maskable.png")); +console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fe1246..ba61772 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -54,6 +54,11 @@ export const metadata: Metadata = { title: "famapp", }, icons: { + icon: [ + { url: "/favicon.svg", type: "image/svg+xml" }, + { url: "/icon-32.png", sizes: "32x32", type: "image/png" }, + { url: "/icon-16.png", sizes: "16x16", type: "image/png" }, + ], apple: "/icon-180.png", }, }; diff --git a/src/components/brand-mark.tsx b/src/components/brand-mark.tsx index a72aebb..7e07c1f 100644 --- a/src/components/brand-mark.tsx +++ b/src/components/brand-mark.tsx @@ -1,12 +1,23 @@ import { cn } from "@/lib/utils"; export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) { + const iconSize = size === "sm" ? 14 : 17; return ( ); } diff --git a/src/lib/session.ts b/src/lib/session.ts index bebf3ab..cf51637 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,4 +1,5 @@ import { eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { householdMembers, households, users } from "@/modules/_core/schema"; @@ -13,7 +14,7 @@ export interface CurrentSession { export async function getCurrentSession(): Promise { const session = await auth(); - if (!session?.user?.id) throw new Error("Not authenticated"); + if (!session?.user?.id) redirect("/login"); const [row] = await db .select({ diff --git a/src/middleware.ts b/src/middleware.ts index 5519687..6fb1281 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,8 @@ import { NextResponse, type NextRequest } from "next/server"; import { consume } from "@/lib/rate-limit"; -const PUBLIC_PREFIXES = ["/api/auth/", "/s/"]; -const PUBLIC_PATHS = new Set(["/login"]); +const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"]; +const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]); const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"]; // Token-prefix length used as part of the rate-limit bucket key.