From b2804bf9dd2da962c3bdedd66eea161d426dd4a1 Mon Sep 17 00:00:00 2001 From: Seungjun Jeong Date: Tue, 27 Oct 2026 21:39:26 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20editor=20toast=20and?= =?UTF-8?q?=20formatting=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 20 +++++ src/components/WriteEditor.tsx | 136 ++++++++++++++++++++++---------- src/components/ui/sonner.tsx | 38 +++++++++ src/components/write-editor.css | 17 ---- 5 files changed, 155 insertions(+), 57 deletions(-) create mode 100644 src/components/ui/sonner.tsx diff --git a/package.json b/package.json index c6cb2e9..63e19dc 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "sonner": "^2.0.8", "tailwindcss": "4.3.3", "tw-animate-css": "^1.4.0", "unified": "^11.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38a2a90..8c37b67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: remark-rehype: specifier: ^11.1.2 version: 11.1.2 + sonner: + specifier: ^2.0.8 + version: 2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -4682,6 +4685,16 @@ packages: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -10739,6 +10752,13 @@ snapshots: smol-toml@1.8.0: {} + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} diff --git a/src/components/WriteEditor.tsx b/src/components/WriteEditor.tsx index ff586a3..b2e48a2 100644 --- a/src/components/WriteEditor.tsx +++ b/src/components/WriteEditor.tsx @@ -1,6 +1,8 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; import { editorRequest as editorApi } from '../lib/editor-api'; import DOMPurify from 'dompurify'; +import { toast } from 'sonner'; +import { Toaster } from './ui/sonner'; import { marked } from 'marked'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; @@ -107,7 +109,6 @@ export default function WriteEditor() { const [newSeries, setNewSeries] = useState(null); const [panel, setPanel] = useState<'posts' | 'publish' | null>(null); const [search, setSearch] = useState(''); - const [status, setStatus] = useState(''); const [busy, setBusy] = useState(false); const [uploads, setUploads] = useState(0); const [dragging, setDragging] = useState(false); @@ -142,12 +143,6 @@ export default function WriteEditor() { return () => window.clearInterval(interval); }, [authorized]); - useEffect(() => { - if (!status) return; - const timeout = window.setTimeout(() => setStatus(''), 5000); - return () => window.clearTimeout(timeout); - }, [status]); - const slug = titleSlug(title); const previewTooLong = body.length > MAX_PREVIEW_CHARS; const previewHtml = useMemo( @@ -227,7 +222,7 @@ export default function WriteEditor() { setNewSeriesName(''); setNewSeries(null); setPanel(null); - setStatus(''); + toast.dismiss('write-status'); } async function openPost(postId: string) { @@ -288,9 +283,9 @@ export default function WriteEditor() { : [...current, draftSeries] ); setPanel(null); - setStatus(''); + toast.dismiss('write-status'); } catch (error) { - setStatus((error as Error).message); + toast.error((error as Error).message, { id: 'write-status' }); } finally { setBusy(false); } @@ -302,12 +297,34 @@ export default function WriteEditor() { setTagInput(''); } - function insertText(before: string, after = '', placeholder = '') { + function insertText( + before: string, + after = '', + placeholder = '', + toggle = false + ) { const element = editor.current; if (!element) return; const start = element.selectionStart; const end = element.selectionEnd; const selected = body.slice(start, end) || placeholder; + if ( + toggle && + start !== end && + body.slice(start - before.length, start) === before && + body.slice(end, end + after.length) === after + ) { + setBody( + body.slice(0, start - before.length) + + selected + + body.slice(end + after.length) + ); + requestAnimationFrame(() => { + element.focus(); + element.setSelectionRange(start - before.length, end - before.length); + }); + return; + } setBody(body.slice(0, start) + before + selected + after + body.slice(end)); requestAnimationFrame(() => { element.focus(); @@ -322,7 +339,9 @@ export default function WriteEditor() { const name = newSeriesName.trim(); if (!name) return; if (series.some((item) => item.name === name)) { - setStatus('이미 같은 이름의 시리즈가 있습니다.'); + toast.error('이미 같은 이름의 시리즈가 있습니다.', { + id: 'write-status', + }); return; } const next = { @@ -335,7 +354,7 @@ export default function WriteEditor() { setNewSeries(next); setSeriesId(next.id); setNewSeriesName(''); - setStatus(''); + toast.dismiss('write-status'); } function imageAtDrop(event: React.DragEvent) { @@ -385,11 +404,13 @@ export default function WriteEditor() { 'image/avif', ].includes(file.type) ) { - setStatus('PNG, JPEG, WebP, GIF, AVIF 이미지만 업로드할 수 있습니다.'); + toast.error('PNG, JPEG, WebP, GIF, AVIF 이미지만 업로드할 수 있습니다.', { + id: 'write-status', + }); return; } if (file.size > 10 * 1024 * 1024) { - setStatus('이미지는 10MB 이하여야 합니다.'); + toast.error('이미지는 10MB 이하여야 합니다.', { id: 'write-status' }); return; } const marker = `![업로드 중: ${file.name}](uploading-${crypto.randomUUID()})`; @@ -397,7 +418,7 @@ export default function WriteEditor() { (current) => current.slice(0, at) + marker + '\n' + current.slice(at) ); setUploads((count) => count + 1); - setStatus('이미지를 R2에 업로드하고 있습니다…'); + toast.loading('이미지를 R2에 업로드하고 있습니다…', { id: 'write-status' }); try { const optimized = await optimizeImage(file); const response = await fetch('/api/content/editor/media', { @@ -420,10 +441,10 @@ export default function WriteEditor() { .replaceAll('[', '') .replaceAll(']', ''); setBody((current) => current.replace(marker, `![${name}](${url})`)); - setStatus('이미지가 R2에 업로드되었습니다.'); + toast.success('이미지가 R2에 업로드되었습니다.', { id: 'write-status' }); } catch (error) { setBody((current) => current.replace(marker + '\n', '')); - setStatus((error as Error).message); + toast.error((error as Error).message, { id: 'write-status' }); } finally { setUploads((count) => count - 1); } @@ -432,22 +453,26 @@ export default function WriteEditor() { async function save(publish: boolean) { if (busy) return; if (!title.trim() || !body.trim()) { - setStatus('제목과 본문을 입력해 주세요.'); + toast.error('제목과 본문을 입력해 주세요.', { id: 'write-status' }); return; } if (!slug) { - setStatus('제목으로 주소를 만들 수 없습니다.'); + toast.error('제목으로 주소를 만들 수 없습니다.', { id: 'write-status' }); return; } if (uploads) { - setStatus('이미지 업로드가 끝난 뒤 저장해 주세요.'); + toast.error('이미지 업로드가 끝난 뒤 저장해 주세요.', { + id: 'write-status', + }); return; } const duplicate = posts.find( (post) => post.slug === slug && post.id !== id ); if (duplicate) { - setStatus('같은 주소를 쓰는 글이 있습니다. 제목을 바꿔 주세요.'); + toast.error('같은 주소를 쓰는 글이 있습니다. 제목을 바꿔 주세요.', { + id: 'write-status', + }); return; } const clickedAt = new Date().toISOString(); @@ -457,7 +482,9 @@ export default function WriteEditor() { .querySelector('img') ?.getAttribute('src') ?? null; setBusy(true); - setStatus(publish ? '발행 준비 중…' : '초안 저장 중…'); + toast.loading(publish ? '발행 준비 중…' : '초안 저장 중…', { + id: 'write-status', + }); try { const postId = id ?? crypto.randomUUID(); setId(postId); @@ -524,7 +551,9 @@ export default function WriteEditor() { setNewSeries(null); } setPanel(null); - setStatus(`발행했습니다. 공개 URL: ${result.url}`); + toast.success(`발행했습니다. 공개 URL: ${result.url}`, { + id: 'write-status', + }); return; } const nextDraft = { @@ -538,11 +567,12 @@ export default function WriteEditor() { nextDraft, ...current.filter((item) => item.id !== postId), ]); - setStatus( - '초안을 D1에 저장했습니다. 공개 글에는 아직 반영되지 않았습니다.' + toast.success( + '초안을 D1에 저장했습니다. 공개 글에는 아직 반영되지 않았습니다.', + { id: 'write-status' } ); } catch (error) { - setStatus((error as Error).message); + toast.error((error as Error).message, { id: 'write-status' }); } finally { setBusy(false); } @@ -567,9 +597,12 @@ export default function WriteEditor() { ...current.filter((post) => post.id !== result.id), ]); setPanel('posts'); - setStatus('글을 휴지통으로 이동했습니다. 언제든 복구할 수 있습니다.'); + toast.success( + '글을 휴지통으로 이동했습니다. 언제든 복구할 수 있습니다.', + { id: 'write-status' } + ); } catch (error) { - setStatus((error as Error).message); + toast.error((error as Error).message, { id: 'write-status' }); } finally { setBusy(false); } @@ -587,9 +620,11 @@ export default function WriteEditor() { post, ...current.filter((item) => item.id !== post.id), ]); - setStatus(`“${post.title}” 글을 복구했습니다.`); + toast.success(`“${post.title}” 글을 복구했습니다.`, { + id: 'write-status', + }); } catch (error) { - setStatus((error as Error).message); + toast.error((error as Error).message, { id: 'write-status' }); } finally { setBusy(false); } @@ -720,21 +755,21 @@ export default function WriteEditor() { - {status && ( -
- {status} -
- )} + {!panel && } { @@ -892,6 +947,7 @@ export default function WriteEditor() { > {panel === 'posts' ? '내 글' : '출간 설정'} + {panel === 'posts' ? ( <>