From 3334277ec9efac8f4eb0c94b7c1fd12f643336f1 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 10 May 2026 13:59:22 +0530 Subject: [PATCH] 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 --- pnpm-lock.yaml | 27 +++++-- src/app/ThemeProvider.tsx | 76 +++++++++++++++++--- src/app/api/upload/route.ts | 70 ++++++++++++++++++ src/app/layout.tsx | 12 ++-- src/app/memories/page.tsx | 139 ++++++++++++++++++++++++++++-------- src/app/menu/page.tsx | 28 ++------ src/app/page.tsx | 15 ++-- src/app/settings/page.tsx | 64 +++++++---------- 8 files changed, 307 insertions(+), 124 deletions(-) create mode 100644 src/app/api/upload/route.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0270e6a..a7cd9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/src/app/ThemeProvider.tsx b/src/app/ThemeProvider.tsx index 7c1420b..213b945 100644 --- a/src/app/ThemeProvider.tsx +++ b/src/app/ThemeProvider.tsx @@ -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({ + 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("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 ( - -
{children}
+ + {children} ); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..f31ca97 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8971b6d..b548b95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - {children} + + + {children} + ); -} +} \ No newline at end of file diff --git a/src/app/memories/page.tsx b/src/app/memories/page.tsx index c17e98b..1e72a7d 100644 --- a/src/app/memories/page.tsx +++ b/src/app/memories/page.tsx @@ -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(null); + const [memories, setMemories] = useState([]); + const [selected, setSelected] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const fileRef = useRef(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) => { + 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 (
- +

Memories 📸

-
- {memories.map((mem) => ( - - ))} -
+ {memories.length === 0 ? ( +
+
📷
+

No memories yet

+

Tap + to add your first photo

+
+ ) : ( +
+ {memories.map((mem) => ( + + ))} +
+ )}
- {/* Add Button */} + {/* Upload Button */}
- +
{/* Modal */} {selected && ( -
setSelected(null)}> -
-
{selected.thumbnail}
-
{selected.title}
-
{selected.date}
+
setSelected(null)}> +
e.stopPropagation()}> + {selected.key}
+
)}
diff --git a/src/app/menu/page.tsx b/src/app/menu/page.tsx index 1dbac2f..eeddbed 100644 --- a/src/app/menu/page.tsx +++ b/src/app/menu/page.tsx @@ -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() {
{/* Dark Mode Toggle */} {/* Settings */} diff --git a/src/app/page.tsx b/src/app/page.tsx index 21877aa..4284f05 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); - 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() {
- +
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index b591fa4..d475683 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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 (
@@ -37,27 +25,27 @@ export default function SettingsPage() {
- {/* Dark Mode Toggle */} -
-
-
Dark Mode
-
Switch to dark theme
+ {/* Theme Mode */} +
+
Theme
+
+ {themeOptions.map((opt) => ( + + ))}
-
- {/* Other Settings */} + {/* Links */}
Profile