Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/src/components/block-note/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ import type {
ReadmeImportMode,
ReadmeImportPreview,
} from '#/components/block-note/schema'
import { uploadEditorImage } from '#/components/block-note/upload-image'

interface Props {
projectId?: string
value?: string
onChange?: (value: string) => void
onBlur?: () => void
Expand All @@ -78,6 +80,7 @@ interface Props {
}

export interface BlockNoteEditorHandle {
isUploading?: () => boolean
// Runs the scheduled markdown export immediately. Call before reading
// the field value (e.g. form submit) so the latest edits are included.
// Safe outside BlockNote's change callback, where React is idle.
Expand All @@ -95,16 +98,19 @@ export interface BlockNoteEditorHandle {
}

export default function BlockNoteEditor({
projectId,
value,
onChange,
onBlur,
className,
ref,
}: Props) {
const { resolvedTheme } = useTheme()
const pendingUploads = useRef(0)
const editor = useCreateBlockNote({
schema: blockNoteSchema,
extensions: contentExtensions(),
uploadFile: (file) => uploadEditorImage(file, projectId, pendingUploads),
})

// Initial content loads once: re-running on every parent value change
Expand Down Expand Up @@ -166,6 +172,7 @@ export default function BlockNoteEditor({
onChangeRef.current?.(editor.blocksToMarkdownLossy(editor.document))
}
return {
isUploading: () => pendingUploads.current > 0,
flush,
readMarkdown: () => {
flush()
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/block-note/upload-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { RefObject } from 'react'

import { requestContentImageUploadInputSchema } from '@altstack/shared/schemas/upload'

import { toast } from '@altstack/ui/components/toast'

export async function uploadContentImage(file: File, projectId?: string) {
const parsed = requestContentImageUploadInputSchema.safeParse({
body: {
filename: file.name.slice(0, 100),
contentType: file.type,
size: file.size,
projectId,
},
})
if (!parsed.success) {
throw new Error('Choose a PNG, JPEG, WebP or GIF image up to 5 MB.')
}

const { client } = await import('#/utils/orpc')
const { key, presignedUrl } = await client.contentImageUpload.request(
parsed.data
)
const response = await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': parsed.data.body.contentType },
})
if (!response.ok) {
throw new Error('Image upload failed. Please try again.')
}
const { url } = await client.contentImageUpload.complete({
body: { key, projectId },
})
return url
}

export async function uploadEditorImage(
file: File,
projectId: string | undefined,
pendingUploads: RefObject<number>
) {
pendingUploads.current += 1
try {
return await uploadContentImage(file, projectId)
} catch (error) {
toast.add({
type: 'error',
title: 'Could not upload image',
description: error instanceof Error ? error.message : 'Please try again.',
})
throw error
} finally {
pendingUploads.current -= 1
}
}

export function canSaveEditorContent(
editor: { isUploading?: () => boolean } | null
) {
if (!editor?.isUploading?.()) return true
toast.add({
type: 'error',
title: 'Image upload in progress',
description: 'Wait for the image upload to finish before saving.',
})
return false
}
31 changes: 31 additions & 0 deletions apps/web/src/components/public-content/image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState } from 'react'

export function ContentImage({
src,
alt = '',
title,
}: {
src?: string
alt?: string
title?: string
}) {
const [failedSource, setFailedSource] = useState<string>()
if (!src || failedSource === src) {
return (
<span className="text-muted-foreground">
{alt ? `Image unavailable: ${alt}` : 'Image unavailable'}
</span>
)
}
return (
<img
src={src}
alt={alt}
title={title}
loading="lazy"
decoding="async"
className="h-auto max-w-full object-contain"
onError={() => setFailedSource(src)}
/>
)
}
4 changes: 4 additions & 0 deletions apps/web/src/components/public-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm'
import { PublicCodeBlock } from '#/components/public-content/code-block'
import { ContentLink } from '#/components/public-content/content-link'
import { ContentHeading } from '#/components/public-content/heading'
import { ContentImage } from '#/components/public-content/image'
import { remarkArticleHeadings } from '#/features/public-content/headings'
import { emptyPublicContent } from '#/features/public-content/model'
import type { PreparedPublicContent } from '#/features/public-content/model'
Expand Down Expand Up @@ -55,6 +56,9 @@ export function PublicContent({
}
skipHtml
components={{
img: ({ src, alt, title }) => (
<ContentImage src={src} alt={alt} title={title} />
),
h1: ({ node, children, ...props }) => (
<ContentHeading
as="h2"
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/about/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Textarea } from '@altstack/ui/components/textarea'
import { ActionConfirmation } from '#/components/action-confirmation'
import BlockNoteEditor from '#/components/block-note/editor'
import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { PublicContent } from '#/components/public-content'
import { aboutQueries } from '#/features/about/queries'
import { orpc } from '#/utils/orpc'
Expand Down Expand Up @@ -72,6 +73,7 @@ export function AboutEditor({ initial }: { initial: AboutPage }) {

async function submit() {
if (busy) return
if (!canSaveEditorContent(editor.current)) return
const current = {
...value,
content: editor.current?.readMarkdown() ?? value.content,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/admin-blogs/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { toast } from '@altstack/ui/components/toast'

import BlockNoteEditor from '#/components/block-note/editor'
import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { PublicContent } from '#/components/public-content'
import { BlogCoverInput } from '#/features/admin-blogs/cover-input'
import { orpc } from '#/utils/orpc'
Expand Down Expand Up @@ -144,6 +145,7 @@ function useBlogEditor(initial: Initial | undefined) {
async function submit(event: React.SubmitEvent<HTMLFormElement>) {
event.preventDefault()
if (busy) return
if (!canSaveEditorContent(editor.current)) return
editor.current?.flush()
const parsed = blogBodySchema.safeParse({
...value,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/admin-collections/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { toast } from '@altstack/ui/components/toast'
import { ActionConfirmation } from '#/components/action-confirmation'
import BlockNoteEditor from '#/components/block-note/editor'
import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { PublicContent } from '#/components/public-content'
import { CollectionProjectSelector } from '#/features/admin-collections/project-selector'
import type { SelectedProject } from '#/features/admin-collections/project-selector'
Expand Down Expand Up @@ -205,6 +206,7 @@ function useCollectionEditor(initial: Initial | undefined) {

async function save() {
if (busy) return
if (!canSaveEditorContent(editor.current)) return
editor.current?.flush()
const parsed = collectionBodySchema.safeParse({
...value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { toast } from '@altstack/ui/components/toast'

import BlockNoteEditor from '#/components/block-note/editor'
import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { LogoUploader, ScreenshotUploader } from '#/components/image-uploader'
import { GithubMetadataPrefill } from '#/features/admin-projects/components/github-metadata-prefill'
import { GithubReadmeImport } from '#/features/admin-projects/components/github-readme-import'
Expand Down Expand Up @@ -182,6 +183,7 @@ export function CreateProjectForm() {
className="min-w-0"
onSubmit={(e) => {
e.preventDefault()
if (!canSaveEditorContent(editorRef.current)) return
editorRef.current?.flush()
void form.handleSubmit()
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { toast } from '@altstack/ui/components/toast'

import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import BlockNoteEditor from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { LogoUploader, ScreenshotUploader } from '#/components/image-uploader'
import { GithubMetadataPrefill } from '#/features/admin-projects/components/github-metadata-prefill'
import { GithubReadmeImport } from '#/features/admin-projects/components/github-readme-import'
Expand Down Expand Up @@ -245,6 +246,7 @@ export function EditProjectForm({ project }: { project: AdminProject }) {
className="min-w-0"
onSubmit={(e) => {
e.preventDefault()
if (!canSaveEditorContent(editorRef.current)) return
editorRef.current?.flush()
void form.handleSubmit()
}}
Expand Down Expand Up @@ -278,6 +280,7 @@ export function EditProjectForm({ project }: { project: AdminProject }) {
disabled={false}
pending={isSubmitting}
onAction={(status) => {
if (!canSaveEditorContent(editorRef.current)) return
form.setFieldValue('status', status)
editorRef.current?.flush()
void form.handleSubmit()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { toast } from '@altstack/ui/components/toast'

import BlockNoteEditor from '#/components/block-note/editor'
import type { BlockNoteEditorHandle } from '#/components/block-note/editor'
import { canSaveEditorContent } from '#/components/block-note/upload-image'
import { CategoryComboboxView } from '#/components/category-combobox-view'
import { LogoUploader, ScreenshotUploader } from '#/components/image-uploader'
import { memberUploadApi, ownershipKey } from '#/features/project/ownership'
Expand Down Expand Up @@ -60,6 +61,7 @@ export function MemberProjectEditor({
async function save(event: React.SyntheticEvent) {
event.preventDefault()
if (saving.current) return
if (!canSaveEditorContent(editor.current)) return
const parsed = memberProjectUpdateBodySchema.safeParse({
revision: project.revision,
name,
Expand Down Expand Up @@ -198,6 +200,7 @@ export function MemberProjectEditor({
<FieldLabel>Project content</FieldLabel>
<ClientOnly>
<BlockNoteEditor
projectId={project.id}
ref={editor}
value={content}
onChange={setContent}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/tests/block-note-import.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ describe('real BlockNote import and export', () => {
editor._tiptapEditor.destroy()
}
})
it('round-trips permanent image URLs and captions through saved Markdown', () => {
const editor = CoreEditor.create({ schema: blockNoteSchema })
const url = 'https://cdn.test/content/images/diagram.png'
editor.replaceBlocks(editor.document, [
{ type: 'image', props: { url, caption: 'Architecture diagram' } },
])
const markdown = editor.blocksToMarkdownLossy(editor.document)
expect(markdown).toContain(url)
const image = editor
.tryParseMarkdownToBlocks(markdown)
.find((block) => block.type === 'image')
expect(image?.props.url).toBe(url)
expect(image?.props.caption).toBe('Architecture diagram')
})
it('empty conversion fails without replacing existing document or form content', () => {
const ref = createRef<BlockNoteEditorHandle>()
const change = vi.fn()
Expand Down
Loading
Loading