390 lines
11 KiB
Svelte
390 lines
11 KiB
Svelte
<svelte:options runes={true} />
|
|
|
|
<script lang="ts">
|
|
import Dialog from './ui/Dialog.svelte'
|
|
import ModalBody from './ui/ModalBody.svelte'
|
|
import ModalFooter from './ui/ModalFooter.svelte'
|
|
import SettingsNav, { type ElementType } from './ui/SettingsNav.svelte'
|
|
import AccountSettings from './settings/AccountSettings.svelte'
|
|
import ProfileSettings from './settings/ProfileSettings.svelte'
|
|
import PrivacySettings from './settings/PrivacySettings.svelte'
|
|
import { users } from '$lib/api/resources/users'
|
|
import type { UserCookie } from '$lib/types/UserCookie'
|
|
import { invalidateAll } from '$app/navigation'
|
|
import { createQuery } from '@tanstack/svelte-query'
|
|
import { crewQueries } from '$lib/api/queries/crew.queries'
|
|
import { userAdapter } from '$lib/api/adapters/user.adapter'
|
|
|
|
interface Props {
|
|
open: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
username: string
|
|
userId: string
|
|
user: UserCookie
|
|
role: number
|
|
}
|
|
|
|
let { open = $bindable(false), onOpenChange, username, userId, user, role }: Props = $props()
|
|
|
|
// Active section for navigation
|
|
let activeSection = $state<string>('profile')
|
|
|
|
// Form state - Account section (initialized empty, populated from API)
|
|
let formUsername = $state(username)
|
|
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 error = $state<string | null>(null)
|
|
let contentElement: HTMLElement | undefined = $state()
|
|
let isScrolledToBottom = $state(true)
|
|
|
|
// Fetch current user data from API
|
|
const currentUserQuery = createQuery(() => ({
|
|
queryKey: ['currentUser', 'settings'],
|
|
queryFn: () => userAdapter.getCurrentUser(),
|
|
enabled: open, // Only fetch when modal is open
|
|
staleTime: 0 // Always refetch when modal opens to ensure fresh data
|
|
}))
|
|
|
|
// Fetch current user's crew (for showing gamertag toggle)
|
|
const myCrewQuery = createQuery(() => ({
|
|
...crewQueries.myCrew(),
|
|
enabled: open // Only fetch when modal is open
|
|
}))
|
|
|
|
const isInCrew = $derived(!!myCrewQuery.data)
|
|
const crewGamertag = $derived(myCrewQuery.data?.gamertag ?? undefined)
|
|
const isLoading = $derived(currentUserQuery.isPending && !formInitialized)
|
|
|
|
// Populate form state when API returns data
|
|
$effect(() => {
|
|
if (currentUserQuery.data && !formInitialized) {
|
|
const data = currentUserQuery.data
|
|
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
|
|
}
|
|
})
|
|
|
|
// Reset form initialized state when modal closes
|
|
$effect(() => {
|
|
if (!open) {
|
|
formInitialized = false
|
|
}
|
|
})
|
|
|
|
// Navigation items
|
|
const navItems = [
|
|
{ value: 'account', label: 'Account' },
|
|
{ value: 'profile', label: 'Profile' },
|
|
{ value: 'privacy', label: 'Privacy' }
|
|
]
|
|
|
|
// Check if scrolled to bottom
|
|
function checkScrollPosition() {
|
|
if (!contentElement) return
|
|
const { scrollTop, scrollHeight, clientHeight } = contentElement
|
|
// Consider "at bottom" if within 5px of the bottom
|
|
isScrolledToBottom = scrollTop + clientHeight >= scrollHeight - 5
|
|
}
|
|
|
|
// Check scroll position when content element is bound or section changes
|
|
$effect(() => {
|
|
if (contentElement) {
|
|
// Small delay to let content render
|
|
setTimeout(checkScrollPosition, 0)
|
|
}
|
|
})
|
|
|
|
// Re-check when section changes
|
|
$effect(() => {
|
|
activeSection // Track this dependency
|
|
if (contentElement) {
|
|
setTimeout(checkScrollPosition, 0)
|
|
}
|
|
})
|
|
|
|
// Handle form submission
|
|
async function handleSave() {
|
|
error = null
|
|
saving = true
|
|
|
|
try {
|
|
// Prepare the update data
|
|
const updateData: Parameters<typeof users.update>[1] = {
|
|
picture,
|
|
element,
|
|
gender,
|
|
language,
|
|
theme,
|
|
granblueId: granblueId || undefined,
|
|
showCrewGamertag,
|
|
showGranblueId,
|
|
collectionPrivacy
|
|
}
|
|
|
|
// Call API to update user settings
|
|
const response = await users.update(userId, updateData)
|
|
|
|
// Update the user cookie
|
|
const updatedUser: UserCookie = {
|
|
picture: response.avatar.picture,
|
|
element: response.avatar.element,
|
|
language: response.language,
|
|
gender: response.gender,
|
|
theme: response.theme,
|
|
bahamut,
|
|
granblueId: response.granblueId,
|
|
showCrewGamertag: response.showCrewGamertag,
|
|
showGranblueId: response.showGranblueId,
|
|
collectionPrivacy: response.collectionPrivacy
|
|
}
|
|
|
|
// Make a request to update the cookie server-side
|
|
await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(updatedUser)
|
|
})
|
|
|
|
// If language, theme, or bahamut mode changed, we need a full page reload
|
|
if (originalLanguage !== language || originalTheme !== theme || user.bahamut !== bahamut) {
|
|
await invalidateAll()
|
|
window.location.reload()
|
|
} else {
|
|
// For other changes (element, picture, gender), invalidate to refresh layout data
|
|
await invalidateAll()
|
|
}
|
|
|
|
// Close the modal
|
|
handleClose()
|
|
} catch (err) {
|
|
console.error('Failed to update settings:', err)
|
|
error = 'Failed to update settings. Please try again.'
|
|
} finally {
|
|
saving = false
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
open = false
|
|
onOpenChange?.(false)
|
|
}
|
|
</script>
|
|
|
|
<Dialog bind:open {...onOpenChange ? { onOpenChange } : {}} size="medium" hideClose>
|
|
{#snippet children()}
|
|
<ModalBody noPadding>
|
|
<div class="settings-layout">
|
|
{#if error}
|
|
<div class="error-message">{error}</div>
|
|
{/if}
|
|
|
|
<aside class="settings-sidebar">
|
|
<div class="sidebar-header">
|
|
<h2 class="title">Settings</h2>
|
|
<p class="username">@{username}</p>
|
|
</div>
|
|
<SettingsNav
|
|
bind:value={activeSection}
|
|
{element}
|
|
items={navItems}
|
|
/>
|
|
</aside>
|
|
|
|
<main class="settings-content" bind:this={contentElement} onscroll={checkScrollPosition}>
|
|
{#if isLoading}
|
|
<div class="loading-state">
|
|
<div class="spinner"></div>
|
|
<span>Loading settings...</span>
|
|
</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}
|
|
</main>
|
|
</div>
|
|
</ModalBody>
|
|
|
|
<ModalFooter
|
|
onCancel={handleClose}
|
|
cancelDisabled={saving}
|
|
primaryAction={{
|
|
label: saving ? 'Saving...' : 'Save Changes',
|
|
onclick: handleSave,
|
|
disabled: saving || isLoading
|
|
}}
|
|
showShadow={!isScrolledToBottom}
|
|
/>
|
|
{/snippet}
|
|
</Dialog>
|
|
|
|
<style lang="scss">
|
|
@use '$src/themes/spacing' as spacing;
|
|
@use '$src/themes/colors' as colors;
|
|
@use '$src/themes/typography' as typography;
|
|
@use '$src/themes/layout' as layout;
|
|
|
|
.settings-layout {
|
|
display: flex;
|
|
height: 480px;
|
|
}
|
|
|
|
.error-message {
|
|
background-color: rgba(colors.$error, 0.1);
|
|
border: 1px solid colors.$error;
|
|
border-radius: layout.$card-corner;
|
|
color: colors.$error;
|
|
padding: spacing.$unit-2x;
|
|
margin-bottom: spacing.$unit-2x;
|
|
width: 100%;
|
|
}
|
|
|
|
.settings-sidebar {
|
|
flex-shrink: 0;
|
|
padding: spacing.$unit;
|
|
padding-right: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: spacing.$unit-2x;
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: spacing.$unit spacing.$unit-2x;
|
|
|
|
.title {
|
|
font-size: typography.$font-large;
|
|
font-weight: typography.$medium;
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
}
|
|
|
|
.username {
|
|
font-size: typography.$font-small;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
margin-top: spacing.$unit-half;
|
|
}
|
|
}
|
|
|
|
.settings-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: spacing.$unit-3x;
|
|
}
|
|
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
gap: spacing.$unit-2x;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border-color);
|
|
border-top-color: var(--text-secondary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
:global(fieldset) {
|
|
border: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
</style>
|