feat: proper logo, favicon, and pwa icons
- replace placeholder house svg with clean geometric house mark on indigo-700 (#4338CA) — roof triangle, body, door cutout - add favicon.svg with prefers-color-scheme dark variant - add icon-16/32 sizes; regenerate all pngs from svg via sharp - update BrandMark component to use inline svg house instead of 'f' - wire favicon + sized pngs into layout metadata - fix middleware to allow icon/manifest/favicon paths without auth (android launcher fetches icons in a cookieless system context) - fix getCurrentSession() to redirect to /login instead of throwing when session cookie is stale/invalid
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
|
||||
</style>
|
||||
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
|
||||
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 547 B After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,12 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
|
||||
<!-- House body -->
|
||||
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
|
||||
<!-- Door -->
|
||||
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
|
||||
<!-- Left window -->
|
||||
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||
<!-- Right window -->
|
||||
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||
<rect width="512" height="512" rx="96" fill="#4338CA"/>
|
||||
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 594 B After Width: | Height: | Size: 345 B |
@@ -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",
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
f
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="12,2 1,12 23,12" />
|
||||
<rect x="3.5" y="11" width="17" height="12" />
|
||||
<rect x="9.5" y="16" width="5" height="7" rx="1" style={{ fill: "var(--ink)" }} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<CurrentSession> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Not authenticated");
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
|
||||
@@ -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.
|
||||
|
||||