Files
quietthanks/src/app/settings/page.tsx

579 lines
22 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { AppShell } from "@/components/AppShell";
import { useAuth } from "@/components/AuthProvider";
import { useServiceWorker } from "@/components/ServiceWorkerProvider";
import { APP_NAME } from "@/lib/constants";
import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react";
import Link from "next/link";
interface UserSettings {
reminderEnabled: boolean;
reminderTime: string;
reminderAlways: boolean;
llmProvider: string | null;
hasLlmKey: boolean;
llmModel: string | null;
}
interface ModelInfo {
id: string;
name: string;
description?: string;
}
const PROVIDERS = [
{ id: "openrouter", name: "OpenRouter", description: "Access many models, often cheapest" },
{ id: "openai", name: "OpenAI", description: "GPT-4o, GPT-4o-mini" },
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
];
export default function SettingsPage() {
const { user, logout } = useAuth();
const { isSupported: swSupported, isRegistered: swRegistered, showNotification, subscribeToPush } = useServiceWorker();
const [settings, setSettings] = useState<UserSettings | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showLlmSetup, setShowLlmSetup] = useState(false);
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
const [isPWA, setIsPWA] = useState(false);
// LLM setup state
const [selectedProvider, setSelectedProvider] = useState<string>("");
const [apiKey, setApiKey] = useState("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelError, setModelError] = useState<string | null>(null);
useEffect(() => {
async function loadSettings() {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setSettings(data);
if (data.llmProvider) {
setSelectedProvider(data.llmProvider);
}
if (data.llmModel) {
setSelectedModel(data.llmModel);
}
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
setIsLoading(false);
}
}
loadSettings();
if ("Notification" in window) {
setNotificationPermission(Notification.permission);
}
// Detect if running as PWA (standalone mode)
const isStandalone = window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
setIsPWA(isStandalone);
}, []);
// Ensure push subscription is active if permissions are granted
useEffect(() => {
if (notificationPermission === "granted" && swRegistered) {
subscribeToPush().catch(console.error);
}
}, [notificationPermission, swRegistered, subscribeToPush]);
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 subscribed = await subscribeToPush();
if (subscribed) {
showNotification(APP_NAME, {
body: "Notifications enabled! You will receive daily reminders.",
icon: "/icons/icon.svg",
tag: "welcome-notification",
});
}
}
}
};
const toggleReminder = async () => {
if (!settings) return;
if (!settings.reminderEnabled) {
if (notificationPermission !== "granted") {
await requestNotificationPermission();
} else {
updateSetting("reminderEnabled", true);
}
} else {
updateSetting("reminderEnabled", false);
}
};
const fetchModels = async () => {
if (!selectedProvider || !apiKey) return;
setIsLoadingModels(true);
setModelError(null);
setModels([]);
try {
const res = await fetch("/api/llm/models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: selectedProvider, apiKey }),
});
const data = await res.json();
if (!res.ok) {
setModelError(data.error || "Failed to fetch models");
return;
}
setModels(data.models);
if (data.models.length > 0) {
setSelectedModel(data.models[0].id);
}
} catch {
setModelError("Failed to connect to API");
} finally {
setIsLoadingModels(false);
}
};
const saveLlmSettings = async () => {
if (!selectedProvider || !apiKey || !selectedModel) return;
setIsSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
llmProvider: selectedProvider,
llmApiKey: apiKey,
llmModel: selectedModel,
}),
});
if (res.ok) {
setSettings((prev) => prev ? {
...prev,
llmProvider: selectedProvider,
hasLlmKey: true,
llmModel: selectedModel,
} : null);
setShowLlmSetup(false);
setApiKey("");
}
} catch (error) {
console.error("Failed to save LLM settings:", error);
} finally {
setIsSaving(false);
}
};
const clearLlmSettings = async () => {
setIsSaving(true);
try {
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
llmProvider: "",
llmApiKey: "",
llmModel: "",
}),
});
setSettings((prev) => prev ? {
...prev,
llmProvider: null,
hasLlmKey: false,
llmModel: null,
} : null);
setSelectedProvider("");
setSelectedModel("");
setModels([]);
} catch (error) {
console.error("Failed to clear LLM settings:", error);
} finally {
setIsSaving(false);
}
};
// Reminder check interval - uses service worker for iOS compatibility
useEffect(() => {
if (!settings?.reminderEnabled || !settings?.reminderTime) return;
// Track if we've shown the notification this minute
let lastShownMinute = -1;
const checkReminder = async () => {
const now = new Date();
const [hours, minutes] = settings.reminderTime.split(":").map(Number);
const currentMinute = now.getHours() * 60 + now.getMinutes();
if (now.getHours() === hours && now.getMinutes() === minutes && currentMinute !== lastShownMinute) {
lastShownMinute = currentMinute;
if (Notification.permission === "granted") {
await showNotification(APP_NAME, {
body: "Take a moment to reflect on what you're grateful for today.",
icon: "/icons/icon.svg",
tag: "daily-reminder",
});
}
}
};
// Check immediately and then every minute
checkReminder();
const interval = setInterval(checkReminder, 60000);
return () => clearInterval(interval);
}, [settings?.reminderEnabled, settings?.reminderTime, showNotification]);
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
return (
<AppShell>
<header className="mb-8">
<h1 className="text-2xl font-light">{APP_NAME}</h1>
<p className="text-sm text-muted">Settings</p>
</header>
<div className="space-y-6">
{/* Account info */}
{user && (
<div className="p-4 bg-surface border border-border rounded-xl">
<h3 className="font-medium mb-1">Account</h3>
<p className="text-sm text-muted">{user.email}</p>
{user.name && <p className="text-sm text-muted">{user.name}</p>}
<button
onClick={logout}
className="mt-3 flex items-center gap-2 text-sm text-red-400 hover:text-red-300"
>
<LogOut size={16} />
Sign out
</button>
</div>
)}
{/* Admin */}
{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">User Management</h3>
<p className="text-sm text-muted">Add users, reset passwords</p>
</div>
</Link>
)}
{/* 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>
{settings.reminderEnabled && (
<div className="mt-4 space-y-4">
<div className="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>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="reminderAlways"
checked={settings.reminderAlways}
onChange={(e) => updateSetting("reminderAlways", e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-accent"
/>
<label htmlFor="reminderAlways" className="text-sm text-muted cursor-pointer">
Always remind me, even if I've already journaled today
</label>
</div>
</div>
)}
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-green-500">
<Check size={14} />
<span>Notifications ready{isPWA ? " (PWA mode)" : ""}</span>
</div>
<button
onClick={() => showNotification(APP_NAME, {
body: "Test notification - reminders are working!",
icon: "/icons/icon.svg",
tag: "test-notification",
})}
className="text-sm text-accent hover:underline"
>
Test
</button>
</div>
)}
{settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && (
<p className="mt-2 text-sm text-amber-400">
Service worker loading... Notifications may be limited.
</p>
)}
{notificationPermission === "denied" && (
<p className="mt-2 text-sm text-red-400">
Notifications are blocked. Please enable them in your browser/device settings.
</p>
)}
{settings.reminderEnabled && notificationPermission === "default" && (
<p className="mt-2 text-sm text-amber-400">
Notification permission not yet granted. Toggle reminders off and on to request permission.
</p>
)}
</div>
)}
{/* 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={() => setShowLlmSetup(true)}
className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90"
>
Set up
</button>
)}
</div>
{settings.hasLlmKey && !showLlmSetup && (
<div className="mt-3 space-y-2">
<p className="text-sm text-green-500">
{getProviderName(settings.llmProvider || "")} configured
{settings.llmModel && (
<span className="text-muted"> {settings.llmModel}</span>
)}
</p>
<div className="flex items-center gap-3">
<button
onClick={() => setShowLlmSetup(true)}
className="text-sm text-muted hover:text-foreground"
>
Change
</button>
<button
onClick={clearLlmSettings}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
<span className="text-muted"></span>
<Link href="/reflections" className="text-sm text-accent hover:underline">
View reflections
</Link>
</div>
</div>
)}
{showLlmSetup && (
<div className="mt-4 space-y-4 border-t border-border pt-4">
{/* Provider selection */}
<div>
<label className="block text-sm text-muted mb-2">Provider</label>
<div className="grid gap-2">
{PROVIDERS.map((provider) => (
<button
key={provider.id}
onClick={() => {
setSelectedProvider(provider.id);
setModels([]);
setSelectedModel("");
setModelError(null);
}}
className={`p-3 text-left rounded-lg border transition-colors ${
selectedProvider === provider.id
? "border-accent bg-accent/10"
: "border-border hover:border-muted"
}`}
>
<p className="font-medium">{provider.name}</p>
<p className="text-sm text-muted">{provider.description}</p>
</button>
))}
</div>
</div>
{/* API Key */}
{selectedProvider && (
<div>
<label className="block text-sm text-muted mb-1">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={
selectedProvider === "anthropic" ? "sk-ant-..." :
selectedProvider === "openai" ? "sk-..." :
"sk-or-..."
}
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={fetchModels}
disabled={!apiKey || isLoadingModels}
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
>
{isLoadingModels ? (
<Loader2 size={14} className="animate-spin" />
) : null}
Load Models
</button>
</div>
<p className="mt-1 text-xs text-muted">
{selectedProvider === "anthropic" && (
<>Get your key from <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">console.anthropic.com</a></>
)}
{selectedProvider === "openai" && (
<>Get your key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">platform.openai.com</a></>
)}
{selectedProvider === "openrouter" && (
<>Get your key from <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">openrouter.ai</a></>
)}
</p>
</div>
)}
{/* Error */}
{modelError && (
<p className="text-sm text-red-400">{modelError}</p>
)}
{/* Model selection */}
{models.length > 0 && (
<div>
<label className="block text-sm text-muted mb-1">Model</label>
<div className="relative">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted appearance-none pr-10"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} {model.description && `- ${model.description}`}
</option>
))}
</select>
<ChevronDown size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
</div>
<p className="mt-1 text-xs text-muted">
{selectedProvider === "openrouter" && "Sorted by price (cheapest first)"}
{selectedProvider === "openai" && "Mini models are cheapest"}
{selectedProvider === "anthropic" && "Haiku is cheapest, Opus is most capable"}
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
setShowLlmSetup(false);
setApiKey("");
setModels([]);
setModelError(null);
}}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
>
Cancel
</button>
{models.length > 0 && (
<button
onClick={saveLlmSettings}
disabled={isSaving || !selectedModel}
className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
Save
</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>
<p className="mt-1">A calm, private gratitude log.</p>
</div>
</div>
</AppShell>
);
}