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() {
|
function openDescriptionPanel() {
|
||||||
openDescriptionSidebar({
|
openDescriptionSidebar({
|
||||||
title: party.name || '(untitled party)',
|
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 = {
|
const initialValues: PartyEditValues = {
|
||||||
name: party.name ?? '',
|
name: party.name ?? '',
|
||||||
|
description: party.description ?? null,
|
||||||
fullAuto: party.fullAuto ?? false,
|
fullAuto: party.fullAuto ?? false,
|
||||||
autoGuard: party.autoGuard ?? false,
|
autoGuard: party.autoGuard ?? false,
|
||||||
autoSummon: party.autoSummon ?? false,
|
autoSummon: party.autoSummon ?? false,
|
||||||
|
|
@ -437,6 +444,7 @@
|
||||||
onSave: async (values) => {
|
onSave: async (values) => {
|
||||||
await updatePartyDetails({
|
await updatePartyDetails({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
fullAuto: values.fullAuto,
|
fullAuto: values.fullAuto,
|
||||||
autoGuard: values.autoGuard,
|
autoGuard: values.autoGuard,
|
||||||
autoSummon: values.autoSummon,
|
autoSummon: values.autoSummon,
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,15 @@
|
||||||
menu?: Snippet
|
menu?: Snippet
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, description, user, canEdit = false, onOpenDescription, onOpenEdit, menu }: Props =
|
let {
|
||||||
$props()
|
name,
|
||||||
|
description,
|
||||||
|
user,
|
||||||
|
canEdit = false,
|
||||||
|
onOpenDescription,
|
||||||
|
onOpenEdit,
|
||||||
|
menu
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const avatarSrc = $derived(getAvatarSrc(user?.avatar?.picture))
|
const avatarSrc = $derived(getAvatarSrc(user?.avatar?.picture))
|
||||||
const avatarSrcSet = $derived(getAvatarSrcSet(user?.avatar?.picture))
|
const avatarSrcSet = $derived(getAvatarSrcSet(user?.avatar?.picture))
|
||||||
|
|
@ -30,40 +37,40 @@
|
||||||
|
|
||||||
<div class="description-tile">
|
<div class="description-tile">
|
||||||
<!-- Header: Title + Actions -->
|
<!-- Header: Title + Actions -->
|
||||||
<div class="tile-header">
|
<div class="tile-header-container">
|
||||||
<h1 class="party-name">{name || '(untitled party)'}</h1>
|
<div class="tile-header">
|
||||||
<div class="actions">
|
<h1 class="party-name">{name || '(untitled party)'}</h1>
|
||||||
{#if canEdit}
|
<div class="actions">
|
||||||
<Button variant="secondary" size="small" onclick={onOpenEdit}>
|
{#if canEdit}
|
||||||
Edit
|
<Button variant="secondary" size="small" onclick={onOpenEdit}>Edit</Button>
|
||||||
</Button>
|
{/if}
|
||||||
{/if}
|
{#if menu}
|
||||||
{#if menu}
|
{@render 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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="username">{user.username}</span>
|
</div>
|
||||||
</a>
|
|
||||||
{/if}
|
<!-- 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) -->
|
<!-- Description content (clickable) -->
|
||||||
<button type="button" class="description-content" onclick={onOpenDescription}>
|
<button type="button" class="description-content" onclick={onOpenDescription}>
|
||||||
|
|
@ -85,12 +92,18 @@
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 0.5px solid var(--button-bg);
|
border: 0.5px solid var(--button-bg);
|
||||||
border-radius: $card-corner;
|
border-radius: $card-corner;
|
||||||
padding: $unit-2x;
|
padding: $unit-2x $unit-2x $unit $unit-2x;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile-header-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
}
|
||||||
|
|
||||||
.tile-header {
|
.tile-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,46 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
import DescriptionRenderer from '$lib/components/DescriptionRenderer.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 {
|
interface Props {
|
||||||
description?: string
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="description-sidebar">
|
<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 YouTubeUrlInput from '$lib/components/party/edit/YouTubeUrlInput.svelte'
|
||||||
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
||||||
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.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 { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
||||||
import { untrack } from 'svelte'
|
import { untrack } from 'svelte'
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
|
|
||||||
export interface PartyEditValues {
|
export interface PartyEditValues {
|
||||||
name: string
|
name: string
|
||||||
|
description: string | null
|
||||||
fullAuto: boolean
|
fullAuto: boolean
|
||||||
autoGuard: boolean
|
autoGuard: boolean
|
||||||
autoSummon: boolean
|
autoSummon: boolean
|
||||||
|
|
@ -64,6 +66,7 @@
|
||||||
let videoUrl = $state(initialValues.videoUrl)
|
let videoUrl = $state(initialValues.videoUrl)
|
||||||
let raid = $state<Raid | null>(initialValues.raid)
|
let raid = $state<Raid | null>(initialValues.raid)
|
||||||
let raidId = $state<string | null>(initialValues.raidId)
|
let raidId = $state<string | null>(initialValues.raidId)
|
||||||
|
let description = $state(initialValues.description)
|
||||||
|
|
||||||
// Check if any values have changed
|
// Check if any values have changed
|
||||||
const hasChanges = $derived(
|
const hasChanges = $derived(
|
||||||
|
|
@ -77,13 +80,15 @@
|
||||||
chainCount !== initialValues.chainCount ||
|
chainCount !== initialValues.chainCount ||
|
||||||
summonCount !== initialValues.summonCount ||
|
summonCount !== initialValues.summonCount ||
|
||||||
videoUrl !== initialValues.videoUrl ||
|
videoUrl !== initialValues.videoUrl ||
|
||||||
raidId !== initialValues.raidId
|
raidId !== initialValues.raidId ||
|
||||||
|
description !== initialValues.description
|
||||||
)
|
)
|
||||||
|
|
||||||
// Expose save function for sidebar action button
|
// Expose save function for sidebar action button
|
||||||
export function save() {
|
export function save() {
|
||||||
const values: PartyEditValues = {
|
const values: PartyEditValues = {
|
||||||
name,
|
name,
|
||||||
|
description,
|
||||||
fullAuto,
|
fullAuto,
|
||||||
autoGuard,
|
autoGuard,
|
||||||
autoSummon,
|
autoSummon,
|
||||||
|
|
@ -185,6 +190,39 @@
|
||||||
}
|
}
|
||||||
paneStack.pop()
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="party-edit-sidebar">
|
<div class="party-edit-sidebar">
|
||||||
|
|
@ -199,6 +237,21 @@
|
||||||
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||||
</div>
|
</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">
|
<DetailsSection title="Battle">
|
||||||
<DetailRow label="Raid" noHover compact>
|
<DetailRow label="Raid" noHover compact>
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
|
|
@ -272,7 +325,8 @@
|
||||||
padding: 0 $unit-2x;
|
padding: 0 $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.raid-select-button {
|
.raid-select-button,
|
||||||
|
.description-select-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
|
|
@ -283,7 +337,8 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.raid-name {
|
.raid-name,
|
||||||
|
.description-preview {
|
||||||
font-size: $font-regular;
|
font-size: $font-regular;
|
||||||
font-weight: $medium;
|
font-weight: $medium;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,21 @@ import DescriptionSidebar from '$lib/components/sidebar/DescriptionSidebar.svelt
|
||||||
interface DescriptionSidebarOptions {
|
interface DescriptionSidebarOptions {
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
description?: 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) {
|
export function openDescriptionSidebar(options: DescriptionSidebarOptions) {
|
||||||
const { title, description } = options
|
const { title, description, canEdit, partyId, partyShortcode, onSave } = options
|
||||||
|
|
||||||
sidebar.openWithComponent(title ?? '', DescriptionSidebar, {
|
sidebar.openWithComponent(title ?? '', DescriptionSidebar, {
|
||||||
description
|
description,
|
||||||
|
canEdit,
|
||||||
|
partyId,
|
||||||
|
partyShortcode,
|
||||||
|
onSave
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue