mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-25 05:41:38 +08:00
Add multiple entries per day, user management, reminders, and AI reflections
- Multiple entries per day: Home page now starts fresh, Save & New button - Admin user management: Add/delete users, reset passwords, toggle admin - Daily reminders: Browser notifications at configurable time - AI reflections: Generate insights from entries using Claude API - Remove cloud sync placeholder (already have user accounts) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserSettings {
|
||||
reminderEnabled: boolean;
|
||||
reminderTime: string;
|
||||
hasLlmKey: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
setSettings(await res.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
// Check notification permission
|
||||
if ("Notification" in window) {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: unknown) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => prev ? { ...prev, [key]: value } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update setting:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestNotificationPermission = async () => {
|
||||
if ("Notification" in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
setNotificationPermission(permission);
|
||||
if (permission === "granted") {
|
||||
updateSetting("reminderEnabled", true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReminder = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
if (!settings.reminderEnabled) {
|
||||
// Enabling - need permission first
|
||||
if (notificationPermission !== "granted") {
|
||||
await requestNotificationPermission();
|
||||
} else {
|
||||
updateSetting("reminderEnabled", true);
|
||||
}
|
||||
} else {
|
||||
updateSetting("reminderEnabled", false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveApiKey = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ llmApiKey: apiKey }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null);
|
||||
setShowApiKeyInput(false);
|
||||
setApiKey("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up reminder check interval
|
||||
useEffect(() => {
|
||||
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
|
||||
|
||||
const checkReminder = () => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
|
||||
|
||||
if (now.getHours() === hours && now.getMinutes() === minutes) {
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(APP_NAME, {
|
||||
body: "Take a moment to reflect on what you're grateful for today.",
|
||||
icon: "/icon.png",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check every minute
|
||||
const interval = setInterval(checkReminder, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [settings?.reminderEnabled, settings?.reminderTime]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -32,59 +153,161 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Admin - only show for admin users */}
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="p-4 bg-surface border border-border rounded-xl flex items-center gap-3 hover:border-muted transition-colors"
|
||||
>
|
||||
<Users size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">Daily reminder</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Get a gentle nudge to check in
|
||||
</p>
|
||||
<h3 className="font-medium">User Management</h3>
|
||||
<p className="text-sm text-muted">Add users, reset passwords</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Sync - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Cloud sync</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Sync across devices (coming soon)
|
||||
</p>
|
||||
{/* Daily Reminder */}
|
||||
{!isLoading && settings && (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">Daily reminder</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Get a gentle nudge to check in
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleReminder}
|
||||
disabled={isSaving}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
settings.reminderEnabled ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
settings.reminderEnabled ? "left-7" : "left-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
{settings.reminderEnabled && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<label className="text-sm text-muted">Remind me at:</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.reminderTime}
|
||||
onChange={(e) => updateSetting("reminderTime", e.target.value)}
|
||||
className="px-3 py-1 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{notificationPermission === "denied" && (
|
||||
<p className="mt-2 text-sm text-red-400">
|
||||
Notifications are blocked. Please enable them in your browser settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Summaries - disabled in MVP */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl opacity-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">AI summaries</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Monthly reflections with LLM (coming soon)
|
||||
</p>
|
||||
{/* AI Reflections */}
|
||||
{!isLoading && settings && (
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles size={20} className="text-accent" />
|
||||
<div>
|
||||
<h3 className="font-medium">AI reflections</h3>
|
||||
<p className="text-sm text-muted">
|
||||
Generate insights from your entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{settings.hasLlmKey ? (
|
||||
<Check size={20} className="text-green-500" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90"
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="relative w-12 h-6 bg-border rounded-full cursor-not-allowed"
|
||||
>
|
||||
<span className="absolute left-1 top-1 w-4 h-4 bg-muted rounded-full" />
|
||||
</button>
|
||||
|
||||
{settings.hasLlmKey && !showApiKeyInput && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-green-500">API key configured</span>
|
||||
<button
|
||||
onClick={() => setShowApiKeyInput(true)}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<span className="text-muted">•</span>
|
||||
<Link
|
||||
href="/reflections"
|
||||
className="text-sm text-accent hover:underline"
|
||||
>
|
||||
View reflections
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKeyInput && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">
|
||||
Claude API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveApiKey}
|
||||
disabled={isSaving || !apiKey}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://console.anthropic.com/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
console.anthropic.com
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowApiKeyInput(false);
|
||||
setApiKey("");
|
||||
}}
|
||||
className="text-sm text-muted hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-6 border-t border-border text-center text-sm text-muted">
|
||||
<p>{APP_NAME} v0.1.0</p>
|
||||
|
||||
Reference in New Issue
Block a user