diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e17367e..1f57614 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -9,6 +9,7 @@ import { useToast } from '@/lib/contexts/ToastContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { refreshStorageSession } from '@/lib/storage/client'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; +import { hasUnsavedChanges } from '@/lib/forms/dirty-state'; export default function AdminPage() { const { showToast } = useToast(); @@ -28,6 +29,8 @@ export default function AdminPage() { turnstileSiteKey: '', turnstileSecretKey: '', }); + const savedNodeSettingsRef = useRef(null); + const nodeSettingsChanged = hasUnsavedChanges(nodeSettings, savedNodeSettingsRef.current); const [savingSettings, setSavingSettings] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false); @@ -55,7 +58,7 @@ export default function AdminPage() { try { const res = await fetch('/api/node'); const data = await res.json(); - setNodeSettings({ + const loadedSettings = { name: data.name || '', description: data.description || '', longDescription: data.longDescription || '', @@ -67,7 +70,9 @@ export default function AdminPage() { isNsfw: data.isNsfw || false, turnstileSiteKey: data.turnstileSiteKey || '', turnstileSecretKey: data.turnstileSecretKey || '', - }); + }; + setNodeSettings(loadedSettings); + savedNodeSettingsRef.current = loadedSettings; } catch { // error } finally { @@ -91,6 +96,7 @@ export default function AdminPage() { body: JSON.stringify(payload), }); if (res.ok) { + savedNodeSettingsRef.current = payload; const data = await res.json().catch(() => ({})); if (data.node) { window.dispatchEvent(new CustomEvent('synapsis:node-updated', { detail: data.node })); @@ -607,7 +613,7 @@ export default function AdminPage() {
-
diff --git a/src/app/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx index 89e96e1..912a540 100644 --- a/src/app/bots/[id]/edit/page.tsx +++ b/src/app/bots/[id]/edit/page.tsx @@ -8,12 +8,13 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useRouter, useParams } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeftIcon } from '@/components/Icons'; import { UserStorageImageUpload } from '@/components/UserStorageImageUpload'; import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react'; +import { hasUnsavedChanges } from '@/lib/forms/dirty-state'; interface ContentSource { id?: string; @@ -66,8 +67,10 @@ export default function EditBotPage() { postingFrequency: 'every_4_hours', customIntervalMinutes: 240, }); + const savedFormDataRef = useRef(null); const [sources, setSources] = useState([]); + const savedSourcesRef = useRef(null); const [newSource, setNewSource] = useState({ type: 'rss', url: '', @@ -78,6 +81,9 @@ export default function EditBotPage() { newsQuery: '', }); const [sourcesToDelete, setSourcesToDelete] = useState([]); + const botChanged = hasUnsavedChanges(formData, savedFormDataRef.current) + || hasUnsavedChanges(sources, savedSourcesRef.current) + || sourcesToDelete.length > 0; useEffect(() => { fetchBot(); @@ -129,7 +135,7 @@ export default function EditBotPage() { } } - setFormData({ + const loadedFormData = { name: bot.name || '', handle: bot.handle || '', bio: bot.bio || '', @@ -143,12 +149,17 @@ export default function EditBotPage() { autonomousMode: bot.autonomousMode || false, postingFrequency, customIntervalMinutes, - }); + }; + setFormData(loadedFormData); + savedFormDataRef.current = loadedFormData; + let loadedSources: ContentSource[] = []; if (sourcesRes.ok) { const sourcesData = await sourcesRes.json(); - setSources(sourcesData.sources || []); + loadedSources = sourcesData.sources || []; } + setSources(loadedSources); + savedSourcesRef.current = loadedSources; } catch (err) { console.error('Failed to fetch bot:', err); setError('Failed to load bot data'); @@ -266,6 +277,7 @@ export default function EditBotPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!botChanged) return; setSaving(true); setError(''); @@ -994,7 +1006,7 @@ export default function EditBotPage() { - diff --git a/src/lib/forms/dirty-state.test.ts b/src/lib/forms/dirty-state.test.ts new file mode 100644 index 0000000..ba81e70 --- /dev/null +++ b/src/lib/forms/dirty-state.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { hasUnsavedChanges } from './dirty-state'; + +describe('hasUnsavedChanges', () => { + it('stays clean until saved values have loaded', () => { + expect(hasUnsavedChanges({ name: '' }, null)).toBe(false); + }); + + it('detects changes and becomes clean again when reverted', () => { + const saved = { name: 'Synapsis', isNsfw: false }; + + expect(hasUnsavedChanges(saved, saved)).toBe(false); + expect(hasUnsavedChanges({ ...saved, name: 'New name' }, saved)).toBe(true); + expect(hasUnsavedChanges({ ...saved }, saved)).toBe(false); + }); + + it('detects nested array changes such as bot sources', () => { + const saved = [{ id: 'source-1', config: { active: true } }]; + + expect(hasUnsavedChanges(saved, saved)).toBe(false); + expect(hasUnsavedChanges([...saved, { id: 'source-2', config: { active: true } }], saved)).toBe(true); + }); +}); + diff --git a/src/lib/forms/dirty-state.ts b/src/lib/forms/dirty-state.ts new file mode 100644 index 0000000..00e83b4 --- /dev/null +++ b/src/lib/forms/dirty-state.ts @@ -0,0 +1,33 @@ +function valuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length + && left.every((value, index) => valuesEqual(value, right[index])); + } + + if ( + left !== null + && right !== null + && typeof left === 'object' + && typeof right === 'object' + ) { + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + + return leftKeys.length === rightKeys.length + && leftKeys.every((key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) + && valuesEqual(leftRecord[key], rightRecord[key]) + ); + } + + return false; +} + +export function hasUnsavedChanges(current: T, saved: T | null): boolean { + return saved !== null && !valuesEqual(current, saved); +} +