add user settings modal and page

This commit is contained in:
Justin Edmund 2025-09-24 22:02:25 -07:00
parent fc958fc444
commit 9bb9bd6320
8 changed files with 1320 additions and 3 deletions

View file

@ -0,0 +1,31 @@
import type { FetchLike } from '../core'
import { put } from '../core'
export interface UserUpdateParams {
picture?: string
element?: string
gender?: number
language?: string
theme?: string
}
export interface UserResponse {
id: string
username: string
avatar: {
picture: string
element: string
}
gender: number
language: string
theme: string
role: number
}
export const users = {
/**
* Update user settings
*/
update: (fetch: FetchLike, userId: string, params: UserUpdateParams) =>
put<UserResponse>(fetch, `/users/${userId}`, { user: params })
}

View file

@ -11,6 +11,7 @@
import { DropdownMenu } from 'bits-ui'
import type { UserCookie } from '$lib/types/UserCookie'
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
import UserSettingsModal from './UserSettingsModal.svelte'
// Props from layout data
const {
@ -83,6 +84,9 @@
isAuth && ($page.url.pathname === meHref || $page.url.pathname === localizeHref(`/${username}`))
)
// Settings modal state
let settingsModalOpen = $state(false)
// Handle logout
async function handleLogout() {
try {
@ -187,8 +191,10 @@
<DropdownMenu.Portal>
<DropdownMenu.Content class="dropdown-content" sideOffset={5}>
<DropdownItem href={settingsHref}>
{m.nav_settings()}
<DropdownItem asChild>
<button onclick={() => (settingsModalOpen = true)}>
{m.nav_settings()}
</button>
</DropdownItem>
{#if role !== null && role >= 7}
<DropdownItem href={databaseHref}>Database</DropdownItem>
@ -218,6 +224,18 @@
/>
</nav>
<!-- Settings Modal -->
{#if isAuth && account && currentUser}
<UserSettingsModal
bind:open={settingsModalOpen}
onOpenChange={(open) => (settingsModalOpen = open)}
{username}
userId={account.userId}
user={currentUser}
role={role ?? 0}
/>
{/if}
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/effects' as effects;
@ -231,7 +249,7 @@
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: spacing.$unit-2x;
padding: spacing.$unit-2x 0;
max-width: var(--main-max-width);
margin: 0 auto;
width: 100%;
@ -296,6 +314,8 @@
align-items: center;
.database-back-section {
min-height: 49px;
ul {
background-color: var(--menu-bg);
border-radius: layout.$full-corner;
@ -346,6 +366,7 @@
flex-direction: row;
padding: spacing.$unit-half;
list-style: none;
min-height: 49px;
a {
border-radius: layout.$full-corner;

View file

@ -0,0 +1,308 @@
<svelte:options runes={true} />
<script lang="ts">
import Dialog from './ui/Dialog.svelte'
import Select from './ui/Select.svelte'
import Switch from './ui/switch/switch.svelte'
import Button from './ui/Button.svelte'
import { pictureData, type Picture } from '$lib/utils/pictureData'
import { users } from '$lib/api/resources/users'
import type { UserCookie } from '$lib/types/UserCookie'
import { setUserCookie } from '$lib/auth/cookies'
import { invalidateAll } from '$app/navigation'
import type { Snippet } from 'svelte'
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()
// Form state
let picture = $state(user.picture)
let gender = $state(user.gender)
let language = $state(user.language)
let theme = $state(user.theme)
let bahamut = $state(user.bahamut ?? false)
let saving = $state(false)
let error = $state<string | null>(null)
// Get current locale from user settings
const locale = $derived(user.language as 'en' | 'ja')
// Prepare options for selects
const pictureOptions = $derived(
pictureData
.sort((a, b) => a.name.en.localeCompare(b.name.en))
.map((p) => ({
value: p.filename,
label: p.name[locale] || p.name.en,
image: `/profile/${p.filename}.png`
}))
)
const genderOptions = [
{ value: 0, label: 'Gran' },
{ value: 1, label: 'Djeeta' }
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' }
]
const themeOptions = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]
// Get current picture data
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
// Handle form submission
async function handleSave(e: Event) {
e.preventDefault()
error = null
saving = true
try {
// Prepare the update data
const updateData = {
picture,
element: currentPicture?.element,
gender,
language,
theme
}
// Call API to update user settings
const response = await users.update(fetch, 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
}
// 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
await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedUser)
})
// If language or theme changed, we need to reload
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) {
await invalidateAll()
window.location.reload()
}
// 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)
}
// Footer snippet for the dialog
const footer: Snippet = {
render: () => ({})
}
</script>
<Dialog bind:open {onOpenChange} title="@{username}" description="Account Settings">
{#snippet children()}
<form onsubmit={handleSave} class="settings-form">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="form-fields">
<!-- Picture Selection with Preview -->
<div class="picture-section">
<div class="current-avatar">
<img
src={`/profile/${picture}.png`}
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{currentPicture?.element}"
/>
</div>
<Select
bind:value={picture}
options={pictureOptions}
label="Avatar"
placeholder="Select an avatar"
fullWidth
/>
</div>
<!-- Gender Selection -->
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
/>
<!-- Language Selection -->
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
/>
<!-- Theme Selection -->
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
/>
<!-- Admin Mode (only for admins) -->
{#if role === 9}
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
</label>
</div>
{/if}
</div>
<div class="form-actions">
<Button variant="outlined" onclick={handleClose} disabled={saving}>Cancel</Button>
<Button type="submit" variant="contained" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
{/snippet}
{#snippet footer()}
<!-- Empty footer, actions are in the form -->
{/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;
@use '$src/themes/effects' as effects;
.settings-form {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.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;
}
.form-fields {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.picture-section {
display: flex;
gap: spacing.$unit-3x;
align-items: center;
.current-avatar {
flex-shrink: 0;
width: 80px;
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 {
label {
display: flex;
align-items: center;
justify-content: space-between;
padding: spacing.$unit-2x;
background-color: var(--input-bg);
border-radius: layout.$card-corner;
span {
font-size: typography.$font-regular;
color: var(--text-primary);
}
}
}
.form-actions {
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
padding-top: spacing.$unit-2x;
border-top: 1px solid var(--border-color);
}
</style>

View file

@ -4,4 +4,5 @@ export interface UserCookie {
language: string
gender: number
theme: string
bahamut?: boolean
}

View file

@ -0,0 +1,547 @@
export interface Picture {
name: {
en: string
ja: string
}
filename: string
element: 'fire' | 'water' | 'earth' | 'wind' | 'light' | 'dark'
}
export const pictureData: Picture[] = [
{
name: {
en: 'Gran 2019',
ja: 'グラン'
},
filename: 'gran_19',
element: 'water'
},
{
name: {
en: 'Djeeta 2019',
ja: 'ジータ'
},
filename: 'djeeta_19',
element: 'fire'
},
{
name: {
en: 'Gran 2020',
ja: 'グラン'
},
filename: 'gran_20',
element: 'water'
},
{
name: {
en: 'Djeeta 2020',
ja: 'ジータ'
},
filename: 'djeeta_20',
element: 'fire'
},
{
name: {
en: 'Gran - Farer of the Skies',
ja: '空駆ける新鋭 グランver'
},
filename: 'gran',
element: 'water'
},
{
name: {
en: 'Djeeta - Farer of the Skies',
ja: '空駆ける新鋭 ジータver'
},
filename: 'djeeta',
element: 'fire'
},
{
name: {
en: 'Vyrn',
ja: 'ビィ'
},
filename: 'vyrn',
element: 'wind'
},
{
name: {
en: 'Lyria',
ja: 'ルリア'
},
filename: 'lyria',
element: 'water'
},
{
name: {
en: 'Katalina',
ja: 'カタリナ'
},
filename: 'katalina',
element: 'water'
},
{
name: {
en: 'Rackam',
ja: 'ラカム'
},
filename: 'rackam',
element: 'fire'
},
{
name: {
en: 'Io',
ja: 'イオ'
},
filename: 'io',
element: 'light'
},
{
name: {
en: 'Eugen',
ja: 'オイゲン'
},
filename: 'eugen',
element: 'earth'
},
{
name: {
en: 'Rosetta',
ja: 'ロゼッタ'
},
filename: 'rosetta',
element: 'wind'
},
{
name: {
en: 'Ferry',
ja: 'フェリ'
},
filename: 'ferry',
element: 'light'
},
{
name: {
en: 'Lecia',
ja: 'リーシャ'
},
filename: 'lecia',
element: 'wind'
},
{
name: {
en: 'Monika',
ja: 'モニカ'
},
filename: 'monika',
element: 'wind'
},
{
name: {
en: 'Sturm',
ja: 'スツルム'
},
filename: 'sturm',
element: 'fire'
},
{
name: {
en: 'Drang',
ja: 'ドランク'
},
filename: 'drang',
element: 'water'
},
{
name: {
en: 'Orchid',
ja: 'オーキス'
},
filename: 'orchid',
element: 'dark'
},
{
name: {
en: 'Black Knight',
ja: '黒騎士'
},
filename: 'black-knight',
element: 'dark'
},
{
name: {
en: 'Noa',
ja: 'ノア'
},
filename: 'noa',
element: 'light'
},
{
name: {
en: 'Rein',
ja: 'ラインハルザ'
},
filename: 'rein',
element: 'earth'
},
{
name: {
en: 'Cain',
ja: 'カイン'
},
filename: 'cain',
element: 'earth'
},
{
name: {
en: 'Pholia',
ja: 'フォリア'
},
filename: 'pholia',
element: 'water'
},
{
name: {
en: 'Alliah',
ja: 'アリアちゃん'
},
filename: 'alliah',
element: 'earth'
},
{
name: {
en: 'Sandalphon',
ja: 'サンダルフォン'
},
filename: 'sandalphon',
element: 'light'
},
{
name: {
en: 'Cagliostro',
ja: 'カリオストロ'
},
filename: 'cagliostro',
element: 'earth'
},
{
name: {
en: 'Clarisse',
ja: 'クラリス'
},
filename: 'clarisse',
element: 'fire'
},
{
name: {
en: 'Vira',
ja: 'ヴィーラ'
},
filename: 'vira',
element: 'earth'
},
{
name: {
en: 'Percival',
ja: 'パーシヴァル'
},
filename: 'percival',
element: 'fire'
},
{
name: {
en: 'Siegfried',
ja: 'ジークフリート'
},
filename: 'siegfried',
element: 'earth'
},
{
name: {
en: 'Lancelot',
ja: 'ランスロット'
},
filename: 'lancelot',
element: 'water'
},
{
name: {
en: 'Vane',
ja: 'ヴェイン'
},
filename: 'vane',
element: 'water'
},
{
name: {
en: 'Yuel',
ja: 'ユエル'
},
filename: 'yuel',
element: 'fire'
},
{
name: {
en: 'Societte',
ja: 'ソシエ'
},
filename: 'societte',
element: 'water'
},
{
name: {
en: 'Anila',
ja: 'アニラ'
},
filename: 'anila',
element: 'fire'
},
{
name: {
en: 'Andira',
ja: 'アンチラ'
},
filename: 'andira',
element: 'wind'
},
{
name: {
en: 'Mahira',
ja: 'マキラ'
},
filename: 'mahira',
element: 'earth'
},
{
name: {
en: 'Vajra',
ja: 'ヴァジラ'
},
filename: 'vajra',
element: 'water'
},
{
name: {
en: 'Kumbhira',
ja: 'クビラ'
},
filename: 'kumbhira',
element: 'light'
},
{
name: {
en: 'Vikala',
ja: 'ビカラ'
},
filename: 'vikala',
element: 'dark'
},
{
name: {
en: 'Catura',
ja: 'シャトラ'
},
filename: 'catura',
element: 'wind'
},
{
name: {
en: 'Seox',
ja: 'シス'
},
filename: 'seox',
element: 'dark'
},
{
name: {
en: 'Seofon',
ja: 'シエテ'
},
filename: 'seofon',
element: 'wind'
},
{
name: {
en: 'Tweyen',
ja: 'ソーン'
},
filename: 'tweyen',
element: 'light'
},
{
name: {
en: 'Threo',
ja: 'サラーサ'
},
filename: 'threo',
element: 'earth'
},
{
name: {
en: 'Feower',
ja: 'カトル'
},
filename: 'feower',
element: 'water'
},
{
name: {
en: 'Fif',
ja: 'フュンフ'
},
filename: 'fif',
element: 'light'
},
{
name: {
en: 'Tien',
ja: 'エッセル'
},
filename: 'tien',
element: 'fire'
},
{
name: {
en: 'Eahta',
ja: 'オクトー'
},
filename: 'eahta',
element: 'earth'
},
{
name: {
en: 'Niyon',
ja: 'ニオ'
},
filename: 'niyon',
element: 'wind'
},
{
name: {
en: 'Uno',
ja: 'ウーノ'
},
filename: 'uno',
element: 'water'
},
{
name: {
en: 'Fraux',
ja: 'フラウ'
},
filename: 'fraux',
element: 'fire'
},
{
name: {
en: 'Nier',
ja: 'ニーア'
},
filename: 'nier',
element: 'dark'
},
{
name: {
en: 'Estarriola',
ja: 'エスタリオラ'
},
filename: 'estarriola',
element: 'wind'
},
{
name: {
en: 'Alanaan',
ja: 'アラナン'
},
filename: 'alanaan',
element: 'fire'
},
{
name: {
en: 'Haaselia',
ja: 'ハーゼリーラ'
},
filename: 'haaselia',
element: 'water'
},
{
name: {
en: 'Maria Theresa',
ja: 'マリア・テレサ'
},
filename: 'maria-theresa',
element: 'water'
},
{
name: {
en: 'Geisenborger',
ja: 'ガイゼンボーガ'
},
filename: 'geisenborger',
element: 'light'
},
{
name: {
en: 'Lobelia',
ja: 'ロベリア'
},
filename: 'lobelia',
element: 'earth'
},
{
name: {
en: 'Caim',
ja: 'カイム'
},
filename: 'caim',
element: 'earth'
},
{
name: {
en: 'Katzelia',
ja: 'カッツェリーラ'
},
filename: 'katzelia',
element: 'wind'
},
{
name: {
en: 'Belial',
ja: 'ベリアル'
},
filename: 'belial',
element: 'dark'
},
{
name: {
en: 'Lucilius',
ja: 'ルシリウス'
},
filename: 'lucilius',
element: 'dark'
},
{
name: {
en: 'Lucifer',
ja: 'ルシファー'
},
filename: 'lucifer',
element: 'light'
},
{
name: {
en: 'Beelzebub',
ja: 'べルゼバブ'
},
filename: 'beelzebub',
element: 'dark'
},
{
name: {
en: 'Avatar',
ja: 'アバター'
},
filename: 'avatar',
element: 'dark'
}
]

View file

@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { setUserCookie } from '$lib/auth/cookies'
import type { UserCookie } from '$lib/types/UserCookie'
export const POST: RequestHandler = async ({ cookies, request }) => {
try {
const userCookie = await request.json() as UserCookie
// Calculate expiry date (60 days from now)
const expires = new Date()
expires.setDate(expires.getDate() + 60)
// Set the user cookie with the updated data
setUserCookie(cookies, userCookie, {
secure: true,
expires
})
return json({ success: true })
} catch (error) {
console.error('Failed to update settings cookie:', error)
return json({ error: 'Failed to update settings' }, { status: 500 })
}
}

View file

@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
import { getAccountFromCookies, getUserFromCookies } from '$lib/auth/cookies'
export const load: PageServerLoad = async ({ cookies, url }) => {
const account = getAccountFromCookies(cookies)
const currentUser = getUserFromCookies(cookies)
// Redirect to login if not authenticated
if (!account || !currentUser) {
throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`)
}
return {
account,
currentUser
}
}

View file

@ -0,0 +1,366 @@
<svelte:options runes={true} />
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import Select from '$lib/components/ui/Select.svelte'
import Switch from '$lib/components/ui/switch/switch.svelte'
import Button from '$lib/components/ui/Button.svelte'
import { pictureData } from '$lib/utils/pictureData'
import { users } from '$lib/api/resources/users'
import type { UserCookie } from '$lib/types/UserCookie'
import { invalidateAll } from '$app/navigation'
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// Check authentication
if (!data.account || !data.currentUser) {
goto('/login')
}
const account = data.account!
const user = data.currentUser!
// Form state
let picture = $state(user.picture)
let gender = $state(user.gender)
let language = $state(user.language)
let theme = $state(user.theme)
let bahamut = $state(user.bahamut ?? false)
let saving = $state(false)
let error = $state<string | null>(null)
let success = $state(false)
// Get current locale from user settings
const locale = $derived(user.language as 'en' | 'ja')
// Prepare options for selects
const pictureOptions = $derived(
pictureData
.sort((a, b) => a.name.en.localeCompare(b.name.en))
.map((p) => ({
value: p.filename,
label: p.name[locale] || p.name.en,
image: `/profile/${p.filename}.png`
}))
)
const genderOptions = [
{ value: 0, label: 'Gran' },
{ value: 1, label: 'Djeeta' }
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' }
]
const themeOptions = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]
// Get current picture data
const currentPicture = $derived(pictureData.find((p) => p.filename === picture))
// Handle form submission
async function handleSave(e: Event) {
e.preventDefault()
error = null
success = false
saving = true
try {
// Prepare the update data
const updateData = {
picture,
element: currentPicture?.element,
gender,
language,
theme
}
// Call API to update user settings
const response = await users.update(fetch, account.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
}
// Make a request to update the cookie server-side
await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedUser)
})
success = true
// If language or theme changed, we need to reload
if (user.language !== language || user.theme !== theme || user.bahamut !== bahamut) {
setTimeout(() => {
invalidateAll()
window.location.reload()
}, 1000)
}
} catch (err) {
console.error('Failed to update settings:', err)
error = 'Failed to update settings. Please try again.'
} finally {
saving = false
}
}
</script>
<div class="settings-page">
<div class="settings-container">
<h1>Account Settings</h1>
<p class="username">@{account.username}</p>
<form onsubmit={handleSave} class="settings-form">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if success}
<div class="success-message">Settings saved successfully!</div>
{/if}
<div class="form-fields">
<!-- Picture Selection with Preview -->
<div class="picture-section">
<label>Avatar</label>
<div class="picture-content">
<div class="current-avatar">
<img
src={`/profile/${picture}.png`}
srcset={`/profile/${picture}.png 1x, /profile/${picture}@2x.png 2x`}
alt={currentPicture?.name[locale] || ''}
class="avatar-preview element-{currentPicture?.element}"
/>
</div>
<Select
bind:value={picture}
options={pictureOptions}
placeholder="Select an avatar"
fullWidth
/>
</div>
</div>
<!-- Gender Selection -->
<div class="form-field">
<Select
bind:value={gender}
options={genderOptions}
label="Gender"
placeholder="Select gender"
fullWidth
/>
</div>
<!-- Language Selection -->
<div class="form-field">
<Select
bind:value={language}
options={languageOptions}
label="Language"
placeholder="Select language"
fullWidth
/>
</div>
<!-- Theme Selection -->
<div class="form-field">
<Select
bind:value={theme}
options={themeOptions}
label="Theme"
placeholder="Select theme"
fullWidth
/>
</div>
<!-- Admin Mode (only for admins) -->
{#if account.role === 9}
<div class="switch-field">
<label for="bahamut-mode">
<span>Admin Mode</span>
<Switch bind:checked={bahamut} name="bahamut-mode" />
</label>
</div>
{/if}
</div>
<div class="form-actions">
<Button
variant="outlined"
href="/me"
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
<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;
@use '$src/themes/effects' as effects;
.settings-page {
padding: spacing.$unit-3x;
max-width: 600px;
margin: 0 auto;
}
.settings-container {
background-color: var(--card-bg);
border: effects.$page-border;
border-radius: layout.$page-corner;
padding: spacing.$unit-4x;
box-shadow: effects.$page-elevation;
h1 {
font-size: typography.$font-xlarge;
color: var(--text-primary);
margin-bottom: spacing.$unit-half;
}
.username {
color: var(--text-secondary);
font-size: typography.$font-regular;
margin-bottom: spacing.$unit-4x;
}
}
.settings-form {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.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;
}
.success-message {
background-color: rgba(colors.$yellow, 0.1);
border: 1px solid colors.$yellow;
border-radius: layout.$card-corner;
color: colors.$yellow;
padding: spacing.$unit-2x;
}
.form-fields {
display: flex;
flex-direction: column;
gap: spacing.$unit-3x;
}
.form-field {
display: flex;
flex-direction: column;
}
.picture-section {
display: flex;
flex-direction: column;
gap: spacing.$unit;
label {
font-size: typography.$font-small;
font-weight: typography.$medium;
color: var(--text-primary);
}
.picture-content {
display: flex;
gap: spacing.$unit-3x;
align-items: center;
.current-avatar {
flex-shrink: 0;
width: 100px;
height: 100px;
.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 {
label {
display: flex;
align-items: center;
justify-content: space-between;
padding: spacing.$unit-2x;
background-color: var(--input-bg);
border-radius: layout.$card-corner;
span {
font-size: typography.$font-regular;
color: var(--text-primary);
}
}
}
.form-actions {
display: flex;
gap: spacing.$unit-2x;
justify-content: flex-end;
padding-top: spacing.$unit-2x;
border-top: 1px solid var(--border-color);
}
</style>