feat(settings): sticky save bar appears only when dirty, replaces static button

This commit is contained in:
Usman Baig
2026-03-25 20:18:26 +01:00
parent 9a3fab3535
commit 9dceca765c
4 changed files with 53 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: stri
const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId) const { data: botStats, mutate: mutateBotStats } = useBotFilterStats(siteId)
const [filterBots, setFilterBots] = useState(false) const [filterBots, setFilterBots] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const initialFilterRef = useRef<boolean | null>(null) const initialFilterRef = useRef<boolean | null>(null)
const [botView, setBotView] = useState<'review' | 'blocked'>('review') const [botView, setBotView] = useState<'review' | 'blocked'>('review')
@@ -32,7 +33,9 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: stri
// Track dirty state // Track dirty state
useEffect(() => { useEffect(() => {
if (initialFilterRef.current === null) return if (initialFilterRef.current === null) return
onDirtyChange?.(filterBots !== initialFilterRef.current) const dirty = filterBots !== initialFilterRef.current
setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [filterBots, onDirtyChange]) }, [filterBots, onDirtyChange])
const handleSave = async () => { const handleSave = async () => {
@@ -219,11 +222,15 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange }: { siteId: stri
</div> </div>
</div> </div>
<div className="flex justify-end pt-2"> {/* Sticky save bar */}
<Button onClick={handleSave} variant="primary" disabled={saving}> {isDirty && (
{saving ? 'Saving...' : 'Save Changes'} <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
</Button> <span className="text-xs text-neutral-400">Unsaved changes</span>
</div> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -41,12 +41,14 @@ export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: stri
const canEdit = user?.role === 'owner' || user?.role === 'admin' const canEdit = user?.role === 'owner' || user?.role === 'admin'
const initialRef = useRef('') const initialRef = useRef('')
const [isDirty, setIsDirty] = useState(false)
useEffect(() => { useEffect(() => {
if (site) { if (site) {
setName(site.name || '') setName(site.name || '')
setTimezone(site.timezone || 'UTC') setTimezone(site.timezone || 'UTC')
initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC' }) initialRef.current = JSON.stringify({ name: site.name || '', timezone: site.timezone || 'UTC' })
setIsDirty(false)
} }
}, [site]) }, [site])
@@ -54,7 +56,9 @@ export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: stri
useEffect(() => { useEffect(() => {
if (!initialRef.current) return if (!initialRef.current) return
const current = JSON.stringify({ name, timezone }) const current = JSON.stringify({ name, timezone })
onDirtyChange?.(current !== initialRef.current) const dirty = current !== initialRef.current
setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [name, timezone, onDirtyChange]) }, [name, timezone, onDirtyChange])
const handleSave = async () => { const handleSave = async () => {
@@ -172,12 +176,15 @@ export default function SiteGeneralTab({ siteId, onDirtyChange }: { siteId: stri
</p> </p>
</div> </div>
{/* Save */} {/* Sticky save bar */}
<div className="flex justify-end pt-2"> {isDirty && (
<Button onClick={handleSave} variant="primary" disabled={saving}> <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
{saving ? 'Saving...' : 'Save Changes'} <span className="text-xs text-neutral-400">Unsaved changes</span>
</Button> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
</div> {saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
{/* Danger Zone */} {/* Danger Zone */}
{canEdit && ( {canEdit && (

View File

@@ -30,6 +30,7 @@ export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: stri
const [excludedPaths, setExcludedPaths] = useState('') const [excludedPaths, setExcludedPaths] = useState('')
const [snippetCopied, setSnippetCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const initialRef = useRef('') const initialRef = useRef('')
useEffect(() => { useEffect(() => {
@@ -52,6 +53,7 @@ export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: stri
dataRetention: site.data_retention_months ?? 6, dataRetention: site.data_retention_months ?? 6,
excludedPaths: (site.excluded_paths || []).join('\n'), excludedPaths: (site.excluded_paths || []).join('\n'),
}) })
setIsDirty(false)
} }
}, [site]) }, [site])
@@ -59,7 +61,9 @@ export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: stri
useEffect(() => { useEffect(() => {
if (!initialRef.current) return if (!initialRef.current) return
const current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths }) const current = JSON.stringify({ collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths })
onDirtyChange?.(current !== initialRef.current) const dirty = current !== initialRef.current
setIsDirty(dirty)
onDirtyChange?.(dirty)
}, [collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, onDirtyChange]) }, [collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, onDirtyChange])
const handleSave = async () => { const handleSave = async () => {
@@ -242,11 +246,15 @@ export default function SitePrivacyTab({ siteId, onDirtyChange }: { siteId: stri
</div> </div>
</div> </div>
<div className="flex justify-end pt-2"> {/* Sticky save bar — only visible when dirty */}
<Button onClick={handleSave} variant="primary" disabled={saving}> {isDirty && (
{saving ? 'Saving...' : 'Save Changes'} <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
</Button> <span className="text-xs text-neutral-400">Unsaved changes</span>
</div> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -16,6 +16,7 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: s
const [passwordEnabled, setPasswordEnabled] = useState(false) const [passwordEnabled, setPasswordEnabled] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [linkCopied, setLinkCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const initialRef = useRef('') const initialRef = useRef('')
useEffect(() => { useEffect(() => {
@@ -23,6 +24,7 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: s
setIsPublic(site.is_public ?? false) setIsPublic(site.is_public ?? false)
setPasswordEnabled(site.has_password ?? false) setPasswordEnabled(site.has_password ?? false)
initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false }) initialRef.current = JSON.stringify({ isPublic: site.is_public ?? false, passwordEnabled: site.has_password ?? false })
setIsDirty(false)
} }
}, [site]) }, [site])
@@ -31,6 +33,7 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: s
if (!initialRef.current) return if (!initialRef.current) return
const current = JSON.stringify({ isPublic, passwordEnabled }) const current = JSON.stringify({ isPublic, passwordEnabled })
const dirty = current !== initialRef.current || password.length > 0 const dirty = current !== initialRef.current || password.length > 0
setIsDirty(dirty)
onDirtyChange?.(dirty) onDirtyChange?.(dirty)
}, [isPublic, passwordEnabled, password, onDirtyChange]) }, [isPublic, passwordEnabled, password, onDirtyChange])
@@ -142,11 +145,15 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange }: { siteId: s
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex justify-end pt-2"> {/* Sticky save bar */}
<Button onClick={handleSave} variant="primary" disabled={saving}> {isDirty && (
{saving ? 'Saving...' : 'Save Changes'} <div className="sticky bottom-0 -mx-6 -mb-6 px-6 py-3 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-800 flex items-center justify-between">
</Button> <span className="text-xs text-neutral-400">Unsaved changes</span>
</div> <Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
</div> </div>
) )
} }