diff --git a/__tests__/customization/preference-navigation.test.ts b/__tests__/customization/preference-navigation.test.ts
new file mode 100644
index 0000000..6ec4bcd
--- /dev/null
+++ b/__tests__/customization/preference-navigation.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ PROFILE_SECTIONS,
+ PROFILE_SECTION_ALIASES,
+ SETTINGS_SECTIONS,
+ SETTINGS_SECTION_ALIASES,
+ readPreferenceSection,
+ resolvePreferenceUrl,
+} from '@/lib/preferences/navigation'
+
+describe('preference link normalization', () => {
+ it.each([
+ ['appearance', 'workspace-appearance'],
+ ['security', 'password'],
+ ])(
+ 'keeps a saved profile %s link on its original card',
+ (section, anchor) => {
+ const input = new URL(
+ `https://flare.example/dashboard/profile?section=${section}&source=bookmark`
+ )
+ const result = resolvePreferenceUrl(
+ input,
+ PROFILE_SECTIONS,
+ 'account',
+ PROFILE_SECTION_ALIASES
+ )
+ expect(result.section).toBe('account')
+ expect(result.url.pathname + result.url.search + result.url.hash).toBe(
+ `/dashboard/profile?section=account&source=bookmark#${anchor}`
+ )
+ expect(input.searchParams.get('section')).toBe(section)
+ }
+ )
+
+ it.each([
+ ['advanced', 'appearance', 'advanced-styles'],
+ ['about', 'general', 'instance-information'],
+ ])('preserves recovery when resolving %s', (legacy, section, anchor) => {
+ const { url, section: selected } = resolvePreferenceUrl(
+ new URL(
+ `https://flare.example/dashboard/settings?section=${legacy}&recovery=1&source=help`
+ ),
+ SETTINGS_SECTIONS,
+ 'general',
+ SETTINGS_SECTION_ALIASES
+ )
+ expect(selected).toBe(section)
+ expect(url.searchParams.get('recovery')).toBe('1')
+ expect(url.searchParams.get('source')).toBe('help')
+ expect(url.searchParams.get('section')).toBe(section)
+ expect(url.hash).toBe(`#${anchor}`)
+ expect(
+ resolvePreferenceUrl(
+ url,
+ SETTINGS_SECTIONS,
+ 'general',
+ SETTINGS_SECTION_ALIASES
+ ).url.href
+ ).toBe(url.href)
+ })
+
+ it('retains an explicitly requested card anchor', () => {
+ const { url } = resolvePreferenceUrl(
+ new URL(
+ 'https://flare.example/dashboard/settings?section=advanced&recovery=1#custom-css'
+ ),
+ SETTINGS_SECTIONS,
+ 'general',
+ SETTINGS_SECTION_ALIASES
+ )
+ expect(url.hash).toBe('#custom-css')
+ expect(url.searchParams.get('section')).toBe('appearance')
+ })
+
+ it.each([
+ undefined,
+ null,
+ '',
+ 'constructor',
+ '__proto__',
+ 'https://untrusted.example',
+ 'advanced&recovery=1',
+ ['advanced', 'appearance'],
+ ])('rejects invalid section values: %j', (value) => {
+ expect(
+ readPreferenceSection(
+ value,
+ SETTINGS_SECTIONS,
+ 'general',
+ SETTINGS_SECTION_ALIASES
+ )
+ ).toBe('general')
+ })
+
+ it('matches server fallback for repeated section parameters', () => {
+ const { section, url } = resolvePreferenceUrl(
+ new URL(
+ 'https://flare.example/dashboard/settings?section=advanced§ion=email&recovery=1'
+ ),
+ SETTINGS_SECTIONS,
+ 'general',
+ SETTINGS_SECTION_ALIASES
+ )
+ expect(section).toBe('general')
+ expect(url.hash).toBe('')
+ expect(url.searchParams.getAll('section')).toEqual(['advanced', 'email'])
+ })
+
+ it('keeps canonical links and fragments unchanged', () => {
+ const input = new URL(
+ 'https://flare.example/dashboard/profile?section=account#workspace-appearance'
+ )
+ expect(
+ resolvePreferenceUrl(
+ input,
+ PROFILE_SECTIONS,
+ 'account',
+ PROFILE_SECTION_ALIASES
+ )
+ ).toEqual({ section: 'account', url: input })
+ })
+})
diff --git a/__tests__/customization/settings-navigation.test.ts b/__tests__/customization/settings-navigation.test.ts
index 95f8a66..723ca97 100644
--- a/__tests__/customization/settings-navigation.test.ts
+++ b/__tests__/customization/settings-navigation.test.ts
@@ -1,5 +1,6 @@
import CustomizePage from '@/app/(main)/dashboard/customize/page'
import IntegrationsPage from '@/app/(main)/dashboard/integrations/page'
+import ProfilePage from '@/app/(main)/dashboard/profile/page'
import SettingsPage from '@/app/(main)/dashboard/settings/page'
import UploadProfilesPage from '@/app/(main)/dashboard/upload-profiles/page'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,6 +31,7 @@ vi.mock('@/lib/email/config', () => ({
vi.mock('@/components/settings/instance-settings', () => ({
InstanceSettings: () => null,
}))
+vi.mock('@/components/profile', () => ({ ProfileClient: () => null }))
beforeEach(() => {
vi.clearAllMocks()
@@ -66,14 +68,20 @@ describe('settings permission and legacy navigation', () => {
mocks.user.mockResolvedValue({ role: 'USER' })
await expect(
CustomizePage({ searchParams: Promise.resolve({ recovery: '1' }) })
- ).rejects.toThrow('redirect:/dashboard/profile?section=appearance')
+ ).rejects.toThrow(
+ 'redirect:/dashboard/profile?section=account#workspace-appearance'
+ )
expect(mocks.config).not.toHaveBeenCalled()
})
it.each([
['appearance', 'appearance'],
+ ['advanced', 'appearance'],
+ ['about', 'general'],
+ ['access', 'access'],
['https://untrusted.example', 'general'],
['appearance&recovery=1', 'general'],
+ [['appearance', 'advanced'], 'general'],
])(
'loads an authorized section without exposing email credentials: %s',
async (section, expected) => {
@@ -123,3 +131,46 @@ describe('settings permission and legacy navigation', () => {
await expect(IntegrationsPage()).rejects.toThrow('redirect:/auth/login')
})
})
+
+describe('profile section compatibility', () => {
+ it.each([
+ ['account', 'account'],
+ ['appearance', 'account'],
+ ['security', 'account'],
+ ['uploads', 'uploads'],
+ ['integrations', 'integrations'],
+ ['data', 'data'],
+ ['advanced', 'account'],
+ [['appearance', 'uploads'], 'account'],
+ ])('loads the canonical section for %s', async (section, expected) => {
+ mocks.user.mockResolvedValue({
+ id: 'operator',
+ role: 'ADMIN',
+ storageUsed: 0,
+ preferences: {},
+ _count: { files: 0, shortenedUrls: 0 },
+ })
+ mocks.config.mockResolvedValue({
+ settings: {
+ general: {
+ storage: {
+ quotas: { enabled: false, default: { value: 1, unit: 'GB' } },
+ },
+ },
+ },
+ })
+ const page = await ProfilePage({
+ searchParams: Promise.resolve({ section }),
+ })
+ expect(page.props.initialSection).toBe(expected)
+ })
+
+ it('still requires sign-in before loading an aliased section', async () => {
+ mocks.session.mockResolvedValue(null)
+ await expect(
+ ProfilePage({ searchParams: Promise.resolve({ section: 'security' }) })
+ ).rejects.toThrow('redirect:/auth/login')
+ expect(mocks.user).not.toHaveBeenCalled()
+ expect(mocks.config).not.toHaveBeenCalled()
+ })
+})
diff --git a/__tests__/hooks/preference-section.test.ts b/__tests__/hooks/preference-section.test.ts
new file mode 100644
index 0000000..3add488
--- /dev/null
+++ b/__tests__/hooks/preference-section.test.ts
@@ -0,0 +1,214 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ SETTINGS_SECTIONS,
+ SETTINGS_SECTION_ALIASES,
+} from '@/lib/preferences/navigation'
+
+import { usePreferenceSection } from '@/hooks/use-preference-section'
+
+const harness = vi.hoisted(() => ({
+ slots: [] as unknown[],
+ cursor: 0,
+ dirty: false,
+ effects: [] as (() => void)[],
+}))
+
+// Flush state and dependency-aware effects as separate renders, including the
+// render that mounts a newly selected panel before its animation frame runs.
+vi.mock('react', () => ({
+ useState: (initial: unknown) => {
+ const index = harness.cursor++
+ if (!(index in harness.slots)) harness.slots[index] = initial
+ return [
+ harness.slots[index],
+ (value: unknown) => {
+ if (!Object.is(harness.slots[index], value)) harness.dirty = true
+ harness.slots[index] = value
+ },
+ ]
+ },
+ useCallback: (callback: unknown) => callback,
+ useEffect: (effect: () => void | (() => void), dependencies: unknown[]) => {
+ const index = harness.cursor++
+ const previous = harness.slots[index] as
+ | { dependencies: unknown[]; cleanup?: () => void }
+ | undefined
+ if (
+ previous &&
+ dependencies.every((value, i) =>
+ Object.is(value, previous.dependencies[i])
+ )
+ )
+ return
+ harness.effects.push(() => {
+ previous?.cleanup?.()
+ harness.slots[index] = { dependencies, cleanup: effect() }
+ })
+ },
+}))
+
+let href: string
+let listeners: Map void>
+let frames: Map void>
+let nextFrame: number
+let target: {
+ parentElement: Details | null
+ closest: ReturnType
+ scrollIntoView: ReturnType
+} | null
+let history: {
+ state: { __NA: boolean }
+ replaceState: ReturnType
+ pushState: ReturnType
+}
+
+class Details {
+ open = false
+ parentElement: Details | null = null
+}
+
+function render(
+ initialSection: (typeof SETTINGS_SECTIONS)[number] = 'appearance'
+) {
+ let result: ReturnType<
+ typeof usePreferenceSection<(typeof SETTINGS_SECTIONS)[number]>
+ >
+ do {
+ harness.dirty = false
+ harness.cursor = 0
+ harness.effects = []
+ // This harness invokes each render explicitly; React itself is mocked above.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ result = usePreferenceSection(
+ SETTINGS_SECTIONS,
+ initialSection,
+ SETTINGS_SECTION_ALIASES
+ )
+ harness.effects.forEach((effect) => effect())
+ } while (harness.dirty)
+ return result!
+}
+
+function flushFrames() {
+ const pending = [...frames.values()]
+ frames.clear()
+ pending.forEach((callback) => callback())
+}
+
+beforeEach(() => {
+ harness.slots = []
+ href = 'https://flare.example/dashboard/settings?section=advanced&recovery=1'
+ listeners = new Map()
+ frames = new Map()
+ nextFrame = 0
+ target = {
+ parentElement: new Details(),
+ closest: vi.fn(() => null),
+ scrollIntoView: vi.fn(),
+ }
+ const updateUrl = (_state: unknown, _title: string, value: string) => {
+ href = new URL(value, href).href
+ }
+ history = {
+ state: { __NA: true },
+ replaceState: vi.fn(updateUrl),
+ pushState: vi.fn(updateUrl),
+ }
+ vi.stubGlobal('window', {
+ location: {
+ get href() {
+ return href
+ },
+ },
+ history,
+ addEventListener: (name: string, callback: (event?: Event) => void) =>
+ listeners.set(name, callback),
+ removeEventListener: (name: string) => listeners.delete(name),
+ requestAnimationFrame: (callback: () => void) => {
+ frames.set(++nextFrame, callback)
+ return nextFrame
+ },
+ cancelAnimationFrame: (id: number) => frames.delete(id),
+ })
+ vi.stubGlobal('document', { getElementById: vi.fn(() => target) })
+ vi.stubGlobal('HTMLDetailsElement', Details)
+})
+
+afterEach(() => vi.unstubAllGlobals())
+
+describe('preference browser navigation', () => {
+ it('replaces a legacy entry and reveals only its target disclosure after rendering', () => {
+ const unrelatedDisclosure = new Details()
+ expect(render()[0]).toBe('appearance')
+ expect(history.replaceState).toHaveBeenCalledWith(
+ null,
+ '',
+ '/dashboard/settings?section=appearance&recovery=1#advanced-styles'
+ )
+ expect(history.pushState).not.toHaveBeenCalled()
+ expect(target!.scrollIntoView).not.toHaveBeenCalled()
+ flushFrames()
+ expect(target!.parentElement!.open).toBe(true)
+ expect(unrelatedDisclosure.open).toBe(false)
+ expect(target!.scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
+ })
+
+ it('clears stale card anchors when selecting a section and allows Next to sync its URL', () => {
+ const [, select] = render()
+ select('storage')
+ expect(render()[0]).toBe('storage')
+ expect(history.pushState).toHaveBeenCalledWith(
+ null,
+ '',
+ '/dashboard/settings?section=storage&recovery=1'
+ )
+ // Passing Next's __NA state would bypass its patched history URL sync.
+ expect(history.pushState.mock.calls[0][0]).not.toBe(history.state)
+ expect(frames.size).toBe(0)
+ })
+
+ it('restores aliased sections and default sections with back and forward', () => {
+ render()
+ href = 'https://flare.example/dashboard/settings?section=about'
+ listeners.get('popstate')!()
+ expect(render()[0]).toBe('general')
+ expect(href).toContain('?section=general#instance-information')
+ href = 'https://flare.example/dashboard/settings'
+ listeners.get('popstate')!()
+ expect(render()[0]).toBe('general')
+ href = 'https://flare.example/dashboard/settings?section=email'
+ listeners.get('popstate')!()
+ expect(render()[0]).toBe('email')
+ })
+
+ it('opens a canonical fragment target when a native link changes the hash', () => {
+ href =
+ 'https://flare.example/dashboard/settings?section=appearance&recovery=1'
+ render()
+ expect(frames.size).toBe(0)
+ href += '#advanced-styles'
+ listeners.get('hashchange')!(new Event('hashchange'))
+ render()
+ expect(history.replaceState).toHaveBeenCalledWith(
+ null,
+ '',
+ '/dashboard/settings?section=appearance&recovery=1#advanced-styles'
+ )
+ flushFrames()
+ expect(target!.parentElement!.open).toBe(true)
+ expect(target!.scrollIntoView).toHaveBeenCalledOnce()
+ })
+
+ it('does not scroll hidden targets or throw on malformed fragments', () => {
+ render()
+ target!.closest.mockReturnValue({ hidden: true })
+ flushFrames()
+ expect(target!.scrollIntoView).not.toHaveBeenCalled()
+ href =
+ 'https://flare.example/dashboard/settings?section=appearance#%E0%A4%A'
+ listeners.get('hashchange')!()
+ expect(() => render()).not.toThrow()
+ expect(frames.size).toBe(0)
+ })
+})
diff --git a/app/(main)/dashboard/customize/page.tsx b/app/(main)/dashboard/customize/page.tsx
index f8c2aac..0c81367 100644
--- a/app/(main)/dashboard/customize/page.tsx
+++ b/app/(main)/dashboard/customize/page.tsx
@@ -15,7 +15,8 @@ export default async function CustomizePage({
select: { role: true },
})
if (!user) redirect('/auth/login')
- if (user.role !== 'ADMIN') redirect('/dashboard/profile?section=appearance')
+ if (user.role !== 'ADMIN')
+ redirect('/dashboard/profile?section=account#workspace-appearance')
const { recovery } = await searchParams
redirect(
diff --git a/app/(main)/dashboard/profile/page.tsx b/app/(main)/dashboard/profile/page.tsx
index f180cde..8143619 100644
--- a/app/(main)/dashboard/profile/page.tsx
+++ b/app/(main)/dashboard/profile/page.tsx
@@ -8,6 +8,7 @@ import { readPersonalAppearance } from '@/lib/customization/schema'
import { prisma } from '@/lib/database/prisma'
import {
PROFILE_SECTIONS,
+ PROFILE_SECTION_ALIASES,
readPreferenceSection,
} from '@/lib/preferences/navigation'
import { formatFileSize } from '@/lib/utils'
@@ -21,7 +22,8 @@ export default async function ProfilePage({
const initialSection = readPreferenceSection(
params.section,
PROFILE_SECTIONS,
- 'account'
+ 'account',
+ PROFILE_SECTION_ALIASES
)
const session = await getPageSession()
diff --git a/app/(main)/dashboard/settings/page.tsx b/app/(main)/dashboard/settings/page.tsx
index 74b3833..41f9787 100644
--- a/app/(main)/dashboard/settings/page.tsx
+++ b/app/(main)/dashboard/settings/page.tsx
@@ -9,13 +9,14 @@ import { prisma } from '@/lib/database/prisma'
import { redactEmailConfig } from '@/lib/email/config'
import {
SETTINGS_SECTIONS,
+ SETTINGS_SECTION_ALIASES,
readPreferenceSection,
} from '@/lib/preferences/navigation'
export default async function SettingsPage({
searchParams,
}: {
- searchParams: Promise<{ section?: string }>
+ searchParams: Promise<{ section?: string | string[] }>
}) {
const session = await getPageSession()
if (!session?.user?.id) redirect('/auth/login')
@@ -41,7 +42,8 @@ export default async function SettingsPage({
initialSection={readPreferenceSection(
params.section,
SETTINGS_SECTIONS,
- 'general'
+ 'general',
+ SETTINGS_SECTION_ALIASES
)}
recovery={recovery}
/>
diff --git a/components/customization/customization-studio.tsx b/components/customization/customization-studio.tsx
index 6c5b3c3..6c76043 100644
--- a/components/customization/customization-studio.tsx
+++ b/components/customization/customization-studio.tsx
@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from 'react'
-import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
@@ -318,10 +317,10 @@ export function CustomizationStudio({
HTML disabled. Your saved settings are unchanged. Restore a previous
appearance below, or{' '}
- repair advanced styles in Settings
+ repair custom CSS and HTML below
.
@@ -504,13 +503,20 @@ export function CustomizationStudio({
/>
- Your favicon is below. Custom CSS and head HTML are in{' '}
- {
+ const styles =
+ window.document.getElementById('advanced-styles')
+ if (styles instanceof HTMLDetailsElement)
+ styles.open = true
+ }}
className="underline underline-offset-4"
>
- Advanced settings
-
+ Custom CSS and HTML
+
.
diff --git a/components/customization/recovery-link.tsx b/components/customization/recovery-link.tsx
index eff0ba7..4504d0a 100644
--- a/components/customization/recovery-link.tsx
+++ b/components/customization/recovery-link.tsx
@@ -11,7 +11,7 @@ export function RecoveryLink({
| '/dashboard/settings?recovery=1'
| '/dashboard/settings?section=appearance'
| '/dashboard/settings?section=appearance&recovery=1'
- | '/dashboard/settings?section=advanced&recovery=1'
+ | '/dashboard/settings?section=appearance&recovery=1#advanced-styles'
className?: string
children: ReactNode
}) {
diff --git a/components/profile/index.tsx b/components/profile/index.tsx
index a13f6b5..b957479 100644
--- a/components/profile/index.tsx
+++ b/components/profile/index.tsx
@@ -11,9 +11,7 @@ import {
Fingerprint,
HardDrive,
KeyRound,
- Palette,
Plug,
- Shield,
Trash2,
Upload,
UserRound,
@@ -34,7 +32,10 @@ import {
} from '@/components/ui/card'
import { ProfileManager } from '@/components/upload-profiles/profile-manager'
-import { PROFILE_SECTIONS } from '@/lib/preferences/navigation'
+import {
+ PROFILE_SECTIONS,
+ PROFILE_SECTION_ALIASES,
+} from '@/lib/preferences/navigation'
import { usePreferenceSection } from '@/hooks/use-preference-section'
@@ -49,18 +50,12 @@ import { ProfileTools } from './tools'
const sections = [
{
id: 'account',
- hint: 'Your identity and email',
+ hint: 'Identity, password, and theme',
title: 'Account',
- description: 'Your identity, avatar, and email address.',
+ description:
+ 'Manage your identity, sign-in details, and workspace preference.',
icon: UserRound,
},
- {
- id: 'appearance',
- hint: 'Your workspace theme',
- title: 'Appearance',
- description: 'Choose how Flare looks in your own workspace.',
- icon: Palette,
- },
{
id: 'uploads',
hint: 'Defaults, profiles, and tools',
@@ -76,13 +71,6 @@ const sections = [
description: 'Connect your apps with personal API tokens and webhooks.',
icon: Plug,
},
- {
- id: 'security',
- hint: 'Password and sign-in',
- title: 'Security',
- description: 'Keep your account and sign-in details secure.',
- icon: Shield,
- },
{
id: 'data',
hint: 'Storage, exports, and account',
@@ -104,7 +92,8 @@ export function ProfileClient({
const router = useRouter()
const [activeSection, setSection] = usePreferenceSection(
PROFILE_SECTIONS,
- initialSection
+ initialSection,
+ PROFILE_SECTION_ALIASES
)
const handleRefresh = useCallback(() => router.refresh(), [router])
@@ -136,10 +125,25 @@ export function ProfileClient({
-
-
-
-
+
+
+
+
+
+
+ Change your password
+
+ Use a strong password that you do not use elsewhere.
+
+
+
+
+
+
+
+
@@ -176,25 +180,6 @@ export function ProfileClient({
-
-
-
-
-
-
-
- Change your password
-
- Use a strong password that you do not use elsewhere.
-
-
-
-
-
-
-
-
-
diff --git a/components/settings/instance-settings.tsx b/components/settings/instance-settings.tsx
index 5fbf94f..c403711 100644
--- a/components/settings/instance-settings.tsx
+++ b/components/settings/instance-settings.tsx
@@ -18,7 +18,6 @@ import {
Github,
HardDrive,
Heart,
- Info,
InfoIcon,
Mail,
Palette,
@@ -28,6 +27,7 @@ import {
Upload,
XCircle,
} from 'lucide-react'
+import { createPortal } from 'react-dom'
import { CustomizationStudio } from '@/components/customization/customization-studio'
import { RecoveryLink } from '@/components/customization/recovery-link'
@@ -60,7 +60,10 @@ import { Switch } from '@/components/ui/switch'
import type { FlareConfig } from '@/lib/config'
import type { CustomizationState } from '@/lib/customization/schema'
-import { SETTINGS_SECTIONS } from '@/lib/preferences/navigation'
+import {
+ SETTINGS_SECTIONS,
+ SETTINGS_SECTION_ALIASES,
+} from '@/lib/preferences/navigation'
import { usePreferenceSection } from '@/hooks/use-preference-section'
import { useToast } from '@/hooks/use-toast'
@@ -107,7 +110,8 @@ export function InstanceSettings({
}) {
const [activeSection, setSection] = usePreferenceSection(
SETTINGS_SECTIONS,
- initialSection
+ initialSection,
+ SETTINGS_SECTION_ALIASES
)
const [studioDirty, setStudioDirty] = useState(false)
const handleStudioStateChange = useCallback(
@@ -371,8 +375,9 @@ export function InstanceSettings({
{
id: 'general',
title: 'General',
- hint: 'Everyday instance details',
- description: 'The everyday details that make your instance work for you.',
+ hint: 'Features, credits, and version',
+ description:
+ 'Manage everyday features and keep your instance up to date.',
icon: Settings2,
dirty:
!deepEqual(
@@ -414,8 +419,9 @@ export function InstanceSettings({
{
id: 'appearance',
title: 'Appearance',
- hint: 'Branding, themes, and sharing',
- description: 'Your identity, your atmosphere, your way of sharing.',
+ hint: 'Branding, themes, and custom styles',
+ description:
+ 'Shape your instance with branding, themes, sharing, and custom styles.',
icon: Palette,
dirty:
studioDirty ||
@@ -423,7 +429,11 @@ export function InstanceSettings({
savedConfig.settings.appearance,
workingConfig.settings.appearance
) ||
- hasFaviconChanged(),
+ hasFaviconChanged() ||
+ !deepEqual(
+ savedConfig.settings.advanced,
+ workingConfig.settings.advanced
+ ),
},
{
id: 'email',
@@ -433,24 +443,6 @@ export function InstanceSettings({
'Account recovery and verification, delivered by your mail provider.',
icon: Mail,
},
- {
- id: 'advanced',
- title: 'Advanced',
- hint: 'Custom CSS and head HTML',
- description: 'The finishing touches, down to your own CSS and HTML.',
- icon: Code,
- dirty: !deepEqual(
- savedConfig.settings.advanced,
- workingConfig.settings.advanced
- ),
- },
- {
- id: 'about',
- title: 'About',
- hint: 'Version and credits',
- description: 'Your Flare version and the people building it.',
- icon: Info,
- },
] as const
const changedSettingsGroups = sections
.filter(
@@ -459,6 +451,10 @@ export function InstanceSettings({
section.dirty &&
(section.id !== 'appearance' ||
pendingFaviconFile ||
+ !deepEqual(
+ savedConfig.settings.advanced,
+ workingConfig.settings.advanced
+ ) ||
!deepEqual(
savedConfig.settings.appearance,
workingConfig.settings.appearance
@@ -573,6 +569,79 @@ export function InstanceSettings({
+
+
+ Instance Information
+
+ View and manage your Flare instance details
+
+
+
+
+
+
+
+ Current version: {pkg.version}
+ {updateInfo && (
+
+ {updateInfo.hasUpdate
+ ? `(Update available: ${updateInfo.latestVersion})`
+ : '(Up to date)'}
+
+ )}
+
+
+
+ {updateInfo?.hasUpdate && (
+
+ )}
+
+
+
+
+
+
+
@@ -1492,245 +1561,185 @@ export function InstanceSettings({
packs are published separately above.
-
-
-
-
-
-
-
-
-
- Custom Styling
- Add custom CSS to your instance
-
-
-
-
-
-
- {isFieldChanged('advanced', ['customCSS']) && (
-
+
+
+ Custom CSS and HTML
+
+
+ Add your own styles and head content. These changes apply when you
+ save settings.
+
+
+
+
+ Custom Styling
+
+ Add custom CSS to your instance
+
+
+
+
+
+
+
+ {isFieldChanged('advanced', ['customCSS']) && (
+
+ )}
+
+
+
+ {cssEditorOpen && (
+
+
+
+ Custom CSS Editor
+
+ Add custom CSS to customize your instance
+
+
+ {isFieldChanged('advanced', ['customCSS']) && (
+
+ )}
+
+
+ {
+ handleSettingChange('advanced', {
+ customCSS: value,
+ })
+ }}
+ theme="dark"
+ className="border rounded-md"
+ />
+
+
)}
-
-
- {cssEditorOpen && (
-
-
-
-
Custom CSS Editor
-
- Add custom CSS to customize your instance
-
+
+
+
+
+ HTML Head Content
+
+ Add custom HTML to the head section
+
+
+
+
+
+
+
+ {isFieldChanged('advanced', ['customHead']) && (
+
+ )}
- {isFieldChanged('advanced', ['customCSS']) && (
-
- )}
-
-
- {
- handleSettingChange('advanced', {
- customCSS: value,
- })
- }}
- theme="dark"
- className="border rounded-md"
- />
-
-
- )}
-
-
-
-
-
- HTML Head Content
-
- Add custom HTML to the head section
-
-
-
-
-
-
-
- {isFieldChanged('advanced', ['customHead']) && (
-
+
+
+ {htmlEditorOpen && (
+
+
+
+ Custom HTML Editor
+
+ Add custom HTML to the head of your instance
+
+
+ {isFieldChanged('advanced', ['customHead']) && (
+
+ )}
+
+
+ {
+ handleSettingChange('advanced', {
+ customHead: value,
+ })
+ }}
+ theme="dark"
+ className="border rounded-md"
+ />
+
+
)}
-
-
- {htmlEditorOpen && (
-
-
-
- Custom HTML Editor
-
- Add custom HTML to the head of your instance
-
-
- {isFieldChanged('advanced', ['customHead']) && (
-
- )}
-
-
- {
- handleSettingChange('advanced', {
- customHead: value,
- })
- }}
- theme="dark"
- className="border rounded-md"
- />
-
-
- )}
-
-
-
+
+
+
+
-
-
-
- Instance Information
-
- View and manage your Flare instance details
-
-
-
-
-
-
-
- Current version: {pkg.version}
- {updateInfo && (
-
- {updateInfo.hasUpdate
- ? `(Update available: ${updateInfo.latestVersion})`
- : '(Up to date)'}
-
- )}
-
-
-
- {updateInfo?.hasUpdate && (
-
- )}
-
-
-
+
+
+
-
-