refactor UserSettingsModal to tabbed layout

This commit is contained in:
Justin Edmund 2025-12-14 01:23:59 -08:00
parent 9ee90fc6fc
commit 376e915ade
4 changed files with 232 additions and 259 deletions

View file

@ -2,18 +2,14 @@
<script lang="ts"> <script lang="ts">
import Dialog from './ui/Dialog.svelte' import Dialog from './ui/Dialog.svelte'
import ModalHeader from './ui/ModalHeader.svelte'
import ModalBody from './ui/ModalBody.svelte' import ModalBody from './ui/ModalBody.svelte'
import ModalFooter from './ui/ModalFooter.svelte' import ModalFooter from './ui/ModalFooter.svelte'
import Select from './ui/Select.svelte' import SettingsNav, { type ElementType } from './ui/SettingsNav.svelte'
import Switch from './ui/switch/Switch.svelte' import AccountSettings from './settings/AccountSettings.svelte'
import Button from './ui/Button.svelte' import ProfileSettings from './settings/ProfileSettings.svelte'
import Input from './ui/Input.svelte' import PrivacySettings from './settings/PrivacySettings.svelte'
import { pictureData, type Picture } from '$lib/utils/pictureData'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import { users } from '$lib/api/resources/users' import { users } from '$lib/api/resources/users'
import type { UserCookie } from '$lib/types/UserCookie' import type { UserCookie } from '$lib/types/UserCookie'
import { setUserCookie } from '$lib/auth/cookies'
import { invalidateAll } from '$app/navigation' import { invalidateAll } from '$app/navigation'
import { createQuery } from '@tanstack/svelte-query' import { createQuery } from '@tanstack/svelte-query'
import { crewQueries } from '$lib/api/queries/crew.queries' import { crewQueries } from '$lib/api/queries/crew.queries'
@ -30,27 +26,48 @@
let { open = $bindable(false), onOpenChange, username, userId, user, role }: Props = $props() let { open = $bindable(false), onOpenChange, username, userId, user, role }: Props = $props()
// Form state - fields from cookie (can use immediately) // Active section for navigation
let picture = $state(user.picture) let activeSection = $state<string>('profile')
let element = $state(user.element)
let gender = $state(user.gender)
let language = $state(user.language)
let theme = $state(user.theme)
let bahamut = $state(user.bahamut ?? false)
// Form state - fields that are also in cookie (use cookie as initial value) // Form state - Account section (initialized empty, populated from API)
let granblueId = $state(user.granblueId ?? '') let formUsername = $state(username)
let showCrewGamertag = $state(user.showCrewGamertag ?? false) let formEmail = $state('')
let currentPassword = $state('')
let newPassword = $state('')
let confirmPassword = $state('')
let bahamut = $state(user.bahamut ?? false) // Client-side preference, kept in cookie
// Form state - Profile section (initialized with defaults, populated from API)
let picture = $state('')
let element = $state<ElementType>('wind')
let granblueId = $state('')
let gender = $state(0)
let language = $state('en')
let theme = $state('system')
// Form state - Privacy section (initialized with defaults, populated from API)
let showGranblueId = $state(false)
let collectionPrivacy = $state(1) // 1 = Everyone (1-based to avoid JS falsy 0)
let showCrewGamertag = $state(false)
// Track whether form has been initialized from API
let formInitialized = $state(false)
// Store original values from API for comparison on save
let originalLanguage = $state('')
let originalTheme = $state('')
let saving = $state(false) let saving = $state(false)
let error = $state<string | null>(null) let error = $state<string | null>(null)
let contentElement: HTMLElement | undefined = $state()
let isScrolledToBottom = $state(true)
// Fetch current user data from API (to sync with latest database values) // Fetch current user data from API
const currentUserQuery = createQuery(() => ({ const currentUserQuery = createQuery(() => ({
queryKey: ['currentUser', 'settings'], queryKey: ['currentUser', 'settings'],
queryFn: () => userAdapter.getCurrentUser(), queryFn: () => userAdapter.getCurrentUser(),
enabled: open, // Only fetch when modal is open enabled: open, // Only fetch when modal is open
staleTime: 5 * 60 * 1000 // Cache for 5 minutes staleTime: 0 // Always refetch when modal opens to ensure fresh data
})) }))
// Fetch current user's crew (for showing gamertag toggle) // Fetch current user's crew (for showing gamertag toggle)
@ -60,74 +77,73 @@
})) }))
const isInCrew = $derived(!!myCrewQuery.data) const isInCrew = $derived(!!myCrewQuery.data)
const crewGamertag = $derived(myCrewQuery.data?.gamertag) const crewGamertag = $derived(myCrewQuery.data?.gamertag ?? undefined)
const isLoading = $derived(currentUserQuery.isPending && !formInitialized)
// Sync form state when API returns fresher data than cookie // Populate form state when API returns data
$effect(() => { $effect(() => {
if (currentUserQuery.data) { if (currentUserQuery.data && !formInitialized) {
granblueId = currentUserQuery.data.granblueId ?? '' const data = currentUserQuery.data
showCrewGamertag = currentUserQuery.data.showCrewGamertag ?? false console.log('[UserSettingsModal] API data received:', data)
console.log('[UserSettingsModal] data.collectionPrivacy:', data.collectionPrivacy, typeof data.collectionPrivacy)
// Account
formEmail = data.email ?? ''
// Profile
picture = data.avatar?.picture ?? ''
element = (data.avatar?.element as ElementType) ?? 'wind'
granblueId = data.granblueId ?? ''
gender = data.gender ?? 0
language = data.language ?? 'en'
theme = data.theme ?? 'system'
// Privacy
showGranblueId = data.showGranblueId ?? false
collectionPrivacy = data.collectionPrivacy ?? 1
console.log('[UserSettingsModal] collectionPrivacy set to:', collectionPrivacy, typeof collectionPrivacy)
showCrewGamertag = data.showCrewGamertag ?? false
// Store original values for comparison
originalLanguage = data.language ?? 'en'
originalTheme = data.theme ?? 'system'
formInitialized = true
} }
}) })
// Get current locale from user settings // Reset form initialized state when modal closes
const locale = $derived(user.language as 'en' | 'ja') $effect(() => {
if (!open) {
formInitialized = false
}
})
// Prepare options for selects // Navigation items
const pictureOptions = $derived( const navItems = [
pictureData { value: 'account', label: 'Account' },
.sort((a, b) => a.name.en.localeCompare(b.name.en)) { value: 'profile', label: 'Profile' },
.map((p) => ({ { value: 'privacy', label: 'Privacy' }
value: p.filename,
label: p.name[locale] || p.name.en,
image: getAvatarSrc(p.filename)
}))
)
const genderOptions = [
{ value: 0, label: 'Gran' },
{ value: 1, label: 'Djeeta' }
] ]
const languageOptions = [ // Check if scrolled to bottom
{ value: 'en', label: 'English' }, function checkScrollPosition() {
{ value: 'ja', label: '日本語' } if (!contentElement) return
] const { scrollTop, scrollHeight, clientHeight } = contentElement
// Consider "at bottom" if within 5px of the bottom
const themeOptions = [ isScrolledToBottom = scrollTop + clientHeight >= scrollHeight - 5
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]
// Element colors for circle indicators
const elementColors: Record<string, string> = {
wind: '#3ee489',
fire: '#fa6d6d',
water: '#6cc9ff',
earth: '#fd9f5b',
dark: '#de7bff',
light: '#e8d633'
} }
// Create SVG circle data URL for element color // Check scroll position when content element is bound or section changes
function getElementCircle(element: string): string { $effect(() => {
const color = elementColors[element] || '#888' if (contentElement) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="${color}"/></svg>` // Small delay to let content render
return `data:image/svg+xml,${encodeURIComponent(svg)}` setTimeout(checkScrollPosition, 0)
} }
})
const elementOptions = [ // Re-check when section changes
{ value: 'wind', label: 'Wind', image: getElementCircle('wind') }, $effect(() => {
{ value: 'fire', label: 'Fire', image: getElementCircle('fire') }, activeSection // Track this dependency
{ value: 'water', label: 'Water', image: getElementCircle('water') }, if (contentElement) {
{ value: 'earth', label: 'Earth', image: getElementCircle('earth') }, setTimeout(checkScrollPosition, 0)
{ value: 'dark', label: 'Dark', image: getElementCircle('dark') }, }
{ value: 'light', label: 'Light', image: getElementCircle('light') } })
]
// Get current picture data
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
// Handle form submission // Handle form submission
async function handleSave() { async function handleSave() {
@ -136,14 +152,16 @@
try { try {
// Prepare the update data // Prepare the update data
const updateData = { const updateData: Parameters<typeof users.update>[1] = {
picture, picture,
element, element,
gender, gender,
language, language,
theme, theme,
granblueId: granblueId || undefined, granblueId: granblueId || undefined,
showCrewGamertag showCrewGamertag,
showGranblueId,
collectionPrivacy
} }
// Call API to update user settings // Call API to update user settings
@ -158,14 +176,11 @@
theme: response.theme, theme: response.theme,
bahamut, bahamut,
granblueId: response.granblueId, granblueId: response.granblueId,
showCrewGamertag: response.showCrewGamertag showCrewGamertag: response.showCrewGamertag,
showGranblueId: response.showGranblueId,
collectionPrivacy: response.collectionPrivacy
} }
// Save to cookie (we'll need to handle this server-side)
// For now, we'll just update the local state
const expires = new Date()
expires.setDate(expires.getDate() + 60)
// Make a request to update the cookie server-side // Make a request to update the cookie server-side
await fetch('/api/settings', { await fetch('/api/settings', {
method: 'POST', method: 'POST',
@ -176,7 +191,7 @@
}) })
// If language, theme, or bahamut mode changed, we need a full page reload // If language, theme, or bahamut mode changed, we need a full page reload
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) { if (originalLanguage !== language || originalTheme !== theme || user.bahamut !== bahamut) {
await invalidateAll() await invalidateAll()
window.location.reload() window.location.reload()
} else { } else {
@ -200,110 +215,78 @@
} }
</script> </script>
<Dialog bind:open {...onOpenChange ? { onOpenChange } : {}}> <Dialog bind:open {...onOpenChange ? { onOpenChange } : {}} size="medium" hideClose>
{#snippet children()} {#snippet children()}
<ModalHeader title="Account settings" description="@{username}" /> <ModalBody noPadding>
<div class="settings-layout">
<ModalBody>
<div class="settings-form">
{#if error} {#if error}
<div class="error-message">{error}</div> <div class="error-message">{error}</div>
{/if} {/if}
<div class="form-fields"> <aside class="settings-sidebar">
<!-- Picture Selection with Preview --> <div class="sidebar-header">
<div class="picture-section"> <h2 class="title">Settings</h2>
<div class="current-avatar"> <p class="username">@{username}</p>
<img
src={getAvatarSrc(picture)}
srcset={getAvatarSrcSet(picture)}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{element}"
/>
</div>
<Select
bind:value={picture}
options={pictureOptions}
label="Avatar"
placeholder="Select an avatar"
fullWidth
contained
/>
</div> </div>
<SettingsNav
<!-- Element Selection --> bind:value={activeSection}
<Select {element}
bind:value={element} items={navItems}
options={elementOptions}
label="Element"
placeholder="Select an element"
fullWidth
contained
/> />
</aside>
<!-- Granblue ID --> <main class="settings-content" bind:this={contentElement} onscroll={checkScrollPosition}>
<Input {#if isLoading}
bind:value={granblueId} <div class="loading-state">
label="Granblue ID" <div class="spinner"></div>
placeholder="Enter your Granblue ID" <span>Loading settings...</span>
contained
fullWidth
/>
<!-- Show Crew Gamertag (only if in a crew with a gamertag) -->
{#if isInCrew && crewGamertag}
<div class="inline-switch">
<label for="show-gamertag">
<span>Show crew tag on profile</span>
<Switch bind:checked={showCrewGamertag} name="show-gamertag" element={element as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined} />
</label>
<p class="field-hint">Display "{crewGamertag}" next to your name</p>
</div> </div>
{:else if activeSection === 'account'}
<AccountSettings
username={formUsername}
email={formEmail}
{currentPassword}
{newPassword}
{confirmPassword}
{bahamut}
{role}
{element}
onUsernameChange={(v) => (formUsername = v)}
onEmailChange={(v) => (formEmail = v)}
onCurrentPasswordChange={(v) => (currentPassword = v)}
onNewPasswordChange={(v) => (newPassword = v)}
onConfirmPasswordChange={(v) => (confirmPassword = v)}
onBahamutChange={(v) => (bahamut = v)}
/>
{:else if activeSection === 'profile'}
<ProfileSettings
{picture}
{element}
{granblueId}
{gender}
{language}
{theme}
onPictureChange={(v) => (picture = v)}
onElementChange={(v) => (element = v as ElementType)}
onGranblueIdChange={(v) => (granblueId = v)}
onGenderChange={(v) => (gender = v)}
onLanguageChange={(v) => (language = v)}
onThemeChange={(v) => (theme = v)}
/>
{:else if activeSection === 'privacy'}
<PrivacySettings
{showGranblueId}
{collectionPrivacy}
{showCrewGamertag}
{isInCrew}
{crewGamertag}
{element}
onShowGranblueIdChange={(v) => (showGranblueId = v)}
onCollectionPrivacyChange={(v) => (collectionPrivacy = v)}
onShowCrewGamertagChange={(v) => (showCrewGamertag = v)}
/>
{/if} {/if}
</main>
<hr class="separator" />
<!-- Gender Selection -->
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
contained
/>
<!-- Language Selection -->
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
contained
/>
<!-- Theme Selection -->
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
contained
/>
<!-- Bahamut Mode (only for admins) -->
{#if role === 9}
<hr class="separator" />
<div class="switch-field">
<label for="bahamut-mode">
<span>Bahamut Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" element={element as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light' | undefined} />
</label>
</div>
{/if}
</div>
</div> </div>
</ModalBody> </ModalBody>
@ -313,8 +296,9 @@
primaryAction={{ primaryAction={{
label: saving ? 'Saving...' : 'Save Changes', label: saving ? 'Saving...' : 'Save Changes',
onclick: handleSave, onclick: handleSave,
disabled: saving disabled: saving || isLoading
}} }}
showShadow={!isScrolledToBottom}
/> />
{/snippet} {/snippet}
</Dialog> </Dialog>
@ -325,10 +309,9 @@
@use '$src/themes/typography' as typography; @use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout; @use '$src/themes/layout' as layout;
.settings-form { .settings-layout {
display: flex; display: flex;
flex-direction: column; height: 480px;
gap: spacing.$unit-3x;
} }
.error-message { .error-message {
@ -337,92 +320,65 @@
border-radius: layout.$card-corner; border-radius: layout.$card-corner;
color: colors.$error; color: colors.$error;
padding: spacing.$unit-2x; padding: spacing.$unit-2x;
margin-bottom: spacing.$unit-2x;
width: 100%;
} }
.form-fields { .settings-sidebar {
flex-shrink: 0;
padding: spacing.$unit;
padding-right: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: spacing.$unit-3x; gap: spacing.$unit-2x;
} }
.separator { .sidebar-header {
border: none; padding: spacing.$unit spacing.$unit-2x;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
margin: 0;
}
.inline-switch { .title {
label { font-size: typography.$font-large;
display: flex; font-weight: typography.$medium;
align-items: center; color: var(--text-primary);
justify-content: space-between; margin: 0;
span {
font-size: typography.$font-regular;
color: var(--text-primary);
}
} }
.field-hint { .username {
margin: spacing.$unit-half 0 0;
font-size: typography.$font-small; font-size: typography.$font-small;
color: var(--text-secondary); color: var(--text-secondary);
margin: 0;
margin-top: spacing.$unit-half;
} }
} }
.picture-section { .settings-content {
flex: 1;
overflow-y: auto;
padding: spacing.$unit-3x;
}
.loading-state {
display: flex; display: flex;
gap: spacing.$unit-3x; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
.current-avatar { height: 100%;
flex-shrink: 0; gap: spacing.$unit-2x;
width: 80px; color: var(--text-secondary);
height: 80px;
.avatar-preview {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: layout.$full-corner;
padding: spacing.$unit;
background-color: var(--placeholder-bg);
&.element-fire {
background-color: colors.$fire-bg-20;
}
&.element-water {
background-color: colors.$water-bg-20;
}
&.element-earth {
background-color: colors.$earth-bg-20;
}
&.element-wind {
background-color: colors.$wind-bg-20;
}
&.element-light {
background-color: colors.$light-bg-20;
}
&.element-dark {
background-color: colors.$dark-bg-20;
}
}
}
} }
.switch-field { .spinner {
label { width: 24px;
display: flex; height: 24px;
align-items: center; border: 2px solid var(--border-color);
justify-content: space-between; border-top-color: var(--text-secondary);
padding: spacing.$unit-2x; border-radius: 50%;
background-color: var(--input-bound-bg); animation: spin 0.8s linear infinite;
border-radius: layout.$card-corner; }
span { @keyframes spin {
font-size: typography.$font-regular; to {
color: var(--text-primary); transform: rotate(360deg);
}
} }
} }

View file

@ -8,6 +8,7 @@
open: boolean open: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
size?: DialogSize size?: DialogSize
hideClose?: boolean
children: Snippet children: Snippet
} }
@ -15,6 +16,7 @@
open = $bindable(false), open = $bindable(false),
onOpenChange, onOpenChange,
size = 'default', size = 'default',
hideClose = false,
children children
}: DialogProps = $props() }: DialogProps = $props()
@ -36,9 +38,11 @@
<DialogBase.Portal> <DialogBase.Portal>
<DialogBase.Overlay class="dialog-overlay" /> <DialogBase.Overlay class="dialog-overlay" />
<DialogBase.Content class="dialog-content {sizeClass}"> <DialogBase.Content class="dialog-content {sizeClass}">
<DialogBase.Close class="dialog-close"> {#if !hideClose}
<span aria-hidden="true">×</span> <DialogBase.Close class="dialog-close">
</DialogBase.Close> <span aria-hidden="true">×</span>
</DialogBase.Close>
{/if}
{@render children()} {@render children()}
</DialogBase.Content> </DialogBase.Content>

View file

@ -5,12 +5,13 @@
interface Props { interface Props {
children: Snippet children: Snippet
noPadding?: boolean
} }
let { children }: Props = $props() let { children, noPadding = false }: Props = $props()
</script> </script>
<div class="modal-body"> <div class="modal-body" class:no-padding={noPadding}>
{@render children()} {@render children()}
</div> </div>
@ -22,5 +23,9 @@
padding-bottom: spacing.$unit-3x; padding-bottom: spacing.$unit-3x;
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
&.no-padding {
padding: 0;
}
} }
</style> </style>

View file

@ -16,12 +16,14 @@
cancelDisabled?: boolean cancelDisabled?: boolean
primaryAction?: PrimaryAction primaryAction?: PrimaryAction
left?: Snippet left?: Snippet
showShadow?: boolean
} }
let { onCancel, cancelDisabled = false, primaryAction, left }: Props = $props() let { onCancel, cancelDisabled = false, primaryAction, left, showShadow = false }: Props =
$props()
</script> </script>
<div class="modal-footer"> <div class="modal-footer" class:shadow={showShadow}>
{#if left} {#if left}
<div class="left"> <div class="left">
{@render left()} {@render left()}
@ -43,13 +45,19 @@
<style lang="scss"> <style lang="scss">
@use '$src/themes/spacing' as spacing; @use '$src/themes/spacing' as spacing;
@use '$src/themes/effects' as effects;
.modal-footer { .modal-footer {
padding: spacing.$unit-2x; padding: spacing.$unit-2x;
padding-top: 0; padding-top: spacing.$unit-2x;
display: flex; display: flex;
gap: spacing.$unit-2x; gap: spacing.$unit-2x;
align-items: center; align-items: center;
@include effects.smooth-transition(effects.$duration-quick, box-shadow);
&.shadow {
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.08);
}
} }
.left { .left {