add rich text description editor

- use edra/tiptap for description editing in sidebar
- fix infinite loop by using onMount instead of $effect
This commit is contained in:
Justin Edmund 2025-12-21 15:13:12 -08:00
parent 2792279f9a
commit 8329ec9de3
7 changed files with 856 additions and 42 deletions

View file

@ -409,7 +409,13 @@
function openDescriptionPanel() {
openDescriptionSidebar({
title: party.name || '(untitled party)',
description: party.description
description: party.description,
canEdit: canEdit(),
partyId: party.id,
partyShortcode: party.shortcode,
onSave: async (description) => {
await updatePartyDetails({ description })
}
})
}
@ -418,6 +424,7 @@
const initialValues: PartyEditValues = {
name: party.name ?? '',
description: party.description ?? null,
fullAuto: party.fullAuto ?? false,
autoGuard: party.autoGuard ?? false,
autoSummon: party.autoSummon ?? false,
@ -437,6 +444,7 @@
onSave: async (values) => {
await updatePartyDetails({
name: values.name,
description: values.description,
fullAuto: values.fullAuto,
autoGuard: values.autoGuard,
autoSummon: values.autoSummon,

View file

@ -21,8 +21,15 @@
menu?: Snippet
}
let { name, description, user, canEdit = false, onOpenDescription, onOpenEdit, menu }: Props =
$props()
let {
name,
description,
user,
canEdit = false,
onOpenDescription,
onOpenEdit,
menu
}: Props = $props()
const avatarSrc = $derived(getAvatarSrc(user?.avatar?.picture))
const avatarSrcSet = $derived(getAvatarSrcSet(user?.avatar?.picture))
@ -30,40 +37,40 @@
<div class="description-tile">
<!-- Header: Title + Actions -->
<div class="tile-header">
<h1 class="party-name">{name || '(untitled party)'}</h1>
<div class="actions">
{#if canEdit}
<Button variant="secondary" size="small" onclick={onOpenEdit}>
Edit
</Button>
{/if}
{#if menu}
{@render menu()}
{/if}
</div>
</div>
<!-- Creator info -->
{#if user}
<a href="/{user.username}" class="creator-link">
<div class="avatar-wrapper {user.avatar?.element || ''}">
{#if user.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${user.username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="24"
height="24"
/>
{:else}
<div class="avatar-placeholder" aria-hidden="true"></div>
<div class="tile-header-container">
<div class="tile-header">
<h1 class="party-name">{name || '(untitled party)'}</h1>
<div class="actions">
{#if canEdit}
<Button variant="secondary" size="small" onclick={onOpenEdit}>Edit</Button>
{/if}
{#if menu}
{@render menu()}
{/if}
</div>
<span class="username">{user.username}</span>
</a>
{/if}
</div>
<!-- Creator info -->
{#if user}
<a href="/{user.username}" class="creator-link">
<div class="avatar-wrapper {user.avatar?.element || ''}">
{#if user.avatar?.picture}
<img
class="avatar"
alt={`Avatar of ${user.username}`}
src={avatarSrc}
srcset={avatarSrcSet}
width="24"
height="24"
/>
{:else}
<div class="avatar-placeholder" aria-hidden="true"></div>
{/if}
</div>
<span class="username">{user.username}</span>
</a>
{/if}
</div>
<!-- Description content (clickable) -->
<button type="button" class="description-content" onclick={onOpenDescription}>
@ -85,12 +92,18 @@
background: var(--card-bg);
border: 0.5px solid var(--button-bg);
border-radius: $card-corner;
padding: $unit-2x;
padding: $unit-2x $unit-2x $unit $unit-2x;
display: flex;
flex-direction: column;
gap: $unit;
}
.tile-header-container {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.tile-header {
display: flex;
align-items: center;

View file

@ -1,11 +1,46 @@
<script lang="ts">
import { onMount } from 'svelte'
import DescriptionRenderer from '$lib/components/DescriptionRenderer.svelte'
import EditDescriptionPane from './EditDescriptionPane.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
import { usePaneStack } from '$lib/stores/paneStack.svelte'
interface Props {
description?: string
canEdit?: boolean
partyId?: string
partyShortcode?: string
onSave?: (description: string) => Promise<void>
}
let { description }: Props = $props()
let { description, canEdit = false, partyId, partyShortcode, onSave }: Props = $props()
const paneStack = usePaneStack()
function openEditPane() {
paneStack.push({
id: 'edit-description',
title: 'Edit Description',
component: EditDescriptionPane,
props: {
description,
onSave: async (content: string) => {
if (onSave) {
await onSave(content)
}
paneStack.pop()
}
},
scrollable: false
})
}
// Set up Edit button in sidebar header when canEdit is true
onMount(() => {
if (canEdit) {
sidebar.setAction(openEditPane, 'Edit')
}
})
</script>
<div class="description-sidebar">

View file

@ -0,0 +1,291 @@
<script lang="ts">
import type { Editor } from '@tiptap/core'
import Button from '$lib/components/ui/Button.svelte'
import { DropdownMenu } from 'bits-ui'
import Icon from '$lib/components/Icon.svelte'
// Lucide icons
import Heading1 from '@lucide/svelte/icons/heading-1'
import Heading2 from '@lucide/svelte/icons/heading-2'
import Heading3 from '@lucide/svelte/icons/heading-3'
import Pilcrow from '@lucide/svelte/icons/pilcrow'
import Bold from '@lucide/svelte/icons/bold'
import Italic from '@lucide/svelte/icons/italic'
import Underline from '@lucide/svelte/icons/underline'
import StrikeThrough from '@lucide/svelte/icons/strikethrough'
import LinkIcon from '@lucide/svelte/icons/link-2'
import List from '@lucide/svelte/icons/list'
import ListOrdered from '@lucide/svelte/icons/list-ordered'
interface Props {
editor: Editor
}
let { editor }: Props = $props()
function getStyleLabel(): string {
if (editor.isActive('heading', { level: 1 })) return 'H1'
if (editor.isActive('heading', { level: 2 })) return 'H2'
if (editor.isActive('heading', { level: 3 })) return 'H3'
return 'P'
}
function setHeading(level: 1 | 2 | 3) {
editor.chain().focus().toggleHeading({ level }).run()
}
function setParagraph() {
editor.chain().focus().setParagraph().run()
}
function toggleBold() {
editor.chain().focus().toggleBold().run()
}
function toggleItalic() {
editor.chain().focus().toggleItalic().run()
}
function toggleUnderline() {
editor.chain().focus().toggleUnderline().run()
}
function toggleStrike() {
editor.chain().focus().toggleStrike().run()
}
function toggleLink() {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run()
} else {
const url = window.prompt('Enter the URL:')
if (url) {
editor.chain().focus().toggleLink({ href: url }).run()
}
}
}
function toggleBulletList() {
editor.chain().focus().toggleBulletList().run()
}
function toggleOrderedList() {
editor.chain().focus().toggleOrderedList().run()
}
</script>
<div class="description-toolbar">
<!-- Text Style Dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="small" class="style-trigger">
{#snippet leftAccessory()}
{#if editor.isActive('heading', { level: 1 })}
<Heading1 size={16} />
{:else if editor.isActive('heading', { level: 2 })}
<Heading2 size={16} />
{:else if editor.isActive('heading', { level: 3 })}
<Heading3 size={16} />
{:else}
<Pilcrow size={16} />
{/if}
{/snippet}
{getStyleLabel()}
{#snippet rightAccessory()}
<Icon name="chevron-down" size={12} />
{/snippet}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="start" sideOffset={4} class="style-dropdown">
<DropdownMenu.Item
class="style-item {editor.isActive('heading', { level: 1 }) ? 'active' : ''}"
onSelect={() => setHeading(1)}
>
<Heading1 size={16} />
<span>Heading 1</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="style-item {editor.isActive('heading', { level: 2 }) ? 'active' : ''}"
onSelect={() => setHeading(2)}
>
<Heading2 size={16} />
<span>Heading 2</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="style-item {editor.isActive('heading', { level: 3 }) ? 'active' : ''}"
onSelect={() => setHeading(3)}
>
<Heading3 size={16} />
<span>Heading 3</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="style-item {editor.isActive('paragraph') ? 'active' : ''}"
onSelect={setParagraph}
>
<Pilcrow size={16} />
<span>Paragraph</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<div class="separator"></div>
<!-- Text Formatting -->
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleBold}
active={editor.isActive('bold')}
title="Bold"
class="toolbar-button"
>
<Bold size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleItalic}
active={editor.isActive('italic')}
title="Italic"
class="toolbar-button"
>
<Italic size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleUnderline}
active={editor.isActive('underline')}
title="Underline"
class="toolbar-button"
>
<Underline size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleStrike}
active={editor.isActive('strike')}
title="Strikethrough"
class="toolbar-button"
>
<StrikeThrough size={16} />
</Button>
<div class="separator"></div>
<!-- Link -->
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleLink}
active={editor.isActive('link')}
title="Link"
class="toolbar-button"
>
<LinkIcon size={16} />
</Button>
<div class="separator"></div>
<!-- Lists -->
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleBulletList}
active={editor.isActive('bulletList')}
title="Bullet List"
class="toolbar-button"
>
<List size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconOnly
onclick={toggleOrderedList}
active={editor.isActive('orderedList')}
title="Ordered List"
class="toolbar-button"
>
<ListOrdered size={16} />
</Button>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.description-toolbar {
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit;
background: var(--button-bg);
border-radius: $card-corner;
}
.separator {
width: 1px;
height: 16px;
background: var(--border-subtle);
margin: 0 $unit-half;
}
:global(.style-trigger) {
gap: $unit-half;
min-width: 56px;
}
:global(.toolbar-button) {
width: 28px;
height: 28px;
padding: 0;
}
:global(.style-dropdown) {
background: var(--menu-bg);
border: 1px solid var(--menu-border);
border-radius: $card-corner;
padding: $unit-half;
min-width: 140px;
box-shadow: var(--shadow-floating);
}
:global(.style-item) {
display: flex;
align-items: center;
gap: $unit;
padding: $unit ($unit * 1.5);
border-radius: 6px;
font-size: $font-small;
font-weight: $medium;
color: var(--text-primary);
cursor: pointer;
outline: none;
&:hover {
background: var(--menu-bg-item-hover);
}
&.active {
background: var(--menu-bg-item-active);
}
}
</style>

View file

@ -0,0 +1,404 @@
<script lang="ts">
import { onMount } from 'svelte'
import type { Editor, Content } from '@tiptap/core'
import EdraEditor from '$lib/components/edra/headless/editor.svelte'
import { sidebar } from '$lib/stores/sidebar.svelte'
// Lucide icons
import Heading1 from '@lucide/svelte/icons/heading-1'
import Heading2 from '@lucide/svelte/icons/heading-2'
import Heading3 from '@lucide/svelte/icons/heading-3'
import Pilcrow from '@lucide/svelte/icons/pilcrow'
import Bold from '@lucide/svelte/icons/bold'
import Italic from '@lucide/svelte/icons/italic'
import Underline from '@lucide/svelte/icons/underline'
import StrikeThrough from '@lucide/svelte/icons/strikethrough'
import LinkIcon from '@lucide/svelte/icons/link-2'
import List from '@lucide/svelte/icons/list'
import ListOrdered from '@lucide/svelte/icons/list-ordered'
import ChevronDown from '@lucide/svelte/icons/chevron-down'
interface Props {
description?: string
onSave: (content: string) => void
}
let { description, onSave }: Props = $props()
// Bind editor instance (same pattern as superhuman)
let editor = $state<Editor>()
let initialContent = $state<Content | undefined>()
// Style dropdown state
let styleDropdownOpen = $state(false)
// Parse description JSON on mount
onMount(() => {
if (description) {
try {
initialContent = JSON.parse(description)
} catch {
// Legacy plain text - wrap in paragraph
initialContent = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }]
}
}
}
sidebar.setAction(save, 'Save')
})
function save() {
if (!editor) return
const json = editor.getJSON()
const content = JSON.stringify(json)
onSave(content)
}
function getStyleLabel(): string {
if (editor?.isActive('heading', { level: 1 })) return 'H1'
if (editor?.isActive('heading', { level: 2 })) return 'H2'
if (editor?.isActive('heading', { level: 3 })) return 'H3'
return 'P'
}
function setHeading(level: 1 | 2 | 3) {
editor?.chain().focus().toggleHeading({ level }).run()
styleDropdownOpen = false
}
function setParagraph() {
editor?.chain().focus().setParagraph().run()
styleDropdownOpen = false
}
function toggleLink() {
if (editor?.isActive('link')) {
editor?.chain().focus().unsetLink().run()
} else {
const url = window.prompt('Enter the URL:')
if (url) {
editor?.chain().focus().toggleLink({ href: url }).run()
}
}
}
</script>
<div class="edit-description-pane">
<!-- Inline Toolbar (same pattern as superhuman) -->
<div class="toolbar-container">
<div class="description-toolbar">
<!-- Text Style Dropdown -->
<div class="style-dropdown-wrapper">
<button
class="toolbar-button style-trigger"
onclick={() => (styleDropdownOpen = !styleDropdownOpen)}
disabled={!editor}
>
{#if editor?.isActive('heading', { level: 1 })}
<Heading1 size={16} />
{:else if editor?.isActive('heading', { level: 2 })}
<Heading2 size={16} />
{:else if editor?.isActive('heading', { level: 3 })}
<Heading3 size={16} />
{:else}
<Pilcrow size={16} />
{/if}
<span>{getStyleLabel()}</span>
<ChevronDown size={12} />
</button>
{#if styleDropdownOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="style-dropdown" onclick={(e) => e.stopPropagation()}>
<button
class="style-item"
class:active={editor?.isActive('heading', { level: 1 })}
onclick={() => setHeading(1)}
>
<Heading1 size={16} />
<span>Heading 1</span>
</button>
<button
class="style-item"
class:active={editor?.isActive('heading', { level: 2 })}
onclick={() => setHeading(2)}
>
<Heading2 size={16} />
<span>Heading 2</span>
</button>
<button
class="style-item"
class:active={editor?.isActive('heading', { level: 3 })}
onclick={() => setHeading(3)}
>
<Heading3 size={16} />
<span>Heading 3</span>
</button>
<button
class="style-item"
class:active={editor?.isActive('paragraph')}
onclick={setParagraph}
>
<Pilcrow size={16} />
<span>Paragraph</span>
</button>
</div>
{/if}
</div>
<div class="separator"></div>
<!-- Text Formatting -->
<button
class="toolbar-button"
class:active={editor?.isActive('bold')}
onclick={() => editor?.chain().focus().toggleBold().run()}
disabled={!editor}
title="Bold"
>
<Bold size={16} />
</button>
<button
class="toolbar-button"
class:active={editor?.isActive('italic')}
onclick={() => editor?.chain().focus().toggleItalic().run()}
disabled={!editor}
title="Italic"
>
<Italic size={16} />
</button>
<button
class="toolbar-button"
class:active={editor?.isActive('underline')}
onclick={() => editor?.chain().focus().toggleUnderline().run()}
disabled={!editor}
title="Underline"
>
<Underline size={16} />
</button>
<button
class="toolbar-button"
class:active={editor?.isActive('strike')}
onclick={() => editor?.chain().focus().toggleStrike().run()}
disabled={!editor}
title="Strikethrough"
>
<StrikeThrough size={16} />
</button>
<div class="separator"></div>
<!-- Link -->
<button
class="toolbar-button"
class:active={editor?.isActive('link')}
onclick={toggleLink}
disabled={!editor}
title="Link"
>
<LinkIcon size={16} />
</button>
<div class="separator"></div>
<!-- Lists -->
<button
class="toolbar-button"
class:active={editor?.isActive('bulletList')}
onclick={() => editor?.chain().focus().toggleBulletList().run()}
disabled={!editor}
title="Bullet List"
>
<List size={16} />
</button>
<button
class="toolbar-button"
class:active={editor?.isActive('orderedList')}
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
disabled={!editor}
title="Ordered List"
>
<ListOrdered size={16} />
</button>
</div>
</div>
<div class="editor-container">
<EdraEditor
bind:editor
content={initialContent}
editable={true}
class="description-editor"
/>
</div>
</div>
<!-- Close dropdown when clicking outside -->
<svelte:window onclick={() => (styleDropdownOpen = false)} />
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/layout' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
.edit-description-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.toolbar-container {
flex-shrink: 0;
padding: $unit-2x;
padding-bottom: 0;
}
.description-toolbar {
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit;
background: var(--button-bg);
border-radius: $card-corner;
}
.separator {
width: 1px;
height: 16px;
background: var(--border-subtle);
margin: 0 $unit-half;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--button-bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--button-bg-active);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.style-dropdown-wrapper {
position: relative;
}
.style-trigger {
display: flex;
align-items: center;
gap: $unit-half;
width: auto;
min-width: 56px;
padding: 0 $unit;
font-size: $font-small;
font-weight: $medium;
}
.style-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--menu-bg);
border: 1px solid var(--menu-border);
border-radius: $card-corner;
padding: $unit-half;
min-width: 140px;
box-shadow: var(--shadow-floating);
z-index: 100;
}
.style-item {
display: flex;
align-items: center;
gap: $unit;
width: 100%;
padding: $unit ($unit * 1.5);
border: none;
background: transparent;
border-radius: 6px;
font-size: $font-small;
font-weight: $medium;
color: var(--text-primary);
cursor: pointer;
text-align: left;
&:hover {
background: var(--menu-bg-item-hover);
}
&.active {
background: var(--menu-bg-item-active);
}
}
.editor-container {
flex: 1;
overflow-y: auto;
padding: $unit-2x;
}
:global(.description-editor) {
min-height: 200px;
outline: none;
&:focus {
outline: none;
}
}
// Override Edra editor styles for our context
:global(.description-editor .ProseMirror) {
min-height: 200px;
outline: none;
&:focus {
outline: none;
}
p {
margin: 0 0 $unit 0;
}
h1,
h2,
h3 {
margin: $unit-2x 0 $unit 0;
}
ul,
ol {
margin: $unit 0;
padding-left: $unit-3x;
}
}
</style>

View file

@ -13,6 +13,7 @@
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 { sidebar } from '$lib/stores/sidebar.svelte'
import { usePaneStack } from '$lib/stores/paneStack.svelte'
import { untrack } from 'svelte'
@ -22,6 +23,7 @@
export interface PartyEditValues {
name: string
description: string | null
fullAuto: boolean
autoGuard: boolean
autoSummon: boolean
@ -64,6 +66,7 @@
let videoUrl = $state(initialValues.videoUrl)
let raid = $state<Raid | null>(initialValues.raid)
let raidId = $state<string | null>(initialValues.raidId)
let description = $state(initialValues.description)
// Check if any values have changed
const hasChanges = $derived(
@ -77,13 +80,15 @@
chainCount !== initialValues.chainCount ||
summonCount !== initialValues.summonCount ||
videoUrl !== initialValues.videoUrl ||
raidId !== initialValues.raidId
raidId !== initialValues.raidId ||
description !== initialValues.description
)
// Expose save function for sidebar action button
export function save() {
const values: PartyEditValues = {
name,
description,
fullAuto,
autoGuard,
autoSummon,
@ -185,6 +190,39 @@
}
paneStack.pop()
}
function getDescriptionPreview(desc: string | null): string {
if (!desc) return ''
try {
const parsed = JSON.parse(desc)
// Extract plain text from TipTap JSON
const extractText = (node: { type?: string; text?: string; content?: unknown[] }): string => {
if (node.text) return node.text
if (node.content) return node.content.map(extractText).join('')
return ''
}
return extractText(parsed).slice(0, 50) || ''
} catch {
// Legacy plain text
return desc.slice(0, 50)
}
}
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">
@ -199,6 +237,21 @@
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
</div>
<DetailsSection title="Content">
<DetailRow label="Description" noHover compact>
{#snippet children()}
<button type="button" class="description-select-button" onclick={openDescriptionPane}>
{#if description}
<span class="description-preview">{getDescriptionPreview(description)}...</span>
{:else}
<span class="placeholder">Add description...</span>
{/if}
<Icon name="chevron-right" size={16} class="chevron-icon" />
</button>
{/snippet}
</DetailRow>
</DetailsSection>
<DetailsSection title="Battle">
<DetailRow label="Raid" noHover compact>
{#snippet children()}
@ -272,7 +325,8 @@
padding: 0 $unit-2x;
}
.raid-select-button {
.raid-select-button,
.description-select-button {
display: flex;
align-items: center;
gap: $unit;
@ -283,7 +337,8 @@
text-align: left;
}
.raid-name {
.raid-name,
.description-preview {
font-size: $font-regular;
font-weight: $medium;
color: var(--text-secondary);

View file

@ -4,13 +4,21 @@ import DescriptionSidebar from '$lib/components/sidebar/DescriptionSidebar.svelt
interface DescriptionSidebarOptions {
title?: string | undefined
description?: string | undefined
canEdit?: boolean | undefined
partyId?: string | undefined
partyShortcode?: string | undefined
onSave?: ((description: string) => Promise<void>) | undefined
}
export function openDescriptionSidebar(options: DescriptionSidebarOptions) {
const { title, description } = options
const { title, description, canEdit, partyId, partyShortcode, onSave } = options
sidebar.openWithComponent(title ?? '', DescriptionSidebar, {
description
description,
canEdit,
partyId,
partyShortcode,
onSave
})
}