Uses the existing bot token to send DMs to users by their Discord user ID instead of posting to a channel webhook. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import api from '@/lib/api';
|
|
import { VolumeIcon, VolumeOffIcon, SparklesIcon } from '@/components/ui/Icons';
|
|
import { playStepComplete } from '@/lib/sounds';
|
|
import { hapticTap } from '@/lib/haptics';
|
|
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
|
|
|
interface Preferences {
|
|
sound_enabled: boolean;
|
|
haptic_enabled: boolean;
|
|
show_launch_screen: boolean;
|
|
celebration_style: string;
|
|
}
|
|
|
|
interface NotifSettings {
|
|
discord_user_id: string;
|
|
discord_enabled: boolean;
|
|
ntfy_topic: string;
|
|
ntfy_enabled: boolean;
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const [prefs, setPrefs] = useState<Preferences>({
|
|
sound_enabled: false,
|
|
haptic_enabled: true,
|
|
show_launch_screen: true,
|
|
celebration_style: 'standard',
|
|
});
|
|
const [notif, setNotif] = useState<NotifSettings>({
|
|
discord_user_id: '',
|
|
discord_enabled: false,
|
|
ntfy_topic: '',
|
|
ntfy_enabled: false,
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
Promise.all([
|
|
api.preferences.get().then((data: Preferences) => setPrefs(data)),
|
|
api.notifications.getSettings().then((data) => setNotif({
|
|
discord_user_id: data.discord_user_id,
|
|
discord_enabled: data.discord_enabled,
|
|
ntfy_topic: data.ntfy_topic,
|
|
ntfy_enabled: data.ntfy_enabled,
|
|
})),
|
|
])
|
|
.catch(() => {})
|
|
.finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
const flashSaved = () => {
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 1500);
|
|
};
|
|
|
|
const updatePref = async (key: keyof Preferences, value: boolean | string) => {
|
|
const updated = { ...prefs, [key]: value };
|
|
setPrefs(updated);
|
|
try {
|
|
await api.preferences.update({ [key]: value });
|
|
flashSaved();
|
|
} catch {
|
|
setPrefs(prefs);
|
|
}
|
|
};
|
|
|
|
const updateNotif = async (updates: Partial<NotifSettings>) => {
|
|
const prev = { ...notif };
|
|
const updated = { ...notif, ...updates };
|
|
setNotif(updated);
|
|
try {
|
|
await api.notifications.updateSettings(updates);
|
|
flashSaved();
|
|
} catch {
|
|
setNotif(prev);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[50vh]">
|
|
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
|
{saved && (
|
|
<span className="text-sm text-green-600 animate-fade-in-up">Saved</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Session Experience */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Session Experience</h2>
|
|
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
|
|
{/* Sound */}
|
|
<div className="flex items-center justify-between p-4">
|
|
<div className="flex items-center gap-3">
|
|
{prefs.sound_enabled ? (
|
|
<VolumeIcon size={20} className="text-indigo-500" />
|
|
) : (
|
|
<VolumeOffIcon size={20} className="text-gray-400" />
|
|
)}
|
|
<div>
|
|
<p className="font-medium text-gray-900">Sound effects</p>
|
|
<p className="text-sm text-gray-500">Subtle audio cues on step completion</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
updatePref('sound_enabled', !prefs.sound_enabled);
|
|
if (!prefs.sound_enabled) playStepComplete();
|
|
}}
|
|
className={`w-12 h-7 rounded-full transition-colors ${
|
|
prefs.sound_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
|
prefs.sound_enabled ? 'translate-x-5' : ''
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Haptics */}
|
|
<div className="flex items-center justify-between p-4">
|
|
<div className="flex items-center gap-3">
|
|
<SparklesIcon size={20} className={prefs.haptic_enabled ? 'text-indigo-500' : 'text-gray-400'} />
|
|
<div>
|
|
<p className="font-medium text-gray-900">Haptic feedback</p>
|
|
<p className="text-sm text-gray-500">Gentle vibration on actions</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
updatePref('haptic_enabled', !prefs.haptic_enabled);
|
|
if (!prefs.haptic_enabled) hapticTap();
|
|
}}
|
|
className={`w-12 h-7 rounded-full transition-colors ${
|
|
prefs.haptic_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
|
prefs.haptic_enabled ? 'translate-x-5' : ''
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Launch Screen */}
|
|
<div className="flex items-center justify-between p-4">
|
|
<div>
|
|
<p className="font-medium text-gray-900">Pre-routine launch screen</p>
|
|
<p className="text-sm text-gray-500">Environment check and emotion bridge</p>
|
|
</div>
|
|
<button
|
|
onClick={() => updatePref('show_launch_screen', !prefs.show_launch_screen)}
|
|
className={`w-12 h-7 rounded-full transition-colors ${
|
|
prefs.show_launch_screen ? 'bg-indigo-500' : 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
|
prefs.show_launch_screen ? 'translate-x-5' : ''
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Notifications</h2>
|
|
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
|
|
{/* Push Notifications */}
|
|
<PushNotificationToggle />
|
|
|
|
{/* ntfy */}
|
|
<div className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-gray-900">ntfy</p>
|
|
<p className="text-sm text-gray-500">Push notifications via ntfy.sh</p>
|
|
</div>
|
|
<button
|
|
onClick={() => updateNotif({ ntfy_enabled: !notif.ntfy_enabled })}
|
|
className={`w-12 h-7 rounded-full transition-colors ${
|
|
notif.ntfy_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
|
notif.ntfy_enabled ? 'translate-x-5' : ''
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{notif.ntfy_enabled && (
|
|
<input
|
|
type="text"
|
|
placeholder="Your ntfy topic ID"
|
|
value={notif.ntfy_topic}
|
|
onChange={(e) => setNotif({ ...notif, ntfy_topic: e.target.value })}
|
|
onBlur={() => updateNotif({ ntfy_topic: notif.ntfy_topic })}
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Discord */}
|
|
<div className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-gray-900">Discord</p>
|
|
<p className="text-sm text-gray-500">Get DMs from the Synculous bot</p>
|
|
</div>
|
|
<button
|
|
onClick={() => updateNotif({ discord_enabled: !notif.discord_enabled })}
|
|
className={`w-12 h-7 rounded-full transition-colors ${
|
|
notif.discord_enabled ? 'bg-indigo-500' : 'bg-gray-300'
|
|
}`}
|
|
>
|
|
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
|
|
notif.discord_enabled ? 'translate-x-5' : ''
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
{notif.discord_enabled && (
|
|
<input
|
|
type="text"
|
|
placeholder="Your Discord user ID"
|
|
value={notif.discord_user_id}
|
|
onChange={(e) => setNotif({ ...notif, discord_user_id: e.target.value })}
|
|
onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })}
|
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder-gray-400"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Celebration Style */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Celebration Style</h2>
|
|
<div className="bg-white rounded-xl shadow-sm divide-y divide-gray-100">
|
|
{[
|
|
{ value: 'standard', label: 'Standard', desc: 'Full animated celebration with stats and rewards' },
|
|
{ value: 'quick', label: 'Quick', desc: 'Brief confirmation, then back to dashboard' },
|
|
{ value: 'none', label: 'None', desc: 'No celebration screen, return immediately' },
|
|
].map(option => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => updatePref('celebration_style', option.value)}
|
|
className="w-full flex items-center justify-between p-4 text-left"
|
|
>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{option.label}</p>
|
|
<p className="text-sm text-gray-500">{option.desc}</p>
|
|
</div>
|
|
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
|
prefs.celebration_style === option.value
|
|
? 'border-indigo-500'
|
|
: 'border-gray-300'
|
|
}`}>
|
|
{prefs.celebration_style === option.value && (
|
|
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|