add settings section components
This commit is contained in:
parent
c785d1d0ab
commit
242aa7c0a9
3 changed files with 620 additions and 0 deletions
208
src/lib/components/settings/AccountSettings.svelte
Normal file
208
src/lib/components/settings/AccountSettings.svelte
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import Input from '../ui/Input.svelte'
|
||||
import Switch from '../ui/switch/Switch.svelte'
|
||||
import SettingsRow from '../ui/SettingsRow.svelte'
|
||||
import type { ElementType } from '../ui/SettingsNav.svelte'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
email: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
bahamut: boolean
|
||||
role: number
|
||||
element: ElementType
|
||||
onUsernameChange: (value: string) => void
|
||||
onEmailChange: (value: string) => void
|
||||
onCurrentPasswordChange: (value: string) => void
|
||||
onNewPasswordChange: (value: string) => void
|
||||
onConfirmPasswordChange: (value: string) => void
|
||||
onBahamutChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
let {
|
||||
username,
|
||||
email,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
bahamut,
|
||||
role,
|
||||
element,
|
||||
onUsernameChange,
|
||||
onEmailChange,
|
||||
onCurrentPasswordChange,
|
||||
onNewPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
onBahamutChange
|
||||
}: Props = $props()
|
||||
|
||||
// Local state for inputs
|
||||
let localUsername = $state(username)
|
||||
let localEmail = $state(email)
|
||||
let localCurrentPassword = $state(currentPassword)
|
||||
let localNewPassword = $state(newPassword)
|
||||
let localConfirmPassword = $state(confirmPassword)
|
||||
|
||||
// Sync local state with props when props change
|
||||
$effect(() => {
|
||||
localUsername = username
|
||||
})
|
||||
$effect(() => {
|
||||
localEmail = email
|
||||
})
|
||||
$effect(() => {
|
||||
localCurrentPassword = currentPassword
|
||||
})
|
||||
$effect(() => {
|
||||
localNewPassword = newPassword
|
||||
})
|
||||
$effect(() => {
|
||||
localConfirmPassword = confirmPassword
|
||||
})
|
||||
|
||||
// Propagate changes back to parent
|
||||
function handleUsernameInput() {
|
||||
onUsernameChange(localUsername)
|
||||
}
|
||||
function handleEmailInput() {
|
||||
onEmailChange(localEmail)
|
||||
}
|
||||
function handleCurrentPasswordInput() {
|
||||
onCurrentPasswordChange(localCurrentPassword)
|
||||
}
|
||||
function handleNewPasswordInput() {
|
||||
onNewPasswordChange(localNewPassword)
|
||||
}
|
||||
function handleConfirmPasswordInput() {
|
||||
onConfirmPasswordChange(localConfirmPassword)
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const isAdmin = $derived(role === 9)
|
||||
|
||||
// Check if any sensitive field has been modified
|
||||
const hasSecurityChanges = $derived(localNewPassword !== '' || localConfirmPassword !== '')
|
||||
|
||||
// Password match validation
|
||||
const passwordsMatch = $derived(localNewPassword === '' || localNewPassword === localConfirmPassword)
|
||||
const passwordError = $derived(!passwordsMatch ? 'Passwords do not match' : '')
|
||||
|
||||
// Current password required when changing password
|
||||
const currentPasswordRequired = $derived(hasSecurityChanges && localCurrentPassword === '')
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<div class="form-fields">
|
||||
<!-- Username -->
|
||||
<Input
|
||||
label="Username"
|
||||
placeholder="Enter username"
|
||||
contained
|
||||
fullWidth
|
||||
bind:value={localUsername}
|
||||
handleInput={handleUsernameInput}
|
||||
/>
|
||||
|
||||
<!-- Email -->
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter email"
|
||||
contained
|
||||
fullWidth
|
||||
bind:value={localEmail}
|
||||
handleInput={handleEmailInput}
|
||||
/>
|
||||
|
||||
<hr class="separator" />
|
||||
|
||||
<p class="section-note">
|
||||
To change your password, enter your current password and a new password below.
|
||||
</p>
|
||||
|
||||
<!-- Current Password (required for changes) -->
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
contained
|
||||
fullWidth
|
||||
required={hasSecurityChanges}
|
||||
error={currentPasswordRequired ? 'Required to change password' : ''}
|
||||
bind:value={localCurrentPassword}
|
||||
handleInput={handleCurrentPasswordInput}
|
||||
/>
|
||||
|
||||
<!-- New Password -->
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
contained
|
||||
fullWidth
|
||||
bind:value={localNewPassword}
|
||||
handleInput={handleNewPasswordInput}
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
contained
|
||||
fullWidth
|
||||
error={passwordError}
|
||||
bind:value={localConfirmPassword}
|
||||
handleInput={handleConfirmPasswordInput}
|
||||
/>
|
||||
|
||||
<!-- Bahamut Mode (admin only) -->
|
||||
{#if isAdmin}
|
||||
<hr class="separator" />
|
||||
<SettingsRow title="Bahamut Mode" subtitle="Enable admin features and tools">
|
||||
{#snippet control()}
|
||||
<Switch
|
||||
checked={bahamut}
|
||||
name="bahamut-mode"
|
||||
{element}
|
||||
onCheckedChange={onBahamutChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
118
src/lib/components/settings/PrivacySettings.svelte
Normal file
118
src/lib/components/settings/PrivacySettings.svelte
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import Select from '../ui/Select.svelte'
|
||||
import Switch from '../ui/switch/Switch.svelte'
|
||||
import SettingsRow from '../ui/SettingsRow.svelte'
|
||||
import type { ElementType } from '../ui/SettingsNav.svelte'
|
||||
|
||||
interface Props {
|
||||
showGranblueId: boolean
|
||||
collectionPrivacy: number
|
||||
showCrewGamertag: boolean
|
||||
isInCrew: boolean
|
||||
crewGamertag?: string
|
||||
element: ElementType
|
||||
onShowGranblueIdChange: (value: boolean) => void
|
||||
onCollectionPrivacyChange: (value: number) => void
|
||||
onShowCrewGamertagChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
let {
|
||||
showGranblueId,
|
||||
collectionPrivacy,
|
||||
showCrewGamertag,
|
||||
isInCrew,
|
||||
crewGamertag,
|
||||
element,
|
||||
onShowGranblueIdChange,
|
||||
onCollectionPrivacyChange,
|
||||
onShowCrewGamertagChange
|
||||
}: Props = $props()
|
||||
|
||||
// Collection privacy options (1-based to avoid JavaScript falsy 0 issues)
|
||||
const collectionPrivacyOptions = [
|
||||
{ value: 1, label: 'Everyone', description: 'Anyone can view your collection' },
|
||||
{ value: 2, label: 'Crew only', description: 'Only crew members can view' },
|
||||
{ value: 3, label: 'Private', description: 'Only you can view your collection' }
|
||||
]
|
||||
|
||||
$effect(() => {
|
||||
console.log('[PrivacySettings] collectionPrivacy prop:', collectionPrivacy, typeof collectionPrivacy)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<div class="form-fields">
|
||||
<!-- Show Granblue ID on profile -->
|
||||
<SettingsRow
|
||||
title="Show Granblue ID on profile"
|
||||
subtitle="Display your in-game ID on your public profile"
|
||||
>
|
||||
{#snippet control()}
|
||||
<Switch
|
||||
checked={showGranblueId}
|
||||
name="show-granblue-id"
|
||||
{element}
|
||||
onCheckedChange={onShowGranblueIdChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<!-- Show Crew Gamertag (only if in a crew with a gamertag) -->
|
||||
{#if isInCrew && crewGamertag}
|
||||
<SettingsRow
|
||||
title="Show crew tag on profile"
|
||||
subtitle={`Display "${crewGamertag}" next to your name`}
|
||||
>
|
||||
{#snippet control()}
|
||||
<Switch
|
||||
checked={showCrewGamertag}
|
||||
name="show-crew-gamertag"
|
||||
{element}
|
||||
onCheckedChange={onShowCrewGamertagChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<hr class="separator" />
|
||||
{/if}
|
||||
|
||||
<!-- Collection Privacy -->
|
||||
<SettingsRow
|
||||
title="Collection visibility"
|
||||
subtitle="Control who can view your character, weapon, and summon collection"
|
||||
>
|
||||
{#snippet control()}
|
||||
<Select
|
||||
value={collectionPrivacy}
|
||||
onValueChange={onCollectionPrivacyChange}
|
||||
options={collectionPrivacyOptions}
|
||||
placeholder="Who can see your collection"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
294
src/lib/components/settings/ProfileSettings.svelte
Normal file
294
src/lib/components/settings/ProfileSettings.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import Select from '../ui/Select.svelte'
|
||||
import Input from '../ui/Input.svelte'
|
||||
import SettingsRow from '../ui/SettingsRow.svelte'
|
||||
import { pictureData } from '$lib/utils/pictureData'
|
||||
import { getAvatarSrc, getAvatarSrcSet } from '$lib/utils/avatar'
|
||||
import type { ElementType } from '../ui/SettingsNav.svelte'
|
||||
|
||||
interface Props {
|
||||
picture: string
|
||||
element: ElementType
|
||||
granblueId: string
|
||||
gender: number
|
||||
language: string
|
||||
theme: string
|
||||
onPictureChange: (value: string) => void
|
||||
onElementChange: (value: string) => void
|
||||
onGranblueIdChange: (value: string) => void
|
||||
onGenderChange: (value: number) => void
|
||||
onLanguageChange: (value: string) => void
|
||||
onThemeChange: (value: string) => void
|
||||
}
|
||||
|
||||
let {
|
||||
picture,
|
||||
element,
|
||||
granblueId,
|
||||
gender,
|
||||
language,
|
||||
theme,
|
||||
onPictureChange,
|
||||
onElementChange,
|
||||
onGranblueIdChange,
|
||||
onGenderChange,
|
||||
onLanguageChange,
|
||||
onThemeChange
|
||||
}: Props = $props()
|
||||
|
||||
// Get current locale from user settings
|
||||
const locale = $derived(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: getAvatarSrc(p.filename)
|
||||
}))
|
||||
)
|
||||
|
||||
// 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
|
||||
function getElementCircle(el: string): string {
|
||||
const color = elementColors[el] || '#888'
|
||||
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>`
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const elementOptions = [
|
||||
{ value: 'wind', label: 'Wind', image: getElementCircle('wind') },
|
||||
{ value: 'fire', label: 'Fire', image: getElementCircle('fire') },
|
||||
{ value: 'water', label: 'Water', image: getElementCircle('water') },
|
||||
{ value: 'earth', label: 'Earth', image: getElementCircle('earth') },
|
||||
{ value: 'dark', label: 'Dark', image: getElementCircle('dark') },
|
||||
{ value: 'light', label: 'Light', image: getElementCircle('light') }
|
||||
]
|
||||
|
||||
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))
|
||||
|
||||
// Local state for bound values
|
||||
let localPicture = $state(picture)
|
||||
let localElement = $state(element)
|
||||
let localGranblueId = $state(granblueId)
|
||||
let localGender = $state(gender)
|
||||
let localLanguage = $state(language)
|
||||
let localTheme = $state(theme)
|
||||
|
||||
// Sync local state with props
|
||||
$effect(() => {
|
||||
localPicture = picture
|
||||
})
|
||||
$effect(() => {
|
||||
localElement = element
|
||||
})
|
||||
$effect(() => {
|
||||
localGranblueId = granblueId
|
||||
})
|
||||
$effect(() => {
|
||||
localGender = gender
|
||||
})
|
||||
$effect(() => {
|
||||
localLanguage = language
|
||||
})
|
||||
$effect(() => {
|
||||
localTheme = theme
|
||||
})
|
||||
|
||||
// Propagate changes
|
||||
$effect(() => {
|
||||
if (localPicture !== picture) onPictureChange(localPicture)
|
||||
})
|
||||
$effect(() => {
|
||||
if (localElement !== element) onElementChange(localElement)
|
||||
})
|
||||
$effect(() => {
|
||||
if (localGranblueId !== granblueId) onGranblueIdChange(localGranblueId)
|
||||
})
|
||||
$effect(() => {
|
||||
if (localGender !== gender) onGenderChange(localGender)
|
||||
})
|
||||
$effect(() => {
|
||||
if (localLanguage !== language) onLanguageChange(localLanguage)
|
||||
})
|
||||
$effect(() => {
|
||||
if (localTheme !== theme) onThemeChange(localTheme)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="section">
|
||||
<div class="form-fields">
|
||||
<!-- Picture Selection with Preview -->
|
||||
<div class="picture-section">
|
||||
<div class="current-avatar">
|
||||
<img
|
||||
src={getAvatarSrc(localPicture)}
|
||||
srcset={getAvatarSrcSet(localPicture)}
|
||||
alt={currentPicture?.name[locale] || ''}
|
||||
class="avatar-preview element-{localElement}"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
bind:value={localPicture}
|
||||
options={pictureOptions}
|
||||
label="Avatar"
|
||||
placeholder="Select an avatar"
|
||||
fullWidth
|
||||
contained
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Element Selection -->
|
||||
<SettingsRow title="Element" subtitle="Your profile accent color">
|
||||
{#snippet control()}
|
||||
<Select
|
||||
bind:value={localElement}
|
||||
options={elementOptions}
|
||||
placeholder="Select an element"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<hr class="separator" />
|
||||
|
||||
<!-- Granblue ID -->
|
||||
<SettingsRow title="Granblue ID" subtitle="Your in-game player ID">
|
||||
{#snippet control()}
|
||||
<Input bind:value={localGranblueId} placeholder="Enter ID" contained />
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<hr class="separator" />
|
||||
|
||||
<!-- Gender Selection -->
|
||||
<SettingsRow title="Gender" subtitle="Your in-game character">
|
||||
{#snippet control()}
|
||||
<Select
|
||||
bind:value={localGender}
|
||||
options={genderOptions}
|
||||
placeholder="Select gender"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<SettingsRow title="Language" subtitle="Display language for the site">
|
||||
{#snippet control()}
|
||||
<Select
|
||||
bind:value={localLanguage}
|
||||
options={languageOptions}
|
||||
placeholder="Select language"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<!-- Theme Selection -->
|
||||
<SettingsRow title="Theme" subtitle="Light, dark, or system default">
|
||||
{#snippet control()}
|
||||
<Select
|
||||
bind:value={localTheme}
|
||||
options={themeOptions}
|
||||
placeholder="Select theme"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$src/themes/spacing' as spacing;
|
||||
@use '$src/themes/typography' as typography;
|
||||
@use '$src/themes/colors' as colors;
|
||||
@use '$src/themes/layout' as layout;
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-3x;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue