Sprint 4: Media Pipeline with Cloudflare R2

- Add R2 credentials (.env.local)
- Create /api/upload with presigned URLs
- Memories gallery UI with grid view and upload
- Images stored in tia bucket
This commit is contained in:
Manohar Gupta 2026-05-10 13:59:22 +05:30
parent 5943ab19eb
commit 3334277ec9
8 changed files with 307 additions and 124 deletions

27
pnpm-lock.yaml generated
View file

@ -10,7 +10,7 @@ importers:
dependencies:
'@auth/drizzle-adapter':
specifier: ^1.11.2
version: 1.11.2
version: 1.11.2(nodemailer@7.0.13)
'@aws-sdk/client-s3':
specifier: ^3.1045.0
version: 3.1045.0
@ -34,7 +34,10 @@ importers:
version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth:
specifier: 5.0.0-beta.31
version: 5.0.0-beta.31(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
version: 5.0.0-beta.31(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4)
nodemailer:
specifier: ^7.0.13
version: 7.0.13
openai:
specifier: ^6.37.0
version: 6.37.0(zod@4.4.3)
@ -1583,6 +1586,10 @@ packages:
sass:
optional: true
nodemailer@7.0.13:
resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==}
engines: {node: '>=6.0.0'}
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
@ -1710,17 +1717,19 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@auth/core@0.41.2':
'@auth/core@0.41.2(nodemailer@7.0.13)':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.2.3
oauth4webapi: 3.8.6
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
optionalDependencies:
nodemailer: 7.0.13
'@auth/drizzle-adapter@1.11.2':
'@auth/drizzle-adapter@1.11.2(nodemailer@7.0.13)':
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.2(nodemailer@7.0.13)
transitivePeerDependencies:
- '@simplewebauthn/browser'
- '@simplewebauthn/server'
@ -3206,11 +3215,13 @@ snapshots:
nanoid@5.1.11: {}
next-auth@5.0.0-beta.31(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
next-auth@5.0.0-beta.31(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4):
dependencies:
'@auth/core': 0.41.2
'@auth/core': 0.41.2(nodemailer@7.0.13)
next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
optionalDependencies:
nodemailer: 7.0.13
next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
@ -3236,6 +3247,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
nodemailer@7.0.13: {}
oauth4webapi@3.8.6: {}
openai@6.37.0(zod@4.4.3):

View file

@ -2,28 +2,86 @@
import { useState, useEffect, createContext, useContext } from "react";
const ThemeContext = createContext<{
type Theme = "light" | "dark" | "system" | "time";
interface ThemeContextType {
theme: "light" | "dark";
mode: Theme;
toggle: () => void;
}>({ theme: "light", toggle: () => {} });
setMode: (mode: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType>({
theme: "light",
mode: "light",
toggle: () => {},
setMode: () => {},
});
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getTimeOfDayTheme(): "light" | "dark" {
const hour = new Date().getHours();
return (hour >= 6 && hour < 18) ? "light" : "dark";
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<Theme>("light");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const saved = localStorage.getItem("tia_theme") as "light" | "dark";
if (saved) setTheme(saved);
const saved = localStorage.getItem("tia_theme") as Theme | null;
const initialMode = saved || "system";
setModeState(initialMode);
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
let nextTheme: "light" | "dark";
if (mode === "system") {
nextTheme = getSystemTheme();
} else if (mode === "time") {
nextTheme = getTimeOfDayTheme();
} else {
nextTheme = mode as "light" | "dark";
}
setTheme(nextTheme);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(nextTheme);
}, [mode, mounted]);
// Listen for system theme changes
useEffect(() => {
if (mode !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
setTheme(e.matches ? "dark" : "light");
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [mode]);
const setMode = (newMode: Theme) => {
setModeState(newMode);
localStorage.setItem("tia_theme", newMode);
};
const toggle = () => {
const next = theme === "light" ? "dark" : "light";
setTheme(next);
localStorage.setItem("tia_theme", next);
setMode(theme === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggle }}>
<div className={theme}>{children}</div>
<ThemeContext.Provider value={{ theme, mode, toggle, setMode }}>
{children}
</ThemeContext.Provider>
);
}

View file

@ -0,0 +1,70 @@
import { S3Client, PutObjectCommand, ListBucketsCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextRequest, NextResponse } from "next/server";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const BUCKET = process.env.R2_BUCKET_NAME!;
export async function POST(req: NextRequest) {
try {
const { filename, contentType, childId } = await req.json();
if (!filename || !contentType) {
return NextResponse.json({ error: "Missing filename or contentType" }, { status: 400 });
}
const ext = filename.split(".").pop() || "jpg";
const key = `memories/${childId || "default"}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(r2, command, { expiresIn: 60 });
return NextResponse.json({
uploadUrl: url,
key,
publicUrl: `${process.env.R2_PUBLIC_URL}/${key}`,
});
} catch (error) {
console.error("R2 upload error:", error);
return NextResponse.json({ error: "Failed to create upload URL" }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId") || "default";
const prefix = `memories/${childId}`;
const command = new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
});
const res = await r2.send(command);
const objects = (res.Contents || []).map((obj) => ({
key: obj.Key,
url: `${process.env.R2_PUBLIC_URL}/${obj.Key}`,
size: obj.Size,
lastModified: obj.LastModified?.toISOString(),
})).sort((a, b) => new Date(b.lastModified!).getTime() - new Date(a.lastModified!).getTime());
return NextResponse.json({ memories: objects });
} catch (error) {
console.error("R2 list error:", error);
return NextResponse.json({ error: "Failed to list memories" }, { status: 500 });
}
}

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "./ThemeProvider";
import "./globals.css";
const geistSans = Geist({
@ -28,11 +29,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-full antialiased`}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
}

View file

@ -1,54 +1,133 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
// Note: Needs Cloudflare R2 configured for actual uploads
// For now, shows placeholders
const memories = [
{ id: 1, date: "2024-01-15", thumbnail: "👶", title: "First day home" },
{ id: 2, date: "2024-02-20", thumbnail: "🍼", title: "First smile" },
{ id: 3, date: "2024-03-10", thumbnail: "🎉", title: "Happy month" },
];
interface Memory {
key: string;
url: string;
size: number;
lastModified: string;
}
export default function MemoriesPage() {
const [selected, setSelected] = useState<any>(null);
const [memories, setMemories] = useState<Memory[]>([]);
const [selected, setSelected] = useState<Memory | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const childId = "default";
useEffect(() => {
fetchMemories();
}, []);
const fetchMemories = async () => {
try {
const res = await fetch(`/api/upload?childId=${childId}`);
const data = await res.json();
setMemories(data.memories || []);
} catch (err) {
console.error("Failed to fetch memories:", err);
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadProgress(0);
try {
// Get presigned URL
const res = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type, childId }),
});
const { uploadUrl, key, publicUrl } = await res.json();
// Upload to R2
const xhr = new XMLHttpRequest();
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setUploadProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.send(file);
xhr.onload = () => {
if (xhr.status === 200) {
setMemories([{ key, url: publicUrl, size: file.size, lastModified: new Date().toISOString() }, ...memories]);
}
setUploading(false);
setUploadProgress(0);
if (fileRef.current) fileRef.current.value = "";
};
} catch (err) {
console.error("Upload failed:", err);
setUploading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex items-center gap-4">
<a href="/menu" className="p-2"></a>
<Link href="/menu" className="p-2"></Link>
<h1 className="text-xl font-bold">Memories 📸</h1>
</div>
<div className="px-4">
<div className="grid grid-cols-3 gap-2">
{memories.map((mem) => (
<button
key={mem.id}
onClick={() => setSelected(mem)}
className="aspect-square bg-white dark:bg-gray-800 rounded-xl flex items-center justify-center text-4xl"
>
{mem.thumbnail}
</button>
))}
</div>
{memories.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<div className="text-6xl mb-4">📷</div>
<p>No memories yet</p>
<p className="text-sm">Tap + to add your first photo</p>
</div>
) : (
<div className="grid grid-cols-3 gap-1">
{memories.map((mem) => (
<button
key={mem.key}
onClick={() => setSelected(mem)}
className="aspect-square bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden"
>
<img src={mem.url} alt={mem.key} className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
{/* Add Button */}
{/* Upload Button */}
<div className="fixed bottom-4 right-4">
<button className="w-14 h-14 bg-rose-400 text-white rounded-full text-2xl shadow-lg">
+
</button>
<label className="w-14 h-14 bg-rose-400 text-white rounded-full text-2xl shadow-lg flex items-center justify-center cursor-pointer">
{uploading ? (
<span className="text-sm">{uploadProgress}%</span>
) : (
<span>+</span>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
disabled={uploading}
/>
</label>
</div>
{/* Modal */}
{selected && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50" onClick={() => setSelected(null)}>
<div className="text-center text-white">
<div className="text-6xl mb-4">{selected.thumbnail}</div>
<div className="text-xl font-bold">{selected.title}</div>
<div className="text-gray-400">{selected.date}</div>
<div className="fixed inset-0 bg-black/90 flex items-center justify-center z-50" onClick={() => setSelected(null)}>
<div className="w-full h-full flex items-center justify-center p-4" onClick={e => e.stopPropagation()}>
<img src={selected.url} alt={selected.key} className="max-w-full max-h-full object-contain" />
</div>
<button onClick={() => setSelected(null)} className="absolute top-4 right-4 text-white text-xl p-2"></button>
</div>
)}
</div>

View file

@ -1,30 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "../ThemeProvider";
export default function MenuPage() {
const router = useRouter();
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
const saved = localStorage.getItem("tia_theme");
if (saved === "dark") {
setDarkMode(true);
}
}, []);
const toggleDarkMode = () => {
const next = !darkMode;
setDarkMode(next);
localStorage.setItem("tia_theme", next ? "dark" : "light");
if (next) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
const { theme, toggle: toggleTheme } = useTheme();
const menuItems = [
{ icon: "🏠", label: "Home", href: "/" },
@ -61,11 +43,11 @@ export default function MenuPage() {
<div className="absolute bottom-0 left-0 right-0 p-4">
{/* Dark Mode Toggle */}
<button
onClick={toggleDarkMode}
onClick={toggleTheme}
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-xl w-full mb-2"
>
<span className="text-2xl">{darkMode ? "🌙" : "☀️"}</span>
<span className="font-medium">{darkMode ? "Dark Mode" : "Light Mode"}</span>
<span className="text-2xl">{theme === "dark" ? "🌙" : "☀️"}</span>
<span className="font-medium">{theme === "dark" ? "Dark Mode" : "Light Mode"}</span>
</button>
{/* Settings */}

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { useTheme } from "./ThemeProvider";
const OFFLINE_QUEUE_KEY = "tia_offline_queue";
const CHATS_KEY = "tia_chat_sessions";
@ -132,15 +133,10 @@ export default function HomePage() {
const [childId] = useState("5ad3b16a-1e0d-45ab-bc91-038397d75d0a");
const [pendingCount, setPendingCount] = useState(0);
const [lastLogs, setLastLogs] = useState<any[]>([]);
const [darkMode, setDarkMode] = useState(false);
const { theme, toggle: toggleTheme } = useTheme();
const child = { name: "Baby Tia", birthDate: "2024-01-15" };
useEffect(() => {
const saved = localStorage.getItem("tia_theme");
if (saved === "dark") { setDarkMode(true); document.documentElement.classList.add("dark"); }
}, []);
// Load latest session on mount
useEffect(() => {
const sessions = getSessions();
@ -167,10 +163,7 @@ export default function HomePage() {
}, [childId]);
const toggleDarkMode = () => {
const next = !darkMode;
setDarkMode(next);
localStorage.setItem("tia_theme", next ? "dark" : "light");
next ? document.documentElement.classList.add("dark") : document.documentElement.classList.remove("dark");
toggleTheme();
};
// Unified AI chat that saves to sessions
@ -222,7 +215,7 @@ export default function HomePage() {
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex justify-between items-center">
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
<button onClick={toggleDarkMode} className="p-2">{darkMode ? "☀️" : "🌙"}</button>
<button onClick={toggleDarkMode} className="p-2">{theme === "dark" ? "☀️" : "🌙"}</button>
</div>
<div className="px-6 pb-4">

View file

@ -1,31 +1,19 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "../ThemeProvider";
export default function SettingsPage() {
const router = useRouter();
const [darkMode, setDarkMode] = useState(false);
const { theme, mode, setMode } = useTheme();
useEffect(() => {
const saved = localStorage.getItem("tia_theme");
setDarkMode(saved === "dark");
if (saved === "dark") {
document.documentElement.classList.add("dark");
}
}, []);
const toggleDarkMode = () => {
const next = !darkMode;
setDarkMode(next);
localStorage.setItem("tia_theme", next ? "dark" : "light");
if (next) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
};
const themeOptions = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
{ value: "time", label: "Time of Day" },
] as const;
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
@ -37,27 +25,27 @@ export default function SettingsPage() {
</div>
<div className="px-4 space-y-3">
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div>
<div className="font-medium">Dark Mode</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Switch to dark theme</div>
{/* Theme Mode */}
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="font-medium mb-3">Theme</div>
<div className="grid grid-cols-2 gap-2">
{themeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setMode(opt.value)}
className={`p-3 rounded-lg text-sm ${
mode === opt.value
? "bg-rose-400 text-white"
: "bg-gray-100 dark:bg-gray-700"
}`}
>
{opt.label}
</button>
))}
</div>
<button
onClick={toggleDarkMode}
className={`w-14 h-8 rounded-full transition-colors ${
darkMode ? "bg-rose-500" : "bg-gray-300"
}`}
>
<div
className={`w-6 h-6 bg-white rounded-full transition-transform ${
darkMode ? "translate-x-7" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Other Settings */}
{/* Links */}
<Link href="/profile" className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="font-medium">Profile</div>
<div className="text-gray-400"></div>