add party edit components and raid selector
This commit is contained in:
parent
d39079c814
commit
b5cc2b7b9f
8 changed files with 1145 additions and 0 deletions
68
src/lib/api/queries/raid.queries.ts
Normal file
68
src/lib/api/queries/raid.queries.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Raid Query Options Factory
|
||||||
|
*
|
||||||
|
* Provides type-safe, reusable query configurations for raid operations
|
||||||
|
* using TanStack Query v6 patterns.
|
||||||
|
*
|
||||||
|
* @module api/queries/raid
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { queryOptions } from '@tanstack/svelte-query'
|
||||||
|
import { raidAdapter } from '$lib/api/adapters/raid.adapter'
|
||||||
|
import type { RaidFull, RaidGroupFull } from '$lib/types/api/raid'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raid query options factory
|
||||||
|
*
|
||||||
|
* Provides query configurations for all raid-related operations.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
* import { raidQueries } from '$lib/api/queries/raid.queries'
|
||||||
|
*
|
||||||
|
* // All raid groups with their raids
|
||||||
|
* const groups = createQuery(() => raidQueries.groups())
|
||||||
|
*
|
||||||
|
* // Single raid by slug
|
||||||
|
* const raid = createQuery(() => raidQueries.bySlug('proto-bahamut'))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const raidQueries = {
|
||||||
|
/**
|
||||||
|
* All raid groups with their raids
|
||||||
|
*
|
||||||
|
* @returns Query options for fetching all raid groups
|
||||||
|
*/
|
||||||
|
groups: () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['raids', 'groups'] as const,
|
||||||
|
queryFn: () => raidAdapter.getGroups(),
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour - raid data rarely changes
|
||||||
|
gcTime: 1000 * 60 * 60 * 24 // 24 hours
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single raid by slug
|
||||||
|
*
|
||||||
|
* @param slug - Raid slug
|
||||||
|
* @returns Query options for fetching a single raid
|
||||||
|
*/
|
||||||
|
bySlug: (slug: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['raids', slug] as const,
|
||||||
|
queryFn: () => raidAdapter.getBySlug(slug),
|
||||||
|
enabled: !!slug,
|
||||||
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
|
gcTime: 1000 * 60 * 60 * 24 // 24 hours
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key helpers for cache invalidation
|
||||||
|
*/
|
||||||
|
export const raidKeys = {
|
||||||
|
all: ['raids'] as const,
|
||||||
|
groups: () => [...raidKeys.all, 'groups'] as const,
|
||||||
|
detail: (slug: string) => [...raidKeys.all, slug] as const
|
||||||
|
}
|
||||||
98
src/lib/components/party/edit/BattleSettingsSection.svelte
Normal file
98
src/lib/components/party/edit/BattleSettingsSection.svelte
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* BattleSettingsSection - Switch toggles for battle settings
|
||||||
|
*
|
||||||
|
* Displays toggles for Full Auto, Auto Guard, Auto Summon, and Charge Attack.
|
||||||
|
*/
|
||||||
|
import DetailsSection from '$lib/components/sidebar/details/DetailsSection.svelte'
|
||||||
|
import DetailRow from '$lib/components/sidebar/details/DetailRow.svelte'
|
||||||
|
import Switch from '$lib/components/ui/switch/Switch.svelte'
|
||||||
|
|
||||||
|
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fullAuto?: boolean
|
||||||
|
autoGuard?: boolean
|
||||||
|
autoSummon?: boolean
|
||||||
|
chargeAttack?: boolean
|
||||||
|
/** Element for switch color theming */
|
||||||
|
element?: ElementType
|
||||||
|
onchange?: (
|
||||||
|
field: 'fullAuto' | 'autoGuard' | 'autoSummon' | 'chargeAttack',
|
||||||
|
value: boolean
|
||||||
|
) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
fullAuto = $bindable(false),
|
||||||
|
autoGuard = $bindable(false),
|
||||||
|
autoSummon = $bindable(false),
|
||||||
|
chargeAttack = $bindable(true),
|
||||||
|
element,
|
||||||
|
onchange,
|
||||||
|
disabled = false
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
function handleChange(
|
||||||
|
field: 'fullAuto' | 'autoGuard' | 'autoSummon' | 'chargeAttack',
|
||||||
|
value: boolean
|
||||||
|
) {
|
||||||
|
if (field === 'fullAuto') fullAuto = value
|
||||||
|
else if (field === 'autoGuard') autoGuard = value
|
||||||
|
else if (field === 'autoSummon') autoSummon = value
|
||||||
|
else chargeAttack = value
|
||||||
|
|
||||||
|
onchange?.(field, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DetailsSection title="Battle Settings">
|
||||||
|
<DetailRow label="Charge Attack" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Switch
|
||||||
|
checked={chargeAttack}
|
||||||
|
size="small"
|
||||||
|
{element}
|
||||||
|
{disabled}
|
||||||
|
onCheckedChange={(v) => handleChange('chargeAttack', v)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow label="Full Auto" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Switch
|
||||||
|
checked={fullAuto}
|
||||||
|
size="small"
|
||||||
|
{element}
|
||||||
|
{disabled}
|
||||||
|
onCheckedChange={(v) => handleChange('fullAuto', v)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow label="Auto Summon" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Switch
|
||||||
|
checked={autoSummon}
|
||||||
|
size="small"
|
||||||
|
{element}
|
||||||
|
{disabled}
|
||||||
|
onCheckedChange={(v) => handleChange('autoSummon', v)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
|
||||||
|
<DetailRow label="Auto Guard" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<Switch
|
||||||
|
checked={autoGuard}
|
||||||
|
size="small"
|
||||||
|
{element}
|
||||||
|
{disabled}
|
||||||
|
onCheckedChange={(v) => handleChange('autoGuard', v)}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
</DetailsSection>
|
||||||
204
src/lib/components/party/edit/ClearTimeInput.svelte
Normal file
204
src/lib/components/party/edit/ClearTimeInput.svelte
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* ClearTimeInput - Duration input for party clear time (MM:SS format)
|
||||||
|
*
|
||||||
|
* Displays minutes and seconds as separate inputs styled as a unified field.
|
||||||
|
* Value is stored/emitted as total seconds.
|
||||||
|
*/
|
||||||
|
import { untrack } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Clear time in seconds (e.g., 225 = 3:45) */
|
||||||
|
value?: number | null
|
||||||
|
/** Callback when value changes */
|
||||||
|
onchange?: (seconds: number | null) => void
|
||||||
|
/** Whether the input is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Use contained background style */
|
||||||
|
contained?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), onchange, disabled = false, contained = false }: Props = $props()
|
||||||
|
|
||||||
|
// Convert seconds to minutes/seconds
|
||||||
|
function secondsToMinutes(totalSeconds: number | null | undefined): {
|
||||||
|
minutes: number | null
|
||||||
|
seconds: number | null
|
||||||
|
} {
|
||||||
|
if (totalSeconds == null || totalSeconds < 0) {
|
||||||
|
return { minutes: null, seconds: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
minutes: Math.floor(totalSeconds / 60),
|
||||||
|
seconds: totalSeconds % 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert minutes/seconds to total seconds
|
||||||
|
function toTotalSeconds(
|
||||||
|
minutes: number | null | undefined,
|
||||||
|
seconds: number | null | undefined
|
||||||
|
): number | null {
|
||||||
|
if (minutes == null && seconds == null) return null
|
||||||
|
const m = minutes ?? 0
|
||||||
|
const s = seconds ?? 0
|
||||||
|
return m * 60 + s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
let { minutes, seconds } = $state(secondsToMinutes(value))
|
||||||
|
|
||||||
|
// Sync from external value changes only
|
||||||
|
$effect(() => {
|
||||||
|
const parsed = secondsToMinutes(value)
|
||||||
|
untrack(() => {
|
||||||
|
if (parsed.minutes !== minutes || parsed.seconds !== seconds) {
|
||||||
|
minutes = parsed.minutes
|
||||||
|
seconds = parsed.seconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleMinutesChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const val = target.value === '' ? null : parseInt(target.value, 10)
|
||||||
|
minutes = val != null && !isNaN(val) ? Math.max(0, val) : null
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSecondsChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const val = target.value === '' ? null : parseInt(target.value, 10)
|
||||||
|
// Clamp seconds to 0-59
|
||||||
|
seconds = val != null && !isNaN(val) ? Math.min(59, Math.max(0, val)) : null
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange() {
|
||||||
|
const total = toTotalSeconds(minutes, seconds)
|
||||||
|
value = total
|
||||||
|
onchange?.(total)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="clear-time-input" class:disabled class:contained>
|
||||||
|
<div class="segment">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
placeholder="0"
|
||||||
|
value={minutes ?? ''}
|
||||||
|
oninput={handleMinutesChange}
|
||||||
|
{disabled}
|
||||||
|
class="time-input minutes"
|
||||||
|
/>
|
||||||
|
<span class="label">min</span>
|
||||||
|
</div>
|
||||||
|
<span class="separator">:</span>
|
||||||
|
<div class="segment">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
placeholder="00"
|
||||||
|
value={seconds != null ? String(seconds).padStart(2, '0') : ''}
|
||||||
|
oninput={handleSecondsChange}
|
||||||
|
{disabled}
|
||||||
|
class="time-input seconds"
|
||||||
|
/>
|
||||||
|
<span class="label">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.clear-time-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
@include smooth-transition($duration-quick, background-color);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within:not(.disabled) {
|
||||||
|
outline: 2px solid $water-text-20;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.contained {
|
||||||
|
background-color: var(--input-bound-bg);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
width: 48px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-family: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-half 0;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
// Hide number input spinners
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
&::-webkit-outer-spin-button,
|
||||||
|
&::-webkit-inner-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.seconds {
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: $medium;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
src/lib/components/party/edit/MetricField.svelte
Normal file
129
src/lib/components/party/edit/MetricField.svelte
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* MetricField - Single metric input with label suffix (B, C, S)
|
||||||
|
*
|
||||||
|
* A compact number input with a single-letter label inside the container.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Numeric value (null for empty) */
|
||||||
|
value?: number | null
|
||||||
|
/** Single-letter label (e.g., "B", "C", "S") */
|
||||||
|
label: string
|
||||||
|
/** Max allowed value */
|
||||||
|
max?: number
|
||||||
|
/** Whether the input is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Use contained background style */
|
||||||
|
contained?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(null),
|
||||||
|
label,
|
||||||
|
max = 999,
|
||||||
|
disabled = false,
|
||||||
|
contained = false
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.value === '') {
|
||||||
|
value = null
|
||||||
|
} else {
|
||||||
|
const parsed = parseInt(target.value, 10)
|
||||||
|
value = isNaN(parsed) ? null : Math.max(0, Math.min(max, parsed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="metric-field" class:disabled class:contained>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
{max}
|
||||||
|
placeholder="—"
|
||||||
|
value={value ?? ''}
|
||||||
|
oninput={handleInput}
|
||||||
|
{disabled}
|
||||||
|
class="metric-input"
|
||||||
|
/>
|
||||||
|
<span class="metric-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.metric-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
@include smooth-transition($duration-quick, background-color);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within:not(.disabled) {
|
||||||
|
outline: 2px solid $water-text-20;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.contained {
|
||||||
|
background-color: var(--input-bound-bg);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-input {
|
||||||
|
width: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-family: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
padding: $unit-half 0;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
// Hide number input spinners
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
&::-webkit-outer-spin-button,
|
||||||
|
&::-webkit-inner-spin-button {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $bold;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
347
src/lib/components/party/edit/YouTubeUrlInput.svelte
Normal file
347
src/lib/components/party/edit/YouTubeUrlInput.svelte
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* YouTubeUrlInput - URL input with validation and video preview
|
||||||
|
*
|
||||||
|
* Validates YouTube URLs and shows a thumbnail preview when valid.
|
||||||
|
*/
|
||||||
|
import { untrack } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** YouTube URL value */
|
||||||
|
value?: string | null
|
||||||
|
/** Callback when value changes */
|
||||||
|
onchange?: (url: string | null) => void
|
||||||
|
/** Whether the input is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Use contained background style */
|
||||||
|
contained?: boolean
|
||||||
|
/** Optional label */
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), onchange, disabled = false, contained = false, label }: Props = $props()
|
||||||
|
|
||||||
|
// YouTube URL regex - matches youtube.com/watch?v= and youtu.be/ formats
|
||||||
|
const YOUTUBE_REGEX = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/
|
||||||
|
|
||||||
|
function extractVideoId(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null
|
||||||
|
const match = url.match(YOUTUBE_REGEX)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidYouTubeUrl(url: string | null | undefined): boolean {
|
||||||
|
if (!url || url.trim() === '') return true // Empty is valid (optional field)
|
||||||
|
return YOUTUBE_REGEX.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local input state
|
||||||
|
let inputValue = $state(value ?? '')
|
||||||
|
let touched = $state(false)
|
||||||
|
let videoTitle = $state<string | null>(null)
|
||||||
|
let isLoadingMetadata = $state(false)
|
||||||
|
|
||||||
|
// Derived states
|
||||||
|
const videoId = $derived(extractVideoId(inputValue))
|
||||||
|
const isValid = $derived(isValidYouTubeUrl(inputValue))
|
||||||
|
const showError = $derived(touched && !isValid)
|
||||||
|
const showPreview = $derived(videoId != null && isValid)
|
||||||
|
const thumbnailUrl = $derived(videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null)
|
||||||
|
|
||||||
|
// Fetch video metadata when videoId changes
|
||||||
|
$effect(() => {
|
||||||
|
const id = videoId
|
||||||
|
if (!id) {
|
||||||
|
videoTitle = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMetadata = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${id}&format=json`,
|
||||||
|
{ signal: controller.signal }
|
||||||
|
)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.title) {
|
||||||
|
videoTitle = data.title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore fetch errors (aborted, network, etc.)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isLoadingMetadata = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync from external value changes only
|
||||||
|
$effect(() => {
|
||||||
|
const externalValue = value
|
||||||
|
untrack(() => {
|
||||||
|
if (externalValue !== inputValue) {
|
||||||
|
inputValue = externalValue ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
inputValue = target.value
|
||||||
|
// Update bound value immediately if valid (so Save captures it)
|
||||||
|
if (isValidYouTubeUrl(inputValue)) {
|
||||||
|
const newValue = inputValue.trim() || null
|
||||||
|
value = newValue
|
||||||
|
onchange?.(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
touched = true
|
||||||
|
// Final validation on blur - ensure value is synced
|
||||||
|
if (isValid) {
|
||||||
|
const newValue = inputValue.trim() || null
|
||||||
|
value = newValue
|
||||||
|
onchange?.(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
;(e.target as HTMLInputElement).blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInput() {
|
||||||
|
inputValue = ''
|
||||||
|
touched = false
|
||||||
|
value = null
|
||||||
|
onchange?.(null)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="youtube-input-wrapper">
|
||||||
|
{#if label}
|
||||||
|
<label class="input-label">{label}</label>
|
||||||
|
{/if}
|
||||||
|
<div class="input-container" class:disabled class:error={showError} class:contained>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
value={inputValue}
|
||||||
|
oninput={handleInput}
|
||||||
|
onblur={handleBlur}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
{disabled}
|
||||||
|
class="url-input"
|
||||||
|
/>
|
||||||
|
{#if inputValue && !disabled}
|
||||||
|
<button type="button" class="clear-button" onclick={clearInput} aria-label="Clear URL">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showError}
|
||||||
|
<p class="error-message">Please enter a valid YouTube URL</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showPreview && thumbnailUrl}
|
||||||
|
<div class="preview-card">
|
||||||
|
<a href={inputValue} target="_blank" rel="noopener noreferrer" class="preview-link">
|
||||||
|
<img src={thumbnailUrl} alt="Video thumbnail" class="thumbnail" />
|
||||||
|
<div class="play-overlay">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="play-icon">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="video-info">
|
||||||
|
{#if isLoadingMetadata}
|
||||||
|
<span class="video-title loading">Loading...</span>
|
||||||
|
{:else if videoTitle}
|
||||||
|
<span class="video-title">{videoTitle}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.youtube-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
padding: 0;
|
||||||
|
@include smooth-transition($duration-quick, background-color, outline);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within:not(.disabled) {
|
||||||
|
outline: 2px solid $water-text-20;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
outline: 2px solid $fire-text-20;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.contained {
|
||||||
|
background-color: var(--input-bound-bg);
|
||||||
|
|
||||||
|
&:hover:not(.disabled) {
|
||||||
|
background-color: var(--input-bound-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: calc($unit * 1.75) $unit-2x;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: $unit;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: $unit-half;
|
||||||
|
@include smooth-transition($duration-quick, background-color, color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $grey-80;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-small;
|
||||||
|
color: $fire-text-20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background-color: var(--input-bound-bg);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
padding: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-link {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover .play-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
@include smooth-transition($duration-quick, opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: white;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
padding: calc($unit * 1.5) $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
// Truncate long titles to 2 lines
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
161
src/lib/components/raid/RaidGroupItem.svelte
Normal file
161
src/lib/components/raid/RaidGroupItem.svelte
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* RaidGroupItem - Displays a single raid group with header and its raids
|
||||||
|
*
|
||||||
|
* Shows the group name as a header and lists all raids underneath.
|
||||||
|
* Each raid shows an icon, name, level, and selected state.
|
||||||
|
*/
|
||||||
|
import type { RaidGroupFull, RaidFull } from '$lib/types/api/raid'
|
||||||
|
import { getRaidImage } from '$lib/utils/images'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: RaidGroupFull
|
||||||
|
selectedRaidId?: string
|
||||||
|
onSelect: (raid: RaidFull) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { group, selectedRaidId, onSelect }: Props = $props()
|
||||||
|
|
||||||
|
function getRaidName(raid: RaidFull): string {
|
||||||
|
if (typeof raid.name === 'string') return raid.name
|
||||||
|
return raid.name?.en || raid.name?.ja || 'Unknown Raid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupName(group: RaidGroupFull): string {
|
||||||
|
if (typeof group.name === 'string') return group.name
|
||||||
|
return group.name?.en || group.name?.ja || 'Unknown Group'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="raid-group">
|
||||||
|
<div class="group-header">
|
||||||
|
<span class="group-name">{getGroupName(group)}</span>
|
||||||
|
{#if group.extra}
|
||||||
|
<span class="ex-badge">EX</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="raid-list">
|
||||||
|
{#each group.raids as raid (raid.id)}
|
||||||
|
{@const isSelected = raid.id === selectedRaidId}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="raid-item"
|
||||||
|
class:selected={isSelected}
|
||||||
|
onclick={() => onSelect(raid)}
|
||||||
|
>
|
||||||
|
<img src={getRaidImage(raid.slug)} alt="" class="raid-icon" />
|
||||||
|
<div class="raid-info">
|
||||||
|
<span class="raid-name">{getRaidName(raid)}</span>
|
||||||
|
{#if raid.level}
|
||||||
|
<span class="raid-level">Lv. {raid.level}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isSelected}
|
||||||
|
<Icon name="check" size={16} class="check-icon" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
|
||||||
|
.raid-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
background: $grey-90;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex-badge {
|
||||||
|
font-size: $font-tiny;
|
||||||
|
font-weight: $bold;
|
||||||
|
color: $light-text-30;
|
||||||
|
background: rgba($light-bg-10, 0.2);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: $unit $unit-2x $unit $unit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
@include smooth-transition($duration-quick, background-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--button-bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-icon {
|
||||||
|
height: 60px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-name {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-level {
|
||||||
|
font-size: $font-small;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.check-icon) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
src/lib/components/raid/RaidGroupList.svelte
Normal file
95
src/lib/components/raid/RaidGroupList.svelte
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* RaidGroupList - Scrollable list of raid groups
|
||||||
|
*
|
||||||
|
* Filters and displays raid groups with search support.
|
||||||
|
* Each group is rendered using RaidGroupItem.
|
||||||
|
*/
|
||||||
|
import type { RaidGroupFull, RaidFull } from '$lib/types/api/raid'
|
||||||
|
import RaidGroupItem from './RaidGroupItem.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: RaidGroupFull[]
|
||||||
|
selectedRaidId?: string
|
||||||
|
onSelect: (raid: RaidFull) => void
|
||||||
|
searchQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { groups, selectedRaidId, onSelect, searchQuery = '' }: Props = $props()
|
||||||
|
|
||||||
|
function getRaidName(raid: RaidFull): string {
|
||||||
|
if (typeof raid.name === 'string') return raid.name
|
||||||
|
const en = raid.name?.en || ''
|
||||||
|
const ja = raid.name?.ja || ''
|
||||||
|
return `${en} ${ja}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupName(group: RaidGroupFull): string {
|
||||||
|
if (typeof group.name === 'string') return group.name
|
||||||
|
const en = group.name?.en || ''
|
||||||
|
const ja = group.name?.ja || ''
|
||||||
|
return `${en} ${ja}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter groups and their raids based on search query
|
||||||
|
const filteredGroups = $derived(() => {
|
||||||
|
if (!searchQuery.trim()) return groups
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase().trim()
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((group) => {
|
||||||
|
// Check if group name matches
|
||||||
|
const groupMatches = getGroupName(group).includes(query)
|
||||||
|
|
||||||
|
// Filter raids within the group
|
||||||
|
const matchingRaids = group.raids.filter((raid) => getRaidName(raid).includes(query))
|
||||||
|
|
||||||
|
// Include group if it matches or has matching raids
|
||||||
|
if (groupMatches) {
|
||||||
|
return group // Return full group if group name matches
|
||||||
|
} else if (matchingRaids.length > 0) {
|
||||||
|
return { ...group, raids: matchingRaids }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter((group): group is RaidGroupFull => group !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasResults = $derived(filteredGroups().length > 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="raid-group-list">
|
||||||
|
{#if hasResults}
|
||||||
|
{#each filteredGroups() as group (group.id)}
|
||||||
|
<RaidGroupItem {group} {selectedRaidId} {onSelect} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-text">No raids found</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.raid-group-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-4x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
43
src/lib/components/raid/RaidSectionTabs.svelte
Normal file
43
src/lib/components/raid/RaidSectionTabs.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* RaidSectionTabs - Segmented control for raid sections
|
||||||
|
*
|
||||||
|
* Provides tabs for switching between Raids, Events, and Solo sections.
|
||||||
|
*/
|
||||||
|
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
|
||||||
|
import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
|
||||||
|
import { RaidSection, getRaidSectionLabel } from '$lib/utils/raidSection'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: number
|
||||||
|
onValueChange?: (section: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(RaidSection.Raid), onValueChange }: Props = $props()
|
||||||
|
|
||||||
|
// Convert number to string for SegmentedControl
|
||||||
|
let stringValue = $state(String(value))
|
||||||
|
|
||||||
|
// Sync stringValue when external value changes
|
||||||
|
$effect(() => {
|
||||||
|
stringValue = String(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleChange(newValue: string) {
|
||||||
|
const numValue = parseInt(newValue, 10)
|
||||||
|
value = numValue
|
||||||
|
onValueChange?.(numValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ value: RaidSection.Raid, label: getRaidSectionLabel(RaidSection.Raid) },
|
||||||
|
{ value: RaidSection.Event, label: getRaidSectionLabel(RaidSection.Event) },
|
||||||
|
{ value: RaidSection.Solo, label: getRaidSectionLabel(RaidSection.Solo) }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SegmentedControl bind:value={stringValue} onValueChange={handleChange} variant="background" size="small" grow>
|
||||||
|
{#each sections as section (section.value)}
|
||||||
|
<Segment value={String(section.value)}>{section.label}</Segment>
|
||||||
|
{/each}
|
||||||
|
</SegmentedControl>
|
||||||
Loading…
Reference in a new issue