hensei-web/src/lib/components/sidebar/PartyEditSidebar.svelte
Justin Edmund f905dc99a9 reorganize party edit sidebar and refactor YouTubeUrlInput
- move description above sharing section
- move raid selector into top fields section, remove battle section
- add edge-to-edge dividers between sections
- add Details section title
- refactor YouTubeUrlInput to use Input component internally
2026-01-06 02:03:38 -08:00

562 lines
14 KiB
Svelte

<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 EditDescriptionPane from '$lib/components/sidebar/EditDescriptionPane.svelte'
import Select from '$lib/components/ui/Select.svelte'
import Switch from '$lib/components/ui/switch/Switch.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { usePaneStack } from '$lib/stores/paneStack.svelte'
import { createQuery } from '@tanstack/svelte-query'
import { crewQueries } from '$lib/api/queries/crew.queries'
import { untrack } from 'svelte'
import type { Raid } from '$lib/types/api/entities'
import type { RaidFull } from '$lib/types/api/raid'
import type { PartyVisibility } from '$lib/types/visibility'
import Icon from '$lib/components/Icon.svelte'
export interface PartyEditValues {
name: string
description: string | null
visibility: PartyVisibility
sharedWithCrew: boolean
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()
// Query user's crew membership to show/hide share toggle
const myCrewQuery = createQuery(() => ({
...crewQueries.myCrew(),
staleTime: 5 * 60 * 1000 // Cache for 5 minutes
}))
const isInCrew = $derived(myCrewQuery.data != null)
// Local state - initialized from initialValues
let name = $state(initialValues.name)
let visibility = $state<PartyVisibility>(initialValues.visibility)
let sharedWithCrew = $state(initialValues.sharedWithCrew)
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)
let description = $state(initialValues.description)
// Visibility options for select (1=Public, 2=Unlisted, 3=Private per Rails API)
const visibilityOptions: Array<{ value: PartyVisibility; label: string }> = [
{ value: 1, label: 'Public' },
{ value: 2, label: 'Unlisted' },
{ value: 3, label: 'Private' }
]
// Check if any values have changed
const hasChanges = $derived(
name !== initialValues.name ||
visibility !== initialValues.visibility ||
sharedWithCrew !== initialValues.sharedWithCrew ||
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 ||
description !== initialValues.description
)
// Expose save function for sidebar action button
export function save() {
const values: PartyEditValues = {
name,
description,
visibility,
sharedWithCrew,
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()
}
interface JSONContent {
type?: string
text?: string
content?: JSONContent[]
attrs?: Record<string, unknown>
}
/** Extract first non-empty paragraph from TipTap JSON content */
function getDescriptionPreview(desc: string | null): string | null {
if (!desc) return null
try {
const parsed = JSON.parse(desc) as JSONContent
if (parsed.type !== 'doc' || !parsed.content?.length) return null
// Extract text from an inline node (text or mention)
const getNodeText = (node: JSONContent): string => {
if (node.type === 'text') return node.text ?? ''
if (node.type === 'mention') {
const id = node.attrs?.id as { name?: { en?: string }; granblue_en?: string } | undefined
return id?.name?.en ?? id?.granblue_en ?? ''
}
return ''
}
// Extract text from a block
const getBlockText = (block: JSONContent): string =>
block.content?.map(getNodeText).join('') ?? ''
// Find first non-empty paragraph or heading
for (const node of parsed.content) {
if (node.type !== 'paragraph' && node.type !== 'heading') continue
const text = getBlockText(node).trim()
if (text) return text
}
return null
} catch {
// Legacy plain text - return first non-empty line
return desc.split('\n').map((l) => l.trim()).find(Boolean) ?? null
}
}
const descriptionPreview = $derived(getDescriptionPreview(description))
function openDescriptionPane() {
paneStack.push({
id: 'edit-description',
title: 'Edit Description',
component: EditDescriptionPane,
props: {
description,
onSave: (content: string) => {
description = content
paneStack.pop()
}
},
scrollable: false
})
}
</script>
<div class="party-edit-sidebar">
<div class="top-section">
<h3>Details</h3>
<div class="top-fields">
<Input
label="Title"
bind:value={name}
placeholder="Enter party title..."
contained
fullWidth
/>
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
<div class="raid-field">
<span class="raid-label">Raid</span>
<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>
</div>
</div>
</div>
<hr class="divider" />
<button type="button" class="description-button" onclick={openDescriptionPane}>
<div class="description-header">
<span class="description-label">Description</span>
<Icon name="chevron-right" size={16} class="description-chevron" />
</div>
{#if descriptionPreview}
<p class="description-preview">{descriptionPreview}</p>
{:else}
<span class="description-placeholder">Add description...</span>
{/if}
</button>
<hr class="divider" />
<DetailsSection title="Sharing">
<DetailRow label="Visibility" noHover compact>
{#snippet children()}
<Select
options={visibilityOptions}
bind:value={visibility}
contained
/>
{/snippet}
</DetailRow>
{#if isInCrew}
<DetailRow label="Share with Crew" noHover compact>
{#snippet children()}
<Switch
bind:checked={sharedWithCrew}
size="small"
{element}
/>
{/snippet}
</DetailRow>
{/if}
</DetailsSection>
<hr class="divider" />
<BattleSettingsSection
bind:fullAuto
bind:autoGuard
bind:autoSummon
bind:chargeAttack
{element}
onchange={handleSettingsChange}
/>
<hr class="divider" />
<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-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit;
h3 {
margin: 0;
font-size: $font-name;
font-weight: $medium;
color: var(--text-primary);
padding: 0 $unit;
}
}
.top-fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: 0 $unit;
// Override Input label styling to match DetailRow
:global(.label) {
color: var(--text-secondary) !important;
font-size: $font-regular !important;
font-weight: normal !important;
}
}
.divider {
border: none;
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.04));
margin: 0;
}
.raid-field {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.raid-label {
font-size: $font-regular;
color: var(--text-secondary);
}
.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;
}
.description-button {
display: flex;
flex-direction: column;
gap: $unit-half;
margin: 0 $unit-2x;
padding: 0;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.description-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.description-label {
font-size: $font-name;
font-weight: $medium;
color: var(--text-primary);
}
.description-preview {
margin: 0;
overflow: hidden;
font-size: $font-regular;
color: var(--text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.description-placeholder {
font-size: $font-regular;
color: var(--text-tertiary);
font-style: italic;
}
.description-chevron {
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>