From 83fc8357da742a8552340c9f190a9281838eee94 Mon Sep 17 00:00:00 2001 From: jettapat Date: Tue, 16 Sep 2025 10:39:25 +0700 Subject: [PATCH 1/2] feat: upload image to storage --- examples/erp/genseki/collections/posts.tsx | 1 + .../erp/genseki/collections/users.client.ts | 3 +- examples/erp/genseki/collections/users.ts | 1 + examples/erp/genseki/config.tsx | 2 +- .../src/app/(admin)/admin/tiptap-style.css | 134 +++++++++++++++++- .../handlers/grab-delete-obj-signed-url.ts | 2 +- packages/react/src/core/richtext/index.ts | 31 ++-- .../extensions/custom-image-extension.ts | 85 +++++++++++ .../extensions/image-upload-node-extension.ts | 5 + .../editor/nodes/custom-image-node.tsx | 131 +++++++++++++++-- .../components/compound/file-upload-field.tsx | 13 +- 11 files changed, 369 insertions(+), 39 deletions(-) diff --git a/examples/erp/genseki/collections/posts.tsx b/examples/erp/genseki/collections/posts.tsx index 7e8edd8c..761068cb 100644 --- a/examples/erp/genseki/collections/posts.tsx +++ b/examples/erp/genseki/collections/posts.tsx @@ -56,6 +56,7 @@ export const postEditorProviderProps = { accept: 'image/*', maxSize: 1024 * 1024 * 10, // 10MB limit: 3, + pathName: 'posts/rich-text', }), ], } diff --git a/examples/erp/genseki/collections/users.client.ts b/examples/erp/genseki/collections/users.client.ts index b9df2097..c7488e2c 100644 --- a/examples/erp/genseki/collections/users.client.ts +++ b/examples/erp/genseki/collections/users.client.ts @@ -2,7 +2,7 @@ import { createColumnHelper } from '@tanstack/react-table' -import type { InferFields } from '@genseki/react' +import { actionsColumn, createEditActionItem, type InferFields } from '@genseki/react' import type { fields } from './users' @@ -19,4 +19,5 @@ export const columns = [ columnHelper.accessor('email', { cell: (info) => info.getValue(), }), + actionsColumn([createEditActionItem()]), ] diff --git a/examples/erp/genseki/collections/users.ts b/examples/erp/genseki/collections/users.ts index 42831f24..25477f6a 100644 --- a/examples/erp/genseki/collections/users.ts +++ b/examples/erp/genseki/collections/users.ts @@ -45,6 +45,7 @@ export const usersCollection = createPlugin('users', (app) => { }, }) ) + .addPageAndApiRouter(collection.update(fields)) .addApiRouter({ example: builder.endpoint( { diff --git a/examples/erp/genseki/config.tsx b/examples/erp/genseki/config.tsx index 06f69357..67f7cad5 100644 --- a/examples/erp/genseki/config.tsx +++ b/examples/erp/genseki/config.tsx @@ -20,7 +20,7 @@ const app = new GensekiApp({ appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3000', apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', appPathPrefix: '/admin', - apiPathPrefix: '/admin/api', + apiPathPrefix: '/api', storageAdapter: StorageAdapterS3.initialize(context, { bucket: process.env.AWS_BUCKET_NAME!, imageBaseUrl: process.env.NEXT_PUBLIC_AWS_IMAGE_URL!, diff --git a/examples/erp/src/app/(admin)/admin/tiptap-style.css b/examples/erp/src/app/(admin)/admin/tiptap-style.css index d9db2492..6f22c86f 100644 --- a/examples/erp/src/app/(admin)/admin/tiptap-style.css +++ b/examples/erp/src/app/(admin)/admin/tiptap-style.css @@ -70,7 +70,7 @@ h5, h6 { font-size: 1rem; - font-weight: 6 00; + font-weight: 600; } /* Code and preformatted text styles */ @@ -118,7 +118,6 @@ /* max-width: 500px; */ width: 100%; height: auto; - margin: 1em 0; border-radius: var(--radius-lg); } @@ -142,18 +141,145 @@ } } - /* Image laoded animation */ + /* Image loaded animation */ .custom-image-loaded { animation: image-loaded-fade-in 0.75s ease-out; } /* Selecting an image */ img.ProseMirror-selectednode, - /* Selcting custom image */ + + /* Selecting custom image */ .ProseMirror-selectednode img { outline: 4px solid var(--color-primary); } + /* Custom Image Figure Styles */ + .custom-image-figure { + margin: calc(var(--spacing) * 6) 0; + position: relative; + } + + /* Custom Image Loading State */ + .custom-image-loading { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bluegray-100); + min-height: 200px; + border-radius: var(--radius-lg); + margin: 1em 0; + } + + .custom-image-loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--spacing) * 3); + } + + .custom-image-loading-text { + font-size: 0.875rem; + color: var(--color-bluegray-500); + } + + /* Custom Image Error State */ + .custom-image-error { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-surface-incorrect, #fef2f2); + border: 1px solid var(--color-border-incorrect, #fecaca); + min-height: 200px; + border-radius: var(--radius-lg); + margin: 1em 0; + padding: calc(var(--spacing) * 4); + } + + .custom-image-error-content { + text-align: center; + } + + .custom-image-error-title { + font-weight: 600; + color: var(--color-text-incorrect, #dc2626); + margin-bottom: calc(var(--spacing) * 1); + } + + .custom-image-error-message { + font-size: 0.875rem; + color: var(--color-text-incorrect, #dc2626); + } + + /* Caption Styles */ + .custom-image-caption { + margin-top: calc(var(--spacing) * 3); + padding: calc(var(--spacing) * 3); + text-align: center; + font-size: 0.875rem; + line-height: 1.5; + cursor: text; + border-radius: calc(var(--spacing) * 2); + transition: + background-color 0.2s ease, + color 0.2s ease; + color: var(--color-text-trivial); + } + + .custom-image-caption-filled { + background-color: var(--color-bluegray-50); + } + + .custom-image-caption-filled:hover { + background-color: var(--color-bluegray-100); + } + + .custom-image-caption-empty { + background-color: var(--color-bluegray-50); + font-style: italic; + opacity: 0.7; + } + + .custom-image-caption-empty:hover { + background-color: var(--color-bluegray-100); + opacity: 1; + } + + /* Caption Editor Styles */ + .custom-image-caption-editor { + margin-top: calc(var(--spacing) * 2); + } + + .custom-image-caption-textarea { + width: 100%; + padding: calc(var(--spacing) * 2); + font-size: 0.875rem; + color: var(--color-text-trivial); + background-color: var(--color-white); + border: 1px solid var(--color-bluegray-200); + border-radius: calc(var(--spacing) * 1); + resize: none; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + font-family: inherit; + line-height: 1.5; + text-align: center; + } + + .custom-image-caption-textarea:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1); + } + + .custom-image-caption-hint { + margin-top: calc(var(--spacing) * 1); + font-size: 0.75rem; + color: var(--color-text-trivial); + text-align: center; + } + .tiptap-image-upload { --outside-padding: calc(var(--spacing) * 4); --outside-border-radius: var(--radius-4xl); diff --git a/packages/react/src/core/file-storage-adapters/handlers/grab-delete-obj-signed-url.ts b/packages/react/src/core/file-storage-adapters/handlers/grab-delete-obj-signed-url.ts index 607d860a..6de8d527 100644 --- a/packages/react/src/core/file-storage-adapters/handlers/grab-delete-obj-signed-url.ts +++ b/packages/react/src/core/file-storage-adapters/handlers/grab-delete-obj-signed-url.ts @@ -11,7 +11,7 @@ export function grabDeleteObjUrl( return createEndpoint( context, { - method: 'DELETE', + method: 'GET', path: '/storage/delete-obj-signed-url', query: z.object({ key: z.string(), diff --git a/packages/react/src/core/richtext/index.ts b/packages/react/src/core/richtext/index.ts index bfb57910..7caf7f50 100644 --- a/packages/react/src/core/richtext/index.ts +++ b/packages/react/src/core/richtext/index.ts @@ -10,7 +10,13 @@ import { toast } from 'sonner' import type { EditorProviderClientProps, SanitizedExtension } from './types' -import { BackColorExtension, CustomImageExtension, ImageUploadNodeExtension } from '../../react' +import { + BackColorExtension, + CustomImageExtension, + generatePutObjSignedUrlData, + ImageUploadNodeExtension, + uploadObject, +} from '../../react' import { SelectionExtension } from '../../react/components/compound/editor/extensions/selection-extension' import type { StorageAdapterClient } from '../file-storage-adapters' @@ -83,18 +89,19 @@ export const constructSanitizedExtensions = ( toast.error(error.message) }, async upload(file) { - const key = `${crypto.randomUUID()}-${file.name}` + const name = file.name.split('.').slice(0, -1).join('.') + const fileType = file.name.split('.').pop() + const key = `${extension.options.pathName ? `${extension.options.pathName}/` : ''}${name}-${crypto.randomUUID()}.${fileType}` + const signedUrlPath = storageAdapter.grabPutObjectSignedUrlApiRoute.path + const putObjSignedUrl = await generatePutObjSignedUrlData(signedUrlPath, key) - // Upload image - const putObjSignedUrlApiRoute = storageAdapter.grabPutObjectSignedUrlApiRoute - const putObjUrlEndpoint = new URL(putObjSignedUrlApiRoute.path, window.location.origin) - putObjUrlEndpoint.searchParams.append('key', key) - const putObjSignedUrl = await fetch(putObjUrlEndpoint.toString()) - const putObjSignedUrlData = await putObjSignedUrl.json() - await fetch(putObjSignedUrlData.signedUrl, { - method: 'PUT', - body: file, - }) + if (!putObjSignedUrl.ok) { + throw new Error('Generating put object signed URL error: ' + putObjSignedUrl.message) + } + const uploadResult = await uploadObject(putObjSignedUrl.data.body.signedUrl, file) + if (!uploadResult.ok) { + throw new Error('File upload error: ' + uploadResult.message) + } return { key } }, diff --git a/packages/react/src/react/components/compound/editor/extensions/custom-image-extension.ts b/packages/react/src/react/components/compound/editor/extensions/custom-image-extension.ts index 58e08403..bcea52df 100644 --- a/packages/react/src/react/components/compound/editor/extensions/custom-image-extension.ts +++ b/packages/react/src/react/components/compound/editor/extensions/custom-image-extension.ts @@ -1,3 +1,4 @@ +import type { ChainedCommands } from '@tiptap/core' import Image, { type ImageOptions } from '@tiptap/extension-image' import type { StorageAdapterClient } from '../../../../../core/file-storage-adapters' @@ -30,6 +31,90 @@ export const CustomImageExtension = Image.extend({ } }, }, + caption: { + default: null, + parseHTML: (element) => { + if (element.tagName === 'FIGURE') { + const figcaption = element.querySelector('figcaption') + return figcaption?.textContent || null + } + return element.getAttribute('data-caption') + }, + renderHTML: (attributes) => { + if (!attributes.caption) { + return {} + } + return { + 'data-caption': attributes.caption, + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'img[data-key]', + getAttrs: (element) => ({ + 'data-key': element.getAttribute('data-key'), + alt: element.getAttribute('alt'), + title: element.getAttribute('title'), + caption: element.getAttribute('data-caption'), + }), + }, + { + tag: 'figure[data-type="custom-image"]', + getAttrs: (element) => { + const img = element.querySelector('img[data-key]') + const figcaption = element.querySelector('figcaption') + + if (!img) return false + + return { + 'data-key': img.getAttribute('data-key'), + alt: img.getAttribute('alt'), + title: img.getAttribute('title'), + caption: figcaption?.textContent || null, + } + }, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + const { caption, ...imgAttributes } = HTMLAttributes + + if (caption) { + return [ + 'figure', + { 'data-type': 'custom-image', class: 'custom-image-with-caption' }, + ['img', imgAttributes], + ['figcaption', { class: 'custom-image-caption' }, caption], + ] + } + + return ['img', imgAttributes] + }, + + addCommands() { + const parentCommands = this.parent?.() || {} + + return { + ...parentCommands, + setCustomImage: + (options: { 'data-key': string; alt?: string; title?: string; caption?: string }) => + ({ commands }: { commands: ChainedCommands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + }, + updateCustomImageCaption: + (caption: string) => + ({ commands }: { commands: ChainedCommands }) => { + return commands.updateAttributes(this.name, { caption }) + }, } }, addNodeView() { diff --git a/packages/react/src/react/components/compound/editor/extensions/image-upload-node-extension.ts b/packages/react/src/react/components/compound/editor/extensions/image-upload-node-extension.ts index 560a391d..a3a9ecb2 100644 --- a/packages/react/src/react/components/compound/editor/extensions/image-upload-node-extension.ts +++ b/packages/react/src/react/components/compound/editor/extensions/image-upload-node-extension.ts @@ -36,6 +36,11 @@ export interface ImageUploadNodeOptions { * Whether to show the progress bar or not. */ showProgress?: boolean + + /** + * Path to store uploaded image in storage + */ + pathName?: string } declare module '@tiptap/react' { diff --git a/packages/react/src/react/components/compound/editor/nodes/custom-image-node.tsx b/packages/react/src/react/components/compound/editor/nodes/custom-image-node.tsx index 00637eee..749e94c1 100644 --- a/packages/react/src/react/components/compound/editor/nodes/custom-image-node.tsx +++ b/packages/react/src/react/components/compound/editor/nodes/custom-image-node.tsx @@ -11,6 +11,7 @@ import { toast } from 'sonner' import type { StorageAdapterClient } from '../../../../../core' import { cn } from '../../../../utils/cn' import { BaseIcon } from '../../../primitives' +import { deleteObject, generateDeleteObjSignedUrlData } from '../../file-upload-field' import type { CustomImageOptions } from '../extensions' interface CustomImageNodeProps extends NodeViewProps { @@ -19,6 +20,9 @@ interface CustomImageNodeProps extends NodeViewProps { export const CustomImageNode: React.FC = (props) => { const dataKey = props.node.attrs['data-key'] + const caption = props.node.attrs.caption + const [isEditingCaption, setIsEditingCaption] = useState(false) + const [captionText, setCaptionText] = useState(caption || '') const storageAdapter = props.extension.options.storageAdapter @@ -32,15 +36,116 @@ export const CustomImageNode: React.FC = (props) => { ) } + const handleCaptionSubmit = () => { + props.updateAttributes({ caption: captionText }) + setIsEditingCaption(false) + } + + const handleCaptionKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleCaptionSubmit() + } + if (e.key === 'Escape') { + setCaptionText(caption || '') + setIsEditingCaption(false) + } + } + + const handleCaptionClick = (e: React.MouseEvent) => { + e.preventDefault() + setIsEditingCaption(true) + } + + const handleDelete = async () => { + try { + const deleteSignedUrlPath = storageAdapter.grabDeleteObjectSignedUrlApiRoute?.path + + if (!deleteSignedUrlPath) { + toast.error('Delete functionality not configured') + return + } + + const deleteObjSignedUrl = await generateDeleteObjSignedUrlData(deleteSignedUrlPath, dataKey) + + if (!deleteObjSignedUrl.ok) { + toast.error('Failed to generate delete URL') + console.error('Delete signed URL error:', deleteObjSignedUrl.message) + return + } + + const deleteResult = await deleteObject(deleteObjSignedUrl.data.body.signedUrl) + + if (!deleteResult.ok) { + toast.error('Failed to delete from storage') + console.error('Delete object error:', deleteResult.message) + return + } + + props.deleteNode() + + toast.success('Image deleted successfully') + } catch (error) { + console.error('Failed to delete image:', error) + toast.error('Failed to delete image') + } + } + + useEffect(() => { + const handleDocumentKeyDown = (e: KeyboardEvent) => { + if (props.selected && !isEditingCaption) { + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + e.stopPropagation() + handleDelete() + } + } + } + + document.addEventListener('keydown', handleDocumentKeyDown, true) + return () => { + document.removeEventListener('keydown', handleDocumentKeyDown, true) + } + }, [props.selected, isEditingCaption]) + return ( - +
+
+ +
+ + {isEditingCaption ? ( +
+