add settings section components

This commit is contained in:
Justin Edmund 2025-12-14 01:23:50 -08:00
parent c785d1d0ab
commit 242aa7c0a9
3 changed files with 620 additions and 0 deletions

View 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>

View 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>

View 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>