[web] integrate editor actions and preserve drafts #165
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@luzzle/web": minor
|
||||
"@luzzle/web.theme": patch
|
||||
---
|
||||
|
||||
Put Save, Preview, Publish and More in the editor frame. Keep saved-file publishing
|
||||
available without stale index gating and preselect the current piece. Preserve
|
||||
newer drafts during saves and give editor commands an explicit shared appearance.
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { onMount, onDestroy, type Snippet } from 'svelte'
|
||||
import { EditorView } from 'codemirror'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { configuredTheme } from './themes/configured'
|
||||
@@ -28,9 +28,10 @@
|
||||
file?: string
|
||||
returnTo?: string
|
||||
assetFields?: string[]
|
||||
toolbar?: Snippet
|
||||
}
|
||||
|
||||
let { value = $bindable(), onchange, file, returnTo, assetFields = [] }: Props = $props()
|
||||
let { value = $bindable(), onchange, file, returnTo, assetFields = [], toolbar }: Props = $props()
|
||||
|
||||
let editorContainer: HTMLDivElement
|
||||
let view: EditorView
|
||||
@@ -169,19 +170,37 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-wrapper" bind:this={editorContainer}></div>
|
||||
<div class="editor-wrapper">
|
||||
{#if toolbar}
|
||||
<div class="editor-toolbar">
|
||||
{@render toolbar()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="editor-body" bind:this={editorContainer}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-wrapper {
|
||||
background-color: var(--editor-background, var(--color-surface));
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--editor-border, var(--color-outline));
|
||||
border-radius: var(--radius-medium);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.editor-wrapper::after {
|
||||
.editor-toolbar {
|
||||
background-color: var(--editor-panelBackground, var(--color-surface-container));
|
||||
color: var(--editor-panelForeground, var(--color-on-surface));
|
||||
border-bottom: 1px solid var(--editor-border, var(--color-outline));
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-body::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createRawSnippet } from 'svelte'
|
||||
import { render } from 'svelte/server'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import MarkdownEditor from './MarkdownEditor.svelte'
|
||||
|
||||
vi.mock('./lsp', () => ({ createLSPExtension: vi.fn(), destroyLSPClient: vi.fn() }))
|
||||
vi.mock('./keybindings', () => ({ editorKeybindings: vi.fn(), finishVimInput: vi.fn() }))
|
||||
|
||||
test('editor actions render inside the same frame, before the CodeMirror mount', () => {
|
||||
const toolbar = createRawSnippet(() => ({ render: () => '<div>Editor actions fixture</div>' }))
|
||||
const { body } = render(MarkdownEditor, { props: { value: 'Saved source', toolbar } })
|
||||
|
||||
expect(body.match(/class="editor-wrapper\b/g)).toHaveLength(1)
|
||||
expect(body).toMatch(
|
||||
/class="editor-wrapper\b[^>]*>[\s\S]*class="editor-toolbar\b[^>]*>[\s\S]*Editor actions fixture[\s\S]*class="editor-body\b/
|
||||
)
|
||||
})
|
||||
|
||||
test('the editor can still render without a toolbar', () => {
|
||||
const { body } = render(MarkdownEditor, { props: { value: 'Saved source' } })
|
||||
|
||||
expect(body).toMatch(/class="editor-body\b/)
|
||||
expect(body).not.toMatch(/class="editor-toolbar\b/)
|
||||
})
|
||||
|
||||
// SSR verifies composition only; actual editor input, focus and visual framing need a browser.
|
||||
@@ -1,26 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui'
|
||||
import DotsThreeVertical from 'virtual:icons/ph/dots-three-vertical'
|
||||
import { goto } from '$app/navigation'
|
||||
import { editorHref } from '$lib/pieces/navigation'
|
||||
import CaretDown from 'virtual:icons/ph/caret-down'
|
||||
|
||||
type Props = {
|
||||
file: string
|
||||
currentMode: 'source' | 'preview'
|
||||
canGenerate: boolean
|
||||
canPublish: boolean
|
||||
disabled?: boolean
|
||||
onDelete: () => void
|
||||
onAttach?: () => void
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
let { file, currentMode, canGenerate, canPublish, onDelete, onAttach, onGenerate }: Props =
|
||||
$props()
|
||||
let { canGenerate, disabled = false, onDelete, onAttach, onGenerate }: Props = $props()
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="button" data-variant="outline" aria-label="Piece actions">
|
||||
<DotsThreeVertical />
|
||||
<DropdownMenu.Trigger class="button" data-variant="editor" {disabled}>
|
||||
More <CaretDown aria-hidden="true" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content sideOffset={8} forceMount>
|
||||
@@ -28,38 +23,25 @@
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} class="dropdown-content">
|
||||
{#if canPublish}
|
||||
<DropdownMenu.Item onSelect={() => goto('/admin/publish?check=1')}>
|
||||
{#if canGenerate && onGenerate}
|
||||
<DropdownMenu.Item onSelect={onGenerate}>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item" {...props}>publish</div>
|
||||
<div class="dropdown-item" {...props}>Generate</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if canGenerate}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (currentMode === 'source' && onGenerate) onGenerate()
|
||||
else goto(editorHref(file) + '?generate=1')
|
||||
}}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item" {...props}>generate</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if currentMode === 'source' && onAttach}
|
||||
{#if onAttach}
|
||||
<DropdownMenu.Item onSelect={onAttach}>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item" {...props}>attach</div>
|
||||
<div class="dropdown-item" {...props}>Attach</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item onSelect={onDelete}>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item destructive" {...props}>delete</div>
|
||||
<div class="dropdown-item destructive" {...props}>Delete</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
@@ -72,10 +54,17 @@
|
||||
|
||||
<style>
|
||||
.dropdown-content {
|
||||
background-color: var(--color-surface-container-highest);
|
||||
border: 1px solid var(--color-outline);
|
||||
background-color: var(--editor-panelBackground, var(--color-surface-container));
|
||||
color: var(--editor-panelForeground, var(--color-on-surface));
|
||||
border: 1px solid var(--editor-border, var(--color-outline));
|
||||
border-radius: var(--radius-small);
|
||||
min-width: 160px;
|
||||
box-sizing: border-box;
|
||||
width: 10rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
padding: var(--space-1);
|
||||
font-family: var(--font-mono-name), monospace;
|
||||
font-size: var(--font-size-xxs);
|
||||
line-height: 1.4;
|
||||
box-shadow: var(--shadow-raised);
|
||||
z-index: 1000;
|
||||
}
|
||||
@@ -83,24 +72,33 @@
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
min-height: calc(var(--space-4) + var(--space-2));
|
||||
padding: var(--space-1) var(--space-2);
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface);
|
||||
color: inherit;
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--radius-small);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
font: inherit;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--color-surface-container-low);
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item[data-highlighted] {
|
||||
background-color: var(--editor-selection, var(--color-surface-container-highest));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropdown-item:focus-visible {
|
||||
outline: 2px solid var(--editor-caret, var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.dropdown-item[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import PieceActions from './PieceActions.svelte'
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
|
||||
const source = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
|
||||
|
||||
// SSR covers only the trigger; portal actions below are source contracts,
|
||||
// not evidence of client selection, keyboard handling or focus behavior.
|
||||
test('SSR names the actions trigger', () => {
|
||||
// SSR covers the trigger, not portal actions, client selection or focus.
|
||||
test.each([false, true])('More is labeled and disabled=%s while saving', (disabled) => {
|
||||
const { body } = render(PieceActions, {
|
||||
props: {
|
||||
file: 'books/a #?.md',
|
||||
currentMode: 'source',
|
||||
canGenerate: true,
|
||||
canPublish: true,
|
||||
disabled,
|
||||
onDelete: vi.fn(),
|
||||
onAttach: vi.fn(),
|
||||
onGenerate: vi.fn()
|
||||
}
|
||||
})
|
||||
expect(body).toMatch(/<button[^>]*aria-label="Piece actions"[^>]*aria-haspopup="menu"/)
|
||||
})
|
||||
|
||||
test('preserves action gates and callbacks, with encoded generate fallback and no legacy views', () => {
|
||||
expect(source).toContain('{#if canPublish}')
|
||||
expect(source).toContain("onSelect={() => goto('/admin/publish?check=1')}")
|
||||
expect(source).toContain('{#if canGenerate}')
|
||||
expect(source).toContain("if (currentMode === 'source' && onGenerate) onGenerate()")
|
||||
expect(source).toContain("else goto(editorHref(file) + '?generate=1')")
|
||||
expect(source).toContain("{#if currentMode === 'source' && onAttach}")
|
||||
expect(source).toContain('onSelect={onAttach}')
|
||||
expect(source).toContain('onSelect={onDelete}')
|
||||
expect(source).not.toMatch(
|
||||
/isDirty|hasPublicVersion|isPublicStale|previewUrl|liveUrl|href=|>preview<|>live</
|
||||
)
|
||||
const trigger = body.match(/<button\b[^>]*aria-haspopup="menu"[^>]*>/)?.[0]
|
||||
expect(trigger).toBeDefined()
|
||||
expect(trigger).toContain('data-variant="editor"')
|
||||
expect(trigger).toContain('type="button"')
|
||||
expect(trigger).toContain('aria-expanded="false"')
|
||||
expect(body).toContain('More')
|
||||
if (disabled) expect(trigger).toMatch(/\sdisabled(?:\s|=|>)/)
|
||||
else expect(trigger).not.toMatch(/\sdisabled(?:\s|=|>)/)
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import { previewHref, publishHref, publishedHref } from '$lib/pieces/navigation'
|
||||
|
||||
type Props = {
|
||||
file: string
|
||||
saveFormId: string
|
||||
isDirty: boolean
|
||||
isSaving: boolean
|
||||
hasPublishedVersion: boolean
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
let { file, saveFormId, isDirty, isSaving, hasPublishedVersion, children }: Props = $props()
|
||||
const needsSave = $derived(isDirty || isSaving)
|
||||
</script>
|
||||
|
||||
<div class="toolbar" role="group" aria-label="Editor actions">
|
||||
<div class="actions">
|
||||
<Button variant="editor" type="submit" form={saveFormId} disabled={!isDirty || isSaving}>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="editor" disabled={needsSave} onclick={() => goto(previewHref(file))}>
|
||||
Preview
|
||||
</Button>
|
||||
<Button variant="editor" disabled={needsSave} onclick={() => goto(publishHref(file))}>
|
||||
Publish
|
||||
</Button>
|
||||
{@render children()}
|
||||
</div>
|
||||
<div class="status" aria-live="polite">
|
||||
<span>
|
||||
{#if isSaving}
|
||||
Saving…
|
||||
{:else if isDirty}
|
||||
Unsaved changes
|
||||
{:else}
|
||||
Saved
|
||||
{/if}
|
||||
</span>
|
||||
{#if hasPublishedVersion}
|
||||
<a href={publishedHref(file)}>Published</a>
|
||||
{:else}
|
||||
<span>Not published</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-family: var(--font-mono-name), monospace;
|
||||
font-size: var(--font-size-xxs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRawSnippet, type ComponentProps } from 'svelte'
|
||||
import { render } from 'svelte/server'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import PieceToolbar from './PieceToolbar.svelte'
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
|
||||
|
||||
const file = 'books/源 #?%.books.md'
|
||||
const children = createRawSnippet(() => ({ render: () => '<span>More fixture</span>' }))
|
||||
const disabledAttribute = /(?:^|\s)disabled(?:\s|=|$)/
|
||||
|
||||
function html(props: Partial<ComponentProps<typeof PieceToolbar>> = {}) {
|
||||
return render(PieceToolbar, {
|
||||
props: {
|
||||
file,
|
||||
saveFormId: 'save-piece',
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
hasPublishedVersion: true,
|
||||
children,
|
||||
...props
|
||||
}
|
||||
}).body
|
||||
}
|
||||
|
||||
function buttonAttributes(output: string, label: string): string {
|
||||
const buttons = [...output.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
|
||||
const button = buttons.find((match) => match[2].replace(/<!--[\s\S]*?-->/g, '').trim() === label)
|
||||
if (!button) throw new Error(`Missing button: ${label}`)
|
||||
return button[1]
|
||||
}
|
||||
|
||||
test.each([false, true])(
|
||||
'saved files keep Preview and Publish visible and available with published version=%s',
|
||||
(hasPublishedVersion) => {
|
||||
const output = html({ hasPublishedVersion })
|
||||
expect(buttonAttributes(output, 'Preview')).not.toMatch(disabledAttribute)
|
||||
expect(buttonAttributes(output, 'Publish')).not.toMatch(disabledAttribute)
|
||||
expect(buttonAttributes(output, 'Save')).toMatch(disabledAttribute)
|
||||
expect(output).toContain('More fixture')
|
||||
expect(output).toContain('Saved')
|
||||
expect(output).toContain('aria-live="polite"')
|
||||
}
|
||||
)
|
||||
|
||||
test('unsaved edits keep primary actions visible with a short status', () => {
|
||||
const output = html({ isDirty: true })
|
||||
expect(buttonAttributes(output, 'Save')).not.toMatch(disabledAttribute)
|
||||
expect(buttonAttributes(output, 'Preview')).toMatch(disabledAttribute)
|
||||
expect(buttonAttributes(output, 'Publish')).toMatch(disabledAttribute)
|
||||
expect(output).toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
test.each([false, true])('saving disables primary actions even when dirty=%s', (isDirty) => {
|
||||
const output = html({ isDirty, isSaving: true })
|
||||
for (const label of ['Saving…', 'Preview', 'Publish']) {
|
||||
expect(buttonAttributes(output, label)).toMatch(disabledAttribute)
|
||||
}
|
||||
expect(output).not.toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ isDirty: false, isSaving: false },
|
||||
{ isDirty: true, isSaving: false },
|
||||
{ isDirty: false, isSaving: true },
|
||||
{ isDirty: true, isSaving: true }
|
||||
])('editor commands retain their explicit appearance with %o', (state) => {
|
||||
const output = html(state)
|
||||
for (const label of [state.isSaving ? 'Saving…' : 'Save', 'Preview', 'Publish']) {
|
||||
expect(buttonAttributes(output, label)).toContain('data-variant="editor"')
|
||||
}
|
||||
})
|
||||
|
||||
test('Save submits the source form instead of navigating or implicitly publishing', () => {
|
||||
const attributes = buttonAttributes(html({ isDirty: true }), 'Save')
|
||||
expect(attributes).toContain('type="submit"')
|
||||
expect(attributes).toContain('form="save-piece"')
|
||||
for (const label of ['Preview', 'Publish']) {
|
||||
expect(buttonAttributes(html(), label)).toContain('type="button"')
|
||||
}
|
||||
})
|
||||
|
||||
test('publication status is an encoded link only when a public version exists', () => {
|
||||
const href = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published'
|
||||
expect(html()).toContain(`href="${href}"`)
|
||||
const unpublished = html({ hasPublishedVersion: false })
|
||||
expect(unpublished).toContain('Not published')
|
||||
expect(unpublished).not.toContain(href)
|
||||
})
|
||||
|
||||
test('editor actions do not duplicate the surrounding archive navigation', () => {
|
||||
const output = html()
|
||||
expect(output).toContain('aria-label="Editor actions"')
|
||||
expect(output).not.toContain('href="/admin"')
|
||||
expect(output).not.toContain('Back to archive')
|
||||
expect(output).not.toContain('Publish…')
|
||||
})
|
||||
|
||||
// SSR proves rendering and disabled/form semantics, not click handling or navigation guards.
|
||||
@@ -1,122 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui'
|
||||
import { goto } from '$app/navigation'
|
||||
import { publishedHref, previewHref } from '$lib/pieces/navigation'
|
||||
import { getViewAvailability } from './view-state'
|
||||
|
||||
type Props = {
|
||||
file: string
|
||||
hasPublishedVersion: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
let { file, hasPublishedVersion, isSaving }: Props = $props()
|
||||
const id = $props.id()
|
||||
const availability = $derived(getViewAvailability(hasPublishedVersion, isSaving))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class="button" data-variant="outline">View</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content sideOffset={8} forceMount>
|
||||
{#snippet child({ open, props, wrapperProps })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} class="dropdown-content">
|
||||
<DropdownMenu.Item
|
||||
textValue="Published"
|
||||
disabled={!availability.published}
|
||||
onSelect={() => goto(publishedHref(file))}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
{...props}
|
||||
class="dropdown-item"
|
||||
aria-labelledby={`${id}-published-label`}
|
||||
aria-describedby={!availability.published
|
||||
? `${id}-published-description`
|
||||
: undefined}
|
||||
>
|
||||
<span id={`${id}-published-label`}>Published</span>
|
||||
{#if !availability.published}
|
||||
<span id={`${id}-published-description`} class="description">
|
||||
{#if isSaving}Saving…{:else}Not published yet{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
textValue="Preview"
|
||||
disabled={!availability.preview}
|
||||
onSelect={() => goto(previewHref(file))}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
{...props}
|
||||
class="dropdown-item"
|
||||
aria-labelledby={`${id}-preview-label`}
|
||||
aria-describedby={!availability.preview
|
||||
? `${id}-preview-description`
|
||||
: undefined}
|
||||
>
|
||||
<span id={`${id}-preview-label`}>Preview</span>
|
||||
{#if !availability.preview}
|
||||
<span id={`${id}-preview-description`} class="description">Saving…</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<style>
|
||||
.dropdown-content {
|
||||
background-color: var(--color-surface-container-highest);
|
||||
border: 1px solid var(--color-outline);
|
||||
border-radius: var(--radius-small);
|
||||
min-width: 160px;
|
||||
box-shadow: var(--shadow-raised);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
color: var(--color-on-surface);
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--radius-small);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item[data-highlighted] {
|
||||
background-color: var(--color-surface-container-low);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropdown-item:focus-visible {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
.dropdown-item[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { render } from 'svelte/server'
|
||||
import PieceView from './PieceView.svelte'
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
|
||||
const source = readFileSync(new URL('./PieceView.svelte', import.meta.url), 'utf8')
|
||||
|
||||
// Bits portals do not render menu contents in SSR. These tests establish the
|
||||
// trigger and source contracts, not popup visibility, clicks, warnings or focus.
|
||||
test.each([false, true])('SSR keeps View available while saving=%s', (isSaving) => {
|
||||
const { body } = render(PieceView, {
|
||||
props: { file: 'books/a #?.md', hasPublishedVersion: false, isSaving }
|
||||
})
|
||||
expect(body.replace(/<!--.*?-->/g, '')).toMatch(
|
||||
/<button[^>]*aria-haspopup="menu"[^>]*>View<\/button>/
|
||||
)
|
||||
expect(body).not.toMatch(/<button[^>]*disabled/)
|
||||
})
|
||||
|
||||
test('both literal items use native disabled state and current-prop navigation callbacks', () => {
|
||||
expect(source).toContain('$derived(getViewAvailability(hasPublishedVersion, isSaving))')
|
||||
expect(source).toMatch(/class="dropdown-content">\s*<DropdownMenu.Item/)
|
||||
expect(source).toMatch(/<\/DropdownMenu.Item>\s*<DropdownMenu.Item/)
|
||||
expect(source.match(/<DropdownMenu.Item\s/g)).toHaveLength(2)
|
||||
for (const [view, helper] of [
|
||||
['published', 'publishedHref'],
|
||||
['preview', 'previewHref']
|
||||
]) {
|
||||
expect(source).toContain(`disabled={!availability.${view}}`)
|
||||
expect(source).toContain(`onSelect={() => goto(${helper}(file))}`)
|
||||
}
|
||||
expect(source).not.toMatch(/<a\b|\bhref=|preload|autoSave|bypassWarning|setTimeout|async\b/)
|
||||
expect(source).not.toMatch(/isDirty|markdownStatus|content_hash|previewUrl|publishedUrl/)
|
||||
})
|
||||
|
||||
test('source editor wires availability and encoded actions without replacing its unsaved guard', () => {
|
||||
const editor = readFileSync(
|
||||
new URL('../../../routes/(auth)/admin/piece/[...path]/source/+page.svelte', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
expect(editor).toContain(
|
||||
'<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />'
|
||||
)
|
||||
expect(editor).toContain(
|
||||
'$derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))'
|
||||
)
|
||||
expect(editor).toContain('$derived(editorHref(data.file))')
|
||||
for (const action of ['save', 'delete'])
|
||||
expect(editor).toContain(`action="{sourceUrl}?/${action}"`)
|
||||
expect(editor).toContain('fetch(`${sourceUrl}?/attach`, {')
|
||||
expect(editor).toContain(
|
||||
"if (isDirty && !bypassWarning && navigation.type !== 'form' && navigation.to)"
|
||||
)
|
||||
expect(editor).toContain('navigation.cancel()')
|
||||
expect(editor).toContain('onclick={confirmDiscard}')
|
||||
})
|
||||
|
||||
test('View menu source matches the original actions menu presentation', () => {
|
||||
const original = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
|
||||
for (const pattern of [
|
||||
/\.dropdown-content\s*\{([^}]+)\}/,
|
||||
/\.dropdown-item\s*\{([^}]+)\}/,
|
||||
/\.dropdown-item\[data-disabled\]\s*\{([^}]+)\}/
|
||||
]) {
|
||||
const expected = original.match(pattern)?.[1]
|
||||
const actual = source.match(pattern)?.[1]
|
||||
expect(expected).toBeDefined()
|
||||
expect(actual).toBeDefined()
|
||||
expect(actual?.trim()).toBe(expected?.trim())
|
||||
}
|
||||
const hover = original.match(/\.dropdown-item:hover\s*\{([^}]+)\}/)?.[1]
|
||||
const highlight = source.match(/\.dropdown-item\[data-highlighted\]\s*\{([^}]+)\}/)?.[1]
|
||||
expect(hover).toBeDefined()
|
||||
expect(highlight).toBeDefined()
|
||||
expect(highlight?.trim()).toBe(hover?.trim())
|
||||
expect(source).toMatch(/\.dropdown-item:focus-visible\s*\{\s*outline: auto;/)
|
||||
})
|
||||
|
||||
test('menuitem source keeps labels and short reasons only while disabled', () => {
|
||||
const markup = source.replace(/\s+/g, ' ')
|
||||
expect(source).toContain('const id = $props.id()')
|
||||
for (const [view, label] of [
|
||||
['published', 'Published'],
|
||||
['preview', 'Preview']
|
||||
]) {
|
||||
expect(source).toContain(`<span id={\`\${id}-${view}-label\`}>${label}</span>`)
|
||||
expect(source).toContain(`aria-labelledby={\`\${id}-${view}-label\`}`)
|
||||
expect(markup).toContain(
|
||||
`aria-describedby={!availability.${view} ? \`\${id}-${view}-description\` : undefined}`
|
||||
)
|
||||
expect(markup).toContain(
|
||||
`{#if !availability.${view}} <span id={\`\${id}-${view}-description\`} class="description">`
|
||||
)
|
||||
}
|
||||
expect(source.match(/\{#snippet child\(\{ props \}\)\}\s*<div\s*\{\.\.\.props\}/g)).toHaveLength(
|
||||
2
|
||||
)
|
||||
expect(source.match(/Saving…/g)).toHaveLength(2)
|
||||
expect(source).toContain('Not published yet')
|
||||
for (const verbose of [
|
||||
'Wait for saving to finish.',
|
||||
'No published version yet.',
|
||||
'View the published version.',
|
||||
'Uses saved Markdown.'
|
||||
])
|
||||
expect(source).not.toContain(verbose)
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { reconcileSave, reconcileSourceRefresh } from './save-state'
|
||||
|
||||
const file = 'a.note.md'
|
||||
const submitted = { file, content: 'A' }
|
||||
const saved = { type: 'success' as const, content: 'A\n' }
|
||||
|
||||
test('an unchanged submitted buffer adopts normalized source and is saved', () => {
|
||||
const content = reconcileSave(submitted, submitted, saved)
|
||||
expect(content).toBe('A\n')
|
||||
expect(content === saved.content).toBe(true)
|
||||
})
|
||||
|
||||
test('typing B during Save A preserves B against the refreshed A baseline', () => {
|
||||
const content = reconcileSave({ file, content: 'B' }, submitted, saved)
|
||||
expect(content).toBe('B')
|
||||
expect(content !== saved.content).toBe(true)
|
||||
|
||||
// The retained draft can be submitted next, then adopt its own normalization.
|
||||
const nextSubmission = { file, content }
|
||||
const nextSaved = { type: 'success' as const, content: 'B\n' }
|
||||
const nextContent = reconcileSave(nextSubmission, nextSubmission, nextSaved)
|
||||
expect(nextContent).toBe('B\n')
|
||||
expect(nextContent === nextSaved.content).toBe(true)
|
||||
})
|
||||
|
||||
test('a failed Save echoing A does not replace newer B or mark it saved', () => {
|
||||
const baseline = 'original source'
|
||||
const content = reconcileSave({ file, content: 'B' }, submitted, {
|
||||
type: 'failure',
|
||||
content: 'A'
|
||||
})
|
||||
expect(content).toBe('B')
|
||||
expect(content !== baseline).toBe(true)
|
||||
})
|
||||
|
||||
test('failure preserves an unchanged submitted draft too', () => {
|
||||
expect(reconcileSave(submitted, submitted, { type: 'failure', content: 'error A' })).toBe('A')
|
||||
expect(reconcileSave(submitted, submitted, { type: 'failure' })).toBe('A')
|
||||
})
|
||||
|
||||
test('clearing the buffer while Save is pending remains an unsaved edit', () => {
|
||||
const content = reconcileSave({ file, content: '' }, submitted, saved)
|
||||
expect(content).toBe('')
|
||||
expect(content !== saved.content).toBe(true)
|
||||
})
|
||||
|
||||
test('an old file response cannot replace the newly loaded file, even with identical text', () => {
|
||||
const current = { file: 'other.note.md', content: 'A' }
|
||||
expect(reconcileSave(current, submitted, saved)).toBe(current.content)
|
||||
expect(reconcileSave(current, submitted, { type: 'failure', content: 'error A' })).toBe(
|
||||
current.content
|
||||
)
|
||||
})
|
||||
|
||||
test('pristine same-file refreshes adopt each new saved baseline', () => {
|
||||
const refreshed = { file, content: 'B' }
|
||||
const content = reconcileSourceRefresh('A', submitted, refreshed, false)
|
||||
expect(content).toBe('B')
|
||||
expect(content).toBe(refreshed.content)
|
||||
|
||||
const nextRefresh = { file, content: 'C' }
|
||||
expect(reconcileSourceRefresh(content, refreshed, nextRefresh, false)).toBe('C')
|
||||
})
|
||||
|
||||
test('same-file refreshes preserve dirty drafts against the latest loaded baseline', () => {
|
||||
const refreshed = { file, content: 'B' }
|
||||
const content = reconcileSourceRefresh('draft', submitted, refreshed, false)
|
||||
expect(content).toBe('draft')
|
||||
expect(content).not.toBe(refreshed.content)
|
||||
|
||||
// Returning to historical A is still dirty against the now-loaded B baseline.
|
||||
const nextRefresh = { file, content: 'C' }
|
||||
expect(reconcileSourceRefresh('A', refreshed, nextRefresh, false)).toBe('A')
|
||||
})
|
||||
|
||||
test('a pending Save defers even a pristine buffer refresh to save reconciliation', () => {
|
||||
const refreshed = { file, content: saved.content }
|
||||
const content = reconcileSourceRefresh('A', submitted, refreshed, true)
|
||||
expect(content).toBe('A')
|
||||
expect(reconcileSave({ file, content }, submitted, saved)).toBe(refreshed.content)
|
||||
})
|
||||
|
||||
test('typing B survives refresh during Save A and either save outcome', () => {
|
||||
const refreshed = { file, content: saved.content }
|
||||
const content = reconcileSourceRefresh('B', submitted, refreshed, true)
|
||||
expect(content).toBe('B')
|
||||
expect(reconcileSave({ file, content }, submitted, saved)).toBe('B')
|
||||
expect(reconcileSave({ file, content }, submitted, { type: 'failure', content: 'A' })).toBe('B')
|
||||
expect(content).not.toBe(refreshed.content)
|
||||
})
|
||||
|
||||
test('navigation always loads the new file despite a dirty buffer or pending Save', () => {
|
||||
const refreshed = { file: 'other.note.md', content: 'other source' }
|
||||
expect(reconcileSourceRefresh('draft', submitted, refreshed, false)).toBe('other source')
|
||||
const content = reconcileSourceRefresh('draft', submitted, refreshed, true)
|
||||
expect(content).toBe('other source')
|
||||
expect(reconcileSave({ file: refreshed.file, content }, submitted, saved)).toBe(content)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
export type SaveSnapshot = { file: string; content: string }
|
||||
|
||||
export function reconcileSourceRefresh(
|
||||
content: string,
|
||||
previous: SaveSnapshot,
|
||||
refreshed: SaveSnapshot,
|
||||
savePending: boolean
|
||||
): string {
|
||||
if (refreshed.file !== previous.file || (!savePending && content === previous.content)) {
|
||||
return refreshed.content
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
type SaveResult = { type: 'success'; content: string } | { type: 'failure'; content?: string }
|
||||
|
||||
export function reconcileSave(
|
||||
current: SaveSnapshot,
|
||||
submitted: SaveSnapshot,
|
||||
result: SaveResult
|
||||
): string {
|
||||
if (
|
||||
result.type === 'failure' ||
|
||||
current.file !== submitted.file ||
|
||||
current.content !== submitted.content
|
||||
) {
|
||||
return current.content
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { MarkdownPublicationStatus } from '$lib/pieces/types'
|
||||
import { getViewAvailability, shouldOfferPublish } from './view-state'
|
||||
|
||||
test.each([
|
||||
[false, false, { published: false, preview: true }],
|
||||
[true, false, { published: true, preview: true }],
|
||||
[false, true, { published: false, preview: false }],
|
||||
[true, true, { published: false, preview: false }]
|
||||
])('availability: published row=%s, saving=%s', (hasPublishedVersion, isSaving, expected) => {
|
||||
expect(getViewAvailability(hasPublishedVersion, isSaving)).toEqual(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[false, MarkdownPublicationStatus.MATCHING, true],
|
||||
[false, MarkdownPublicationStatus.CHANGED, true],
|
||||
[false, MarkdownPublicationStatus.UNKNOWN, true],
|
||||
[true, MarkdownPublicationStatus.MATCHING, false],
|
||||
[true, MarkdownPublicationStatus.CHANGED, true],
|
||||
[true, MarkdownPublicationStatus.UNKNOWN, true]
|
||||
])('publish offer: published row=%s, status=%s', (hasPublishedVersion, status, expected) => {
|
||||
expect(shouldOfferPublish(hasPublishedVersion, status)).toBe(expected)
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MarkdownPublicationStatus } from '$lib/pieces/types'
|
||||
|
||||
export function getViewAvailability(hasPublishedVersion: boolean, isSaving: boolean) {
|
||||
return {
|
||||
published: hasPublishedVersion && !isSaving,
|
||||
preview: !isSaving
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldOfferPublish(
|
||||
hasPublishedVersion: boolean,
|
||||
status: MarkdownPublicationStatus
|
||||
): boolean {
|
||||
return !hasPublishedVersion || status !== MarkdownPublicationStatus.MATCHING
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'outline' | 'link'
|
||||
variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'outline' | 'link' | 'editor'
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
form?: string
|
||||
onclick?: (e: MouseEvent) => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { editorHref, getPieceLinks, previewHref, publishedHref } from './navigation'
|
||||
import { editorHref, getPieceLinks, previewHref, publishedHref, publishHref } from './navigation'
|
||||
|
||||
const { resolve } = vi.hoisted(() => ({
|
||||
resolve: vi.fn(() => {
|
||||
@@ -52,6 +52,29 @@ test.each([
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
'books/源 #?%&"+=.books.md',
|
||||
'folder with spaces/a book.books.md',
|
||||
'nested/literal%2F.books.md',
|
||||
'books/📚.books.md',
|
||||
'nested/name?check=0&file=other#fragment.books.md'
|
||||
])('publish navigation preselects the complete raw filename %s', (file) => {
|
||||
const url = new URL(publishHref(file), 'https://app.test')
|
||||
expect(url.pathname).toBe('/admin/publish')
|
||||
expect(url.hash).toBe('')
|
||||
expect([...url.searchParams.entries()]).toEqual([
|
||||
['check', '1'],
|
||||
['file', file]
|
||||
])
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('publish navigation encodes a query value rather than a pathname', () => {
|
||||
expect(publishHref('folder/a b+c.books.md')).toBe(
|
||||
'/admin/publish?check=1&file=folder%2Fa+b%2Bc.books.md'
|
||||
)
|
||||
})
|
||||
|
||||
test.each(
|
||||
['', '/garden'].flatMap((mount) =>
|
||||
['', '/page', '/icon', '/share'].map((suffix) => ({ mount, suffix }))
|
||||
|
||||
@@ -10,6 +10,11 @@ export function publishedHref(file: string): string {
|
||||
return `/admin/piece/${piecePath(file)}/published`
|
||||
}
|
||||
|
||||
export function publishHref(file: string): string {
|
||||
const query = new URLSearchParams({ check: '1', file })
|
||||
return `/admin/publish?${query}`
|
||||
}
|
||||
|
||||
export function previewHref(file: string, job?: string): string {
|
||||
const href = `/admin/piece/${piecePath(file)}/preview`
|
||||
return job === undefined ? href : `${href}/${encodeURIComponent(job)}`
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from '@luzzle/core'
|
||||
import { Readable } from 'stream'
|
||||
import { normalizeLineEndings, normalizeMarkdown } from '$lib/server/markdown'
|
||||
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
|
||||
|
||||
async function resolveAssetUrls(
|
||||
piece: Piece<PieceFrontmatter>,
|
||||
@@ -103,8 +102,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
schema: piece.schema,
|
||||
canGenerate: config.ai !== undefined,
|
||||
assetFields: assetPaths,
|
||||
hasPublishedVersion: Boolean(webPiece),
|
||||
savedMarkdown: await getMarkdownPublicationStatus(file, webPiece?.content_hash)
|
||||
hasPublishedVersion: Boolean(webPiece)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { deserialize, enhance } from '$app/forms'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import MarkdownEditor from '$lib/components/editor/MarkdownEditor.svelte'
|
||||
import {
|
||||
reconcileSave,
|
||||
reconcileSourceRefresh,
|
||||
type SaveSnapshot
|
||||
} from '$lib/components/editor/save-state'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import PieceActions from '$lib/components/editor/PieceActions.svelte'
|
||||
import PieceView from '$lib/components/editor/PieceView.svelte'
|
||||
import { shouldOfferPublish } from '$lib/components/editor/view-state'
|
||||
import PieceToolbar from '$lib/components/editor/PieceToolbar.svelte'
|
||||
import { editorHref } from '$lib/pieces/navigation'
|
||||
import GenerationDialog from '$lib/components/editor/GenerationDialog.svelte'
|
||||
import type { PageProps } from './$types'
|
||||
@@ -13,16 +18,17 @@
|
||||
|
||||
import { page } from '$app/state'
|
||||
import { beforeNavigate, goto, replaceState } from '$app/navigation'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { onMount, onDestroy, tick, untrack } from 'svelte'
|
||||
|
||||
let { data, form }: PageProps = $props()
|
||||
const saveFormId = $props.id()
|
||||
|
||||
let targetUrl = $state<string | null>(null)
|
||||
let showWarningDialog = $state(false)
|
||||
let bypassWarning = $state(false)
|
||||
let isSaving = $state(false)
|
||||
let saveSuccess = $state(false)
|
||||
let editorError = $state('')
|
||||
let editorErrorReason = $state<'sign-in' | 'save' | 'refresh' | null>(null)
|
||||
let editorErrorDetail = $state('')
|
||||
let disposed = false
|
||||
onDestroy(() => {
|
||||
disposed = true
|
||||
@@ -57,31 +63,87 @@
|
||||
let dialog: HTMLDialogElement
|
||||
let editorRef = $state<MarkdownEditor>()
|
||||
|
||||
let rawContent = $state<string>(
|
||||
(form && 'rawContent' in form ? (form.rawContent as string) : undefined) ||
|
||||
data.rawContent ||
|
||||
''
|
||||
)
|
||||
function initialRawContent(): string {
|
||||
if (form && 'rawContent' in form) {
|
||||
if (typeof form.rawContent === 'string') return form.rawContent
|
||||
}
|
||||
if (data.rawContent != null) return data.rawContent
|
||||
return ''
|
||||
}
|
||||
|
||||
let rawContent = $state(initialRawContent())
|
||||
let loadedSource = untrack(() => ({ file: data.file, content: data.rawContent ?? '' }))
|
||||
let activeSave: SaveSnapshot | undefined
|
||||
|
||||
const enhanceSave: SubmitFunction = ({ formData, cancel }) => {
|
||||
if (isSaving) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
const submitted = { file: data.file, content: String(formData.get('content') ?? '') }
|
||||
activeSave = submitted
|
||||
isSaving = true
|
||||
editorErrorReason = null
|
||||
editorErrorDetail = ''
|
||||
const isCurrentSave = () =>
|
||||
!disposed && activeSave === submitted && data.file === submitted.file
|
||||
return async ({ result, update }) => {
|
||||
if (!isCurrentSave()) return
|
||||
try {
|
||||
if (result.type === 'redirect') {
|
||||
editorErrorReason = 'sign-in'
|
||||
return
|
||||
}
|
||||
if (result.type === 'error') {
|
||||
editorErrorReason = 'save'
|
||||
return
|
||||
}
|
||||
await update({ reset: false })
|
||||
await tick()
|
||||
if (!isCurrentSave()) return
|
||||
const saveResult =
|
||||
result.type === 'success'
|
||||
? { type: result.type, content: data.rawContent ?? '' }
|
||||
: { type: result.type }
|
||||
rawContent = reconcileSave({ file: data.file, content: rawContent }, submitted, saveResult)
|
||||
} catch {
|
||||
if (isCurrentSave()) {
|
||||
editorErrorReason = 'refresh'
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentSave()) {
|
||||
activeSave = undefined
|
||||
isSaving = false
|
||||
bypassWarning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = $derived(rawContent !== (data.rawContent ?? ''))
|
||||
const isDirty = $derived(!!form?.error || (hasChanges && !isSaving))
|
||||
const isDirty = $derived(!!form?.error || hasChanges)
|
||||
|
||||
const sourceUrl = $derived(editorHref(data.file))
|
||||
const canPublish = $derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'rawContent' in form && form.rawContent) {
|
||||
rawContent = form.rawContent as string
|
||||
} else {
|
||||
rawContent = data.rawContent || ''
|
||||
}
|
||||
const refreshed = { file: data.file, content: data.rawContent ?? '' }
|
||||
untrack(() => {
|
||||
const changedFile = refreshed.file !== loadedSource.file
|
||||
rawContent = reconcileSourceRefresh(rawContent, loadedSource, refreshed, !!activeSave)
|
||||
loadedSource = refreshed
|
||||
if (!changedFile) return
|
||||
activeSave = undefined
|
||||
isSaving = false
|
||||
bypassWarning = false
|
||||
})
|
||||
})
|
||||
|
||||
let generateDialogOpen = $state(false)
|
||||
$effect(() => {
|
||||
if (data.file) {
|
||||
generateDialogOpen = false
|
||||
editorError = ''
|
||||
editorErrorReason = null
|
||||
editorErrorDetail = ''
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
@@ -100,7 +162,10 @@
|
||||
let customName = $state('')
|
||||
let isNameManuallyEdited = $state(false)
|
||||
let isUploading = $state(false)
|
||||
let uploadError = $state<string | null>(null)
|
||||
let uploadErrorReason = $state<
|
||||
'file-required' | 'url-required' | 'filename-required' | 'failed' | null
|
||||
>(null)
|
||||
let uploadErrorDetail = $state<string | null>(null)
|
||||
let fileInputRef = $state<HTMLInputElement>()
|
||||
|
||||
function getBasenameWithoutExtension(filename: string): string {
|
||||
@@ -131,32 +196,55 @@
|
||||
}
|
||||
})
|
||||
|
||||
function openAttachDialog() {
|
||||
const selectedText = editorRef?.getSelectedText()?.trim() || ''
|
||||
let isUrl = false
|
||||
if (selectedText.startsWith('http://') || selectedText.startsWith('https://')) {
|
||||
try {
|
||||
new URL(selectedText)
|
||||
isUrl = true
|
||||
} catch (_) {
|
||||
// Ignore invalid URL formatting
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
activeTab = 'url'
|
||||
urlValue = selectedText
|
||||
} else {
|
||||
activeTab = 'file'
|
||||
}
|
||||
attachDialogOpen = true
|
||||
}
|
||||
|
||||
function resetAttachState() {
|
||||
selectedFile = null
|
||||
urlValue = ''
|
||||
customName = ''
|
||||
isNameManuallyEdited = false
|
||||
uploadError = null
|
||||
uploadErrorReason = null
|
||||
uploadErrorDetail = null
|
||||
isUploading = false
|
||||
}
|
||||
|
||||
async function handleAttachSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
uploadErrorReason = null
|
||||
uploadErrorDetail = null
|
||||
if (activeTab === 'file' && !selectedFile) {
|
||||
uploadError = 'Please select a file to upload.'
|
||||
uploadErrorReason = 'file-required'
|
||||
return
|
||||
}
|
||||
if (activeTab === 'url' && !urlValue) {
|
||||
uploadError = 'Please enter a URL.'
|
||||
uploadErrorReason = 'url-required'
|
||||
return
|
||||
}
|
||||
if (activeTab === 'create' && !customName.trim()) {
|
||||
uploadError = 'Please enter a filename.'
|
||||
uploadErrorReason = 'filename-required'
|
||||
return
|
||||
}
|
||||
|
||||
isUploading = true
|
||||
uploadError = null
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('mode', activeTab)
|
||||
@@ -183,13 +271,14 @@
|
||||
attachDialogOpen = false
|
||||
resetAttachState()
|
||||
} else {
|
||||
uploadError = resData?.error?.message || 'Attachment failed'
|
||||
uploadErrorDetail = resData?.error?.message || null
|
||||
if (!uploadErrorDetail) uploadErrorReason = 'failed'
|
||||
}
|
||||
} else {
|
||||
uploadError = 'Attachment failed'
|
||||
uploadErrorReason = 'failed'
|
||||
}
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err.message : String(err)
|
||||
uploadErrorDetail = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
isUploading = false
|
||||
}
|
||||
@@ -204,14 +293,14 @@
|
||||
fields={Object.keys(data.schema.properties ?? {}).filter((key) => key !== '__proto__')}
|
||||
onFocusEditor={() => editorRef?.focus()}
|
||||
onError={(message) => {
|
||||
editorError = message
|
||||
saveSuccess = false
|
||||
editorErrorReason = null
|
||||
editorErrorDetail = message
|
||||
}}
|
||||
onApply={(source, markdown) => {
|
||||
if (!editorRef) throw new Error('The editor is not ready. Please try again.')
|
||||
editorRef.replaceContent(markdown, source)
|
||||
saveSuccess = false
|
||||
editorError = ''
|
||||
editorErrorReason = null
|
||||
editorErrorDetail = ''
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
@@ -332,9 +421,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if uploadError}
|
||||
{#if uploadErrorReason || uploadErrorDetail}
|
||||
<div class="dialog-error">
|
||||
{uploadError}
|
||||
{#if uploadErrorReason === 'file-required'}
|
||||
Please select a file to upload.
|
||||
{:else if uploadErrorReason === 'url-required'}
|
||||
Please enter a URL.
|
||||
{:else if uploadErrorReason === 'filename-required'}
|
||||
Please enter a filename.
|
||||
{:else if uploadErrorReason === 'failed'}
|
||||
Attachment failed
|
||||
{:else}
|
||||
{uploadErrorDetail}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -419,99 +518,56 @@
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="piece-page">
|
||||
<div class="header">
|
||||
<div style="display:flex; gap: var(--space-2);">
|
||||
<form
|
||||
method="post"
|
||||
action="{sourceUrl}?/save"
|
||||
use:enhance={() => {
|
||||
isSaving = true
|
||||
bypassWarning = true
|
||||
saveSuccess = false
|
||||
editorError = ''
|
||||
return async ({ result, update }) => {
|
||||
if (disposed) return
|
||||
try {
|
||||
if (result.type === 'redirect') {
|
||||
editorError =
|
||||
'Please sign in again in another tab, then retry. Your edits are still here.'
|
||||
return
|
||||
}
|
||||
if (result.type === 'error') {
|
||||
editorError = 'Unable to save. Your edits are still here. Please try again.'
|
||||
return
|
||||
}
|
||||
if (result.type === 'success') saveSuccess = true
|
||||
await update({ reset: false })
|
||||
} finally {
|
||||
isSaving = false
|
||||
bypassWarning = false
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="content" value={rawContent} />
|
||||
<Button type="submit" disabled={!isDirty || isSaving}>
|
||||
{isSaving ? 'saving...' : 'save'}
|
||||
</Button>
|
||||
</form>
|
||||
<a href="/admin">
|
||||
<Button variant="outline">cancel</Button>
|
||||
</a>
|
||||
</div>
|
||||
<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />
|
||||
<PieceActions
|
||||
file={data.file}
|
||||
currentMode="source"
|
||||
canGenerate={data.canGenerate && !isSaving}
|
||||
{canPublish}
|
||||
onDelete={() => dialog.showModal()}
|
||||
onGenerate={() => {
|
||||
editorError = ''
|
||||
generateDialogOpen = true
|
||||
}}
|
||||
onAttach={() => {
|
||||
const selectedText = editorRef?.getSelectedText()?.trim() || ''
|
||||
let isUrl = false
|
||||
if (selectedText.startsWith('http://') || selectedText.startsWith('https://')) {
|
||||
try {
|
||||
new URL(selectedText)
|
||||
isUrl = true
|
||||
} catch (_) {
|
||||
// Ignore invalid URL formatting
|
||||
}
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
activeTab = 'url'
|
||||
urlValue = selectedText
|
||||
} else {
|
||||
activeTab = 'file'
|
||||
}
|
||||
attachDialogOpen = true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if saveSuccess}
|
||||
<div class="banner success-banner">Piece saved successfully!</div>
|
||||
{/if}
|
||||
|
||||
{#if editorError || form?.error}
|
||||
<form id={saveFormId} hidden method="post" action="{sourceUrl}?/save" use:enhance={enhanceSave}>
|
||||
<input type="hidden" name="content" value={rawContent} />
|
||||
</form>
|
||||
{#if editorErrorReason || editorErrorDetail || form?.error}
|
||||
<div class="banner error-banner" role="alert">
|
||||
<strong>Error:</strong>
|
||||
{editorError || form?.error?.message}
|
||||
{#if editorErrorReason === 'sign-in'}
|
||||
Please sign in again in another tab, then retry. Your edits are still here.
|
||||
{:else if editorErrorReason === 'save'}
|
||||
Unable to save. Your edits are still here. Please try again.
|
||||
{:else if editorErrorReason === 'refresh'}
|
||||
Unable to refresh after saving. Your edits are still here. Please try again.
|
||||
{:else}
|
||||
{editorErrorDetail || form?.error?.message}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-container">
|
||||
<MarkdownEditor
|
||||
bind:this={editorRef}
|
||||
bind:value={rawContent}
|
||||
file={data.file}
|
||||
returnTo={page.url.pathname}
|
||||
assetFields={data.assetFields}
|
||||
/>
|
||||
{#key data.file}
|
||||
<MarkdownEditor
|
||||
bind:this={editorRef}
|
||||
bind:value={rawContent}
|
||||
file={data.file}
|
||||
returnTo={page.url.pathname}
|
||||
assetFields={data.assetFields}
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
<PieceToolbar
|
||||
file={data.file}
|
||||
{saveFormId}
|
||||
{isDirty}
|
||||
{isSaving}
|
||||
hasPublishedVersion={data.hasPublishedVersion}
|
||||
>
|
||||
<PieceActions
|
||||
canGenerate={data.canGenerate}
|
||||
disabled={isSaving}
|
||||
onDelete={() => dialog.showModal()}
|
||||
onGenerate={() => {
|
||||
editorErrorReason = null
|
||||
editorErrorDetail = ''
|
||||
generateDialogOpen = true
|
||||
}}
|
||||
onAttach={openAttachDialog}
|
||||
/>
|
||||
</PieceToolbar>
|
||||
{/snippet}
|
||||
</MarkdownEditor>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -527,15 +583,6 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-small);
|
||||
@@ -543,12 +590,6 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background-color: var(--color-success-container, #d4edda);
|
||||
color: var(--color-on-success-container, #155724);
|
||||
border: 1px solid var(--color-success, #c3e6cb);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background-color: var(--color-error-container);
|
||||
color: var(--color-on-error-container);
|
||||
|
||||
@@ -16,12 +16,9 @@ import { actions, load } from './+page.server'
|
||||
import { config } from '$lib/server/config'
|
||||
import { db } from '$lib/server/database'
|
||||
import type { WebPieces } from '@luzzle/web.db'
|
||||
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
|
||||
import { MarkdownPublicationStatus } from '$lib/pieces/types'
|
||||
|
||||
vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
|
||||
vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() }))
|
||||
vi.mock('$lib/pieces/publication.server', () => ({ getMarkdownPublicationStatus: vi.fn() }))
|
||||
vi.mock('$lib/server/database', () => ({ db: {} }))
|
||||
vi.mock('$lib/server/config', async () => {
|
||||
const { makeConfig } = await import('$lib/server/config.fixture')
|
||||
@@ -294,13 +291,12 @@ function setupLoad() {
|
||||
writeFile: vi.fn()
|
||||
})
|
||||
vi.mocked(getWebPiece).mockResolvedValue(undefined)
|
||||
vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(MarkdownPublicationStatus.UNKNOWN)
|
||||
const event = { params: { path: file } } as Parameters<typeof load>[0]
|
||||
return { piece, storage, event }
|
||||
}
|
||||
|
||||
test.each([false, true])(
|
||||
'source loader exposes only generation availability (configured: %s)',
|
||||
'source loader reports generation availability (configured: %s)',
|
||||
async (configured) => {
|
||||
config.ai = configured ? { provider: 'google', api_key: 'private-ai-key' } : undefined
|
||||
const { event } = setupLoad()
|
||||
@@ -311,44 +307,35 @@ test.each([false, true])(
|
||||
schema: { type: 'object', properties: {} },
|
||||
canGenerate: configured,
|
||||
assetFields: [],
|
||||
hasPublishedVersion: false,
|
||||
savedMarkdown: MarkdownPublicationStatus.UNKNOWN
|
||||
hasPublishedVersion: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.each([
|
||||
{ name: 'no serving row', row: undefined, status: MarkdownPublicationStatus.UNKNOWN },
|
||||
{ name: 'no serving row', row: undefined },
|
||||
{
|
||||
name: 'serving row without a hash or publication time',
|
||||
row: { ...publishedPiece, content_hash: null },
|
||||
status: MarkdownPublicationStatus.UNKNOWN
|
||||
row: { ...publishedPiece, content_hash: null }
|
||||
},
|
||||
{
|
||||
name: 'matching saved Markdown',
|
||||
row: publishedPiece,
|
||||
status: MarkdownPublicationStatus.MATCHING
|
||||
},
|
||||
{
|
||||
name: 'changed saved Markdown',
|
||||
row: publishedPiece,
|
||||
status: MarkdownPublicationStatus.CHANGED
|
||||
},
|
||||
{ name: 'unknown saved Markdown', row: publishedPiece, status: MarkdownPublicationStatus.UNKNOWN }
|
||||
{ name: 'serving row with a publication hash', row: publishedPiece }
|
||||
])(
|
||||
'source loader reports $name without mutations or a status-only stat',
|
||||
async ({ row, status }) => {
|
||||
'source loader returns raw saved source and reports $name without mutations or extra reads',
|
||||
async ({ row }) => {
|
||||
const { piece, storage, event } = setupLoad()
|
||||
vi.mocked(getWebPiece).mockResolvedValue(row)
|
||||
vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(status)
|
||||
|
||||
expect(await load(event)).toMatchObject({
|
||||
expect(await load(event)).toEqual({
|
||||
file,
|
||||
type: 'books',
|
||||
rawContent: rawSource,
|
||||
hasPublishedVersion: Boolean(row),
|
||||
savedMarkdown: status
|
||||
schema: piece.schema,
|
||||
canGenerate: config.ai !== undefined,
|
||||
assetFields: [],
|
||||
hasPublishedVersion: Boolean(row)
|
||||
})
|
||||
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
|
||||
expect(getMarkdownPublicationStatus).toHaveBeenCalledExactlyOnceWith(file, row?.content_hash)
|
||||
expect(piece.get).toHaveBeenCalledExactlyOnceWith(file)
|
||||
expect(storage.readFile).toHaveBeenCalledExactlyOnceWith(file, 'text')
|
||||
expect(storage.stat).not.toHaveBeenCalled()
|
||||
expect(storage.writeFile).not.toHaveBeenCalled()
|
||||
@@ -359,22 +346,15 @@ test.each([
|
||||
}
|
||||
)
|
||||
|
||||
test.each(['serving database', 'publication database', 'raw source'])(
|
||||
'source loader propagates %s failures rather than claiming an unpublished or unknown state',
|
||||
test.each(['serving database', 'raw source'])(
|
||||
'source loader propagates %s failures',
|
||||
async (failure) => {
|
||||
const { piece, storage, event } = setupLoad()
|
||||
const error = new Error(`${failure} failed`)
|
||||
if (failure === 'serving database') vi.mocked(getWebPiece).mockRejectedValueOnce(error)
|
||||
if (failure === 'publication database') {
|
||||
vi.mocked(getWebPiece).mockResolvedValueOnce(publishedPiece)
|
||||
vi.mocked(getMarkdownPublicationStatus).mockRejectedValueOnce(error)
|
||||
}
|
||||
if (failure === 'raw source') storage.readFile.mockRejectedValueOnce(error)
|
||||
|
||||
await expect(load(event)).rejects.toBe(error)
|
||||
if (failure !== 'publication database') {
|
||||
expect(getMarkdownPublicationStatus).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(storage.stat).not.toHaveBeenCalled()
|
||||
expect(storage.writeFile).not.toHaveBeenCalled()
|
||||
expect(storage.delete).not.toHaveBeenCalled()
|
||||
|
||||
@@ -69,6 +69,35 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.button[data-variant='editor'] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
height: auto;
|
||||
min-height: calc(var(--space-4) + var(--space-2));
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
font: inherit;
|
||||
text-transform: none;
|
||||
top: 0;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.button[data-variant='editor']:not(:disabled):hover,
|
||||
.button[data-variant='editor'][data-state='open'] {
|
||||
background-color: var(--editor-selection, var(--color-surface-container-highest));
|
||||
border-color: var(--editor-border, var(--color-outline));
|
||||
}
|
||||
|
||||
.button[data-variant='editor']:focus-visible {
|
||||
outline: 2px solid var(--editor-caret, var(--color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.input::file-selector-button {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
|
||||
Reference in new issue
Block a user