refactor UserSettingsModal to tabbed layout
This commit is contained in:
parent
9ee90fc6fc
commit
376e915ade
4 changed files with 232 additions and 259 deletions
|
|
@ -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);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue