mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
579 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|