add user settings modal and page
This commit is contained in:
parent
fc958fc444
commit
9bb9bd6320
8 changed files with 1320 additions and 3 deletions
31
src/lib/api/resources/users.ts
Normal file
31
src/lib/api/resources/users.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { DropdownMenu } from 'bits-ui'
|
import { DropdownMenu } from 'bits-ui'
|
||||||
import type { UserCookie } from '$lib/types/UserCookie'
|
import type { UserCookie } from '$lib/types/UserCookie'
|
||||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||||
|
import UserSettingsModal from './UserSettingsModal.svelte'
|
||||||
|
|
||||||
// Props from layout data
|
// Props from layout data
|
||||||
const {
|
const {
|
||||||
|
|
@ -83,6 +84,9 @@
|
||||||
isAuth && ($page.url.pathname === meHref || $page.url.pathname === localizeHref(`/${username}`))
|
isAuth && ($page.url.pathname === meHref || $page.url.pathname === localizeHref(`/${username}`))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Settings modal state
|
||||||
|
let settingsModalOpen = $state(false)
|
||||||
|
|
||||||
// Handle logout
|
// Handle logout
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,8 +191,10 @@
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="dropdown-content" sideOffset={5}>
|
<DropdownMenu.Content class="dropdown-content" sideOffset={5}>
|
||||||
<DropdownItem href={settingsHref}>
|
<DropdownItem asChild>
|
||||||
{m.nav_settings()}
|
<button onclick={() => (settingsModalOpen = true)}>
|
||||||
|
{m.nav_settings()}
|
||||||
|
</button>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
{#if role !== null && role >= 7}
|
{#if role !== null && role >= 7}
|
||||||
<DropdownItem href={databaseHref}>Database</DropdownItem>
|
<DropdownItem href={databaseHref}>Database</DropdownItem>
|
||||||
|
|
@ -218,6 +224,18 @@
|
||||||
/>
|
/>
|
||||||
</nav>
|
</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">
|
<style lang="scss">
|
||||||
@use '$src/themes/colors' as colors;
|
@use '$src/themes/colors' as colors;
|
||||||
@use '$src/themes/effects' as effects;
|
@use '$src/themes/effects' as effects;
|
||||||
|
|
@ -231,7 +249,7 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: spacing.$unit-2x;
|
padding: spacing.$unit-2x 0;
|
||||||
max-width: var(--main-max-width);
|
max-width: var(--main-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -296,6 +314,8 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.database-back-section {
|
.database-back-section {
|
||||||
|
min-height: 49px;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
background-color: var(--menu-bg);
|
background-color: var(--menu-bg);
|
||||||
border-radius: layout.$full-corner;
|
border-radius: layout.$full-corner;
|
||||||
|
|
@ -346,6 +366,7 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding: spacing.$unit-half;
|
padding: spacing.$unit-half;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
min-height: 49px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
border-radius: layout.$full-corner;
|
border-radius: layout.$full-corner;
|
||||||
|
|
|
||||||
308
src/lib/components/UserSettingsModal.svelte
Normal file
308
src/lib/components/UserSettingsModal.svelte
Normal 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>
|
||||||
1
src/lib/types/UserCookie.d.ts
vendored
1
src/lib/types/UserCookie.d.ts
vendored
|
|
@ -4,4 +4,5 @@ export interface UserCookie {
|
||||||
language: string
|
language: string
|
||||||
gender: number
|
gender: number
|
||||||
theme: string
|
theme: string
|
||||||
|
bahamut?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
547
src/lib/utils/pictureData.ts
Normal file
547
src/lib/utils/pictureData.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
25
src/routes/api/settings/+server.ts
Normal file
25
src/routes/api/settings/+server.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/routes/settings/+page.server.ts
Normal file
18
src/routes/settings/+page.server.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
366
src/routes/settings/+page.svelte
Normal file
366
src/routes/settings/+page.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue