refactor(settings): move save bar to modal level, remove from tabs

This commit is contained in:
Usman Baig
2026-03-25 21:23:42 +01:00
parent 570dda7bd2
commit 549ac273a1
3 changed files with 25 additions and 100 deletions

View File

@@ -1,18 +1,16 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Button, Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui' import { Toggle, toast, Spinner, getDateRange } from '@ciphera-net/ui'
import { ShieldCheck } from '@phosphor-icons/react' import { ShieldCheck } from '@phosphor-icons/react'
import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard' import { useSite, useBotFilterStats, useSessions } from '@/lib/swr/dashboard'
import { updateSite } from '@/lib/api/sites' import { updateSite } from '@/lib/api/sites'
import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter' import { botFilterSessions, botUnfilterSessions } from '@/lib/api/bot-filter'
export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) { export default function SiteBotSpamTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
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 [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')
@@ -35,12 +33,10 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
useEffect(() => { useEffect(() => {
if (initialFilterRef.current === null) return if (initialFilterRef.current === null) return
const dirty = filterBots !== initialFilterRef.current const dirty = filterBots !== initialFilterRef.current
setIsDirty(dirty)
onDirtyChange?.(dirty) onDirtyChange?.(dirty)
}, [filterBots, onDirtyChange]) }, [filterBots, onDirtyChange])
const handleSave = async () => { const handleSave = useCallback(async () => {
setSaving(true)
try { try {
await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots }) await updateSite(siteId, { name: site?.name || '', filter_bots: filterBots })
await mutate() await mutate()
@@ -49,10 +45,12 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
toast.success('Bot filtering updated') toast.success('Bot filtering updated')
} catch { } catch {
toast.error('Failed to save') toast.error('Failed to save')
} finally {
setSaving(false)
} }
} }, [siteId, site?.name, filterBots, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleBotFilter = async (sessionIds: string[]) => { const handleBotFilter = async (sessionIds: string[]) => {
try { try {
@@ -223,28 +221,6 @@ export default function SiteBotSpamTab({ siteId, onDirtyChange, hasPendingAction
</div> </div>
</div> </div>
{/* Sticky save bar */}
{isDirty && (
<div className={`sticky bottom-0 -mx-6 px-6 py-3 backdrop-blur-md border-t flex items-center justify-between transition-colors ${
hasPendingAction
? 'bg-rose-950/95 border-rose-800/50'
: 'bg-neutral-950/90 border-neutral-800'
}`}>
<span className={`text-sm font-medium ${hasPendingAction ? 'text-rose-100' : 'text-neutral-400'}`}>
{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<Button onClick={onDiscard} variant="secondary" className="text-sm border-rose-700/50 text-rose-200 hover:bg-rose-900/40">
Discard
</Button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui' import { Input, Button, Select, toast, Spinner, getAuthErrorMessage, CheckIcon, ZapIcon } from '@ciphera-net/ui'
import { useSite } from '@/lib/swr/dashboard' import { useSite } from '@/lib/swr/dashboard'
@@ -28,21 +28,19 @@ const TIMEZONES = [
{ value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' }, { value: 'Australia/Sydney', label: 'Australia/Sydney (AEST)' },
] ]
export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) { export default function SiteGeneralTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const router = useRouter() const router = useRouter()
const { user } = useAuth() const { user } = useAuth()
const { closeUnifiedSettings: closeSettings } = useUnifiedSettings() const { closeUnifiedSettings: closeSettings } = useUnifiedSettings()
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
const [name, setName] = useState('') const [name, setName] = useState('')
const [timezone, setTimezone] = useState('UTC') const [timezone, setTimezone] = useState('UTC')
const [saving, setSaving] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showVerificationModal, setShowVerificationModal] = useState(false) const [showVerificationModal, setShowVerificationModal] = useState(false)
const canEdit = user?.role === 'owner' || user?.role === 'admin' const canEdit = user?.role === 'owner' || user?.role === 'admin'
const initialRef = useRef('') const initialRef = useRef('')
const hasInitialized = useRef(false) const hasInitialized = useRef(false)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => { useEffect(() => {
if (!site || hasInitialized.current) return if (!site || hasInitialized.current) return
@@ -50,7 +48,6 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
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' })
hasInitialized.current = true hasInitialized.current = true
setIsDirty(false)
}, [site]) }, [site])
// Track dirty state // Track dirty state
@@ -58,13 +55,11 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
if (!initialRef.current) return if (!initialRef.current) return
const current = JSON.stringify({ name, timezone }) const current = JSON.stringify({ name, timezone })
const dirty = current !== initialRef.current const dirty = current !== initialRef.current
setIsDirty(dirty)
onDirtyChange?.(dirty) onDirtyChange?.(dirty)
}, [name, timezone, onDirtyChange]) }, [name, timezone, onDirtyChange])
const handleSave = async () => { const handleSave = useCallback(async () => {
if (!site) return if (!site) return
setSaving(true)
try { try {
await updateSite(siteId, { name, timezone }) await updateSite(siteId, { name, timezone })
await mutate() await mutate()
@@ -73,10 +68,12 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
toast.success('Site updated') toast.success('Site updated')
} catch { } catch {
toast.error('Failed to save') toast.error('Failed to save')
} finally {
setSaving(false)
} }
} }, [site, siteId, name, timezone, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const handleResetData = async () => { const handleResetData = async () => {
if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) return if (!confirm('Are you sure you want to delete ALL data for this site? This action cannot be undone.')) return
@@ -177,29 +174,6 @@ export default function SiteGeneralTab({ siteId, onDirtyChange, hasPendingAction
</p> </p>
</div> </div>
{/* Sticky save bar */}
{isDirty && (
<div className={`sticky bottom-0 -mx-6 px-6 py-3 backdrop-blur-md border-t flex items-center justify-between transition-colors ${
hasPendingAction
? 'bg-rose-950/95 border-rose-800/50'
: 'bg-neutral-950/90 border-neutral-800'
}`}>
<span className={`text-sm font-medium ${hasPendingAction ? 'text-rose-100' : 'text-neutral-400'}`}>
{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<Button onClick={onDiscard} variant="secondary" className="text-sm border-rose-700/50 text-rose-200 hover:bg-rose-900/40">
Discard
</Button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)}
{/* Danger Zone */} {/* Danger Zone */}
{canEdit && ( {canEdit && (
<div className="space-y-4 pt-4 border-t border-neutral-800"> <div className="space-y-4 pt-4 border-t border-neutral-800">

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui' import { Button, Input, Toggle, toast, Spinner } from '@ciphera-net/ui'
import { Copy, CheckCircle, Lock } from '@phosphor-icons/react' import { Copy, CheckCircle, Lock } from '@phosphor-icons/react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
@@ -9,14 +9,12 @@ import { updateSite } from '@/lib/api/sites'
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) { export default function SiteVisibilityTab({ siteId, onDirtyChange, onRegisterSave }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; onRegisterSave?: (fn: () => Promise<void>) => void }) {
const { data: site, mutate } = useSite(siteId) const { data: site, mutate } = useSite(siteId)
const [isPublic, setIsPublic] = useState(false) const [isPublic, setIsPublic] = useState(false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [passwordEnabled, setPasswordEnabled] = useState(false) const [passwordEnabled, setPasswordEnabled] = 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('')
const hasInitialized = useRef(false) const hasInitialized = useRef(false)
@@ -26,7 +24,6 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
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 })
hasInitialized.current = true hasInitialized.current = true
setIsDirty(false)
}, [site]) }, [site])
// Track dirty state // Track dirty state
@@ -34,12 +31,10 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
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])
const handleSave = async () => { const handleSave = useCallback(async () => {
setSaving(true)
try { try {
await updateSite(siteId, { await updateSite(siteId, {
name: site?.name || '', name: site?.name || '',
@@ -54,10 +49,12 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
toast.success('Visibility updated') toast.success('Visibility updated')
} catch { } catch {
toast.error('Failed to save') toast.error('Failed to save')
} finally {
setSaving(false)
} }
} }, [siteId, site?.name, isPublic, passwordEnabled, password, mutate, onDirtyChange])
useEffect(() => {
onRegisterSave?.(handleSave)
}, [handleSave, onRegisterSave])
const copyLink = () => { const copyLink = () => {
navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`) navigator.clipboard.writeText(`${APP_URL}/share/${siteId}`)
@@ -146,28 +143,6 @@ export default function SiteVisibilityTab({ siteId, onDirtyChange, hasPendingAct
)} )}
</AnimatePresence> </AnimatePresence>
{/* Sticky save bar */}
{isDirty && (
<div className={`sticky bottom-0 -mx-6 px-6 py-3 backdrop-blur-md border-t flex items-center justify-between transition-colors ${
hasPendingAction
? 'bg-rose-950/95 border-rose-800/50'
: 'bg-neutral-950/90 border-neutral-800'
}`}>
<span className={`text-sm font-medium ${hasPendingAction ? 'text-rose-100' : 'text-neutral-400'}`}>
{hasPendingAction ? 'Save or discard to continue' : 'Unsaved changes'}
</span>
<div className="flex items-center gap-2">
{hasPendingAction && (
<Button onClick={onDiscard} variant="secondary" className="text-sm border-rose-700/50 text-rose-200 hover:bg-rose-900/40">
Discard
</Button>
)}
<Button onClick={handleSave} variant="primary" disabled={saving} className="text-sm">
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
)}
</div> </div>
) )
} }