diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 2c9a4fa5..b9426697 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -25,6 +25,20 @@ const TEXT_FIELDS = [ { key: 'loginWebsiteUrl', label: 'Company Website URL' }, ]; +const PWA_IMAGE_FIELDS = [ + { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, +]; + +const PWA_TEXT_FIELDS = [ + { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, + { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, +]; + +const PWA_COLOR_FIELDS = [ + { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, + { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, +]; + export function BrandingTab() { const [config, setConfig] = useState>({}); const [edits, setEdits] = useState>({}); @@ -262,6 +276,142 @@ export function BrandingTab() { +
+
+

Progressive Web App

+

Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.

+
+
+ {PWA_IMAGE_FIELDS.map(field => ( +
+
+
+ + {config[field.key]?.source === 'admin' && ( + + {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder="Enter URL or upload a file" + className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + { fileInputRefs.current[field.key] = el; }} + type="file" + accept={field.accept} + className="hidden" + onChange={(e) => { + const file = e.target.files?.[0]; + if (file) handleUpload(field.key, file); + e.target.value = ''; + }} + /> + + {isUploadedFile(field.key) && ( + + )} + {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + + )} +
+
+ {currentValue(field.key) && ( +
+ +
+ {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+ ))} + {PWA_TEXT_FIELDS.map(field => ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder={field.placeholder} + className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ))} + {PWA_COLOR_FIELDS.map(field => { + const value = currentValue(field.key) || field.defaultValue; + return ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ handleChange(field.key, e.target.value)} + className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5" + title="Pick a color" + /> + handleChange(field.key, e.target.value)} + placeholder={field.defaultValue} + className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ); + })} +
+
+

Company Information

diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index 9d0e8242..de9b4457 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -24,6 +24,7 @@ const ALLOWED_MIME_TYPES = new Set([ /** Slots that correspond to branding config keys */ const VALID_SLOTS = new Set([ 'faviconUrl', + 'pwaIconUrl', 'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 14977870..54ad6c4a 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -3,11 +3,13 @@ import sharp from 'sharp'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; +import { getConfigDir } from '@/lib/admin/paths'; const VALID_SIZES = new Set([192, 512]); -// Cache resized images in memory to avoid reprocessing on every request -const cache = new Map(); +// Cache resized images keyed by (size, source URL) so admin re-uploads or URL +// changes invalidate the prior render instead of serving stale bytes forever. +const cache = new Map(); async function fetchSourceImage(iconUrl: string): Promise { // Absolute URL (http/https) @@ -17,6 +19,14 @@ async function fetchSourceImage(iconUrl: string): Promise { return Buffer.from(await res.arrayBuffer()); } + // Admin-uploaded branding asset: served from /api/admin/branding/ + // but stored on disk under getConfigDir()/branding/. + const ADMIN_BRANDING_PREFIX = '/api/admin/branding/'; + if (iconUrl.startsWith(ADMIN_BRANDING_PREFIX)) { + const filename = path.basename(iconUrl.slice(ADMIN_BRANDING_PREFIX.length)); + return readFile(path.join(getConfigDir(), 'branding', filename)); + } + // Path relative to public/ directory const publicPath = path.join(process.cwd(), 'public', iconUrl.replace(/^\//, '')); return readFile(publicPath); @@ -47,9 +57,11 @@ export async function GET( 'Cache-Control': 'public, max-age=86400', }; + const cacheKey = `${size}|${iconUrl}`; + try { - if (cache.has(size)) { - return new NextResponse(cache.get(size)!, { headers: pngHeaders }); + if (cache.has(cacheKey)) { + return new NextResponse(cache.get(cacheKey)!, { headers: pngHeaders }); } const sourceBuffer = await fetchSourceImage(iconUrl); @@ -61,7 +73,7 @@ export async function GET( const ab = new ArrayBuffer(resized.byteLength); new Uint8Array(ab).set(resized); const blob = new Blob([ab], { type: 'image/png' }); - cache.set(size, blob); + cache.set(cacheKey, blob); return new NextResponse(blob, { headers: pngHeaders }); } catch (err) {