first commit

This commit is contained in:
2026-07-18 01:34:52 +05:30
commit d1308bf149
113 changed files with 20593 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
// Static security headers. The Content-Security-Policy is set per-request in
// middleware.ts so it can carry a unique nonce (no 'unsafe-inline' scripts).
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(self), geolocation=(self)' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
];
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@photo-gallery/sdk'],
async headers() {
return [{ source: '/:path*', headers: securityHeaders }];
},
};
export default nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@huggingface/transformers": "^3.8.1",
"@imgly/background-removal": "^1.7.0",
"@photo-gallery/sdk": "workspace:*",
"@supabase/supabase-js": "^2.108.2",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"leaflet": "^1.9.4",
"next": "^15.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tesseract.js": "5.1.1"
},
"devDependencies": {
"@types/node": "^20.16.11",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
+241
View File
@@ -0,0 +1,241 @@
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 60; // SD / cold-start models can take a while
/**
* Generative image-edit proxy. The BACKEND is pluggable so you are not tied to a
* paid model — pick one with env `AI_EDIT_PROVIDER` (default `auto`):
*
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
* SD.Next img2img API). Env: LOCAL_SD_URL (e.g. http://127.0.0.1:7860).
* 100% free + private, needs a GPU box you run. Most reliable.
* - `huggingface` → free Hugging Face Inference API (instruction image editing).
* Env: HF_API_TOKEN (free), HF_IMAGE_MODEL (default instruct-pix2pix).
* - `gemini` → Google Gemini image model (needs a billed key for image output).
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
* - `auto` → first configured of: local → huggingface → gemini.
*
* NOTE: `remove-background` never reaches here — it runs fully in-browser (@imgly,
* free, no key). Object detection / faces / OCR / embeddings are also 100% in-browser.
* See docs/AI-SETUP.md.
*/
const OP_PROMPTS: Record<string, string> = {
restore:
'Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.',
colorize: 'Colorize this image with natural, realistic, well-balanced colors.',
'replace-sky':
'Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.',
};
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits (e.g. Vercel ~4.5MB)
type Provider = 'local' | 'huggingface' | 'gemini' | 'none';
function resolveProvider(): Provider {
const explicit = (process.env.AI_EDIT_PROVIDER || 'auto').toLowerCase();
if (explicit === 'local' || explicit === 'huggingface' || explicit === 'gemini') return explicit;
if (explicit === 'none') return 'none';
// auto: prefer a private local server, then free HF, then Gemini.
if (process.env.LOCAL_SD_URL) return 'local';
if (process.env.HF_API_TOKEN) return 'huggingface';
if (process.env.GEMINI_API_KEY) return 'gemini';
return 'none';
}
interface EditResult {
imageBase64: string;
mimeType: string;
}
export async function POST(req: NextRequest) {
const provider = resolveProvider();
if (provider === 'none') {
return NextResponse.json(
{
error:
'AI image editing is not configured. Set LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (free Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key. See docs/AI-SETUP.md.',
},
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body.' }, { status: 400 });
}
const { imageBase64, mimeType, op } = (body ?? {}) as {
imageBase64?: unknown;
mimeType?: unknown;
op?: { type?: string; prompt?: string };
};
if (typeof imageBase64 !== 'string' || imageBase64.length === 0 || imageBase64.length > MAX_BASE64) {
return NextResponse.json({ error: 'Invalid or oversized image (try a smaller image).' }, { status: 400 });
}
const safeMime =
typeof mimeType === 'string' && /^image\/(jpeg|png|webp)$/.test(mimeType) ? mimeType : 'image/jpeg';
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
let instruction: string;
if (op?.type === 'prompt') {
const p = typeof op.prompt === 'string' ? op.prompt.trim() : '';
if (!p) return NextResponse.json({ error: 'Empty prompt.' }, { status: 400 });
instruction = p.slice(0, 500);
} else if (op?.type === 'replace-sky' && typeof op.prompt === 'string' && op.prompt.trim()) {
instruction = `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`;
} else if (op?.type && OP_PROMPTS[op.type]) {
instruction = OP_PROMPTS[op.type]!;
} else {
return NextResponse.json({ error: 'Unsupported operation.' }, { status: 400 });
}
try {
let result: EditResult;
if (provider === 'local') result = await editLocal(instruction, imageBase64);
else if (provider === 'huggingface') result = await editHuggingFace(instruction, imageBase64);
else result = await editGemini(instruction, imageBase64, safeMime);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed.';
const status = err instanceof AiError ? err.status : 502;
return NextResponse.json({ error: message }, { status });
}
}
class AiError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.status = status;
}
}
// ---------------------------------------------------------------------------
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
// ---------------------------------------------------------------------------
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
const base = process.env.LOCAL_SD_URL;
if (!base || !/^https?:\/\//i.test(base)) {
throw new AiError('LOCAL_SD_URL is not a valid http(s) URL.', 500);
}
const url = `${base.replace(/\/$/, '')}/sdapi/v1/img2img`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
init_images: [imageBase64],
prompt: instruction,
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
cfg_scale: 7,
sampler_name: process.env.LOCAL_SD_SAMPLER || 'Euler a',
}),
});
} catch {
throw new AiError('Could not reach your local Stable Diffusion server (LOCAL_SD_URL).', 502);
}
if (!res.ok) {
throw new AiError(`Local SD server error (${res.status}).`, 502);
}
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
const out = data?.images?.[0];
if (!out) throw new AiError('Local SD server did not return an image.', 502);
// A1111 returns raw base64 PNG (no data: prefix).
return { imageBase64: out.includes(',') ? out.split(',')[1]! : out, mimeType: 'image/png' };
}
// ---------------------------------------------------------------------------
// Backend: Hugging Face Inference API (free tier) — instruction image editing.
// ---------------------------------------------------------------------------
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
const token = process.env.HF_API_TOKEN;
if (!token) throw new AiError('HF_API_TOKEN is not set.', 500);
const model = process.env.HF_IMAGE_MODEL || 'timbrooks/instruct-pix2pix';
let res: Response;
try {
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
// Wait for the (free) model to warm up instead of a fast 503.
'x-wait-for-model': 'true',
},
body: JSON.stringify({
inputs: imageBase64,
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
}),
});
} catch {
throw new AiError('Could not reach the Hugging Face Inference API.', 502);
}
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 160);
if (res.status === 503)
throw new AiError('The free model is loading — try again in ~20s.', 503);
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
}
// Success returns raw image bytes.
const outMime = res.headers.get('content-type') || 'image/png';
if (outMime.startsWith('application/json')) {
const j = (await res.json().catch(() => null)) as { error?: string } | null;
throw new AiError(j?.error ? `Hugging Face: ${j.error}` : 'Hugging Face returned no image.', 502);
}
const buf = await res.arrayBuffer();
return { imageBase64: Buffer.from(buf).toString('base64'), mimeType: outMime };
}
// ---------------------------------------------------------------------------
// Backend: Google Gemini image model (needs a billed key for image output).
// ---------------------------------------------------------------------------
async function editGemini(instruction: string, imageBase64: string, safeMime: string): Promise<EditResult> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new AiError('GEMINI_API_KEY is not set.', 500);
const model = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
let res: Response;
try {
res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: 'POST',
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
body: JSON.stringify({
contents: [
{ role: 'user', parts: [{ inlineData: { mimeType: safeMime, data: imageBase64 } }, { text: prompt }] },
],
generationConfig: { responseModalities: ['IMAGE'] },
}),
},
);
} catch {
throw new AiError('Could not reach the AI service.', 502);
}
if (!res.ok) {
const detail = (await res.text().catch(() => '')).slice(0, 160);
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
}
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
if (!out) throw new AiError('The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).', 502);
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? 'image/png';
return { imageBase64: out, mimeType: outMime };
}
interface GeminiPart {
text?: string;
inlineData?: { mimeType?: string; data?: string };
inline_data?: { mime_type?: string; data?: string };
}
interface GeminiResponse {
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
}
+5
View File
@@ -0,0 +1,5 @@
import { GalleryClient } from '../../components/GalleryClient';
export default function GalleryPage() {
return <GalleryClient />;
}
+335
View File
@@ -0,0 +1,335 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
/* ===================== Landing page ===================== */
.apg-landing {
position: relative;
color: #f4f4f7;
background: #07070b;
overflow-x: hidden;
padding-bottom: 60px;
}
.landing-bg {
position: fixed;
inset: 0;
z-index: 0;
background:
radial-gradient(60vw 60vw at 75% -10%, rgba(10, 132, 255, 0.28), transparent 60%),
radial-gradient(50vw 50vw at 0% 20%, rgba(191, 90, 242, 0.18), transparent 60%),
#07070b;
pointer-events: none;
}
.apg-landing > *:not(.landing-bg) {
position: relative;
z-index: 1;
}
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px clamp(16px, 5vw, 64px);
max-width: 1200px;
margin: 0 auto;
}
.brand {
font-weight: 700;
font-size: 17px;
display: flex;
align-items: center;
gap: 9px;
}
.brand-dot {
width: 16px;
height: 16px;
border-radius: 5px;
background: linear-gradient(135deg, #0a84ff, #bf5af2);
}
.brand-muted {
color: #8e8e96;
font-weight: 600;
}
.landing-links {
display: flex;
align-items: center;
gap: 18px;
}
.muted-link {
color: #c7c7cf;
text-decoration: none;
font-size: 14px;
}
.muted-link:hover {
color: #fff;
}
.cta {
display: inline-flex;
align-items: center;
gap: 6px;
background: linear-gradient(135deg, #0a84ff, #0a6fff);
color: #fff;
text-decoration: none;
font-weight: 600;
padding: 12px 22px;
border-radius: 12px;
font-size: 16px;
box-shadow: 0 10px 30px rgba(10, 132, 255, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cta:hover {
transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(10, 132, 255, 0.5);
}
.cta-sm {
padding: 8px 16px;
font-size: 14px;
border-radius: 10px;
}
.ghost {
color: #d7d7df;
text-decoration: none;
font-weight: 500;
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.07);
}
.hero {
max-width: 1000px;
margin: 0 auto;
text-align: center;
padding: clamp(40px, 8vw, 90px) clamp(16px, 5vw, 40px) 30px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 12px;
color: #8ab4ff;
font-weight: 600;
margin: 0 0 18px;
}
.hero h1 {
font-size: clamp(34px, 6.5vw, 68px);
line-height: 1.04;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0 0 22px;
}
.grad {
background: linear-gradient(120deg, #0a84ff, #bf5af2 60%, #ff9f0a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lead {
font-size: clamp(16px, 2.4vw, 20px);
color: #b9b9c3;
max-width: 680px;
margin: 0 auto 30px;
line-height: 1.5;
}
.hero-cta {
display: flex;
gap: 14px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 54px;
}
.window {
max-width: 920px;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 40px 120px rgba(0, 0, 0, 0.6);
background: #1c1c1e;
}
.window-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: #2a2a2c;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tl {
width: 12px;
height: 12px;
border-radius: 50%;
}
.tl-r {
background: #ff5f57;
}
.tl-y {
background: #febc2e;
}
.tl-g {
background: #28c840;
}
.window-title {
margin: 0 auto;
font-size: 13px;
color: #9b9ba1;
font-weight: 600;
}
.window-body {
display: flex;
min-height: 320px;
text-align: left;
}
.wb-sidebar {
width: 200px;
flex-shrink: 0;
padding: 14px 10px;
background: rgba(255, 255, 255, 0.02);
border-right: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13.5px;
}
.wb-row {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: #d7d7df;
}
.wb-row--active {
background: #0a84ff;
color: #fff;
}
.wb-ico {
width: 15px;
height: 15px;
border-radius: 4px;
background: currentColor;
opacity: 0.7;
}
.wb-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 6px;
padding: 12px;
}
.wb-tile {
aspect-ratio: 1;
border-radius: 6px;
background: linear-gradient(135deg, #3a3a3c, #2a2a2c);
animation: wb-fade 0.5s ease both;
}
@keyframes wb-fade {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.usage {
max-width: 760px;
margin: 70px auto 0;
padding: 0 clamp(16px, 5vw, 24px);
text-align: center;
}
.usage h2,
.final h2 {
font-size: clamp(24px, 4vw, 34px);
font-weight: 800;
letter-spacing: -0.01em;
}
.code {
text-align: left;
background: #0f0f17;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 20px 22px;
overflow-x: auto;
font-size: 13.5px;
line-height: 1.6;
color: #d7e0ff;
font-family: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
}
.usage-note {
color: #9b9ba1;
font-size: 14px;
margin-top: 16px;
}
.usage-note code,
.landing-foot code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 5px;
font-size: 0.9em;
}
.features {
max-width: 1100px;
margin: 70px auto 0;
padding: 0 clamp(16px, 5vw, 24px);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.feature {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 20px;
}
.feature h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
}
.feature p {
margin: 0;
color: #a9a9b3;
font-size: 14px;
line-height: 1.5;
}
.final {
text-align: center;
margin: 80px auto 0;
padding: 0 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.final p {
color: #a9a9b3;
margin: 0;
}
.landing-foot {
text-align: center;
color: #6e6e78;
font-size: 13px;
margin-top: 60px;
padding: 0 20px;
}
@media (max-width: 620px) {
.wb-sidebar {
display: none;
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a84ff"/>
<stop offset="1" stop-color="#bf5af2"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#g)"/>
<circle cx="11" cy="12" r="2.4" fill="#fff"/>
<path d="M5 23l6.5-6 4 3.2L21 13l6 8z" fill="#fff" opacity="0.95"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+32
View File
@@ -0,0 +1,32 @@
import type { Metadata, Viewport } from 'next';
import { headers } from 'next/headers';
import './globals.css';
// The SDK's self-contained stylesheet + Leaflet's CSS for the Map view.
import '@photo-gallery/sdk/styles.css';
import 'leaflet/dist/leaflet.css';
export const metadata: Metadata = {
title: 'Photo Gallery SDK — macOS Photos for the web',
description:
'A reusable, macOS Photos-style photo gallery for React / Next.js. Albums, smart albums, map, editor, search and more — drop-in via one component.',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#0a84ff',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Reading the per-request nonce header opts every route into dynamic rendering,
// which lets Next apply the CSP nonce (set in middleware.ts) to its framework
// scripts — required for the nonce-based, no-'unsafe-inline' script policy.
void (await headers()).get('x-nonce');
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+126
View File
@@ -0,0 +1,126 @@
import Link from 'next/link';
const FEATURES: Array<{ title: string; body: string }> = [
{ title: 'Pixel-faithful UI', body: 'macOS Photos sidebar, Library (Years/Months/All), Collections, Map and toolbars — in light & dark mode.' },
{ title: 'Albums & Smart Albums', body: 'Create, nest, rename, duplicate. Auto smart albums for Screenshots, Videos, Favourites, RAW and more.' },
{ title: 'Auto source detection', body: 'Camera vs. screenshot vs. download vs. social — classified on import with zero AI cost.' },
{ title: 'Map & places', body: 'Plot located photos on a free OpenStreetMap / satellite map. Map · Satellite · Grid modes.' },
{ title: 'Non-destructive editor', body: 'Exposure, contrast, colour, filters, rotate & flip — applied live, reversible any time.' },
{ title: 'Search & find-similar', body: 'Search by name, place, tag or detected object. Click an object to surface every photo with it.' },
{ title: 'Recycle bin & duplicates', body: '30-day recoverable trash, multi-select, copy/move, and duplicate detection with one-tap merge.' },
{ title: 'Pluggable everything', body: 'Swap the storage adapter (localStorage → S3/Postgres) and AI provider (free in-browser → Gemini).' },
];
export default function Home() {
return (
<main style={{ minHeight: '100%' }} className="apg-landing">
<div className="landing-bg" />
<header className="landing-nav">
<div className="brand">
<span className="brand-dot" />
Photo Gallery <span className="brand-muted">SDK</span>
</div>
<nav className="landing-links">
<a href="https://github.com" className="muted-link" target="_blank" rel="noreferrer">
Docs
</a>
<Link href="/gallery" className="cta cta-sm">
Open Gallery
</Link>
</nav>
</header>
<section className="hero">
<p className="eyebrow">React · Next.js · TypeScript · tree-shakeable</p>
<h1>
The macOS Photos experience,
<br />
as a drop-in <span className="grad">SDK</span>.
</h1>
<p className="lead">
A reusable, enterprise-grade photo gallery for any React or Next.js app. Albums, smart
albums, map, a non-destructive editor, search and recycle bin behind a single component.
</p>
<div className="hero-cta">
<Link href="/gallery" className="cta">
Open the Gallery
</Link>
<a href="#usage" className="ghost">
See the code
</a>
</div>
<div className="window">
<div className="window-bar">
<span className="tl tl-r" />
<span className="tl tl-y" />
<span className="tl tl-g" />
<span className="window-title">Photos</span>
</div>
<div className="window-body">
<div className="wb-sidebar">
{['Library', 'Collections', 'Favourites', 'Map', 'Videos', 'Screenshots', 'People'].map(
(s, i) => (
<div key={s} className={`wb-row ${i === 0 ? 'wb-row--active' : ''}`}>
<span className="wb-ico" />
{s}
</div>
),
)}
</div>
<div className="wb-grid">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="wb-tile" style={{ animationDelay: `${i * 18}ms` }} />
))}
</div>
</div>
</div>
</section>
<section id="usage" className="usage">
<h2>Three lines to a full gallery</h2>
<pre className="code">
<code>{`import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default function App() {
return (
<PhotoGallery
photos={myPhotos} // your data, any source
theme="system" // light · dark · system
features={{ editor: true, map: true, import: true }}
/>
);
}`}</code>
</pre>
<p className="usage-note">
Defaults to a zero-config localStorage backend, so it runs with no server. Provide a
<code> StorageAdapter</code> or <code> AIProvider</code> to scale up.
</p>
</section>
<section className="features">
{FEATURES.map((f) => (
<div key={f.title} className="feature">
<h3>{f.title}</h3>
<p>{f.body}</p>
</div>
))}
</section>
<section className="final">
<h2>Try it now</h2>
<p>The demo ships with 40+ sample photos, videos, locations and tags.</p>
<Link href="/gallery" className="cta">
Open the Gallery
</Link>
</section>
<footer className="landing-foot">
Built as <code>@photo-gallery/sdk</code> · original implementation, not affiliated with
Apple.
</footer>
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { PhotoGallery, type StorageAdapter } from '@photo-gallery/sdk';
import { useEffect, useMemo, useState } from 'react';
import { createDemoAIProvider } from '../lib/ai/createDemoAIProvider';
import { getGalleryConfigFromEnv } from '../lib/galleryConfig';
import { buildSeedPhotos } from '../lib/seed';
export function GalleryClient() {
// Render the gallery only after mount — it relies on browser APIs
// (localStorage, matchMedia, Leaflet) and should not be server-rendered.
const [mounted, setMounted] = useState(false);
// undefined = still resolving; null = no backend (use default localStorage).
const [adapter, setAdapter] = useState<StorageAdapter | null | undefined>(undefined);
useEffect(() => {
setMounted(true);
let cancelled = false;
void (async () => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
setAdapter(null);
return;
}
// Lazy-load the Supabase client so it isn't in the initial bundle.
const { createSupabaseAdapter } = await import('../lib/adapters/supabaseAdapter');
if (!cancelled) {
setAdapter(
createSupabaseAdapter({
url,
anonKey,
bucket: process.env.NEXT_PUBLIC_SUPABASE_BUCKET || 'media',
}),
);
}
})();
return () => {
cancelled = true;
};
}, []);
const photos = useMemo(() => buildSeedPhotos(), []);
// AI: in-browser object detection (free) + Gemini generative edits (server-proxied).
const ai = useMemo(() => createDemoAIProvider(), []);
// Wait for mount + adapter resolution so we initialize on the right backend once.
if (!mounted || adapter === undefined) {
return (
<div
style={{
position: 'fixed',
inset: 0,
display: 'grid',
placeItems: 'center',
background: '#1c1c1e',
color: '#aeaeb2',
fontFamily: 'system-ui, sans-serif',
}}
>
Loading Photos
</div>
);
}
// Optional NEXT_PUBLIC_APG_* env overrides (theme colors/gradients, sidebar,
// per-feature flags). Unset values fall back to the defaults below. See docs/ENV.md.
const env = getGalleryConfigFromEnv();
return (
<div style={{ position: 'fixed', inset: 0 }}>
<PhotoGallery
photos={photos}
theme={env.theme ?? 'system'}
ai={ai}
adapter={adapter ?? undefined}
borderRadius={env.borderRadius}
themeTokens={env.themeTokens}
features={{
editor: true,
camera: true,
ai: true,
map: true,
import: true,
export: true,
sharing: true,
...env.features,
}}
/>
</div>
);
}
Binary file not shown.
+114
View File
@@ -0,0 +1,114 @@
import type { MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser semantic search via CLIP (transformers.js / ONNX-WASM).
*
* CLIP maps images and text into the SAME 512-D space, so "show me beach photos"
* ranks visually-matching images even when they have no tags or filename hints.
* The model is downloaded once from the Hugging Face CDN (allow-listed in the CSP)
* and cached by the browser; the module is dynamically imported on first use so it
* never bloats the initial bundle. Nothing leaves the browser.
*/
const MODEL_ID = 'Xenova/clip-vit-base-patch16';
type TF = typeof import('@huggingface/transformers');
let tfMod: TF | null = null;
let visionPromise: Promise<{ processor: any; model: any } | null> | null = null;
let textPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
async function loadTf(): Promise<TF> {
if (!tfMod) {
tfMod = await import('@huggingface/transformers');
// Remote-only (models from HF CDN); rely on browser cache between sessions.
tfMod.env.allowLocalModels = false;
}
return tfMod;
}
function ensureVision() {
visionPromise ??= (async () => {
try {
const tf = await loadTf();
const [processor, model] = await Promise.all([
tf.AutoProcessor.from_pretrained(MODEL_ID),
tf.CLIPVisionModelWithProjection.from_pretrained(MODEL_ID),
]);
return { processor, model };
} catch (err) {
console.warn('[clipProvider] vision model load failed; semantic search disabled.', err);
return null;
}
})();
return visionPromise;
}
function ensureText() {
textPromise ??= (async () => {
try {
const tf = await loadTf();
const [tokenizer, model] = await Promise.all([
tf.AutoTokenizer.from_pretrained(MODEL_ID),
tf.CLIPTextModelWithProjection.from_pretrained(MODEL_ID),
]);
return { tokenizer, model };
} catch (err) {
console.warn('[clipProvider] text model load failed; semantic search disabled.', err);
return null;
}
})();
return textPromise;
}
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
function toCanvas(image: ImageBitmap | HTMLImageElement, maxDim = 384): HTMLCanvasElement {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext('2d')!;
ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
return canvas;
}
function tensorToArray(t: any): number[] {
const data: Float32Array = t?.data ?? t;
return Array.from(data as ArrayLike<number>);
}
export function createClipProvider() {
return {
async embedImage(_item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<number[]> {
const v = await ensureVision();
if (!v) return [];
try {
const tf = await loadTf();
const canvas = toCanvas(image);
const imageData = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height);
const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb();
const inputs = await v.processor(raw);
const out = await v.model(inputs);
return tensorToArray(out.image_embeds);
} catch (err) {
console.warn('[clipProvider] embedImage failed.', err);
return [];
}
},
async embedText(query: string): Promise<number[]> {
const t = await ensureText();
if (!t) return [];
try {
const inputs = t.tokenizer([query], { padding: true, truncation: true });
const out = await t.model(inputs);
return tensorToArray(out.text_embeds);
} catch (err) {
console.warn('[clipProvider] embedText failed.', err);
return [];
}
},
};
}
+102
View File
@@ -0,0 +1,102 @@
import type { AIProvider } from '@photo-gallery/sdk';
import { createClipProvider } from './clipProvider';
import { createFaceProvider } from './faceProvider';
import { createOCRProvider } from './ocrProvider';
import { createTensorflowProvider } from './tensorflowProvider';
/**
* The demo's AI provider, combining free in-browser capabilities + Gemini:
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key)
* - face detection + recognition: face-api.js, in-browser → clustered into People
* - OCR: tesseract.js, in-browser → searchable text + the Documents album (no key)
* - background removal: @imgly, in-browser WASM (no key) — see generativeEdit
* - other generative edits: Gemini, proxied through /api/ai/edit so the API key
* stays server-side and is never exposed to the browser.
*/
export function createDemoAIProvider(): AIProvider {
const tf = createTensorflowProvider();
const face = createFaceProvider();
const ocr = createOCRProvider();
const clip = createClipProvider();
return {
name: 'demo-ai (coco-ssd + face-api + tesseract + clip + gemini)',
detectObjects: tf.detectObjects,
detectFaces: face.detectFaces,
ocr: ocr.ocr,
embedImage: clip.embedImage,
embedText: clip.embedText,
async generativeEdit(item, image, op) {
// Remove Background runs fully in-browser (free, no key) via @imgly — works
// even when Gemini is unavailable. Other ops go through the Gemini route.
if (op.type === 'remove-background') {
const inputBlob = await canvasBlob(image, 1600);
const { removeBackground } = await import('@imgly/background-removal');
return removeBackground(inputBlob, { output: { format: 'image/png' } });
}
const { data, mimeType } = imageToBase64(image, 1280);
const res = await fetch('/api/ai/edit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ imageBase64: data, mimeType, op }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `AI request failed (${res.status}).`);
}
const { imageBase64, mimeType: outMime } = (await res.json()) as {
imageBase64: string;
mimeType?: string;
};
return base64ToBlob(imageBase64, outMime || 'image/png');
},
};
}
/** Draw an image to a canvas (downscaled) and return base64 JPEG (no data: prefix). */
function imageToBase64(
image: ImageBitmap | HTMLImageElement,
maxDim: number,
): { data: string; mimeType: string } {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported.');
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
return { data: dataUrl.split(',')[1] ?? '', mimeType: 'image/jpeg' };
}
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
function canvasBlob(image: ImageBitmap | HTMLImageElement, maxDim: number): Promise<Blob> {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) return Promise.reject(new Error('Canvas not supported.'));
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
return new Promise((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.92),
);
}
function base64ToBlob(base64: string, mime: string): Blob {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Blob([bytes], { type: mime });
}
+103
View File
@@ -0,0 +1,103 @@
import type { DetectedFace, MediaItem } from '@photo-gallery/sdk';
// Type-only imports are erased at compile time — no bundle cost.
import type { TNetInput } from '@vladmandic/face-api';
/**
* Free, in-browser face detection + recognition via `@vladmandic/face-api`
* (TensorFlow.js under the hood). It returns a 128-D descriptor per face which
* the SDK clusters into People — no API key, nothing leaves the browser.
*
* Models are fetched once from the jsDelivr CDN (allow-listed in the CSP). The
* whole module is dynamically imported on first use so it never bloats the
* initial bundle.
*/
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model';
type FaceApi = typeof import('@vladmandic/face-api');
let loadPromise: Promise<FaceApi | null> | null = null;
/** Load face-api + its models exactly once; resolves to null if anything fails. */
function ensureModels(): Promise<FaceApi | null> {
loadPromise ??= (async () => {
try {
const faceapi = await import('@vladmandic/face-api');
// The bundled tf re-export is typed narrowly; backend control lives on the
// runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully.
const tf = faceapi.tf as unknown as {
setBackend: (b: string) => Promise<boolean>;
ready: () => Promise<void>;
};
try {
await tf.setBackend('webgl');
} catch {
/* keep default backend */
}
await tf.ready();
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
]);
return faceapi;
} catch (err) {
// Degrade gracefully — People simply stays empty if models can't load.
console.warn('[faceProvider] model load failed; face clustering disabled.', err);
return null;
}
})();
return loadPromise;
}
let warned = false;
export function createFaceProvider() {
return {
async detectFaces(
item: MediaItem,
image: ImageBitmap | HTMLImageElement,
): Promise<DetectedFace[]> {
const faceapi = await ensureModels();
if (!faceapi) return [];
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1;
let results;
try {
results = await faceapi
.detectAllFaces(
image as unknown as TNetInput,
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }),
)
.withFaceLandmarks()
.withFaceDescriptors();
} catch (err) {
if (!warned) {
warned = true;
console.warn('[faceProvider] face detection failed on', item.name, err);
}
return [];
}
return results.map((r) => {
const b = r.detection.box;
return {
confidence: r.detection.score,
box: {
x: clamp01(b.x / w),
y: clamp01(b.y / h),
width: clamp01(b.width / w),
height: clamp01(b.height / h),
},
embedding: Array.from(r.descriptor),
} satisfies DetectedFace;
});
},
};
}
function clamp01(n: number): number {
return n < 0 ? 0 : n > 1 ? 1 : n;
}
+122
View File
@@ -0,0 +1,122 @@
import type { MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser OCR via tesseract.js (no API key, nothing leaves the browser).
*
* tesseract spins up a Web Worker that compiles a WASM core and downloads the
* English language model. ALL three assets are pinned to `cdn.jsdelivr.net`,
* which is already allow-listed in the CSP (faceProvider uses it too) — so no
* fetch silently falls back to tesseract's default `tessdata.projectnaptha.com`
* (which is NOT allow-listed and would be blocked). The module is dynamically
* imported on first use so it never bloats the initial bundle.
*
* IMPORTANT: tesseract hallucinates low-confidence gibberish for photos that
* contain no text. We therefore keep only high-confidence, word-shaped tokens
* and require several of them — otherwise a landscape photo would wrongly count
* as a "document". See `meaningfulText`.
*/
// Must equal the EXACT tesseract.js version in apps/web/package.json (pinned, no
// caret) so the worker CDN URL can never drift from the installed main-thread code.
const VERSION = '5.1.1';
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${VERSION}/dist/worker.min.js`;
const CORE_PATH = 'https://cdn.jsdelivr.net/npm/tesseract.js-core@5';
// jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com).
const LANG_PATH = 'https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0';
/** A word per tesseract's output hierarchy. */
interface OcrWord {
text?: string;
confidence?: number;
}
interface OcrData {
text?: string;
confidence?: number;
words?: OcrWord[];
blocks?: Array<{
paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }>;
}> | null;
}
type Worker = import('tesseract.js').Worker;
let workerPromise: Promise<Worker | null> | null = null;
/** Create + initialize the tesseract worker exactly once; null if it fails. */
function ensureWorker(): Promise<Worker | null> {
workerPromise ??= (async () => {
try {
const { createWorker } = await import('tesseract.js');
// v5: createWorker(langs, oem, options) already loads + initializes the
// language internally — do NOT call the removed v4 worker.load().
return await createWorker('eng', 1, {
workerPath: WORKER_PATH,
corePath: CORE_PATH,
langPath: LANG_PATH,
});
} catch (err) {
console.warn('[ocrProvider] worker init failed; OCR disabled.', err);
return null;
}
})();
return workerPromise;
}
const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about
const MIN_WORDS = 4; // need several confident words to call it a document
const MIN_CHARS = 10;
/** Flatten tesseract's word list from whichever shape this version returns. */
function collectWords(data: OcrData): OcrWord[] {
if (Array.isArray(data.words) && data.words.length) return data.words;
const out: OcrWord[] = [];
for (const b of data.blocks ?? [])
for (const p of b.paragraphs ?? [])
for (const l of p.lines ?? [])
for (const w of l.words ?? []) out.push(w);
return out;
}
/**
* Return real text or '' (not a document). Prefers per-word confidence; falls
* back to the overall mean confidence + a word-shape heuristic if the build
* doesn't expose words.
*/
function meaningfulText(data: OcrData): string {
const words = collectWords(data);
if (words.length > 0) {
const good = words.filter(
(w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ''),
);
const text = good
.map((w) => (w.text ?? '').trim())
.filter(Boolean)
.join(' ')
.trim();
return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : '';
}
// Fallback: overall confidence + count of word-shaped tokens.
const raw = (data.text ?? '').trim();
const conf = typeof data.confidence === 'number' ? data.confidence : 0;
const realWords = raw.match(/[A-Za-z]{3,}/g) ?? [];
return conf >= 72 && realWords.length >= 6 ? raw : '';
}
export function createOCRProvider() {
return {
async ocr(_item: MediaItem, image: ImageBitmap | HTMLImageElement): Promise<string> {
const worker = await ensureWorker();
if (!worker) return '';
try {
// Request the block hierarchy so per-word confidence is available.
const { data } = (await worker.recognize(image as unknown as HTMLImageElement, {}, {
text: true,
blocks: true,
})) as { data: OcrData };
return meaningfulText(data);
} catch {
return '';
}
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import type { AIProvider, DetectedObject, MediaItem } from '@photo-gallery/sdk';
/**
* Free, in-browser AI provider using TensorFlow.js + COCO-SSD object detection.
* No API key, no server — the model (~6 MB) downloads from the public TF model
* CDN on first use and runs on the GPU via the WebGL backend.
*
* Detects 80 COCO classes (person, dog, cat, car, laptop, chair, dining table,
* tv, bottle, cup, …) which power search, find-similar and the Objects browser.
*/
export function createTensorflowProvider(): AIProvider {
// Loaded lazily and memoized so the heavy bundle/model is fetched only once.
let modelPromise: Promise<CocoModel> | null = null;
async function getModel(): Promise<CocoModel> {
if (!modelPromise) {
modelPromise = (async () => {
const tf = await import('@tensorflow/tfjs');
try {
await tf.setBackend('webgl');
} catch {
// Fall back to the default backend if WebGL is unavailable.
}
await tf.ready();
const cocoSsd = await import('@tensorflow-models/coco-ssd');
return cocoSsd.load({ base: 'lite_mobilenet_v2' }) as unknown as CocoModel;
})();
}
return modelPromise;
}
return {
name: 'tensorflow-coco-ssd',
async detectObjects(item: MediaItem, image): Promise<DetectedObject[]> {
const model = await getModel();
const el = image as HTMLImageElement;
const w = el.naturalWidth || el.width || item.width || 1;
const h = el.naturalHeight || el.height || item.height || 1;
const predictions = await model.detect(el, 20, 0.4);
return predictions.map((p) => ({
label: p.class,
confidence: p.score,
box: {
x: p.bbox[0] / w,
y: p.bbox[1] / h,
width: p.bbox[2] / w,
height: p.bbox[3] / h,
},
}));
},
};
}
interface CocoPrediction {
bbox: [number, number, number, number];
class: string;
score: number;
}
interface CocoModel {
detect(
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
maxNumBoxes?: number,
minScore?: number,
): Promise<CocoPrediction[]>;
}
+73
View File
@@ -0,0 +1,73 @@
import type { GalleryFeatures, ThemeTokens } from '@photo-gallery/sdk';
/**
* Reads NEXT_PUBLIC_APG_* environment variables into <PhotoGallery> props so the
* whole gallery can be customized without touching code. Every value is optional
* — anything unset falls back to the SDK's built-in defaults (dark/light/semi-dark).
*
* See docs/ENV.md for the full list.
*
* NOTE: Next.js only inlines NEXT_PUBLIC_* vars that are referenced statically, so
* each one is read explicitly below (not via a dynamic key).
*/
const bool = (v: string | undefined): boolean | undefined =>
v === undefined || v === '' ? undefined : v === 'true' || v === '1';
const num = (v: string | undefined): number | undefined => {
if (v === undefined || v === '') return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
/** Drop undefined entries so partial objects don't clobber SDK defaults. */
function compact<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
}
export function getFeaturesFromEnv(): Partial<GalleryFeatures> {
return compact({
editor: bool(process.env.NEXT_PUBLIC_APG_EDITOR),
camera: bool(process.env.NEXT_PUBLIC_APG_CAMERA),
ai: bool(process.env.NEXT_PUBLIC_APG_AI),
map: bool(process.env.NEXT_PUBLIC_APG_MAP),
import: bool(process.env.NEXT_PUBLIC_APG_IMPORT),
export: bool(process.env.NEXT_PUBLIC_APG_EXPORT),
sharing: bool(process.env.NEXT_PUBLIC_APG_SHARING),
});
}
export function getThemeTokensFromEnv(): ThemeTokens | undefined {
const tokens = compact({
bgLight: process.env.NEXT_PUBLIC_APG_BG_LIGHT || undefined,
bgDark: process.env.NEXT_PUBLIC_APG_BG_DARK || undefined,
elevatedLight: process.env.NEXT_PUBLIC_APG_ELEVATED_LIGHT || undefined,
elevatedDark: process.env.NEXT_PUBLIC_APG_ELEVATED_DARK || undefined,
sidebarBgLight: process.env.NEXT_PUBLIC_APG_SIDEBAR_BG_LIGHT || undefined,
sidebarBgDark: process.env.NEXT_PUBLIC_APG_SIDEBAR_BG_DARK || undefined,
textLight: process.env.NEXT_PUBLIC_APG_TEXT_LIGHT || undefined,
textDark: process.env.NEXT_PUBLIC_APG_TEXT_DARK || undefined,
sidebarRadius: num(process.env.NEXT_PUBLIC_APG_SIDEBAR_RADIUS),
accent: process.env.NEXT_PUBLIC_APG_ACCENT || undefined,
}) as ThemeTokens;
return Object.keys(tokens).length ? tokens : undefined;
}
export interface EnvGalleryConfig {
features: Partial<GalleryFeatures>;
themeTokens?: ThemeTokens;
borderRadius?: number;
theme?: 'system' | 'light' | 'dark' | 'semi-dark';
}
export function getGalleryConfigFromEnv(): EnvGalleryConfig {
const theme = process.env.NEXT_PUBLIC_APG_THEME as EnvGalleryConfig['theme'] | undefined;
// Accent comes through themeTokens.accent (PhotoGallery reads it there) — no
// separate accentColor field, to avoid a confusing double source for one env var.
return {
features: getFeaturesFromEnv(),
themeTokens: getThemeTokensFromEnv(),
borderRadius: num(process.env.NEXT_PUBLIC_APG_RADIUS),
theme: theme || undefined,
};
}
+141
View File
@@ -0,0 +1,141 @@
import type { MediaInput } from '@photo-gallery/sdk';
/**
* Demo seed data. Uses free Picsum photos with synthetic-but-realistic metadata
* (capture dates, GPS locations, tags, detected objects, sources) so that the
* Map, Search, Smart Albums and "find similar object" features all have data to
* show without any backend or API key.
*
* Picsum is allow-listed in the CSP (see next.config.mjs). Videos use Google's
* public sample bucket, also allow-listed.
*/
const CITIES: Array<{ place: string; city: string; country: string; lat: number; lng: number }> = [
{ place: 'Mumbai, India', city: 'Mumbai', country: 'India', lat: 19.076, lng: 72.8777 },
{ place: 'New Delhi, India', city: 'New Delhi', country: 'India', lat: 28.6139, lng: 77.209 },
{ place: 'Bengaluru, India', city: 'Bengaluru', country: 'India', lat: 12.9716, lng: 77.5946 },
{ place: 'Goa, India', city: 'Goa', country: 'India', lat: 15.2993, lng: 74.124 },
{ place: 'Manali, India', city: 'Manali', country: 'India', lat: 32.2432, lng: 77.1892 },
{ place: 'Jaipur, India', city: 'Jaipur', country: 'India', lat: 26.9124, lng: 75.7873 },
{ place: 'San Francisco, USA', city: 'San Francisco', country: 'USA', lat: 37.7749, lng: -122.4194 },
{ place: 'New York, USA', city: 'New York', country: 'USA', lat: 40.7128, lng: -74.006 },
{ place: 'London, UK', city: 'London', country: 'UK', lat: 51.5074, lng: -0.1278 },
{ place: 'Paris, France', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 },
{ place: 'Tokyo, Japan', city: 'Tokyo', country: 'Japan', lat: 35.6762, lng: 139.6503 },
{ place: 'Dubai, UAE', city: 'Dubai', country: 'UAE', lat: 25.2048, lng: 55.2708 },
];
const TAG_POOL = [
['beach', 'sunset', 'ocean'],
['mountain', 'snow', 'hike'],
['food', 'restaurant'],
['city', 'building', 'night'],
['nature', 'flower', 'plant'],
['friends', 'party'],
['travel', 'landmark'],
['selfie', 'portrait'],
];
const OBJECT_POOL = [
['person'],
['dog'],
['cat'],
['car'],
['laptop', 'cup'],
['dining table', 'chair', 'bottle'],
['tv', 'chair'],
['potted plant'],
['bicycle', 'person'],
];
// Served from the demo's own /public so it plays same-origin (no CORS / ORB issues).
const SAMPLE_VIDEOS = ['/sample-clip.mp4'];
const DAY = 24 * 60 * 60 * 1000;
export function buildSeedPhotos(now = Date.now()): MediaInput[] {
const items: MediaInput[] = [];
for (let i = 0; i < 44; i++) {
const seed = `apg-${i}`;
const takenAt = now - Math.floor(i * 8.4 + (i % 5) * 3) * DAY;
// ~75% of demo photos are geotagged (real photos populate location via EXIF GPS on import).
const hasLoc = i % 4 !== 0;
const city = CITIES[i % CITIES.length]!;
const tags = TAG_POOL[i % TAG_POOL.length]!;
const objects = i % 3 === 0 ? OBJECT_POOL[i % OBJECT_POOL.length]! : [];
// Distribute sources to populate smart albums.
let source: MediaInput['source'] = 'camera';
let name = `IMG_${4000 + i}.jpg`;
let width = 1600;
let height = 1067;
const variant = i % 7;
if (variant === 1) {
source = 'screenshot';
name = `Screenshot 2026-0${(i % 9) + 1}-12 at 9.41.${10 + i}.png`;
width = 1170;
height = 2532;
} else if (variant === 2) {
source = 'download';
name = `download-${i}.jpg`;
} else if (variant === 3) {
source = 'social';
name = `IMG-2026031${i % 9}-WA00${i % 9}.jpg`;
} else if (variant === 5) {
// panorama
width = 2400;
height = 820;
name = `PANO_${i}.jpg`;
}
const isVideo = i % 11 === 4; // a few videos
if (isVideo) {
const v = SAMPLE_VIDEOS[i % SAMPLE_VIDEOS.length]!;
items.push({
src: v,
poster: `https://picsum.photos/seed/${seed}/1280/720`,
thumbnail: `https://picsum.photos/seed/${seed}/600/400`,
name: `MOV_${5000 + i}.mp4`,
kind: 'video',
mime: 'video/mp4',
width: 1280,
height: 720,
duration: 30 + (i % 4) * 12,
takenAt,
source: 'camera',
bytes: 18_000_000 + i * 250_000,
tags,
objectLabels: objects,
favorite: i % 9 === 0,
location: hasLoc ? { ...city } : undefined,
});
continue;
}
items.push({
src: `https://picsum.photos/seed/${seed}/${width}/${height}`,
thumbnail: `https://picsum.photos/seed/${seed}/${Math.round(width / 3)}/${Math.round(height / 3)}`,
name,
kind: 'image',
mime: source === 'screenshot' ? 'image/png' : 'image/jpeg',
width,
height,
takenAt,
source,
bytes: 2_400_000 + i * 90_000,
tags,
objectLabels: objects,
favorite: i % 6 === 0,
isPanorama: variant === 5,
location: hasLoc ? { ...city } : undefined,
exif:
source === 'camera'
? { Make: 'Apple', Model: 'iPhone 16 Pro', FNumber: 1.8, ISO: 100 + (i % 8) * 50 }
: undefined,
});
}
return items;
}
+61
View File
@@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from 'next/server';
/**
* Per-request Content-Security-Policy with a unique nonce.
*
* `script-src` uses a nonce + 'strict-dynamic' instead of 'unsafe-inline', so an
* injected inline <script> cannot execute even if markup injection occurs.
* Next.js reads the nonce from the request header and applies it to its own
* bootstrap scripts. 'unsafe-eval' is allowed only in development (HMR needs it).
*/
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
const csp = [
"default-src 'self'",
// 'wasm-unsafe-eval' lets in-browser ML (background removal, OCR, etc.) compile WASM.
// cdn.jsdelivr.net = defense for tesseract.js' worker bootstrap (the load-bearing
// path is the blob: worker + connect-src fetch; under 'strict-dynamic' this host is
// ignored, but it covers non-strict-dynamic fallback browsers).
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://cdn.jsdelivr.net${isDev ? " 'unsafe-eval'" : ''}`,
// Leaflet & Framer Motion inject inline styles; keep style inline allowed.
"style-src 'self' 'unsafe-inline'",
// *.supabase.co serves uploaded media from Supabase Storage public URLs.
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://server.arcgisonline.com https://picsum.photos https://*.picsum.photos https://fastly.picsum.photos https://*.supabase.co",
"media-src 'self' data: blob: https://*.supabase.co",
"font-src 'self' data:",
// blob: = in-browser ML workers fetch WASM/model from blob URLs; storage.googleapis.com = TF.js weights;
// *.supabase.co = backend; staticimgly.com = background-removal model;
// cdn.jsdelivr.net = face-api models + tesseract.js worker/WASM-core/traineddata (OCR) + onnxruntime WASM;
// huggingface.co + *.hf.co = transformers.js CLIP model config + weights, which
// redirect to HF's Xet CDN (e.g. us.aws.cdn.hf.co) — semantic search.
"connect-src 'self' blob: data: https://*.tile.openstreetmap.org https://server.arcgisonline.com https://picsum.photos https://fastly.picsum.photos https://storage.googleapis.com https://*.supabase.co https://staticimgly.com https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co",
"worker-src 'self' blob:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
'upgrade-insecure-requests',
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
// Next reads this request header to nonce its framework scripts.
requestHeaders.set('content-security-policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('content-security-policy', csp);
return response;
}
export const config = {
matcher: [
{
source: '/((?!api|_next/static|_next/image|favicon.ico|icon.svg).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
+26
View File
@@ -0,0 +1,26 @@
import type { Config } from 'tailwindcss';
// Tailwind is used for the demo's own landing page chrome only.
// The SDK ships its own self-contained stylesheet (no Tailwind required).
const config: Config = {
content: ['./src/**/*.{ts,tsx,mdx}'],
theme: {
extend: {
fontFamily: {
sans: [
'-apple-system',
'BlinkMacSystemFont',
'SF Pro Text',
'SF Pro Display',
'Inter',
'Segoe UI',
'system-ui',
'sans-serif',
],
},
},
},
plugins: [],
};
export default config;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}