refactor(settings): move save bar to modal level — always flush with modal bottom
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { X, GearSix, Buildings, User } from '@phosphor-icons/react'
|
import { X, GearSix, Buildings, User } from '@phosphor-icons/react'
|
||||||
|
import { Button } from '@ciphera-net/ui'
|
||||||
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
import { useUnifiedSettings } from '@/lib/unified-settings-context'
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { useSite } from '@/lib/swr/dashboard'
|
import { useSite } from '@/lib/swr/dashboard'
|
||||||
@@ -173,17 +174,15 @@ function TabContent({
|
|||||||
activeTab,
|
activeTab,
|
||||||
siteId,
|
siteId,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
hasPendingAction,
|
onRegisterSave,
|
||||||
onDiscard,
|
|
||||||
}: {
|
}: {
|
||||||
context: SettingsContext
|
context: SettingsContext
|
||||||
activeTab: string
|
activeTab: string
|
||||||
siteId: string | null
|
siteId: string | null
|
||||||
onDirtyChange: (dirty: boolean) => void
|
onDirtyChange: (dirty: boolean) => void
|
||||||
hasPendingAction: boolean
|
onRegisterSave: (fn: () => Promise<void>) => void
|
||||||
onDiscard: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const dirtyProps = { onDirtyChange, hasPendingAction, onDiscard }
|
const dirtyProps = { onDirtyChange, onRegisterSave }
|
||||||
// Site tabs
|
// Site tabs
|
||||||
if (context === 'site' && siteId) {
|
if (context === 'site' && siteId) {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -236,11 +235,15 @@ export default function UnifiedSettingsModal() {
|
|||||||
|
|
||||||
// ─── Dirty state & pending navigation ────────────────────────
|
// ─── Dirty state & pending navigation ────────────────────────
|
||||||
const isDirtyRef = useRef(false)
|
const isDirtyRef = useRef(false)
|
||||||
|
const [isDirtyVisible, setIsDirtyVisible] = useState(false)
|
||||||
const pendingActionRef = useRef<(() => void) | null>(null)
|
const pendingActionRef = useRef<(() => void) | null>(null)
|
||||||
const [hasPendingAction, setHasPendingAction] = useState(false)
|
const [hasPendingAction, setHasPendingAction] = useState(false)
|
||||||
|
const saveHandlerRef = useRef<(() => Promise<void>) | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||||
isDirtyRef.current = dirty
|
isDirtyRef.current = dirty
|
||||||
|
setIsDirtyVisible(dirty)
|
||||||
// If user saved and there was a pending action, execute it
|
// If user saved and there was a pending action, execute it
|
||||||
if (!dirty && pendingActionRef.current) {
|
if (!dirty && pendingActionRef.current) {
|
||||||
const action = pendingActionRef.current
|
const action = pendingActionRef.current
|
||||||
@@ -250,6 +253,20 @@ export default function UnifiedSettingsModal() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleRegisterSave = useCallback((fn: () => Promise<void>) => {
|
||||||
|
saveHandlerRef.current = fn
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveFromBar = useCallback(async () => {
|
||||||
|
if (!saveHandlerRef.current) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await saveHandlerRef.current()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
/** Run action if clean, or store as pending if dirty */
|
/** Run action if clean, or store as pending if dirty */
|
||||||
const guardedAction = useCallback((action: () => void) => {
|
const guardedAction = useCallback((action: () => void) => {
|
||||||
if (isDirtyRef.current) {
|
if (isDirtyRef.current) {
|
||||||
@@ -415,10 +432,43 @@ export default function UnifiedSettingsModal() {
|
|||||||
transition={{ duration: 0.12 }}
|
transition={{ duration: 0.12 }}
|
||||||
className="p-6"
|
className="p-6"
|
||||||
>
|
>
|
||||||
<TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} hasPendingAction={hasPendingAction} onDiscard={handleDiscard} />
|
<TabContent context={context} activeTab={activeTab} siteId={activeSiteId} onDirtyChange={handleDirtyChange} onRegisterSave={handleRegisterSave} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Save bar — fixed at modal bottom, outside scroll */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isDirtyVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`px-6 py-3 border-t flex items-center justify-between ${
|
||||||
|
hasPendingAction
|
||||||
|
? 'bg-rose-950 border-rose-800/50'
|
||||||
|
: 'bg-neutral-950 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={handleDiscard} variant="secondary" className="text-sm border-rose-700/50 text-rose-200 hover:bg-rose-900/40">
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSaveFromBar} variant="primary" disabled={saving} className="text-sm">
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Button, Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
import { Select, Toggle, toast, Spinner } from '@ciphera-net/ui'
|
||||||
import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard'
|
import { useSite, useSubscription, usePageSpeedConfig } from '@/lib/swr/dashboard'
|
||||||
import { updateSite } from '@/lib/api/sites'
|
import { updateSite } from '@/lib/api/sites'
|
||||||
import { updatePageSpeedConfig } from '@/lib/api/pagespeed'
|
import { updatePageSpeedConfig } from '@/lib/api/pagespeed'
|
||||||
@@ -28,7 +28,7 @@ function PrivacyToggle({ label, desc, checked, onToggle }: { label: string; desc
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction, onDiscard }: { siteId: string; onDirtyChange?: (dirty: boolean) => void; hasPendingAction?: boolean; onDiscard?: () => void }) {
|
export default function SitePrivacyTab({ 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: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
|
const { data: subscription, error: subscriptionError, mutate: mutateSubscription } = useSubscription()
|
||||||
const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId)
|
const { data: psiConfig, mutate: mutatePSIConfig } = usePageSpeedConfig(siteId)
|
||||||
@@ -41,8 +41,6 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction
|
|||||||
const [dataRetention, setDataRetention] = useState(6)
|
const [dataRetention, setDataRetention] = useState(6)
|
||||||
const [excludedPaths, setExcludedPaths] = useState('')
|
const [excludedPaths, setExcludedPaths] = useState('')
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false)
|
const [snippetCopied, setSnippetCopied] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
|
||||||
const initialRef = useRef('')
|
const initialRef = useRef('')
|
||||||
|
|
||||||
// Sync form state from site data — only on first load, not on SWR revalidation
|
// Sync form state from site data — only on first load, not on SWR revalidation
|
||||||
@@ -68,20 +66,16 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction
|
|||||||
excludedPaths: (site.excluded_paths || []).join('\n'),
|
excludedPaths: (site.excluded_paths || []).join('\n'),
|
||||||
})
|
})
|
||||||
hasInitialized.current = true
|
hasInitialized.current = true
|
||||||
setIsDirty(false)
|
|
||||||
}, [site])
|
}, [site])
|
||||||
|
|
||||||
// Track dirty state
|
// Track dirty state
|
||||||
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 })
|
||||||
const dirty = current !== initialRef.current
|
onDirtyChange?.(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 = useCallback(async () => {
|
||||||
setSaving(true)
|
|
||||||
try {
|
try {
|
||||||
await updateSite(siteId, {
|
await updateSite(siteId, {
|
||||||
name: site?.name || '',
|
name: site?.name || '',
|
||||||
@@ -100,10 +94,13 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction
|
|||||||
toast.success('Privacy settings updated')
|
toast.success('Privacy settings updated')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save')
|
toast.error('Failed to save')
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
}
|
}, [siteId, site?.name, collectPagePaths, collectReferrers, collectDeviceInfo, collectScreenRes, collectGeoData, hideUnknownLocations, dataRetention, excludedPaths, mutate, onDirtyChange])
|
||||||
|
|
||||||
|
// Register save handler with modal
|
||||||
|
useEffect(() => {
|
||||||
|
onRegisterSave?.(handleSave)
|
||||||
|
}, [handleSave, onRegisterSave])
|
||||||
|
|
||||||
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
if (!site) return <div className="flex items-center justify-center py-12"><Spinner className="w-6 h-6 text-neutral-500" /></div>
|
||||||
|
|
||||||
@@ -250,28 +247,6 @@ export default function SitePrivacyTab({ siteId, onDirtyChange, hasPendingAction
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sticky save bar — only visible when dirty */}
|
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user