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 && ( - - )} - -
-
+ + + -
- -
-
-
-
- {hasChanges && ( -
-
-

- You have unsaved instance settings -

-

- {changedSettingsGroups.join(', ')} - {pendingFaviconFile ? ' · Favicon' : ''} -

-
-
- - -
-
- )} +
, + document.body + )} ) } diff --git a/docs/appearance.md b/docs/appearance.md index 19e299f..4bc6d09 100644 --- a/docs/appearance.md +++ b/docs/appearance.md @@ -1,8 +1,8 @@ # Make Flare yours -Open **Settings → Appearance** (`/dashboard/settings?section=appearance`) to manage the instance design. Every account can save a light, dark, system, or inherited dashboard preference under **Profile → Appearance** (`/dashboard/profile?section=appearance`). Public share pages use the instance appearance. +Open **Settings → Appearance** (`/dashboard/settings?section=appearance`) to manage the instance design. Every account can save a light, dark, system, or inherited dashboard preference under **Profile → Account** (`/dashboard/profile?section=account#workspace-appearance`). Public share pages use the instance appearance. -Administrators can use **Settings → Appearance** to change the instance name, tagline, light/dark logos and footer text; choose paired color palettes, typeface, background and corner radius; and configure Minimal, Framed or Delivery share pages. Turn on **Use studio theme** to apply studio palettes. Existing color and favicon controls are in the same Appearance section. Custom CSS and head HTML are under **Settings → Advanced**. +Administrators can use **Settings → Appearance** to change the instance name, tagline, light/dark logos and footer text; choose paired color palettes, typeface, background and corner radius; and configure Minimal, Framed or Delivery share pages. Turn on **Use studio theme** to apply studio palettes. Existing color and favicon controls are in the same Appearance section. Custom CSS and head HTML are under **Settings → Appearance → Custom CSS and HTML**. Sharing controls configure uploader attribution, filename, size, footer, image fit and social preview text. Templates accept `{{instanceName}}`, `{{filename}}`, `{{size}}` and `{{uploader}}`. A hidden detail is also omitted from generated social text. These are presentation choices: existing URLs and downloaded files can still contain their original names. Visibility and passwords continue to control file access. Upload profiles can select a share style; files with no stored style inherit the instance default. @@ -12,6 +12,6 @@ Sharing controls configure uploader attribution, filename, size, footer, image f ## Recover an unusable appearance -Sign in as an administrator and open `/dashboard/settings?section=appearance&recovery=1` directly. This page uses the original Flare theme and suppresses legacy custom CSS/head HTML without modifying saved settings. Restore a previous studio appearance, or follow **repair advanced styles in Settings** to `/dashboard/settings?section=advanced&recovery=1` and fix the CSS/head fields. Return to `/dashboard/settings?section=appearance` to check the live result. +Sign in as an administrator and open `/dashboard/settings?section=appearance&recovery=1` directly. This page uses the original Flare theme and suppresses legacy custom CSS/head HTML without modifying saved settings. Restore a previous studio appearance, or follow **repair custom CSS and HTML below** to `/dashboard/settings?section=appearance&recovery=1#advanced-styles` and fix the CSS/head fields. Return to `/dashboard/settings?section=appearance` to check the live result. Recovery is limited to Settings and the legacy appearance link. It requires the signed session and a current administrator role; adding a request header cannot activate it on public pages. It does not disable authentication or change file access. diff --git a/docs/images/preferences-grouping/profile-account-desktop.png b/docs/images/preferences-grouping/profile-account-desktop.png new file mode 100644 index 0000000..f16dd45 Binary files /dev/null and b/docs/images/preferences-grouping/profile-account-desktop.png differ diff --git a/docs/images/preferences-grouping/profile-account-mobile.png b/docs/images/preferences-grouping/profile-account-mobile.png new file mode 100644 index 0000000..964d37c Binary files /dev/null and b/docs/images/preferences-grouping/profile-account-mobile.png differ diff --git a/docs/images/preferences-grouping/settings-appearance-custom-styles.png b/docs/images/preferences-grouping/settings-appearance-custom-styles.png new file mode 100644 index 0000000..133238e Binary files /dev/null and b/docs/images/preferences-grouping/settings-appearance-custom-styles.png differ diff --git a/docs/images/preferences-grouping/settings-general-desktop.png b/docs/images/preferences-grouping/settings-general-desktop.png new file mode 100644 index 0000000..b6bb403 Binary files /dev/null and b/docs/images/preferences-grouping/settings-general-desktop.png differ diff --git a/docs/images/preferences-grouping/settings-general-mobile.png b/docs/images/preferences-grouping/settings-general-mobile.png new file mode 100644 index 0000000..c60349a Binary files /dev/null and b/docs/images/preferences-grouping/settings-general-mobile.png differ diff --git a/docs/workspace-ui.md b/docs/workspace-ui.md index 40ec14e..f1baadf 100644 --- a/docs/workspace-ui.md +++ b/docs/workspace-ui.md @@ -31,6 +31,23 @@ A keyboard skip link leads to page content. Dialogs keep Flare’s translucent s with reliable margins and scrolling on smaller screens. The existing file-drop overlay refreshes an open library when an upload completes. +## Profile and Settings + +Profile groups identity, password, and the personal workspace theme under **Account**. +**Uploads** contains upload profiles, defaults, and capture tools; **Integrations** +contains API tokens and webhooks; **Your data** contains storage usage, export, and +account deletion. + +Instance Settings groups version information with everyday features in **General**. +**Appearance** contains branding, themes, sharing, favicon, and a **Custom CSS and HTML** +disclosure for advanced styling. **Access**, **Storage**, and **Email** keep their own +sections for their larger configuration forms. + +Existing links to Profile’s Appearance and Security sections, and Settings’ About +and Advanced sections, still open the corresponding controls in their new groups. +Section changes retain unfinished edits, and browser Back/Forward follows section +and anchored links. + ## Public pages Login, registration, account recovery, verification, and exceptional states share a @@ -56,8 +73,8 @@ The review covers every rendered route family: | `/dashboard/paste` | Content, filename, real creation, result copy/open, mobile | | `/dashboard/urls` | Create, copy/open, delete/cancel, empty/error, mobile | | `/dashboard/users` | Create, edit, search/roles, content, delete/pagination, administrator gating, mobile | -| `/dashboard/settings` | General, Access, Storage, Appearance, Email, Advanced, About; desktop/mobile | -| `/dashboard/profile` | Account, Appearance, Uploads, Integrations, Security, Your data; desktop/mobile, light appearance | +| `/dashboard/settings` | General, Access, Storage, Appearance, Email; desktop/mobile | +| `/dashboard/profile` | Account, Uploads, Integrations, Your data; desktop/mobile, light appearance | | `/auth/*` | Login, registration, recovery, reset, verification; status/error and mobile states | | `/setup` | Fresh Account → Storage → Access → Appearance → Email → Ready; validation/back/reload and mobile | | `/[userUrlId]/[filename]` | Public viewer types, layouts, protected and private access, long content, mobile | diff --git a/hooks/use-preference-section.ts b/hooks/use-preference-section.ts index 641b2f8..df6b8e8 100644 --- a/hooks/use-preference-section.ts +++ b/hooks/use-preference-section.ts @@ -2,42 +2,86 @@ import { useCallback, useEffect, useState } from 'react' -import { readPreferenceSection } from '@/lib/preferences/navigation' +import { + type PreferenceSectionAliases, + resolvePreferenceUrl, +} from '@/lib/preferences/navigation' export function usePreferenceSection( sections: readonly T[], - initialSection: T + initialSection: T, + aliases?: PreferenceSectionAliases ) { - const [activeSection, setActiveSection] = useState(initialSection) + const [navigation, setNavigation] = useState({ + section: initialSection, + hash: '', + }) useEffect(() => { - const sync = () => { - setActiveSection( - readPreferenceSection( - new URL(window.location.href).searchParams.get('section'), - sections, - initialSection - ) + const sync = (event?: Event) => { + const { section, url } = resolvePreferenceUrl( + new URL(window.location.href), + sections, + sections[0] ?? initialSection, + aliases ) + // Native anchor links update the browser URL; also sync Next's router so + // a later save/refresh keeps that anchor instead of restoring its old URL. + if (url.href !== window.location.href || event?.type === 'hashchange') { + window.history.replaceState( + null, + '', + url.pathname + url.search + url.hash + ) + } + setNavigation({ section, hash: url.hash }) } sync() window.addEventListener('popstate', sync) - return () => window.removeEventListener('popstate', sync) - }, [initialSection, sections]) + window.addEventListener('hashchange', sync) + return () => { + window.removeEventListener('popstate', sync) + window.removeEventListener('hashchange', sync) + } + }, [aliases, initialSection, sections]) + + useEffect(() => { + if (!navigation.hash) return + let anchor: string + try { + anchor = decodeURIComponent(navigation.hash.slice(1)) + } catch { + return + } + // Wait until the selected panel has mounted before revealing its target. + const frame = window.requestAnimationFrame(() => { + const target = document.getElementById(anchor) + if (!target || target.closest('[hidden]')) return + let ancestor: HTMLElement | null = target + while (ancestor) { + if (ancestor instanceof HTMLDetailsElement) ancestor.open = true + ancestor = ancestor.parentElement + } + target.scrollIntoView({ block: 'start' }) + }) + return () => window.cancelAnimationFrame(frame) + }, [navigation]) const selectSection = useCallback( (section: T) => { if (!sections.includes(section)) return const url = new URL(window.location.href) - if (url.searchParams.get('section') !== section) { - url.searchParams.set('section', section) + url.searchParams.set('section', section) + // A card anchor belongs to its section, not to the next sidebar choice. + url.hash = '' + if (url.href !== window.location.href) { // Keep the page mounted so changing sections preserves unfinished edits. - window.history.pushState(null, '', url.pathname + url.search + url.hash) + window.history.pushState(null, '', url.pathname + url.search) } - setActiveSection(section) + setNavigation({ section, hash: '' }) }, [sections] ) - return [activeSection, selectSection] as const + return [navigation.section, selectSection] as const } diff --git a/lib/preferences/navigation.ts b/lib/preferences/navigation.ts index f1581ee..342af37 100644 --- a/lib/preferences/navigation.ts +++ b/lib/preferences/navigation.ts @@ -4,25 +4,73 @@ export const SETTINGS_SECTIONS = [ 'storage', 'appearance', 'email', - 'advanced', - 'about', ] as const export const PROFILE_SECTIONS = [ 'account', - 'appearance', 'uploads', 'integrations', - 'security', 'data', ] as const +export type PreferenceSectionAliases = Readonly< + Record +> + +export const SETTINGS_SECTION_ALIASES = { + advanced: { section: 'appearance', anchor: 'advanced-styles' }, + about: { section: 'general', anchor: 'instance-information' }, +} as const satisfies PreferenceSectionAliases< + (typeof SETTINGS_SECTIONS)[number] +> + +export const PROFILE_SECTION_ALIASES = { + appearance: { section: 'account', anchor: 'workspace-appearance' }, + security: { section: 'account', anchor: 'password' }, +} as const satisfies PreferenceSectionAliases<(typeof PROFILE_SECTIONS)[number]> + +function readSectionAlias( + value: string | string[] | undefined | null, + sections: readonly T[], + aliases?: PreferenceSectionAliases +) { + if ( + typeof value !== 'string' || + sections.includes(value as T) || + !aliases || + !Object.hasOwn(aliases, value) + ) + return undefined + const alias = aliases[value] + return sections.includes(alias.section) ? alias : undefined +} + export function readPreferenceSection( value: string | string[] | undefined | null, sections: readonly T[], - fallback: T + fallback: T, + aliases?: PreferenceSectionAliases ): T { return typeof value === 'string' && sections.includes(value as T) ? (value as T) - : fallback + : (readSectionAlias(value, sections, aliases)?.section ?? fallback) +} + +export function resolvePreferenceUrl( + input: URL, + sections: readonly T[], + fallback: T, + aliases?: PreferenceSectionAliases +) { + const url = new URL(input) + const values = url.searchParams.getAll('section') + // Match the server's rejection of repeated query parameters. + const value = values.length > 1 ? values : values[0] + const section = readPreferenceSection(value, sections, fallback, aliases) + const alias = readSectionAlias(value, sections, aliases) + if (alias) { + url.searchParams.set('section', section) + if (!url.hash) url.hash = alias.anchor + } + return { section, url } } diff --git a/types/components/profile.ts b/types/components/profile.ts index cbd8797..5877291 100644 --- a/types/components/profile.ts +++ b/types/components/profile.ts @@ -1,4 +1,5 @@ import type { PersonalAppearance } from '@/lib/customization/schema' +import type { PROFILE_SECTIONS } from '@/lib/preferences/navigation' import { User } from './user' @@ -9,7 +10,7 @@ export interface ProfileClientProps { formattedUsed: string usagePercentage: number isAdmin: boolean - initialSection: string + initialSection: (typeof PROFILE_SECTIONS)[number] initialPreference: PersonalAppearance }