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:
parent
2792279f9a
commit
8329ec9de3
7 changed files with 856 additions and 42 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
291
src/lib/components/sidebar/DescriptionToolbar.svelte
Normal file
291
src/lib/components/sidebar/DescriptionToolbar.svelte
Normal 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>
|
||||
404
src/lib/components/sidebar/EditDescriptionPane.svelte
Normal file
404
src/lib/components/sidebar/EditDescriptionPane.svelte
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue