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:
parent
5943ab19eb
commit
3334277ec9
8 changed files with 307 additions and 124 deletions
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
70
src/app/api/upload/route.ts
Normal file
70
src/app/api/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue