party info grid + edit sidebar (#446)
adds a bunch of stuff for viewing and editing party metadata ## what's new - **party info grid** - shows raid, settings, performance metrics, video in a nice tile layout - **party edit sidebar** - edit all the party metadata: battle settings, clear time, button/chain/summon counts, video url, raid selection - **raid selector** - browse raids by section (raids/events/solo), search, sort by difficulty - **input clearable prop** - inputs can now have a clear button also some switch component cleanup and misc fixes
This commit is contained in:
parent
3340e3661f
commit
28b5f3de4f
26 changed files with 2554 additions and 182 deletions
|
|
@ -117,13 +117,14 @@ describe('PartyAdapter', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await adapter.update({
|
const result = await adapter.update({
|
||||||
|
id: 'uuid-123',
|
||||||
shortcode: 'ABC123',
|
shortcode: 'ABC123',
|
||||||
name: 'Updated Party'
|
name: 'Updated Party'
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result).toEqual(updatedParty)
|
expect(result).toEqual(updatedParty)
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
'https://api.example.com/parties/ABC123',
|
'https://api.example.com/parties/uuid-123',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export interface CreatePartyParams {
|
||||||
description?: string | undefined
|
description?: string | undefined
|
||||||
visibility?: 'public' | 'private' | 'unlisted' | undefined
|
visibility?: 'public' | 'private' | 'unlisted' | undefined
|
||||||
jobId?: string | undefined
|
jobId?: string | undefined
|
||||||
raidId?: string | undefined
|
raidId?: string | null | undefined
|
||||||
guidebookId?: string | undefined
|
guidebookId?: string | undefined
|
||||||
extras?: Record<string, any> | undefined
|
extras?: Record<string, any> | undefined
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +30,24 @@ export interface CreatePartyParams {
|
||||||
* Parameters for updating a party
|
* Parameters for updating a party
|
||||||
*/
|
*/
|
||||||
export interface UpdatePartyParams extends CreatePartyParams {
|
export interface UpdatePartyParams extends CreatePartyParams {
|
||||||
|
/** Party UUID (required for API update) */
|
||||||
|
id: string
|
||||||
|
/** Party shortcode (for cache invalidation) */
|
||||||
shortcode: string
|
shortcode: string
|
||||||
|
// Battle settings
|
||||||
|
fullAuto?: boolean
|
||||||
|
autoGuard?: boolean
|
||||||
|
autoSummon?: boolean
|
||||||
|
chargeAttack?: boolean
|
||||||
|
// Performance metrics (null to clear)
|
||||||
|
clearTime?: number | null
|
||||||
|
buttonCount?: number | null
|
||||||
|
chainCount?: number | null
|
||||||
|
summonCount?: number | null
|
||||||
|
// Video (null to clear)
|
||||||
|
videoUrl?: string | null
|
||||||
|
// Raid (null to clear)
|
||||||
|
raidId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -119,10 +136,11 @@ export class PartyAdapter extends BaseAdapter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a party
|
* Updates a party
|
||||||
|
* Note: API expects UUID for update, not shortcode
|
||||||
*/
|
*/
|
||||||
async update(params: UpdatePartyParams): Promise<Party> {
|
async update(params: UpdatePartyParams): Promise<Party> {
|
||||||
const { shortcode, ...updateParams } = params
|
const { id, shortcode, ...updateParams } = params
|
||||||
const response = await this.request<{ party: Party }>(`/parties/${shortcode}`, {
|
const response = await this.request<{ party: Party }>(`/parties/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: {
|
||||||
party: updateParams
|
party: updateParams
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,8 @@ const MinimalScalarsSchema = z
|
||||||
buttonCount: z.number().nullish().optional(),
|
buttonCount: z.number().nullish().optional(),
|
||||||
chainCount: z.number().nullish().optional(),
|
chainCount: z.number().nullish().optional(),
|
||||||
turnCount: z.number().nullish().optional(),
|
turnCount: z.number().nullish().optional(),
|
||||||
|
summonCount: z.number().nullish().optional(),
|
||||||
|
videoUrl: z.string().nullish().optional(),
|
||||||
visibility: z.enum(['public', 'private', 'unlisted']).nullish().optional()
|
visibility: z.enum(['public', 'private', 'unlisted']).nullish().optional()
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
|
|
@ -373,6 +375,10 @@ export const PartySchemaRaw = z.object({
|
||||||
button_count: z.number().nullish(),
|
button_count: z.number().nullish(),
|
||||||
turn_count: z.number().nullish(),
|
turn_count: z.number().nullish(),
|
||||||
chain_count: z.number().nullish(),
|
chain_count: z.number().nullish(),
|
||||||
|
summon_count: z.number().nullish(),
|
||||||
|
|
||||||
|
// Video URL
|
||||||
|
video_url: z.string().nullish(),
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
raid_id: z.string().nullish(),
|
raid_id: z.string().nullish(),
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,17 @@
|
||||||
import Icon from '$lib/components/Icon.svelte'
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
|
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
|
||||||
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte'
|
import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte'
|
||||||
|
import {
|
||||||
|
openPartyEditSidebar,
|
||||||
|
type PartyEditValues
|
||||||
|
} from '$lib/features/party/openPartyEditSidebar.svelte'
|
||||||
|
import PartyInfoGrid from '$lib/components/party/info/PartyInfoGrid.svelte'
|
||||||
import { DropdownMenu } from 'bits-ui'
|
import { DropdownMenu } from 'bits-ui'
|
||||||
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
|
import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte'
|
||||||
import JobSection from '$lib/components/job/JobSection.svelte'
|
import JobSection from '$lib/components/job/JobSection.svelte'
|
||||||
import { Gender } from '$lib/utils/jobUtils'
|
import { Gender } from '$lib/utils/jobUtils'
|
||||||
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
|
import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte'
|
||||||
import { partyAdapter } from '$lib/api/adapters/party.adapter'
|
import { partyAdapter, type UpdatePartyParams } from '$lib/api/adapters/party.adapter'
|
||||||
import { extractErrorMessage } from '$lib/utils/errors'
|
import { extractErrorMessage } from '$lib/utils/errors'
|
||||||
import { transformSkillsToArray } from '$lib/utils/jobSkills'
|
import { transformSkillsToArray } from '$lib/utils/jobSkills'
|
||||||
import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers'
|
import { findNextEmptySlot, SLOT_NOT_FOUND } from '$lib/utils/gridHelpers'
|
||||||
|
|
@ -269,6 +274,16 @@
|
||||||
return result.canEdit
|
return result.canEdit
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Element mapping for theming (used for party element which is numeric)
|
||||||
|
const ELEMENT_MAP: Record<number, 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'> = {
|
||||||
|
1: 'wind',
|
||||||
|
2: 'fire',
|
||||||
|
3: 'water',
|
||||||
|
4: 'earth',
|
||||||
|
5: 'dark',
|
||||||
|
6: 'light'
|
||||||
|
}
|
||||||
|
|
||||||
// Derived elements for character image logic
|
// Derived elements for character image logic
|
||||||
const mainWeapon = $derived(
|
const mainWeapon = $derived(
|
||||||
(party?.weapons ?? []).find((w) => w?.mainhand || w?.position === -1)
|
(party?.weapons ?? []).find((w) => w?.mainhand || w?.position === -1)
|
||||||
|
|
@ -276,6 +291,10 @@
|
||||||
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
|
const mainWeaponElement = $derived(mainWeapon?.element ?? mainWeapon?.weapon?.element)
|
||||||
const partyElement = $derived((party as any)?.element)
|
const partyElement = $derived((party as any)?.element)
|
||||||
|
|
||||||
|
// User's element preference (string) - used for UI theming
|
||||||
|
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
const userElement = $derived(party.user?.avatar?.element as ElementType | undefined)
|
||||||
|
|
||||||
// Check if any items in the party are linked to collection (for sync menu option)
|
// Check if any items in the party are linked to collection (for sync menu option)
|
||||||
const hasCollectionLinks = $derived.by(() => {
|
const hasCollectionLinks = $derived.by(() => {
|
||||||
const hasLinkedWeapons = (party?.weapons ?? []).some((w) => w?.collectionWeaponId)
|
const hasLinkedWeapons = (party?.weapons ?? []).some((w) => w?.collectionWeaponId)
|
||||||
|
|
@ -333,7 +352,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Party operations
|
// Party operations
|
||||||
async function updatePartyDetails(updates: Partial<Party>) {
|
async function updatePartyDetails(updates: Omit<UpdatePartyParams, 'id' | 'shortcode'>) {
|
||||||
if (!canEdit()) return
|
if (!canEdit()) return
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
|
@ -341,7 +360,8 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use TanStack Query mutation to update party
|
// Use TanStack Query mutation to update party
|
||||||
await updatePartyMutation.mutateAsync({ shortcode: party.shortcode, ...updates })
|
// Note: API expects UUID (id), shortcode is for cache invalidation
|
||||||
|
await updatePartyMutation.mutateAsync({ id: party.id, shortcode: party.shortcode, ...updates })
|
||||||
// Party will be updated via cache invalidation
|
// Party will be updated via cache invalidation
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message || 'Failed to update party'
|
error = err.message || 'Failed to update party'
|
||||||
|
|
@ -417,6 +437,45 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSettingsPanel() {
|
||||||
|
if (!canEdit()) return
|
||||||
|
|
||||||
|
const initialValues: PartyEditValues = {
|
||||||
|
name: party.name ?? '',
|
||||||
|
fullAuto: party.fullAuto ?? false,
|
||||||
|
autoGuard: party.autoGuard ?? false,
|
||||||
|
autoSummon: party.autoSummon ?? false,
|
||||||
|
chargeAttack: party.chargeAttack ?? true,
|
||||||
|
clearTime: party.clearTime ?? null,
|
||||||
|
buttonCount: party.buttonCount ?? null,
|
||||||
|
chainCount: party.chainCount ?? null,
|
||||||
|
summonCount: party.summonCount ?? null,
|
||||||
|
videoUrl: party.videoUrl ?? null,
|
||||||
|
raid: party.raid ?? null,
|
||||||
|
raidId: party.raid?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
openPartyEditSidebar({
|
||||||
|
initialValues,
|
||||||
|
element: userElement,
|
||||||
|
onSave: async (values) => {
|
||||||
|
await updatePartyDetails({
|
||||||
|
name: values.name,
|
||||||
|
fullAuto: values.fullAuto,
|
||||||
|
autoGuard: values.autoGuard,
|
||||||
|
autoSummon: values.autoSummon,
|
||||||
|
chargeAttack: values.chargeAttack,
|
||||||
|
clearTime: values.clearTime,
|
||||||
|
buttonCount: values.buttonCount,
|
||||||
|
chainCount: values.chainCount,
|
||||||
|
summonCount: values.summonCount,
|
||||||
|
videoUrl: values.videoUrl,
|
||||||
|
raidId: values.raidId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteParty() {
|
async function deleteParty() {
|
||||||
// Only allow deletion if user owns the party
|
// Only allow deletion if user owns the party
|
||||||
if (party.user?.id !== authUserId) return
|
if (party.user?.id !== authUserId) return
|
||||||
|
|
@ -888,7 +947,7 @@
|
||||||
<DropdownMenu.Content class="dropdown-content" sideOffset={6} align="end">
|
<DropdownMenu.Content class="dropdown-content" sideOffset={6} align="end">
|
||||||
{#if canEdit()}
|
{#if canEdit()}
|
||||||
<DropdownItem>
|
<DropdownItem>
|
||||||
<button onclick={openEditDialog} disabled={loading}>Edit</button>
|
<button onclick={openSettingsPanel} disabled={loading}>Edit</button>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
{#if hasCollectionLinks}
|
{#if hasCollectionLinks}
|
||||||
<DropdownItem>
|
<DropdownItem>
|
||||||
|
|
@ -926,41 +985,12 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if party.description || party.raid}
|
<PartyInfoGrid
|
||||||
<div class="cards">
|
{party}
|
||||||
{#if party.description}
|
canEdit={canEdit()}
|
||||||
<div
|
onOpenDescription={openDescriptionPanel}
|
||||||
class="description-card clickable"
|
onOpenEdit={openSettingsPanel}
|
||||||
onclick={openDescriptionPanel}
|
/>
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && openDescriptionPanel()}
|
|
||||||
aria-label="View full description"
|
|
||||||
>
|
|
||||||
<h2 class="card-label">Description</h2>
|
|
||||||
<div class="card-content">
|
|
||||||
<DescriptionRenderer content={party.description} truncate={true} maxLines={4} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if party.raid}
|
|
||||||
<div class="raid-card">
|
|
||||||
<h2 class="card-label">Raid</h2>
|
|
||||||
<div class="raid-content">
|
|
||||||
<span class="raid-name">
|
|
||||||
{typeof party.raid.name === 'string'
|
|
||||||
? party.raid.name
|
|
||||||
: party.raid.name?.en || party.raid.name?.ja || 'Unknown Raid'}
|
|
||||||
</span>
|
|
||||||
{#if party.raid.group}
|
|
||||||
<span class="raid-difficulty">Difficulty: {party.raid.group.difficulty}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<PartySegmentedControl
|
<PartySegmentedControl
|
||||||
selectedTab={activeTab}
|
selectedTab={activeTab}
|
||||||
|
|
@ -1230,99 +1260,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards container
|
|
||||||
.cards {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
|
|
||||||
// Individual card styles
|
|
||||||
.description-card,
|
|
||||||
.raid-card {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0; // Allow flexbox to shrink items
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 0.5px solid var(--button-bg);
|
|
||||||
border-radius: $card-corner;
|
|
||||||
padding: $unit-2x;
|
|
||||||
// box-shadow: $card-elevation;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
.card-label {
|
|
||||||
margin: 0 0 $unit 0;
|
|
||||||
font-size: $font-small;
|
|
||||||
font-weight: $bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-text {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-regular;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
// Text truncation after 3 lines
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-hint {
|
|
||||||
display: none;
|
|
||||||
margin-top: $unit;
|
|
||||||
font-size: $font-small;
|
|
||||||
color: var(--accent-blue);
|
|
||||||
font-weight: $medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
@include smooth-transition($duration-quick, box-shadow);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: $card-elevation-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Specific styling for raid card
|
|
||||||
.raid-card {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 250px;
|
|
||||||
|
|
||||||
.raid-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-half;
|
|
||||||
}
|
|
||||||
|
|
||||||
.raid-name {
|
|
||||||
font-weight: $bold;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: $font-regular;
|
|
||||||
}
|
|
||||||
|
|
||||||
.raid-difficulty {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: $font-small;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description card takes up more space
|
|
||||||
.description-card {
|
|
||||||
flex: 2;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
padding: $unit-three-quarter;
|
padding: $unit-three-quarter;
|
||||||
background: rgba(209, 58, 58, 0.1); // Using raw value since CSS variables don't work in rgba()
|
background: rgba(209, 58, 58, 0.1); // Using raw value since CSS variables don't work in rgba()
|
||||||
|
|
|
||||||
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>
|
||||||
29
src/lib/components/party/info/DescriptionTile.svelte
Normal file
29
src/lib/components/party/info/DescriptionTile.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import InfoTile from './InfoTile.svelte'
|
||||||
|
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description?: string
|
||||||
|
onOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { description, onOpen }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InfoTile label="Description" clickable onclick={onOpen} class="description-tile">
|
||||||
|
{#if description}
|
||||||
|
<DescriptionRenderer content={description} truncate={true} maxLines={4} />
|
||||||
|
{:else}
|
||||||
|
<span class="empty-state">No description</span>
|
||||||
|
{/if}
|
||||||
|
</InfoTile>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/lib/components/party/info/InfoTile.svelte
Normal file
73
src/lib/components/party/info/InfoTile.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string
|
||||||
|
clickable?: boolean
|
||||||
|
onclick?: () => void
|
||||||
|
class?: string
|
||||||
|
children: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { label, clickable = false, onclick, class: className = '', children }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="info-tile {className}"
|
||||||
|
class:clickable
|
||||||
|
role={clickable ? 'button' : undefined}
|
||||||
|
tabindex={clickable ? 0 : undefined}
|
||||||
|
onclick={clickable ? onclick : undefined}
|
||||||
|
onkeydown={clickable ? (e) => e.key === 'Enter' && onclick?.() : undefined}
|
||||||
|
>
|
||||||
|
{#if label}
|
||||||
|
<h3 class="tile-label">{label}</h3>
|
||||||
|
{/if}
|
||||||
|
<div class="tile-content">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.info-tile {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 0.5px solid var(--button-bg);
|
||||||
|
border-radius: $card-corner;
|
||||||
|
padding: $unit-2x;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
@include smooth-transition($duration-quick, box-shadow, transform);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: $card-elevation-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-label {
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
140
src/lib/components/party/info/PartyInfoGrid.svelte
Normal file
140
src/lib/components/party/info/PartyInfoGrid.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Party } from '$lib/types/api/party'
|
||||||
|
import DescriptionTile from './DescriptionTile.svelte'
|
||||||
|
import RaidTile from './RaidTile.svelte'
|
||||||
|
import PerformanceTile from './PerformanceTile.svelte'
|
||||||
|
import SettingsTile from './SettingsTile.svelte'
|
||||||
|
import VideoTile from './VideoTile.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
party: Party
|
||||||
|
canEdit: boolean
|
||||||
|
onOpenDescription: () => void
|
||||||
|
onOpenEdit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { party, canEdit, onOpenDescription, onOpenEdit }: Props = $props()
|
||||||
|
|
||||||
|
// Check if data exists for each tile
|
||||||
|
const hasDescription = $derived(!!party.description)
|
||||||
|
const hasRaid = $derived(!!party.raid)
|
||||||
|
const hasPerformanceData = $derived(
|
||||||
|
party.clearTime != null ||
|
||||||
|
party.buttonCount != null ||
|
||||||
|
party.chainCount != null ||
|
||||||
|
party.summonCount != null
|
||||||
|
)
|
||||||
|
const hasVideo = $derived(!!party.videoUrl)
|
||||||
|
|
||||||
|
// Show tile if: has data OR (is owner's team and can prompt to fill)
|
||||||
|
const showDescription = $derived(hasDescription || canEdit)
|
||||||
|
const showRaid = $derived(hasRaid || canEdit)
|
||||||
|
const showPerformance = $derived(hasPerformanceData || canEdit)
|
||||||
|
const showVideo = $derived(hasVideo || canEdit)
|
||||||
|
// Settings always shown - they have default values
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="party-info-grid">
|
||||||
|
<!-- Row 1: Description + Raid -->
|
||||||
|
<div class="row row-1">
|
||||||
|
{#if showDescription}
|
||||||
|
<DescriptionTile description={party.description} onOpen={onOpenDescription} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRaid}
|
||||||
|
<RaidTile raid={party.raid} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Performance, Settings, Video -->
|
||||||
|
<div class="row row-2">
|
||||||
|
{#if showPerformance}
|
||||||
|
<PerformanceTile
|
||||||
|
clearTime={party.clearTime}
|
||||||
|
buttonCount={party.buttonCount}
|
||||||
|
chainCount={party.chainCount}
|
||||||
|
summonCount={party.summonCount}
|
||||||
|
clickable={canEdit}
|
||||||
|
onclick={onOpenEdit}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SettingsTile
|
||||||
|
fullAuto={party.fullAuto}
|
||||||
|
autoGuard={party.autoGuard}
|
||||||
|
autoSummon={party.autoSummon}
|
||||||
|
chargeAttack={party.chargeAttack}
|
||||||
|
clickable={canEdit}
|
||||||
|
onclick={onOpenEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showVideo}
|
||||||
|
<VideoTile videoUrl={party.videoUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
|
||||||
|
.party-info-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-1 {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
|
||||||
|
// If only one item, let it take full width
|
||||||
|
&:has(> :only-child) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|
||||||
|
// Adjust columns based on number of children
|
||||||
|
&:has(> :nth-child(2):last-child) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(> :only-child) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tablet breakpoint
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.row-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-2 {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
&:has(> :nth-child(3)) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|
||||||
|
// Third item spans full width
|
||||||
|
> :nth-child(3) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile breakpoint
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.row-1,
|
||||||
|
.row-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
89
src/lib/components/party/info/PerformanceTile.svelte
Normal file
89
src/lib/components/party/info/PerformanceTile.svelte
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import InfoTile from './InfoTile.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clearTime?: number
|
||||||
|
buttonCount?: number
|
||||||
|
chainCount?: number
|
||||||
|
summonCount?: number
|
||||||
|
clickable?: boolean
|
||||||
|
onclick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { clearTime, buttonCount, chainCount, summonCount, clickable = false, onclick }: Props =
|
||||||
|
$props()
|
||||||
|
|
||||||
|
function formatClearTime(seconds?: number): string {
|
||||||
|
if (seconds == null || seconds <= 0) return '—'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(count?: number): string {
|
||||||
|
if (count == null) return '—'
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const bcsDisplay = $derived(() => {
|
||||||
|
const b = formatCount(buttonCount)
|
||||||
|
const c = formatCount(chainCount)
|
||||||
|
const s = formatCount(summonCount)
|
||||||
|
return `${b}B ${c}C ${s}S`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InfoTile label="Performance" class="performance-tile" {clickable} {onclick}>
|
||||||
|
<div class="performance-content">
|
||||||
|
<div class="clear-time">
|
||||||
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span class="value">{formatClearTime(clearTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bcs-counts">
|
||||||
|
<span class="bcs-value">{bcsDisplay()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InfoTile>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.performance-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-time {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: $font-large;
|
||||||
|
font-weight: $bold;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bcs-counts {
|
||||||
|
.bcs-value {
|
||||||
|
font-size: $font-medium;
|
||||||
|
font-weight: $medium;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
src/lib/components/party/info/RaidTile.svelte
Normal file
57
src/lib/components/party/info/RaidTile.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import InfoTile from './InfoTile.svelte'
|
||||||
|
import type { Raid } from '$lib/types/api/entities'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
raid?: Raid
|
||||||
|
}
|
||||||
|
|
||||||
|
let { raid }: Props = $props()
|
||||||
|
|
||||||
|
const raidName = $derived(() => {
|
||||||
|
if (!raid) return null
|
||||||
|
if (typeof raid.name === 'string') return raid.name
|
||||||
|
return raid.name?.en || raid.name?.ja || 'Unknown Raid'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InfoTile label="Raid" class="raid-tile">
|
||||||
|
{#if raid}
|
||||||
|
<div class="raid-info">
|
||||||
|
<span class="raid-name">{raidName()}</span>
|
||||||
|
{#if raid.group?.difficulty}
|
||||||
|
<span class="raid-difficulty">Lv. {raid.group.difficulty}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="empty-state">No raid selected</span>
|
||||||
|
{/if}
|
||||||
|
</InfoTile>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.raid-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-name {
|
||||||
|
font-size: $font-medium;
|
||||||
|
font-weight: $bold;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-difficulty {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
src/lib/components/party/info/SettingsTile.svelte
Normal file
83
src/lib/components/party/info/SettingsTile.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import InfoTile from './InfoTile.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fullAuto?: boolean
|
||||||
|
autoGuard?: boolean
|
||||||
|
autoSummon?: boolean
|
||||||
|
chargeAttack?: boolean
|
||||||
|
clickable?: boolean
|
||||||
|
onclick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { fullAuto, autoGuard, autoSummon, chargeAttack, clickable = false, onclick }: Props =
|
||||||
|
$props()
|
||||||
|
|
||||||
|
interface Setting {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings: Setting[] = $derived([
|
||||||
|
{ key: 'chargeAttack', label: `Charge Attack ${chargeAttack ?? true ? 'On' : 'Off'}`, active: chargeAttack ?? true },
|
||||||
|
{ key: 'fullAuto', label: `Full Auto ${fullAuto ? 'On' : 'Off'}`, active: fullAuto ?? false },
|
||||||
|
{ key: 'autoSummon', label: `Auto Summon ${autoSummon ? 'On' : 'Off'}`, active: autoSummon ?? false },
|
||||||
|
{ key: 'autoGuard', label: `Auto Guard ${autoGuard ? 'On' : 'Off'}`, active: autoGuard ?? false }
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InfoTile label="Settings" class="settings-tile" {clickable} {onclick}>
|
||||||
|
<div class="settings-tokens">
|
||||||
|
{#each settings as setting (setting.key)}
|
||||||
|
<span class="token {setting.key}" class:on={setting.active} class:off={!setting.active}>
|
||||||
|
{setting.label}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</InfoTile>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.settings-tokens {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $unit-three-quarter $unit-2x;
|
||||||
|
border-radius: $full-corner;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $bold;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--input-bg);
|
||||||
|
|
||||||
|
&.off {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.chargeAttack.on {
|
||||||
|
background: var(--charge-attack-bg);
|
||||||
|
color: var(--charge-attack-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fullAuto.on,
|
||||||
|
&.autoSummon.on {
|
||||||
|
background: var(--full-auto-bg);
|
||||||
|
color: var(--full-auto-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.autoGuard.on {
|
||||||
|
background: var(--auto-guard-bg);
|
||||||
|
color: var(--auto-guard-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
src/lib/components/party/info/VideoTile.svelte
Normal file
198
src/lib/components/party/info/VideoTile.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import InfoTile from './InfoTile.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
videoUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { videoUrl }: Props = $props()
|
||||||
|
|
||||||
|
// State for video playback
|
||||||
|
let isPlaying = $state(false)
|
||||||
|
let videoTitle = $state<string | null>(null)
|
||||||
|
|
||||||
|
function extractYoutubeId(url: string): string | null {
|
||||||
|
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = $derived(videoUrl ? extractYoutubeId(videoUrl) : null)
|
||||||
|
const thumbnailUrl = $derived(
|
||||||
|
videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null
|
||||||
|
)
|
||||||
|
const embedUrl = $derived(
|
||||||
|
videoId ? `https://www.youtube.com/embed/${videoId}?autoplay=1` : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch video title when videoId changes
|
||||||
|
$effect(() => {
|
||||||
|
const id = videoId
|
||||||
|
if (!id) {
|
||||||
|
videoTitle = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset playing state when videoUrl changes
|
||||||
|
$effect(() => {
|
||||||
|
videoUrl
|
||||||
|
isPlaying = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePlay() {
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InfoTile label="Video" class="video-tile">
|
||||||
|
{#if videoUrl && videoId}
|
||||||
|
<div class="video-container">
|
||||||
|
{#if isPlaying && embedUrl}
|
||||||
|
<div class="embed-container">
|
||||||
|
<iframe
|
||||||
|
src={embedUrl}
|
||||||
|
title={videoTitle ?? 'YouTube video'}
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
{:else if thumbnailUrl}
|
||||||
|
<button type="button" class="thumbnail-button" onclick={handlePlay}>
|
||||||
|
<div class="thumbnail-container">
|
||||||
|
<img src={thumbnailUrl} alt={videoTitle ?? 'Video thumbnail'} class="thumbnail" />
|
||||||
|
<div class="play-overlay">
|
||||||
|
<svg viewBox="0 0 68 48" class="play-icon">
|
||||||
|
<path
|
||||||
|
d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
|
||||||
|
fill="#f00"
|
||||||
|
/>
|
||||||
|
<path d="M45 24L27 14v20" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if videoTitle}
|
||||||
|
<p class="video-title">{videoTitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="empty-state">No video</span>
|
||||||
|
{/if}
|
||||||
|
</InfoTile>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.1);
|
||||||
|
@include smooth-transition($duration-quick, background);
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
width: 68px;
|
||||||
|
height: 48px;
|
||||||
|
opacity: 0.9;
|
||||||
|
@include smooth-transition($duration-quick, opacity, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-button:hover .play-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.play-icon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
// Truncate to 2 lines
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-size: $font-regular;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
153
src/lib/components/sidebar/EditRaidPane.svelte
Normal file
153
src/lib/components/sidebar/EditRaidPane.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* EditRaidPane - Raid selection pane for the sidebar
|
||||||
|
*
|
||||||
|
* Allows users to browse and select a raid from organized groups.
|
||||||
|
* Features section tabs (Raids/Events/Solo), search, and sort toggle.
|
||||||
|
*/
|
||||||
|
import type { RaidFull } from '$lib/types/api/raid'
|
||||||
|
import type { Raid } from '$lib/types/api/entities'
|
||||||
|
import { createQuery } from '@tanstack/svelte-query'
|
||||||
|
import { raidQueries } from '$lib/api/queries/raid.queries'
|
||||||
|
import { RaidSection } from '$lib/utils/raidSection'
|
||||||
|
import RaidSectionTabs from '$lib/components/raid/RaidSectionTabs.svelte'
|
||||||
|
import RaidGroupList from '$lib/components/raid/RaidGroupList.svelte'
|
||||||
|
import Button from '$lib/components/ui/Button.svelte'
|
||||||
|
import Input from '$lib/components/ui/Input.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Currently selected raid */
|
||||||
|
currentRaid?: Raid | null
|
||||||
|
/** Callback when a raid is selected */
|
||||||
|
onSelect: (raid: RaidFull | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentRaid, onSelect }: Props = $props()
|
||||||
|
|
||||||
|
// State
|
||||||
|
let searchQuery = $state('')
|
||||||
|
let selectedSection = $state(RaidSection.Raid)
|
||||||
|
let sortAscending = $state(false)
|
||||||
|
|
||||||
|
// Fetch all raid groups
|
||||||
|
const groupsQuery = createQuery(() => raidQueries.groups())
|
||||||
|
|
||||||
|
// Filter groups by section and sort
|
||||||
|
const filteredGroups = $derived(() => {
|
||||||
|
if (!groupsQuery.data) return []
|
||||||
|
|
||||||
|
// Filter by section
|
||||||
|
let groups = groupsQuery.data.filter((group) => {
|
||||||
|
const groupSection =
|
||||||
|
typeof group.section === 'string' ? parseInt(group.section, 10) : group.section
|
||||||
|
return groupSection === selectedSection
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by difficulty
|
||||||
|
groups = [...groups].sort((a, b) => {
|
||||||
|
const diff = a.difficulty - b.difficulty
|
||||||
|
return sortAscending ? diff : -diff
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleRaidSelect(raid: RaidFull) {
|
||||||
|
// Toggle: clicking selected raid unselects it
|
||||||
|
if (raid.id === currentRaid?.id) {
|
||||||
|
onSelect(null)
|
||||||
|
} else {
|
||||||
|
onSelect(raid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort() {
|
||||||
|
sortAscending = !sortAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = $derived(groupsQuery.isLoading)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="edit-raid-pane">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="search-section">
|
||||||
|
<Input
|
||||||
|
leftIcon="search"
|
||||||
|
placeholder="Search raids..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
contained
|
||||||
|
clearable
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls: Tabs + Sort -->
|
||||||
|
<div class="controls-section">
|
||||||
|
<RaidSectionTabs bind:value={selectedSection} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
iconOnly
|
||||||
|
icon={sortAscending ? 'arrow-up' : 'arrow-down'}
|
||||||
|
onclick={toggleSort}
|
||||||
|
title="Toggle sort order"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raid List -->
|
||||||
|
<div class="raid-list-container">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<span>Loading raids...</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<RaidGroupList
|
||||||
|
groups={filteredGroups()}
|
||||||
|
selectedRaidId={currentRaid?.id}
|
||||||
|
onSelect={handleRaidSelect}
|
||||||
|
{searchQuery}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</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 *;
|
||||||
|
|
||||||
|
.edit-raid-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
padding: $unit-2x $unit-2x $unit $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit $unit-2x $unit-2x $unit-2x;
|
||||||
|
border-bottom: 1px solid var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-list-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-4x;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
342
src/lib/components/sidebar/PartyEditSidebar.svelte
Normal file
342
src/lib/components/sidebar/PartyEditSidebar.svelte
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* PartyEditSidebar - Edit party metadata (settings, performance, video)
|
||||||
|
*
|
||||||
|
* This sidebar is opened via openPartyEditSidebar() and provides
|
||||||
|
* editing for battle settings, clear time, metrics, video URL, and raid.
|
||||||
|
*/
|
||||||
|
import DetailsSection from './details/DetailsSection.svelte'
|
||||||
|
import DetailRow from './details/DetailRow.svelte'
|
||||||
|
import Input from '$lib/components/ui/Input.svelte'
|
||||||
|
import BattleSettingsSection from '$lib/components/party/edit/BattleSettingsSection.svelte'
|
||||||
|
import ClearTimeInput from '$lib/components/party/edit/ClearTimeInput.svelte'
|
||||||
|
import YouTubeUrlInput from '$lib/components/party/edit/YouTubeUrlInput.svelte'
|
||||||
|
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
||||||
|
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.svelte'
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
||||||
|
import { untrack } from 'svelte'
|
||||||
|
import type { Raid } from '$lib/types/api/entities'
|
||||||
|
import type { RaidFull } from '$lib/types/api/raid'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
|
export interface PartyEditValues {
|
||||||
|
name: string
|
||||||
|
fullAuto: boolean
|
||||||
|
autoGuard: boolean
|
||||||
|
autoSummon: boolean
|
||||||
|
chargeAttack: boolean
|
||||||
|
clearTime: number | null
|
||||||
|
buttonCount: number | null
|
||||||
|
chainCount: number | null
|
||||||
|
summonCount: number | null
|
||||||
|
videoUrl: string | null
|
||||||
|
raid: Raid | null
|
||||||
|
raidId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Current party values */
|
||||||
|
initialValues: PartyEditValues
|
||||||
|
/** Party element for switch theming */
|
||||||
|
element?: ElementType
|
||||||
|
/** Callback when save is requested */
|
||||||
|
onSave?: (values: PartyEditValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { initialValues, element, onSave }: Props = $props()
|
||||||
|
|
||||||
|
// Get the pane stack for pushing EditRaidPane
|
||||||
|
const paneStack = usePaneStack()
|
||||||
|
|
||||||
|
// Local state - initialized from initialValues
|
||||||
|
let name = $state(initialValues.name)
|
||||||
|
let fullAuto = $state(initialValues.fullAuto)
|
||||||
|
let autoGuard = $state(initialValues.autoGuard)
|
||||||
|
let autoSummon = $state(initialValues.autoSummon)
|
||||||
|
let chargeAttack = $state(initialValues.chargeAttack)
|
||||||
|
let clearTime = $state(initialValues.clearTime)
|
||||||
|
let buttonCount = $state(initialValues.buttonCount)
|
||||||
|
let chainCount = $state(initialValues.chainCount)
|
||||||
|
let summonCount = $state(initialValues.summonCount)
|
||||||
|
let videoUrl = $state(initialValues.videoUrl)
|
||||||
|
let raid = $state<Raid | null>(initialValues.raid)
|
||||||
|
let raidId = $state<string | null>(initialValues.raidId)
|
||||||
|
|
||||||
|
// Check if any values have changed
|
||||||
|
const hasChanges = $derived(
|
||||||
|
name !== initialValues.name ||
|
||||||
|
fullAuto !== initialValues.fullAuto ||
|
||||||
|
autoGuard !== initialValues.autoGuard ||
|
||||||
|
autoSummon !== initialValues.autoSummon ||
|
||||||
|
chargeAttack !== initialValues.chargeAttack ||
|
||||||
|
clearTime !== initialValues.clearTime ||
|
||||||
|
buttonCount !== initialValues.buttonCount ||
|
||||||
|
chainCount !== initialValues.chainCount ||
|
||||||
|
summonCount !== initialValues.summonCount ||
|
||||||
|
videoUrl !== initialValues.videoUrl ||
|
||||||
|
raidId !== initialValues.raidId
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expose save function for sidebar action button
|
||||||
|
export function save() {
|
||||||
|
const values: PartyEditValues = {
|
||||||
|
name,
|
||||||
|
fullAuto,
|
||||||
|
autoGuard,
|
||||||
|
autoSummon,
|
||||||
|
chargeAttack,
|
||||||
|
clearTime,
|
||||||
|
buttonCount,
|
||||||
|
chainCount,
|
||||||
|
summonCount,
|
||||||
|
videoUrl,
|
||||||
|
raid,
|
||||||
|
raidId
|
||||||
|
}
|
||||||
|
onSave?.(values)
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sidebar action button state based on changes
|
||||||
|
$effect(() => {
|
||||||
|
// Read hasChanges to track it
|
||||||
|
const changed = hasChanges
|
||||||
|
// Use untrack to prevent tracking sidebar mutations
|
||||||
|
untrack(() => {
|
||||||
|
if (changed) {
|
||||||
|
sidebar.setAction(save, 'Save', element)
|
||||||
|
} else {
|
||||||
|
sidebar.setAction(undefined, 'Save', element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSettingsChange(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRaidName(r: Raid | null): string {
|
||||||
|
if (!r) return ''
|
||||||
|
if (typeof r.name === 'string') return r.name
|
||||||
|
return r.name?.en || r.name?.ja || 'Unknown Raid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRaidPane() {
|
||||||
|
paneStack.push({
|
||||||
|
id: 'edit-raid',
|
||||||
|
title: 'Select Raid',
|
||||||
|
component: EditRaidPane,
|
||||||
|
props: {
|
||||||
|
currentRaid: raid,
|
||||||
|
onSelect: handleRaidSelected
|
||||||
|
},
|
||||||
|
scrollable: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRaidElementClass(r: Raid | null): string {
|
||||||
|
if (!r) return ''
|
||||||
|
const elementMap: Record<number, string> = {
|
||||||
|
1: 'wind',
|
||||||
|
2: 'fire',
|
||||||
|
3: 'water',
|
||||||
|
4: 'earth',
|
||||||
|
5: 'dark',
|
||||||
|
6: 'light'
|
||||||
|
}
|
||||||
|
return elementMap[r.element] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRaidSelected(selectedRaid: RaidFull | null) {
|
||||||
|
if (selectedRaid) {
|
||||||
|
// Convert RaidFull to Raid (they have compatible structures)
|
||||||
|
raid = {
|
||||||
|
id: selectedRaid.id,
|
||||||
|
slug: selectedRaid.slug,
|
||||||
|
name: selectedRaid.name,
|
||||||
|
level: selectedRaid.level,
|
||||||
|
element: selectedRaid.element,
|
||||||
|
group: selectedRaid.group
|
||||||
|
? {
|
||||||
|
id: selectedRaid.group.id,
|
||||||
|
name: selectedRaid.group.name,
|
||||||
|
section: String(selectedRaid.group.section),
|
||||||
|
order: selectedRaid.group.order,
|
||||||
|
difficulty: selectedRaid.group.difficulty,
|
||||||
|
hl: selectedRaid.group.hl,
|
||||||
|
extra: selectedRaid.group.extra,
|
||||||
|
guidebooks: selectedRaid.group.guidebooks
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
raidId = selectedRaid.id
|
||||||
|
} else {
|
||||||
|
raid = null
|
||||||
|
raidId = null
|
||||||
|
}
|
||||||
|
paneStack.pop()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="party-edit-sidebar">
|
||||||
|
<div class="top-fields">
|
||||||
|
<Input
|
||||||
|
label="Title"
|
||||||
|
bind:value={name}
|
||||||
|
placeholder="Enter party title..."
|
||||||
|
contained
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailsSection title="Battle">
|
||||||
|
<DetailRow label="Raid" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="raid-select-button {getRaidElementClass(raid)}"
|
||||||
|
onclick={openRaidPane}
|
||||||
|
>
|
||||||
|
{#if raid}
|
||||||
|
<span class="raid-name">{getRaidName(raid)}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder">Select raid...</span>
|
||||||
|
{/if}
|
||||||
|
<Icon name="chevron-right" size={16} class="chevron-icon" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
</DetailsSection>
|
||||||
|
|
||||||
|
<BattleSettingsSection
|
||||||
|
bind:fullAuto
|
||||||
|
bind:autoGuard
|
||||||
|
bind:autoSummon
|
||||||
|
bind:chargeAttack
|
||||||
|
{element}
|
||||||
|
onchange={handleSettingsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailsSection title="Performance">
|
||||||
|
<DetailRow label="Clear Time" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<ClearTimeInput bind:value={clearTime} contained />
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Button Count" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<MetricField bind:value={buttonCount} label="B" contained />
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Chain Count" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<MetricField bind:value={chainCount} label="C" contained />
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Summon Count" noHover compact>
|
||||||
|
{#snippet children()}
|
||||||
|
<MetricField bind:value={summonCount} label="S" contained />
|
||||||
|
{/snippet}
|
||||||
|
</DetailRow>
|
||||||
|
</DetailsSection>
|
||||||
|
</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 *;
|
||||||
|
|
||||||
|
.party-edit-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
padding-bottom: $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: 0 $unit-2x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-select-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raid-name {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
font-size: $font-regular;
|
||||||
|
font-weight: $medium;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element-specific colors when raid is selected
|
||||||
|
.raid-select-button {
|
||||||
|
&.wind {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $wind-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.fire {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $fire-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.water {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $water-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.earth {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $earth-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.dark {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $dark-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.light {
|
||||||
|
.raid-name,
|
||||||
|
.chevron-icon {
|
||||||
|
color: $light-text-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -9,12 +9,14 @@
|
||||||
noHover?: boolean
|
noHover?: boolean
|
||||||
/** Remove padding for inline edit contexts */
|
/** Remove padding for inline edit contexts */
|
||||||
noPadding?: boolean
|
noPadding?: boolean
|
||||||
|
/** Remove min-width from value (for compact controls like switches) */
|
||||||
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { label, value, children, noHover = false, noPadding = false }: Props = $props()
|
let { label, value, children, noHover = false, noPadding = false, compact = false }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="detail-row" class:no-hover={noHover} class:no-padding={noPadding}>
|
<div class="detail-row" class:no-hover={noHover} class:no-padding={noPadding} class:compact>
|
||||||
<span class="label">{label}</span>
|
<span class="label">{label}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
{#if children}
|
{#if children}
|
||||||
|
|
@ -62,5 +64,9 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.compact .value {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
label?: string
|
label?: string
|
||||||
leftIcon?: string
|
leftIcon?: string
|
||||||
rightIcon?: string
|
rightIcon?: string
|
||||||
|
clearable?: boolean
|
||||||
|
onClear?: () => void
|
||||||
counter?: number
|
counter?: number
|
||||||
maxLength?: number
|
maxLength?: number
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
|
@ -31,6 +33,8 @@
|
||||||
label,
|
label,
|
||||||
leftIcon,
|
leftIcon,
|
||||||
rightIcon,
|
rightIcon,
|
||||||
|
clearable = false,
|
||||||
|
onClear,
|
||||||
counter,
|
counter,
|
||||||
maxLength,
|
maxLength,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
|
|
@ -69,7 +73,12 @@
|
||||||
const showCounter = $derived(
|
const showCounter = $derived(
|
||||||
counter !== undefined || (charsRemaining !== undefined && charsRemaining <= 5)
|
counter !== undefined || (charsRemaining !== undefined && charsRemaining <= 5)
|
||||||
)
|
)
|
||||||
const hasWrapper = $derived(accessory || leftIcon || rightIcon || maxLength !== undefined || validationIcon)
|
const hasWrapper = $derived(accessory || leftIcon || rightIcon || clearable || maxLength !== undefined || validationIcon)
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
value = ''
|
||||||
|
onClear?.()
|
||||||
|
}
|
||||||
|
|
||||||
const fieldsetClasses = $derived(
|
const fieldsetClasses = $derived(
|
||||||
['fieldset', hidden && 'hidden', fullWidth && 'full', className].filter(Boolean).join(' ')
|
['fieldset', hidden && 'hidden', fullWidth && 'full', className].filter(Boolean).join(' ')
|
||||||
|
|
@ -92,11 +101,6 @@
|
||||||
.join(' ')
|
.join(' ')
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug: log what's in restProps
|
|
||||||
$effect(() => {
|
|
||||||
console.log('[Input] restProps keys:', Object.keys(restProps))
|
|
||||||
console.log('[Input] hasWrapper:', hasWrapper, 'validationIcon:', validationIcon)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class={fieldsetClasses}>
|
<fieldset class={fieldsetClasses}>
|
||||||
|
|
@ -144,6 +148,12 @@
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if clearable && value}
|
||||||
|
<button type="button" class="clearButton" onclick={handleClear}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showCounter}
|
{#if showCounter}
|
||||||
<span class="counter" class:warning={charsRemaining !== undefined && charsRemaining <= 5}>
|
<span class="counter" class:warning={charsRemaining !== undefined && charsRemaining <= 5}>
|
||||||
{charsRemaining !== undefined ? charsRemaining : currentCount}
|
{charsRemaining !== undefined ? charsRemaining : currentCount}
|
||||||
|
|
@ -345,6 +355,32 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
position: absolute;
|
||||||
|
right: $unit-2x;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
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: var(--surface-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:has(.iconLeft) input {
|
&:has(.iconLeft) input {
|
||||||
padding-left: $unit-5x;
|
padding-left: $unit-5x;
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +393,10 @@
|
||||||
padding-right: $unit-5x;
|
padding-right: $unit-5x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(.clearButton) input {
|
||||||
|
padding-right: $unit-5x;
|
||||||
|
}
|
||||||
|
|
||||||
&:has(.counter) input {
|
&:has(.counter) input {
|
||||||
padding-right: $unit-8x;
|
padding-right: $unit-8x;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,16 +69,18 @@
|
||||||
--sw-checked-bg-hover: var(--null-button-bg-hover);
|
--sw-checked-bg-hover: var(--null-button-bg-hover);
|
||||||
|
|
||||||
background: $grey-70;
|
background: $grey-70;
|
||||||
border: 2px solid transparent;
|
border: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
@include smooth-transition($duration-instant, background-color);
|
@include smooth-transition($duration-instant, background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch:focus),
|
:global([data-switch-root].switch:focus),
|
||||||
:global([data-switch-root].switch:focus-visible) {
|
:global([data-switch-root].switch:focus-visible) {
|
||||||
@include focus-ring($blue);
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch:hover:not(:disabled)) {
|
:global([data-switch-root].switch:hover:not(:disabled)) {
|
||||||
|
|
@ -139,76 +141,93 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size: Small
|
// Size: Small (22px track height, 16px thumb)
|
||||||
:global([data-switch-root].switch.small) {
|
:global([data-switch-root].switch.small) {
|
||||||
$height: $unit-3x; // 24px
|
$track-height: 22px;
|
||||||
border-radius: calc($height / 2);
|
$thumb-size: 16px;
|
||||||
padding-left: $unit-fourth;
|
$track-padding: 3px;
|
||||||
padding-right: $unit-fourth;
|
$track-width: 40px;
|
||||||
width: calc($unit-5x + $unit-half); // 44px
|
|
||||||
height: $height;
|
border-radius: calc($track-height / 2);
|
||||||
|
padding: 0 $track-padding;
|
||||||
|
width: $track-width;
|
||||||
|
height: $track-height;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.small .thumb) {
|
:global([data-switch-root].switch.small .thumb) {
|
||||||
height: calc($unit-2x + $unit-half); // 20px
|
$thumb-size: 16px;
|
||||||
width: calc($unit-2x + $unit-half); // 20px
|
height: $thumb-size;
|
||||||
border-radius: calc(($unit-2x + $unit-half) / 2);
|
width: $thumb-size;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.small .thumb[data-state='checked']) {
|
:global([data-switch-root].switch.small .thumb[data-state='checked']) {
|
||||||
transform: translateX(calc($unit-2x + $unit-half)); // 20px
|
// Move distance: track-width - thumb-size - (2 * padding)
|
||||||
|
transform: translateX(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size: Medium (default)
|
// Size: Medium (default) (26px track height, 20px thumb)
|
||||||
:global([data-switch-root].switch.medium) {
|
:global([data-switch-root].switch.medium) {
|
||||||
$height: calc($unit-4x + $unit-fourth); // 34px
|
$track-height: 26px;
|
||||||
border-radius: calc($height / 2);
|
$thumb-size: 20px;
|
||||||
padding-left: $unit-half;
|
$track-padding: 3px;
|
||||||
padding-right: $unit-half;
|
$track-width: 48px;
|
||||||
width: $unit-7x + $unit-fourth; // 58px
|
|
||||||
height: $height;
|
border-radius: calc($track-height / 2);
|
||||||
|
padding: 0 $track-padding;
|
||||||
|
width: $track-width;
|
||||||
|
height: $track-height;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.medium .thumb) {
|
:global([data-switch-root].switch.medium .thumb) {
|
||||||
height: $unit-3x + $unit-fourth; // 26px
|
$thumb-size: 20px;
|
||||||
width: $unit-3x + $unit-fourth; // 26px
|
height: $thumb-size;
|
||||||
border-radius: calc(($unit-3x + $unit-fourth) / 2);
|
width: $thumb-size;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.medium .thumb[data-state='checked']) {
|
:global([data-switch-root].switch.medium .thumb[data-state='checked']) {
|
||||||
transform: translateX(21px);
|
// Move distance: track-width - thumb-size - (2 * padding)
|
||||||
|
transform: translateX(22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size: Large
|
// Size: Large (30px track height, 24px thumb)
|
||||||
:global([data-switch-root].switch.large) {
|
:global([data-switch-root].switch.large) {
|
||||||
$height: $unit-5x; // 40px
|
$track-height: 30px;
|
||||||
border-radius: calc($height / 2);
|
$thumb-size: 24px;
|
||||||
padding-left: $unit-half;
|
$track-padding: 3px;
|
||||||
padding-right: $unit-half;
|
$track-width: 56px;
|
||||||
width: calc($unit-8x + $unit); // 72px
|
|
||||||
height: $height;
|
border-radius: calc($track-height / 2);
|
||||||
|
padding: 0 $track-padding;
|
||||||
|
width: $track-width;
|
||||||
|
height: $track-height;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.large .thumb) {
|
:global([data-switch-root].switch.large .thumb) {
|
||||||
height: calc($unit-4x); // 32px
|
$thumb-size: 24px;
|
||||||
width: calc($unit-4x); // 32px
|
height: $thumb-size;
|
||||||
border-radius: $unit-2x;
|
width: $thumb-size;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root].switch.large .thumb[data-state='checked']) {
|
:global([data-switch-root].switch.large .thumb[data-state='checked']) {
|
||||||
transform: translateX(calc($unit-4x)); // 32px
|
// Move distance: track-width - thumb-size - (2 * padding)
|
||||||
|
transform: translateX(26px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumb base styles
|
// Thumb base styles
|
||||||
:global([data-switch-root] .thumb) {
|
:global([data-switch-root] .thumb) {
|
||||||
background: $grey-100;
|
background: white;
|
||||||
display: block;
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
@include smooth-transition($duration-instant, transform);
|
@include smooth-transition($duration-instant, transform);
|
||||||
transform: translateX(-1px);
|
transform: translateX(0);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global([data-switch-root] .thumb[data-state='checked']) {
|
:global([data-switch-root] .thumb[data-state='checked']) {
|
||||||
background: $grey-100;
|
background: white;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
34
src/lib/features/party/openPartyEditSidebar.svelte.ts
Normal file
34
src/lib/features/party/openPartyEditSidebar.svelte.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
|
import PartyEditSidebar, {
|
||||||
|
type PartyEditValues
|
||||||
|
} from '$lib/components/sidebar/PartyEditSidebar.svelte'
|
||||||
|
|
||||||
|
type ElementType = 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
|
||||||
|
interface PartyEditSidebarOptions {
|
||||||
|
/** Current party values for editing */
|
||||||
|
initialValues: PartyEditValues
|
||||||
|
/** Party element for switch theming */
|
||||||
|
element?: ElementType
|
||||||
|
/** Callback when user saves changes */
|
||||||
|
onSave: (values: PartyEditValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the party edit sidebar for editing battle settings, performance metrics, and video URL.
|
||||||
|
*/
|
||||||
|
export function openPartyEditSidebar(options: PartyEditSidebarOptions) {
|
||||||
|
const { initialValues, element, onSave } = options
|
||||||
|
|
||||||
|
sidebar.openWithComponent('Edit Party Settings', PartyEditSidebar, {
|
||||||
|
initialValues,
|
||||||
|
element,
|
||||||
|
onSave
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePartyEditSidebar() {
|
||||||
|
sidebar.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PartyEditValues }
|
||||||
|
|
@ -107,6 +107,8 @@ export interface Party {
|
||||||
buttonCount?: number
|
buttonCount?: number
|
||||||
turnCount?: number
|
turnCount?: number
|
||||||
chainCount?: number
|
chainCount?: number
|
||||||
|
summonCount?: number
|
||||||
|
videoUrl?: string
|
||||||
visibility?: import('$lib/types/visibility').PartyVisibility
|
visibility?: import('$lib/types/visibility').PartyVisibility
|
||||||
element?: number
|
element?: number
|
||||||
favorited?: boolean
|
favorited?: boolean
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue