diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b4abadc..34e3996 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,6 +33,8 @@ jobs:
- run: npm run format
- run: npm run lint
- run: npm run test-ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:e2e -- e2e/signin.spec.ts
- run: npm run build:collaboration
- run: npm run check:cli
- run: npm run build
diff --git a/.gitignore b/.gitignore
index f78f98c..8ed7c45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,8 @@
# testing
/coverage
+/playwright-report
+/test-results
# next.js
/.next/
diff --git a/AGENTS.md b/AGENTS.md
index 7e1c87a..beed120 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,8 @@ These instructions apply to the entire repository.
## Product language
-Use the lowercase product name `doc`. Keep the visual and written system compatible with
-`fullstack-ai-infra/mem`: restrained, technical, dark-first, and explicit about current capability
-versus roadmap.
+Use the lowercase product name `doc`. Consume the organization-owned semantic tokens and shared
+components from `@fullstack-ai-infra/ui`; do not recreate brand primitives or introduce raw product
+colors. The approved C direction is warm paper canvas, stone navigation, sage primary actions, and
+lavender reserved for AI affordances. Light and dark themes are equally supported, and product copy
+must remain explicit about current capability versus roadmap.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab58180..f0e08b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,15 @@ All notable changes to `doc` are documented here.
### Added
+- Adoption of the shared `@fullstack-ai-infra/ui` design system for the first document
+ workflow: workspace shell (responsive sidebar with compact mode), editor chrome, and
+ landing/sign-in surfaces consume shared tokens and components; adds a Playwright
+ end-to-end baseline for the sign-in flow and component coverage for the new shell.
+- Shared design system adoption for the first document workflow: the web app consumes
+ `@fullstack-ai-infra/ui` tokens and components (supplied via the vendored tarball), with a
+ responsive workspace and compact-mode navigation, accessibility labels, theme bootstrap,
+ editor legacy fallback and parsed-content helpers, and a Playwright end-to-end baseline for
+ the sign-in flow.
- New `doc` product identity and mem-aligned dark-first design system.
- Next.js document product and integrated Yjs/Hocuspocus collaboration service.
- Document version history, diff, and restore capability.
diff --git a/e2e/signin.spec.ts b/e2e/signin.spec.ts
new file mode 100644
index 0000000..1403e0e
--- /dev/null
+++ b/e2e/signin.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test } from '@playwright/test'
+
+const providers = {
+ nodemailer: {
+ id: 'nodemailer',
+ name: 'Email',
+ type: 'email',
+ signinUrl: '/api/auth/signin/nodemailer',
+ callbackUrl: '/api/auth/callback/nodemailer',
+ },
+}
+
+for (const viewport of [
+ { name: 'phone-390', width: 390, height: 844 },
+ { name: 'compact-600', width: 600, height: 900 },
+]) {
+ test(`sign-in is keyboard-accessible in light and dark at ${viewport.width}px`, async ({ page }) => {
+ await page.setViewportSize(viewport)
+ await page.addInitScript(() => localStorage.setItem('theme', 'light'))
+ await page.route('**/api/auth/providers', (route) => route.fulfill({ json: providers }))
+ await page.goto('/en/signin')
+
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
+ await expect(page.locator('main')).toHaveClass(/doc-grid/)
+ await expect(page.locator('.ui-card')).toBeVisible()
+ await expect(page.locator('main').getByRole('alert')).toHaveCount(0)
+ await expect(page.getByLabel('Email')).toBeVisible()
+ await expectNoHorizontalClipping(page)
+ await expect(page).toHaveScreenshot(`signin-${viewport.name}-light.png`, { fullPage: true })
+
+ const themeButton = page.getByRole('button', { name: /theme/i })
+ await themeButton.focus()
+ await page.keyboard.press('Enter')
+ await page.getByRole('menuitem', { name: 'Dark' }).click()
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
+ await expect(page.locator('html')).toHaveClass(/dark/)
+
+ await page.keyboard.press('Tab')
+ await expect(page.locator(':focus')).not.toHaveCount(0)
+ await expect(page).toHaveScreenshot(`signin-${viewport.name}-dark.png`, { fullPage: true })
+ })
+}
+
+async function expectNoHorizontalClipping(page: import('@playwright/test').Page) {
+ expect(
+ await page.evaluate(
+ () => Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) <= window.innerWidth
+ )
+ ).toBe(true)
+}
diff --git a/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png b/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png
new file mode 100644
index 0000000..cb2c2e7
Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-compact-600-dark-linux.png differ
diff --git a/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png b/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png
new file mode 100644
index 0000000..9dfebea
Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-compact-600-light-linux.png differ
diff --git a/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png b/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png
new file mode 100644
index 0000000..a872b0c
Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-phone-390-dark-linux.png differ
diff --git a/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png b/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png
new file mode 100644
index 0000000..be062c2
Binary files /dev/null and b/e2e/signin.spec.ts-snapshots/signin-phone-390-light-linux.png differ
diff --git a/e2e/workspace.spec.ts b/e2e/workspace.spec.ts
new file mode 100644
index 0000000..1637296
--- /dev/null
+++ b/e2e/workspace.spec.ts
@@ -0,0 +1,231 @@
+import { expect, test, type APIRequestContext, type Page } from '@playwright/test'
+
+const runFullLoop = process.env.DOC_E2E_FULL_LOOP === '1'
+const mailpitBase = process.env.DOC_E2E_MAILPIT_URL || 'http://127.0.0.1:8025'
+
+test.skip(!runFullLoop, 'Requires the zero-credential Docker stack; run npm run test:e2e:full after doc up.')
+test.setTimeout(120_000)
+
+test('authenticated workspace remains usable, persistent, responsive, and localized', async ({ page, request }) => {
+ const email = `doc-e2e-${Date.now()}@example.test`
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await page.addInitScript(() => localStorage.setItem('theme', 'light'))
+ await signInWithMailpit(page, request, email)
+ await page.goto('/en/work')
+
+ await expect(page).toHaveURL(/\/en\/user-info$/, { timeout: 30_000 })
+ await expect(page.getByRole('heading', { name: 'Complete your profile' })).toBeVisible()
+ await expect(page.getByLabel('Email')).toHaveValue(email)
+ await page.getByLabel('Name', { exact: true }).fill('Doc E2E')
+ await page.getByRole('button', { name: 'Submit' }).click()
+ await expect(page).toHaveURL(/\/en\/work\/[\w-]+/, { timeout: 30_000 })
+ const docId = page.url().split('/').at(-1)!
+ const contentPanel = page.locator('#work-content-panel')
+ await expect(contentPanel).toBeVisible()
+ await expect(page.locator('#work-content-scroll-container')).toBeVisible()
+ await expect(page.locator('#work-content-container')).toBeVisible()
+ await expect(page.locator('main#workspace-main')).toBeVisible()
+ const editor = page.locator('[contenteditable="true"]')
+ await expect(editor).toBeVisible()
+ await expect(page.getByRole('banner', { name: 'Document toolbar' })).toBeVisible()
+ await expect(page.locator('[role="collaborative-state"]')).toHaveAttribute('data-title', 'connected', {
+ timeout: 30_000,
+ })
+
+ const marker = 'browser-loop-persisted'
+ const title = `First document ${marker}`
+ await page.locator('#DOC_TITLE_INPUT_ID').fill(title)
+ await editor.locator('p').last().click()
+ await page.keyboard.press('End')
+ await page.keyboard.type(marker)
+ await expect(editor).toContainText(marker)
+
+ await expect
+ .poll(async () => documentIsPersisted(page, docId, title, marker), {
+ timeout: 30_000,
+ intervals: [250, 500, 1_000],
+ message: 'title and collaborative body are persisted by the server APIs',
+ })
+ .toBe(true)
+
+ const iconResponse = await page.request.patch(`/api/doc/${encodeURIComponent(docId)}`, {
+ data: { icon: '📄' },
+ })
+ expect(iconResponse.ok()).toBe(true)
+ expect(await iconResponse.json()).toMatchObject({ errno: 0 })
+ await page.reload()
+ await expect(page.locator('#DOC_TITLE_INPUT_ID')).toHaveValue(title)
+ await expect(page.locator('[contenteditable="true"]')).toContainText(marker)
+ await expect(page).toHaveURL(/\/en\/work\/[\w-]+/)
+ await expect(page.locator('[role="collaborative-state"]')).toHaveAttribute('data-title', 'connected', {
+ timeout: 30_000,
+ })
+
+ await page.locator('footer').evaluate((footer) => {
+ Array.from(footer.children).forEach((child) => ((child as HTMLElement).style.visibility = 'hidden'))
+ })
+ const screenshotOptions = { fullPage: true }
+ await expectNoHorizontalClipping(page)
+ await expect(page).toHaveScreenshot('workspace-desktop-light.png', screenshotOptions)
+ await chooseTheme(page, 'Dark')
+ await expect(page).toHaveScreenshot('workspace-desktop-dark.png', screenshotOptions)
+ await chooseTheme(page, 'Light')
+
+ await page.setViewportSize({ width: 704, height: 900 })
+ await assertCompactTopbar(page)
+ const navigationButton = page.getByRole('button', { name: 'Open document navigation' })
+ await expect(navigationButton).toHaveAttribute('aria-expanded', 'false')
+ await expect(page.getByText('Documents')).toHaveCount(0)
+
+ await navigationButton.click()
+ const navigationDialog = page.getByRole('dialog', { name: 'Document navigation' })
+ await expect(navigationDialog).toBeVisible()
+ await navigationDialog.getByRole('button', { name: 'Search' }).click()
+ const searchDialog = page.getByRole('dialog', { name: 'Search' })
+ await expect(searchDialog).toBeVisible()
+ const searchInput = searchDialog.locator('input')
+ await expect(searchInput).toBeFocused()
+ await page.keyboard.press('Tab')
+ expect(await searchDialog.evaluate((dialog) => dialog.contains(document.activeElement))).toBe(true)
+ await page.keyboard.press('Escape')
+ await expect(searchDialog).toBeHidden()
+ await expect(navigationDialog).toBeVisible()
+ await navigationDialog.getByRole('button', { name: 'Close document navigation' }).click()
+ await expect(navigationDialog).toBeHidden()
+ await expect(navigationButton).toBeFocused()
+
+ await exerciseAiDrawer(page, 704)
+
+ await navigationButton.click()
+ await expect(navigationDialog).toBeVisible()
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await expect(page.getByRole('button', { name: 'Open document navigation' })).toHaveCount(0)
+ await expect(page.getByRole('dialog', { name: 'Document navigation' })).toHaveCount(0)
+ await page.setViewportSize({ width: 704, height: 900 })
+ await expect(page.getByRole('button', { name: 'Open document navigation' })).toHaveAttribute('aria-expanded', 'false')
+
+ await page.setViewportSize({ width: 390, height: 844 })
+ await assertCompactTopbar(page)
+ await expect(page).toHaveScreenshot('workspace-phone-390-light.png', screenshotOptions)
+ await chooseTheme(page, 'Dark')
+ await expect(page).toHaveScreenshot('workspace-phone-390-dark.png', screenshotOptions)
+ await exerciseAiDrawer(page, 390)
+
+ await page.getByRole('button', { name: 'Change language, current en' }).click()
+ await page.getByRole('menuitem', { name: /中文/ }).click()
+ await expect(page).toHaveURL(/\/zh-cn\/work\/[\w-]+/)
+ await page.getByRole('button', { name: 'AI 写作' }).click()
+ await expect(page.getByPlaceholder('输入 AI 指令,如:根据标题写大纲')).toBeVisible()
+ await page.getByRole('button', { name: '关闭' }).click()
+ await expectNoHorizontalClipping(page)
+})
+
+async function assertCompactTopbar(page: Page) {
+ const toolbar = page.getByRole('banner', { name: 'Document toolbar' })
+ await expect(toolbar).toBeVisible()
+ const box = await toolbar.boundingBox()
+ expect(box).toBeTruthy()
+ expect(box!.x).toBeGreaterThanOrEqual(0)
+ expect(box!.x + box!.width).toBeLessThanOrEqual((page.viewportSize()?.width || 0) + 1)
+ await expectNoHorizontalClipping(page)
+
+ const actionsButton = page.getByRole('button', { name: 'Document actions' })
+ await expect(actionsButton).toBeVisible()
+ await expect(actionsButton).toBeEnabled()
+ await actionsButton.click()
+ await expect(page.getByRole('button', { name: 'Duplicate' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Move' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Export PDF' })).toBeVisible()
+ await page.keyboard.press('Escape')
+}
+
+async function exerciseAiDrawer(page: Page, width: number) {
+ const contentPanel = page.locator('#work-content-panel')
+ const widthBefore = (await contentPanel.boundingBox())?.width || 0
+ const trigger = page.getByRole('button', { name: 'AI Writing' })
+ await trigger.click()
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
+ const drawer = page.getByTestId('ai-panel-drawer')
+ await expect(drawer).toBeVisible()
+ expect(await drawer.evaluate((panel) => panel.contains(document.activeElement))).toBe(true)
+ await expect(page.getByPlaceholder('Input AI command, such as: outline based on title')).toBeVisible()
+ expect((await contentPanel.boundingBox())?.width).toBeGreaterThanOrEqual(widthBefore - 1)
+ expect((await drawer.boundingBox())?.width).toBeLessThanOrEqual(width + 1)
+ await page.keyboard.press('Tab')
+ expect(await drawer.evaluate((panel) => panel.contains(document.activeElement))).toBe(true)
+ await page.keyboard.press('Escape')
+ await expect(drawer).toBeHidden()
+ await expect(trigger).toBeFocused()
+}
+
+async function chooseTheme(page: Page, theme: 'Light' | 'Dark') {
+ await page.getByRole('button', { name: /theme/i }).click()
+ await page.getByRole('menuitem', { name: theme }).click()
+ await expect(page.locator('html')).toHaveAttribute('data-theme', theme.toLowerCase())
+}
+
+async function documentIsPersisted(page: Page, docId: string, title: string, marker: string) {
+ const [documentResponse, listResponse] = await Promise.all([
+ page.request.get(`/api/doc/${encodeURIComponent(docId)}`),
+ page.request.get('/api/doc'),
+ ])
+ if (!documentResponse.ok() || !listResponse.ok()) return false
+ const documentPayload = await documentResponse.json()
+ const listPayload = await listResponse.json()
+ const content = String(documentPayload?.data?.content || '')
+ const savedDoc = Array.isArray(listPayload?.data)
+ ? listPayload.data.find((document: { id?: string }) => document.id === docId)
+ : null
+ return content.includes(marker) && savedDoc?.title === title
+}
+
+async function expectNoHorizontalClipping(page: Page) {
+ expect(
+ await page.evaluate(
+ () => Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) <= window.innerWidth
+ )
+ ).toBe(true)
+}
+
+async function signInWithMailpit(page: Page, request: APIRequestContext, email: string) {
+ const previousIds = new Set((await listMessages(request)).map(messageId))
+ await page.goto('/en/signin')
+ await page.getByLabel('Email').fill(email)
+ await page.locator('button[type="submit"]').click()
+
+ const deadline = Date.now() + 30_000
+ while (Date.now() < deadline) {
+ const message = (await listMessages(request)).find(
+ (item) => !previousIds.has(messageId(item)) && JSON.stringify(item).toLowerCase().includes(email.toLowerCase())
+ )
+ if (message) {
+ const response = await request.get(`${mailpitBase}/api/v1/message/${encodeURIComponent(messageId(message))}`)
+ const detail = await response.json()
+ const text = collectStrings(detail).join('\n').replaceAll('&', '&')
+ const match = text.match(/https?:\/\/[^\s"'<>]+\/api\/auth\/callback\/nodemailer[^\s"'<>]*/i)
+ expect(match, 'Mailpit message contains an Auth.js callback').toBeTruthy()
+ await page.goto(match![0])
+ return
+ }
+ await page.waitForTimeout(500)
+ }
+ throw new Error('Mailpit did not receive the sign-in email')
+}
+
+async function listMessages(request: APIRequestContext): Promise {
+ const response = await request.get(`${mailpitBase}/api/v1/messages?limit=100`)
+ expect(response.ok()).toBe(true)
+ const payload = await response.json()
+ return Array.isArray(payload?.messages) ? payload.messages : []
+}
+
+function messageId(message: any) {
+ return String(message?.ID ?? message?.Id ?? message?.id ?? '')
+}
+
+function collectStrings(value: unknown, output: string[] = []): string[] {
+ if (typeof value === 'string') output.push(value)
+ else if (Array.isArray(value)) value.forEach((item) => collectStrings(item, output))
+ else if (value && typeof value === 'object') Object.values(value).forEach((item) => collectStrings(item, output))
+ return output
+}
diff --git a/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png
new file mode 100644
index 0000000..9c42041
Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-desktop-dark-linux.png differ
diff --git a/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png
new file mode 100644
index 0000000..71c82fd
Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-desktop-light-linux.png differ
diff --git a/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png
new file mode 100644
index 0000000..bd2deda
Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-dark-linux.png differ
diff --git a/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png
new file mode 100644
index 0000000..fbb11d6
Binary files /dev/null and b/e2e/workspace.spec.ts-snapshots/workspace-phone-390-light-linux.png differ
diff --git a/messages/en.json b/messages/en.json
index e373883..0b4d244 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1,7 +1,15 @@
{
"common": {
"brandName": "doc",
- "notSupportMobile": "Mobile devices are not supported yet"
+ "notSupportMobile": "Mobile devices are not supported yet",
+ "openDocumentNavigation": "Open document navigation",
+ "closeDocumentNavigation": "Close document navigation",
+ "documentNavigation": "Document navigation",
+ "documentToolbar": "Document toolbar",
+ "documentActions": "Document actions",
+ "moreDocumentActions": "More document actions",
+ "changeLanguage": "Change language, current {locale}",
+ "closeDialog": "Close dialog"
},
"metadata": {
"title": "doc — self-hosted collaborative document infrastructure",
@@ -75,7 +83,9 @@
"haveLogin": "You have logged in",
"login": "Login",
"logout": "Logout",
- "logoutConfirm": "Are you sure you want to log out?"
+ "logoutConfirm": "Are you sure you want to log out?",
+ "profileTitle": "Complete your profile",
+ "profileDescription": "Choose the name collaborators will see, then continue to your workspace."
},
"personalAccessTokens": {
"title": "Personal access tokens",
@@ -116,6 +126,9 @@
"others": "OR CONTINUE WITH",
"email": "Email",
"enterYourEmail": "Enter your email address",
+ "invalidEmail": "Enter a valid email address.",
+ "requiredEmail": "Email is required.",
+ "signInFailed": "Sign-in failed. Please try again.",
"withEmail": "Continue with Email",
"loading": "Loading sign-in options…",
"unavailable": "No sign-in method is configured. Ask the instance operator to configure one."
@@ -127,6 +140,11 @@
"userAndSetting": {
"title": "User / Setting",
"changeUerInfo": "Change User Info",
+ "email": "Email",
+ "name": "Name",
+ "namePlaceholder": "Input your name",
+ "avatar": "Avatar",
+ "avatarPlaceholder": "https://github.com/username.png",
"submit": "Submit",
"success": "Success"
},
@@ -191,10 +209,14 @@
"notFound": "Cannot find the doc",
"create": "Create",
"titleInputPlaceholder": "Doc title...",
+ "titleInputLabel": "Document title",
"exportPDF": "Export PDF",
"downloadPDF": "Click to download PDF manually",
"createdAt": "CreatedAt",
- "updatedAt": "UpdatedAt"
+ "updatedAt": "UpdatedAt",
+ "collapseDocument": "Collapse {title}",
+ "expandDocument": "Expand {title}",
+ "createDocumentUnder": "Create document under {title}"
},
"shareDoc": {
"share": "Share",
@@ -341,6 +363,8 @@
"inputPlaceholder2": "Input AI command, such as: outline based on title",
"inputPlaceholder3": "Input AI command for selected content, such as: expand this content",
"AIgenerating": "AI generating...",
+ "sendInstruction": "Send AI instruction",
+ "stopGenerating": "Stop AI generation",
"copy": "Copy",
"replace": "Replace",
"insert": "Insert",
diff --git a/messages/zh-cn.json b/messages/zh-cn.json
index 4b237cf..7399889 100644
--- a/messages/zh-cn.json
+++ b/messages/zh-cn.json
@@ -1,7 +1,15 @@
{
"common": {
"brandName": "doc",
- "notSupportMobile": "不支持移动端,请使用 PC 浏览器访问"
+ "notSupportMobile": "不支持移动端,请使用 PC 浏览器访问",
+ "openDocumentNavigation": "打开文档导航",
+ "closeDocumentNavigation": "关闭文档导航",
+ "documentNavigation": "文档导航",
+ "documentToolbar": "文档工具栏",
+ "documentActions": "文档操作",
+ "moreDocumentActions": "更多文档操作",
+ "changeLanguage": "切换语言,当前为 {locale}",
+ "closeDialog": "关闭对话框"
},
"metadata": {
"title": "doc — 自托管协作文档基础设施",
@@ -75,7 +83,9 @@
"haveLogin": "你已经登录",
"login": "登录",
"logout": "退出",
- "logoutConfirm": "是否退出登录?"
+ "logoutConfirm": "是否退出登录?",
+ "profileTitle": "完善个人资料",
+ "profileDescription": "设置协作者可见的名称,然后进入工作区。"
},
"personalAccessTokens": {
"title": "个人访问令牌",
@@ -116,6 +126,9 @@
"others": "使用其他方式",
"email": "邮箱",
"enterYourEmail": "输入你的邮箱",
+ "invalidEmail": "请输入有效的邮箱地址。",
+ "requiredEmail": "请输入邮箱地址。",
+ "signInFailed": "登录失败,请重试。",
"withEmail": "使用邮箱登录",
"loading": "正在加载登录方式…",
"unavailable": "当前未配置可用的登录方式,请联系实例管理员。"
@@ -127,6 +140,11 @@
"userAndSetting": {
"title": "用户/设置",
"changeUerInfo": "修改用户信息",
+ "email": "邮箱",
+ "name": "名称",
+ "namePlaceholder": "输入你的名称",
+ "avatar": "头像",
+ "avatarPlaceholder": "https://github.com/username.png",
"submit": "提交",
"success": "成功"
},
@@ -191,10 +209,14 @@
"notFound": "找不到该文档",
"create": "新建文档",
"titleInputPlaceholder": "请输入标题...",
+ "titleInputLabel": "文档标题",
"exportPDF": "导出 PDF",
"downloadPDF": "点击手动下载 PDF",
"createdAt": "创建时间",
- "updatedAt": "更新时间"
+ "updatedAt": "更新时间",
+ "collapseDocument": "收起 {title}",
+ "expandDocument": "展开 {title}",
+ "createDocumentUnder": "在 {title} 下新建文档"
},
"shareDoc": {
"share": "分享",
@@ -341,6 +363,8 @@
"inputPlaceholder2": "输入 AI 指令,如:根据标题写大纲",
"inputPlaceholder3": "针对选中内容,输入 AI 指令,如:扩展一下这段内容",
"AIgenerating": "AI 生成中......",
+ "sendInstruction": "发送 AI 指令",
+ "stopGenerating": "停止 AI 生成",
"copy": "复制",
"replace": "替换",
"insert": "插入",
diff --git a/package-lock.json b/package-lock.json
index 7432968..f342958 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -106,6 +106,7 @@
"devDependencies": {
"@commitlint/cli": "^19.2.1",
"@commitlint/config-conventional": "^19.1.0",
+ "@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.12",
"@testing-library/react": "^16.1.0",
"@types/ali-oss": "^6.16.11",
@@ -132,7 +133,7 @@
"postcss": "^8.5.6",
"prettier": "^3.2.5",
"prisma": "^5.11.0",
- "tailwindcss": "^3.3.0",
+ "tailwindcss": "^3.4.19",
"typescript": "^5",
"vite": "8.1.5",
"vitest": "4.1.10"
@@ -2357,6 +2358,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -13983,6 +14000,52 @@
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
diff --git a/package.json b/package.json
index bd030ce..c97fc8d 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
"db:preflight": "prisma db execute --file prisma/preflight/ensure-share-relation-unique.sql --schema prisma/schema.prisma",
"db:push": "npm run db:preflight && prisma db push",
"test": "vitest",
+ "test:e2e": "playwright test",
+ "test:e2e:full": "DOC_E2E_FULL_LOOP=1 playwright test e2e/workspace.spec.ts",
"test:cli": "npm test --workspace @fullstack-ai-infra/doc-cli",
"test-ci": "vitest run && npm run test:cli",
"verify:local-loop": "node scripts/verify-local-loop.mjs",
@@ -128,6 +130,7 @@
"devDependencies": {
"@commitlint/cli": "^19.2.1",
"@commitlint/config-conventional": "^19.1.0",
+ "@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.12",
"@testing-library/react": "^16.1.0",
"@types/ali-oss": "^6.16.11",
@@ -154,7 +157,7 @@
"postcss": "^8.5.6",
"prettier": "^3.2.5",
"prisma": "^5.11.0",
- "tailwindcss": "^3.3.0",
+ "tailwindcss": "^3.4.19",
"typescript": "^5",
"vite": "8.1.5",
"vitest": "4.1.10"
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..f72b11e
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const externalBaseURL = process.env.DOC_E2E_BASE_URL
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: false,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? 'github' : 'list',
+ use: {
+ baseURL: externalBaseURL || 'http://localhost:3101',
+ channel: 'chromium',
+ trace: 'retain-on-failure',
+ ...devices['Desktop Chrome'],
+ },
+ webServer: externalBaseURL
+ ? undefined
+ : {
+ command: 'npm run dev -- --hostname 127.0.0.1 --port 3101',
+ url: 'http://localhost:3101/en/signin',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ AUTH_SECRET: 'local-visual-test-only',
+ NEXTAUTH_URL: 'http://localhost:3101',
+ NEXT_PUBLIC_APP_URL: 'http://localhost:3101',
+ },
+ },
+})
diff --git a/src/__tests__/components/ai-input.test.tsx b/src/__tests__/components/ai-input.test.tsx
new file mode 100644
index 0000000..51794ff
--- /dev/null
+++ b/src/__tests__/components/ai-input.test.tsx
@@ -0,0 +1,38 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+import { NextIntlClientProvider } from 'next-intl'
+import AIInput from '@/components/ai-panel/ai-input'
+import enMessages from '../../../messages/en.json'
+import zhMessages from '../../../messages/zh-cn.json'
+
+afterEach(cleanup)
+
+function renderInput(locale: 'en' | 'zh-cn') {
+ const messages = locale === 'en' ? enMessages : zhMessages
+ return render(
+
+
+
+ )
+}
+
+test('uses the English AI input placeholder in the English locale', () => {
+ renderInput('en')
+ expect(screen.getByPlaceholderText('Input AI command, such as: outline based on title')).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Send AI instruction' })).toBeTruthy()
+ expect(screen.queryByPlaceholderText(/输入 AI 指令/)).toBeNull()
+})
+
+test('uses the Chinese AI input placeholder in the Chinese locale', () => {
+ renderInput('zh-cn')
+ expect(screen.getByPlaceholderText('输入 AI 指令,如:根据标题写大纲')).toBeTruthy()
+ expect(screen.getByRole('button', { name: '发送 AI 指令' })).toBeTruthy()
+})
diff --git a/src/__tests__/components/auto-growing-title.test.tsx b/src/__tests__/components/auto-growing-title.test.tsx
new file mode 100644
index 0000000..5e6f6b9
--- /dev/null
+++ b/src/__tests__/components/auto-growing-title.test.tsx
@@ -0,0 +1,50 @@
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import AutoGrowingTitle from '@/components/auto-growing-title'
+
+describe('AutoGrowingTitle', () => {
+ let resize: (() => void) | undefined
+ const disconnect = vi.fn()
+
+ beforeEach(() => {
+ resize = undefined
+ disconnect.mockReset()
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(callback: ResizeObserverCallback) {
+ resize = () => callback([], this as unknown as ResizeObserver)
+ }
+ observe() {}
+ disconnect() {
+ disconnect()
+ }
+ }
+ )
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+ })
+
+ it('is an accessible multiline field and forwards title edits', () => {
+ const onChange = vi.fn()
+ render()
+
+ const title = screen.getByRole('textbox', { name: 'Document title' })
+ expect(title.tagName).toBe('TEXTAREA')
+ fireEvent.change(title, { target: { value: 'Updated title' } })
+ expect(onChange).toHaveBeenCalledOnce()
+ })
+
+ it('recalculates height when wrapping width changes', () => {
+ render()
+ const title = screen.getByRole('textbox', { name: 'Document title' }) as HTMLTextAreaElement
+ Object.defineProperty(title, 'scrollHeight', { configurable: true, value: 112 })
+
+ act(() => resize?.())
+
+ expect(title.style.height).toBe('112px')
+ })
+})
diff --git a/src/__tests__/components/change-theme.test.tsx b/src/__tests__/components/change-theme.test.tsx
index fb9b950..a9bd0d7 100644
--- a/src/__tests__/components/change-theme.test.tsx
+++ b/src/__tests__/components/change-theme.test.tsx
@@ -1,5 +1,5 @@
import { expect, test, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
import NextIntlClientProviderWrapper from '../utils/next-intl-client-provider-wrapper'
import ChangeTheme from '@/components/change-theme'
import { ThemeProvider } from '@/components/theme-provider'
@@ -28,6 +28,7 @@ test('Change theme component', async () => {
)
- const button = screen.getByRole('button')
+ const button = screen.getByRole('button', { name: 'Change theme' })
expect(button.getAttribute('data-title')).toBe(theme)
+ await waitFor(() => expect(document.documentElement.dataset.theme).toBe(theme))
})
diff --git a/src/__tests__/components/dialog-store.test.ts b/src/__tests__/components/dialog-store.test.ts
new file mode 100644
index 0000000..e573d91
--- /dev/null
+++ b/src/__tests__/components/dialog-store.test.ts
@@ -0,0 +1,12 @@
+import { beforeEach, expect, test } from 'vitest'
+import { useDialogStore } from '@/stores/dialog-store'
+
+beforeEach(() => {
+ useDialogStore.setState({ AIPanelOpen: false })
+})
+
+test('keeps the AI panel closed until the user opens it', () => {
+ expect(useDialogStore.getState().AIPanelOpen).toBe(false)
+ useDialogStore.getState().setAIPanelOpen(true)
+ expect(useDialogStore.getState().AIPanelOpen).toBe(true)
+})
diff --git a/src/__tests__/components/doc-update-status.test.tsx b/src/__tests__/components/doc-update-status.test.tsx
index c0a2f6a..fd6225a 100644
--- a/src/__tests__/components/doc-update-status.test.tsx
+++ b/src/__tests__/components/doc-update-status.test.tsx
@@ -36,6 +36,8 @@ test('Doc update status component', async () => {
const state = screen.getByRole('collaborative-state')
expect(state.getAttribute('data-title')).toBe('connected')
+ expect(state.className).toContain('ui-status--source')
+ expect(state.className).toContain('is-available')
// const charCount = screen.getByRole('char-count')
// expect(charCount.textContent).toContain(CHAR_COUNT.toString())
diff --git a/src/__tests__/components/responsive-workspace.test.tsx b/src/__tests__/components/responsive-workspace.test.tsx
new file mode 100644
index 0000000..cdb66bb
--- /dev/null
+++ b/src/__tests__/components/responsive-workspace.test.tsx
@@ -0,0 +1,122 @@
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, expect, test, vi } from 'vitest'
+import ResponsiveWorkspace from '@/components/responsive-workspace'
+
+vi.mock('@/components/ui/resizable', () => ({
+ ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => {children}
,
+ ResizablePanel: ({ children }: { children: React.ReactNode }) => {children}
,
+ ResizableHandle: () => ,
+}))
+
+let compactMediaQuery: MediaQueryList
+
+beforeEach(() => {
+ const listeners = new Set()
+ compactMediaQuery = {
+ matches: true,
+ media: '(max-width: 1023px)',
+ onchange: null,
+ addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
+ if (typeof listener === 'function') listeners.add(listener)
+ },
+ removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
+ if (typeof listener === 'function') listeners.delete(listener)
+ },
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: (event) => {
+ listeners.forEach((listener) => listener(event))
+ return true
+ },
+ }
+ Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => compactMediaQuery) })
+})
+
+afterEach(cleanup)
+
+function renderWorkspace() {
+ return render(
+
+ Documents
+
+
+ Selected document
+
+
+ }
+ labels={{
+ open: 'Open document navigation',
+ close: 'Close document navigation',
+ navigation: 'Document navigation',
+ }}
+ >
+ Editor
+
+ )
+}
+
+function resizeToCompact(matches: boolean) {
+ Object.defineProperty(compactMediaQuery, 'matches', { configurable: true, value: matches })
+ act(() => compactMediaQuery.dispatchEvent(new Event('change')))
+}
+
+test('keeps the closed compact navigation out of the accessibility tree and closes it after selection', () => {
+ renderWorkspace()
+
+ const openButton = screen.getByRole('button', { name: 'Open document navigation' })
+ expect(screen.queryByText('Documents')).toBeNull()
+ expect(document.querySelector('[inert][aria-hidden="true"]')).toBeTruthy()
+ fireEvent.click(openButton)
+ expect(screen.getByRole('dialog', { name: 'Document navigation' })).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('link', { name: 'Selected document' }))
+ expect(openButton.getAttribute('aria-expanded')).toBe('false')
+ expect(screen.queryByText('Documents')).toBeNull()
+})
+
+test('traps drawer focus without stealing focus or Escape from a nested portal dialog', () => {
+ renderWorkspace()
+
+ const openButton = screen.getByRole('button', { name: 'Open document navigation' })
+ fireEvent.click(openButton)
+ expect(screen.getByRole('button', { name: 'Close document navigation' })).toBe(document.activeElement)
+
+ const nestedDialog = document.createElement('div')
+ nestedDialog.setAttribute('role', 'dialog')
+ const nestedInput = document.createElement('input')
+ nestedDialog.appendChild(nestedInput)
+ document.body.appendChild(nestedDialog)
+ nestedInput.focus()
+
+ fireEvent.keyDown(nestedInput, { key: 'Tab' })
+ expect(nestedInput).toBe(document.activeElement)
+ fireEvent.keyDown(nestedInput, { key: 'Escape' })
+ expect(openButton.getAttribute('aria-expanded')).toBe('true')
+
+ nestedDialog.remove()
+ screen.getByRole('link', { name: 'Selected document' }).focus()
+ fireEvent.keyDown(window, { key: 'Tab' })
+ expect(screen.getByRole('button', { name: 'Close document navigation' })).toBe(document.activeElement)
+
+ fireEvent.keyDown(window, { key: 'Escape' })
+ expect(openButton.getAttribute('aria-expanded')).toBe('false')
+ expect(openButton).toBe(document.activeElement)
+})
+
+test('automatically closes the drawer when resizing wide and returns compact in the closed state', () => {
+ renderWorkspace()
+ fireEvent.click(screen.getByRole('button', { name: 'Open document navigation' }))
+ expect(screen.getByRole('dialog', { name: 'Document navigation' })).toBeTruthy()
+
+ resizeToCompact(false)
+ expect(screen.queryByRole('button', { name: 'Open document navigation' })).toBeNull()
+ expect(screen.queryByRole('dialog', { name: 'Document navigation' })).toBeNull()
+ expect(screen.getByText('Documents')).toBeTruthy()
+
+ resizeToCompact(true)
+ const openButton = screen.getByRole('button', { name: 'Open document navigation' })
+ expect(openButton.getAttribute('aria-expanded')).toBe('false')
+ expect(screen.queryByText('Documents')).toBeNull()
+})
diff --git a/src/__tests__/components/share-doc-button.test.tsx b/src/__tests__/components/share-doc-button.test.tsx
index cb4d966..78ae64f 100644
--- a/src/__tests__/components/share-doc-button.test.tsx
+++ b/src/__tests__/components/share-doc-button.test.tsx
@@ -41,6 +41,8 @@ test('Share doc button component', async () => {
const shareNewButton = screen.getByTestId('share-new-button')
fireEvent.click(shareNewButton)
await waitFor(() => {
- expect(shareButton.textContent).toBe('Shared')
+ // Re-query: the shared-UI Button remounts its DOM node on state change,
+ // so the pre-click element reference goes stale even though the toggle works.
+ expect(screen.getByRole('share-button').textContent).toBe('Shared')
})
})
diff --git a/src/__tests__/components/signin-page.test.tsx b/src/__tests__/components/signin-page.test.tsx
index 6ed2741..0383475 100644
--- a/src/__tests__/components/signin-page.test.tsx
+++ b/src/__tests__/components/signin-page.test.tsx
@@ -48,6 +48,7 @@ describe('sign-in provider selection', () => {
expect(await screen.findByRole('button', { name: 'Continue with Email' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Continue with Github' })).toBeNull()
+ expect(document.querySelector('.ui-card')).toBeTruthy()
})
it('prefers Resend when both email providers are available', async () => {
@@ -65,10 +66,42 @@ describe('sign-in provider selection', () => {
})
})
+ it('uses inline live validation instead of the browser validation bubble', async () => {
+ authMocks.getProviders.mockResolvedValue({ nodemailer: emailProvider })
+ renderPage()
+
+ const emailInput = await screen.findByLabelText('Email')
+ const form = screen.getByRole('button', { name: 'Continue with Email' }).closest('form')!
+ expect(form.noValidate).toBe(true)
+ fireEvent.submit(form)
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Email is required.')
+ expect(emailInput.getAttribute('aria-invalid')).toBe('true')
+
+ fireEvent.change(emailInput, { target: { value: 'not-an-email' } })
+ fireEvent.submit(form)
+ expect((await screen.findByRole('alert')).textContent).toContain('Enter a valid email address.')
+ expect(authMocks.signIn).not.toHaveBeenCalled()
+ })
+
+ it('announces an email sign-in rejection', async () => {
+ authMocks.getProviders.mockResolvedValue({ nodemailer: emailProvider })
+ authMocks.signIn.mockRejectedValueOnce(new Error('provider unavailable'))
+ renderPage()
+
+ fireEvent.change(await screen.findByLabelText('Email'), { target: { value: 'owner@example.test' } })
+ fireEvent.submit(screen.getByRole('button', { name: 'Continue with Email' }).closest('form')!)
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Sign-in failed. Please try again.')
+ })
+
it('shows an operator-facing error when no provider is configured', async () => {
authMocks.getProviders.mockResolvedValue({})
renderPage()
+ expect((await screen.findByText(/No sign-in method is configured/)).textContent).toContain(
+ 'No sign-in method is configured'
+ )
expect((await screen.findByRole('alert')).textContent).toContain('No sign-in method is configured')
expect(screen.queryByRole('button', { name: 'Continue with Email' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Continue with Github' })).toBeNull()
diff --git a/src/__tests__/components/star-doc-button.test.tsx b/src/__tests__/components/star-doc-button.test.tsx
index 678e667..1063f4b 100644
--- a/src/__tests__/components/star-doc-button.test.tsx
+++ b/src/__tests__/components/star-doc-button.test.tsx
@@ -25,6 +25,8 @@ test('Star doc button component', async () => {
// click button
fireEvent.click(button)
await waitFor(() => {
- expect(button.textContent).toBe('Favorited')
+ // Re-query: the shared-UI Button remounts its DOM node on state change,
+ // so the pre-click element reference goes stale even though the toggle works.
+ expect(screen.getByRole('button').textContent).toBe('Favorited')
})
})
diff --git a/src/__tests__/components/theme-bootstrap-script.test.ts b/src/__tests__/components/theme-bootstrap-script.test.ts
new file mode 100644
index 0000000..5ca37f9
--- /dev/null
+++ b/src/__tests__/components/theme-bootstrap-script.test.ts
@@ -0,0 +1,31 @@
+import { beforeEach, expect, test, vi } from 'vitest'
+import { THEME_BOOTSTRAP_SCRIPT } from '@/components/theme-bootstrap-script'
+
+beforeEach(() => {
+ localStorage.clear()
+ document.documentElement.className = ''
+ delete document.documentElement.dataset.theme
+})
+
+test('sets stored theme on both shared data attribute and next-themes class before hydration', () => {
+ localStorage.setItem('theme', 'dark')
+
+ Function(THEME_BOOTSTRAP_SCRIPT)()
+
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+})
+
+test('resolves system theme before hydration', () => {
+ localStorage.setItem('theme', 'system')
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn(() => ({ matches: true }))
+ )
+
+ Function(THEME_BOOTSTRAP_SCRIPT)()
+
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ vi.unstubAllGlobals()
+})
diff --git a/src/__tests__/components/toast.test.tsx b/src/__tests__/components/toast.test.tsx
new file mode 100644
index 0000000..90f16d1
--- /dev/null
+++ b/src/__tests__/components/toast.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from '@testing-library/react'
+import { expect, test } from 'vitest'
+import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast'
+
+test('destructive toast uses shared semantic danger styles and an accessible close action', () => {
+ render(
+
+
+ Connection failed
+ Try again
+
+
+
+
+ )
+
+ const toast = screen.getByText('Connection failed').closest('li')
+ expect(toast?.className).toContain('bg-danger-soft')
+ expect(toast?.className).toContain('border-danger')
+ expect(screen.getByRole('button', { name: 'Dismiss notification' })).toBeTruthy()
+})
diff --git a/src/__tests__/lib/editor-legacy-fallback.test.ts b/src/__tests__/lib/editor-legacy-fallback.test.ts
new file mode 100644
index 0000000..23a7c72
--- /dev/null
+++ b/src/__tests__/lib/editor-legacy-fallback.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi } from 'vitest'
+import { hydrateLegacyEditorContent } from '@/lib/editor-legacy-fallback'
+
+describe('hydrateLegacyEditorContent', () => {
+ it('preserves an edit made while the legacy request is in flight', async () => {
+ let resolveFetch!: (value: { errno: number; data: { content: string } }) => void
+ const fetchPersistedDocument = vi.fn(
+ () =>
+ new Promise<{ errno: number; data: { content: string } }>((resolve) => {
+ resolveFetch = resolve
+ })
+ )
+ let empty = true
+ let version = 'before'
+ const applyPersistedContent = vi.fn()
+ const markHydrated = vi.fn()
+
+ const hydration = hydrateLegacyEditorContent({
+ needsFallback: true,
+ isEditorEmpty: () => empty,
+ getDocumentVersion: () => version,
+ hasHydrated: () => false,
+ fetchPersistedDocument,
+ applyPersistedContent,
+ markHydrated,
+ })
+
+ empty = false
+ version = 'after-local-edit'
+ resolveFetch({ errno: 0, data: { content: JSON.stringify({ type: 'doc' }) } })
+
+ await expect(hydration).resolves.toBe('preserved-newer-content')
+ expect(applyPersistedContent).not.toHaveBeenCalled()
+ expect(markHydrated).toHaveBeenCalledOnce()
+ })
+
+ it('preserves a changed Y document even while the editor still reports empty', async () => {
+ let version = 'before'
+ const applyPersistedContent = vi.fn()
+
+ await expect(
+ hydrateLegacyEditorContent({
+ needsFallback: true,
+ isEditorEmpty: () => true,
+ getDocumentVersion: () => version,
+ hasHydrated: () => false,
+ fetchPersistedDocument: async () => {
+ version = 'after-indexeddb-update'
+ return { errno: 0, data: { content: JSON.stringify({ type: 'doc' }) } }
+ },
+ applyPersistedContent,
+ markHydrated: vi.fn(),
+ })
+ ).resolves.toBe('preserved-newer-content')
+ expect(applyPersistedContent).not.toHaveBeenCalled()
+ })
+
+ it('marks hydration only after persisted content is applied', async () => {
+ const events: string[] = []
+
+ await expect(
+ hydrateLegacyEditorContent({
+ needsFallback: true,
+ isEditorEmpty: () => true,
+ getDocumentVersion: () => 'stable',
+ hasHydrated: () => false,
+ fetchPersistedDocument: async () => ({
+ errno: 0,
+ data: { content: JSON.stringify({ type: 'doc', content: [] }) },
+ }),
+ applyPersistedContent: () => events.push('apply'),
+ markHydrated: () => events.push('mark'),
+ })
+ ).resolves.toBe('applied')
+ expect(events).toEqual(['apply', 'mark'])
+ })
+
+ it('does not mark hydration after a failed request or invalid JSON', async () => {
+ const markHydrated = vi.fn()
+ const base = {
+ needsFallback: true,
+ isEditorEmpty: () => true,
+ getDocumentVersion: () => 'stable',
+ hasHydrated: () => false,
+ applyPersistedContent: vi.fn(),
+ markHydrated,
+ }
+
+ await expect(
+ hydrateLegacyEditorContent({ ...base, fetchPersistedDocument: async () => ({ errno: -1 }) })
+ ).resolves.toBe('retryable-error')
+ await expect(
+ hydrateLegacyEditorContent({
+ ...base,
+ fetchPersistedDocument: async () => ({ errno: 0, data: { content: '{' } }),
+ })
+ ).resolves.toBe('retryable-error')
+ expect(markHydrated).not.toHaveBeenCalled()
+ })
+
+ it('finishes hydration when marking storage is unavailable', async () => {
+ await expect(
+ hydrateLegacyEditorContent({
+ needsFallback: false,
+ isEditorEmpty: () => false,
+ getDocumentVersion: () => 'stable',
+ hasHydrated: () => false,
+ fetchPersistedDocument: vi.fn(),
+ applyPersistedContent: vi.fn(),
+ markHydrated: () => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ },
+ })
+ ).resolves.toBe('not-needed')
+ })
+})
diff --git a/src/__tests__/lib/editor-parsed-content.test.ts b/src/__tests__/lib/editor-parsed-content.test.ts
new file mode 100644
index 0000000..5dc23e5
--- /dev/null
+++ b/src/__tests__/lib/editor-parsed-content.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+import { hasDocParsedContent, markDocParsedContent } from '@/lib/editor-parsed-content'
+
+describe('editor parsed-content storage', () => {
+ it('does not throw or claim success when localStorage rejects a write', () => {
+ const storage = {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError')
+ }),
+ } as unknown as Storage
+
+ expect(() => markDocParsedContent('doc-1', storage)).not.toThrow()
+ expect(markDocParsedContent('doc-1', storage)).toBe(false)
+ })
+
+ it('reads only a valid parsed id list', () => {
+ const storage = {
+ getItem: vi.fn(() => JSON.stringify(['doc-1'])),
+ } as unknown as Storage
+
+ expect(hasDocParsedContent('doc-1', storage)).toBe(true)
+ expect(hasDocParsedContent('doc-2', storage)).toBe(false)
+ })
+})
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 56b0aa0..f8c1a06 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -1,9 +1,9 @@
import '../globals.css'
import { ThemeProvider } from '@/components/theme-provider'
-import { isMobileDevice } from '@/lib/isMobileDevice'
import { Toaster } from '@/components/ui/toaster'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, getTranslations } from 'next-intl/server'
+import ThemeBootstrapScript from '@/components/theme-bootstrap-script'
export async function generateMetadata({ params: { locale } }: { params: { locale: string } }) {
const t = await getTranslations({ locale, namespace: 'metadata' })
@@ -24,23 +24,18 @@ export default async function RootLayout({
children: React.ReactNode
params: { locale: string }
}>) {
- const isMobile = await isMobileDevice()
- const t = await getTranslations('common')
-
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages()
return (
+
+
+
- {isMobile && (
-
-
{t('notSupportMobile')}
-
- )}
-
+
{children}
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx
index c6eecbc..2830375 100644
--- a/src/app/[locale]/page.tsx
+++ b/src/app/[locale]/page.tsx
@@ -19,7 +19,7 @@ import Logo from '@/components/logo-component'
import SignInButton from '@/components/sign-in-button'
import StartButton from '@/components/start-button'
import { Badge } from '@/components/ui/badge'
-import { Button } from '@/components/ui/button'
+import { buttonVariants } from '@/components/ui/button'
import { getUserInfo } from '@/lib/session'
const capabilities = [
@@ -80,12 +80,10 @@ export default async function HomePage() {
{[t('selfHosted'), t('realtime'), t('agentReady')].map((item) => (
@@ -229,14 +227,14 @@ function ProductPreview() {
)
)}
-
-
+
+
Agent suggestion
Turn the portability principle into an acceptance test and link it to the architecture decision.
-
+
diff --git a/src/app/[locale]/signin/page.tsx b/src/app/[locale]/signin/page.tsx
index 649e7fe..99a5375 100644
--- a/src/app/[locale]/signin/page.tsx
+++ b/src/app/[locale]/signin/page.tsx
@@ -7,12 +7,14 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
// import { Link } from '@/i18n/routing'
import HomeNav from '@/components/home-nav'
import { useTranslations } from 'next-intl'
export default function SignInPage() {
const [providers, setProviders] = useState
>>()
+ const [authError, setAuthError] = useState('')
useEffect(() => {
let active = true
@@ -42,26 +44,42 @@ export default function SignInPage() {
const [isGithubSignInPending, startGithubSignInTransition] = useTransition()
const handleGitHubSignIn = () => {
startGithubSignInTransition(async () => {
- await signIn('github', { callbackUrl: callbackUrl || '/' })
+ setAuthError('')
+ try {
+ await signIn('github', { callbackUrl: callbackUrl || '/' })
+ } catch {
+ setAuthError(t('signInFailed'))
+ }
})
}
// handle email sign in
const [email, setEmail] = useState('')
+ const [emailError, setEmailError] = useState('')
const [isEmailSignInPending, setIsEmailSignInPending] = useState(false)
const emailProviderId = providers?.resend ? 'resend' : providers?.nodemailer ? 'nodemailer' : null
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault()
- if (!validateEmail(email)) {
- return alert('Invalid email format 邮箱格式错误')
+ const normalizedEmail = email.trim()
+ if (!normalizedEmail) {
+ setEmailError(t('requiredEmail'))
+ return
+ }
+ if (!validateEmail(normalizedEmail)) {
+ setEmailError(t('invalidEmail'))
+ return
}
+ setAuthError('')
+ setEmailError('')
setIsEmailSignInPending(true)
if (!emailProviderId) {
setIsEmailSignInPending(false)
return
}
try {
- await signIn(emailProviderId, { email, callbackUrl: callbackUrl || '/' })
+ await signIn(emailProviderId, { email: normalizedEmail, callbackUrl: callbackUrl || '/' })
+ } catch {
+ setEmailError(t('signInFailed'))
} finally {
setIsEmailSignInPending(false)
}
@@ -77,78 +95,94 @@ export default function SignInPage() {
const hasEmail = Boolean(emailProviderId)
return (
-
+
-
- {/* Header */}
-
-
{t('title')}
-
{t('subTitle')}
-
+
+
+ doc workspace
+ {t('title')}
+ {t('subTitle')}
+
- {/* Login Form */}
- {hasGitHub && (
-
- {/* GitHub Login */}
+
+ {hasGitHub && (
-
- )}
+ )}
- {/* Divider */}
- {hasGitHub && hasEmail && (
-
-
-
+ {hasGitHub && hasEmail && (
+
+
+
+
+
+ {t('others')}
+
-
- {t('others')}
-
-
- )}
+ )}
- {/* Email Login Form */}
- {hasEmail && (
-
+ )}
+
+ {authError && (
+
- {t('withEmail')}
-
-
- )}
+ {authError}
+
+ )}
- {providers === undefined &&
{t('loading')}
}
- {providers === null || (providers !== undefined && !hasGitHub && !hasEmail) ? (
-
- {t('unavailable')}
-
- ) : null}
-
-
+ {providers === undefined && {t('loading')}
}
+ {providers === null || (providers !== undefined && !hasGitHub && !hasEmail) ? (
+
+ {t('unavailable')}
+
+ ) : null}
+
+
+
)
}
diff --git a/src/app/[locale]/signin/verify-request/page.tsx b/src/app/[locale]/signin/verify-request/page.tsx
index 7ded8dd..4e014ea 100644
--- a/src/app/[locale]/signin/verify-request/page.tsx
+++ b/src/app/[locale]/signin/verify-request/page.tsx
@@ -1,19 +1,20 @@
import HomeNav from '@/components/home-nav'
+import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useTranslations } from 'next-intl'
export default function VerifyRequestPage() {
const t = useTranslations('verifyRequest')
return (
-
+
-
- {/* Header */}
-
-
{t('title')}
-
{t('subTitle')}
-
-
-
+
+
+ doc workspace
+ {t('title')}
+ {t('subTitle')}
+
+
+
)
}
diff --git a/src/app/[locale]/user-info/page.tsx b/src/app/[locale]/user-info/page.tsx
index 8912421..5b18f18 100644
--- a/src/app/[locale]/user-info/page.tsx
+++ b/src/app/[locale]/user-info/page.tsx
@@ -2,10 +2,11 @@ import { Link } from '@/i18n/routing'
import HomeNav from '@/components/home-nav'
import SignOutButton from '@/components/sign-out-button'
import PersonalAccessTokenManager from '@/components/personal-access-token-manager'
+import { UserProfileForm } from '@/components/user-profile-form'
import { getUserInfo } from '@/lib/session'
import { getTranslations } from 'next-intl/server'
-export default async function UserTestPage() {
+export default async function UserTestPage({ params }: { params: { locale: string } }) {
const user = await getUserInfo()
const t = await getTranslations('userInfo')
@@ -31,6 +32,20 @@ export default async function UserTestPage() {
{t('logout')}
+
+
+
+ {t('profileTitle')}
+
+
{t('profileDescription')}
+
+
+
diff --git a/src/app/[locale]/work/(dialog-pages)/pub-list.tsx b/src/app/[locale]/work/(dialog-pages)/pub-list.tsx
index d175059..1f0e393 100644
--- a/src/app/[locale]/work/(dialog-pages)/pub-list.tsx
+++ b/src/app/[locale]/work/(dialog-pages)/pub-list.tsx
@@ -60,7 +60,7 @@ function PubDocListContent() {
)}
{pubDocs.map((p) => (
-
+
{p.title}
@@ -70,10 +70,10 @@ function PubDocListContent() {
{getPubDocStatusLabel(p.status)}
@@ -107,8 +107,8 @@ function CopyLinkButton({ publishId }: { publishId: string }) {
return (
)
}
diff --git a/src/app/[locale]/work/[id]/(content)/content-for-my-doc.tsx b/src/app/[locale]/work/[id]/(content)/content-for-my-doc.tsx
index 0510cf2..cccfc08 100644
--- a/src/app/[locale]/work/[id]/(content)/content-for-my-doc.tsx
+++ b/src/app/[locale]/work/[id]/(content)/content-for-my-doc.tsx
@@ -1,7 +1,6 @@
'use client'
import React, { useEffect, useState, useMemo } from 'react'
-import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import TiptapEditor from '@/components/editor'
import { updateTitle, updateIcon } from '../client-action'
@@ -18,6 +17,7 @@ import { useDocsStore } from '@/stores/docs-store'
import { useTranslations } from 'next-intl'
import { getRandomElement } from '@/lib/utils'
import { flushCurrentDocVersionByBeacon, flushDocVersionById } from '@/lib/doc-version/client'
+import AutoGrowingTitle from '@/components/auto-growing-title'
export default function ContentForMyDoc() {
const docs = useDocsStore((s) => s.docs)
@@ -72,10 +72,10 @@ export default function ContentForMyDoc() {
return (
-
+
{/* 可能还会再增加其他功能,例如设置 Icon 、背景等 */}
@@ -111,8 +111,8 @@ function IconInput(props: { id: string; icon: string | null; updateDocIcon: (id:
return (
-
-
{icon}
+
+ {icon}
@@ -134,28 +134,29 @@ function TitleInput(props: { id: string; title: string; updateDocTitle: (id: str
document.title = title || t('unTitled')
}, [title, t])
- function handleChange(e: React.ChangeEvent) {
+ function handleChange(e: React.ChangeEvent) {
const newTitle = e.target.value
updateDocTitle(id, newTitle)
updateTitle(id, newTitle || t('unTitled')) // 更新数据库
}
- function handleKeyUp(e: React.KeyboardEvent) {
+ function handleKeyDown(e: React.KeyboardEvent) {
if (e.code !== 'Enter') return
- const pos = (e.target as HTMLInputElement).selectionStart || 0 // cursor position
+ e.preventDefault()
+ const pos = e.currentTarget.selectionStart || 0 // cursor position
if (pos < title.length) return
emitter.emit(EVENT_KEY_FOCUS_CONTENT)
}
return (
-
)
}
diff --git a/src/app/[locale]/work/[id]/(content)/content-for-share-doc.tsx b/src/app/[locale]/work/[id]/(content)/content-for-share-doc.tsx
index c983eef..b072dc8 100644
--- a/src/app/[locale]/work/[id]/(content)/content-for-share-doc.tsx
+++ b/src/app/[locale]/work/[id]/(content)/content-for-share-doc.tsx
@@ -3,12 +3,12 @@
import { useState, useEffect } from 'react'
import { useUserStore } from '@/stores/user-store'
import { CONTENT_WIDTH, WORK_CONTENT_CONTAINER_ID } from '@/constants'
-import { Input } from '@/components/ui/input'
import TiptapEditor from '@/components/editor'
import { useDocsStore } from '@/stores/docs-store'
import { useShareStore, IShareRelationDoc } from '@/stores/share-store'
import { useTranslations } from 'next-intl'
import { patch } from '@/lib/ajax'
+import AutoGrowingTitle from '@/components/auto-growing-title'
export default function ContentForShareDoc() {
const userInfo = useUserStore((s) => s.userInfo)
@@ -21,6 +21,7 @@ export default function ContentForShareDoc() {
const [readonly, setReadonly] = useState(false)
const t = useTranslations('sharedDocPage')
+ const docT = useTranslations('docItem')
const [renderEditor, setRenderEditor] = useState(false)
useEffect(() => {
@@ -76,24 +77,19 @@ export default function ContentForShareDoc() {
return (
-
+
{doc.icon && (
-
-
{doc.icon}
+
+ {doc.icon}
)}
-
+
{/* 可能还会再增加其他功能,例如设置 Icon 、背景等 */}
- {readonly &&
{t('readonly')}
}
+ {readonly &&
{t('readonly')}
}
{authority && renderEditor &&
}
{/* {authority &&
editor {doc.id}
} */}
diff --git a/src/app/[locale]/work/[id]/(content)/content-home.tsx b/src/app/[locale]/work/[id]/(content)/content-home.tsx
index 98d0ee3..2d778b7 100644
--- a/src/app/[locale]/work/[id]/(content)/content-home.tsx
+++ b/src/app/[locale]/work/[id]/(content)/content-home.tsx
@@ -25,7 +25,7 @@ export default function ContentHome() {
return (
@@ -35,11 +35,11 @@ export default function ContentHome() {
-
+
-
+
)
}
@@ -64,10 +64,10 @@ function SearchInput() {
return (
-
+
@@ -109,14 +109,23 @@ function RecentDocsList() {
{t('recentDocs')}
{recentDocs.length === 0 &&
}
{recentDocs.length > 0 && (
-
+
{recentDocs.map((doc) => (
- nav(doc.id)}>
+ nav(doc.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') nav(doc.id)
+ }}
+ role="link"
+ tabIndex={0}
+ >
{doc.icon} {doc.title}
-
+
@@ -165,14 +174,23 @@ function FavoriteDocsList() {
{favorDocs.length === 0 &&
}
{favorDocs.length > 0 && (
-
+
{favorDocs.map((doc) => (
- nav(doc.id)}>
+ nav(doc.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') nav(doc.id)
+ }}
+ role="link"
+ tabIndex={0}
+ >
{doc.icon} {doc.title}
-
+
@@ -227,14 +245,23 @@ function SharedDocsList() {
{docs.length === 0 &&
}
{docs.length > 0 && (
-
+
{docs.map((doc) => (
-
nav(doc.id)}>
+ nav(doc.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') nav(doc.id)
+ }}
+ role="link"
+ tabIndex={0}
+ >
{doc.icon} {doc.title}
-
+
diff --git a/src/app/[locale]/work/[id]/(content)/wrapper.tsx b/src/app/[locale]/work/[id]/(content)/wrapper.tsx
index 40a917b..5d5813b 100644
--- a/src/app/[locale]/work/[id]/(content)/wrapper.tsx
+++ b/src/app/[locale]/work/[id]/(content)/wrapper.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useEffect, useMemo } from 'react'
+import { useState, useEffect, useMemo, useRef } from 'react'
import { User } from 'next-auth'
import { Skeleton } from '@/components/ui/skeleton'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
@@ -16,6 +16,8 @@ import { useTranslations } from 'next-intl'
import RightBottomBar from '@/components/right-bottom-bar'
import AIPanel from '@/components/ai-panel'
import { useDialogStore } from '@/stores/dialog-store'
+import { Button } from '@/components/ui/button'
+import { useCompactWorkspace } from '@/hooks/use-compact-workspace'
interface IProps {
id: string
userInfo: User | null
@@ -84,8 +86,71 @@ export default function ContentWrapper(props: IProps) {
const { createDoc } = useDocs()
const t = useTranslations('docItem')
+ const aiT = useTranslations('AIInput')
const AIPanelOpen = useDialogStore((s) => s.AIPanelOpen)
+ const setAIPanelOpen = useDialogStore((s) => s.setAIPanelOpen)
+ const isCompact = useCompactWorkspace()
+ const compactAiPanelRef = useRef(null)
+ const compactAiWasOpenRef = useRef(false)
+
+ useEffect(() => {
+ if (!AIPanelOpen || !isCompact) return
+ compactAiWasOpenRef.current = true
+ const panel = compactAiPanelRef.current
+ panel
+ ?.querySelector('button:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')
+ ?.focus()
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return
+ const target = event.target instanceof HTMLElement ? event.target : null
+ const focusIsInNestedLayer =
+ target != null &&
+ !panel?.contains(target) &&
+ Boolean(
+ target.closest(
+ '[data-radix-popper-content-wrapper], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]'
+ )
+ )
+ if (focusIsInNestedLayer) return
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setAIPanelOpen(false)
+ return
+ }
+ if (event.key !== 'Tab' || !panel) return
+ const focusable = panel.querySelectorAll(
+ 'button:not([disabled]), a[href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ )
+ if (!focusable.length) {
+ event.preventDefault()
+ return
+ }
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ } else if (!panel.contains(document.activeElement)) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [AIPanelOpen, isCompact, setAIPanelOpen])
+
+ useEffect(() => {
+ if (compactAiWasOpenRef.current && !AIPanelOpen) {
+ document.querySelector('[aria-controls="ai-assistant-panel"]')?.focus()
+ compactAiWasOpenRef.current = false
+ }
+ if (!isCompact) compactAiWasOpenRef.current = false
+ }, [AIPanelOpen, isCompact])
if (loading || creating || userInfo == null) {
return (
@@ -123,39 +188,68 @@ export default function ContentWrapper(props: IProps) {
if (curDoc || shareDoc) {
return (
-
-
+
+
{getContentComponent()}
- {AIPanelOpen && }
- {AIPanelOpen && (
+ {AIPanelOpen && !isCompact && }
+ {AIPanelOpen && !isCompact && (
)}
+ {AIPanelOpen && isCompact && (
+ <>
+ setAIPanelOpen(false)}
+ />
+
+ >
+ )}
)
}
if (notFound) {
return (
-
+
- {t('notFound')},
- createDoc()}>
+ {t('notFound')}
+
+
)
diff --git a/src/app/[locale]/work/[id]/@directory/directory-dnd.tsx b/src/app/[locale]/work/[id]/@directory/directory-dnd.tsx
index bb8a345..32bc0fd 100644
--- a/src/app/[locale]/work/[id]/@directory/directory-dnd.tsx
+++ b/src/app/[locale]/work/[id]/@directory/directory-dnd.tsx
@@ -210,7 +210,7 @@ export function RootDropZone({ position, children }: { position: 'first' | 'last
{children}
diff --git a/src/app/[locale]/work/[id]/@directory/item-handlers.tsx b/src/app/[locale]/work/[id]/@directory/item-handlers.tsx
index 18f269d..a3d3b96 100644
--- a/src/app/[locale]/work/[id]/@directory/item-handlers.tsx
+++ b/src/app/[locale]/work/[id]/@directory/item-handlers.tsx
@@ -7,6 +7,8 @@ import DocDeleteButton from '@/components/delete-doc-button'
import StarDocButton from '@/components/star-doc-button'
import DuplicateDocButton from '@/components/duplicate-doc-button'
import MoveDocButton from '@/components/move-doc-button'
+import { Button } from '@/components/ui/button'
+import { useTranslations } from 'next-intl'
interface IProps {
id: string
@@ -14,13 +16,14 @@ interface IProps {
export default function ItemHandlers(props: IProps) {
const { id } = props
+ const t = useTranslations('common')
return (
-
+
+
diff --git a/src/app/[locale]/work/[id]/@directory/item.tsx b/src/app/[locale]/work/[id]/@directory/item.tsx
index 45ff766..1ca413f 100644
--- a/src/app/[locale]/work/[id]/@directory/item.tsx
+++ b/src/app/[locale]/work/[id]/@directory/item.tsx
@@ -37,7 +37,7 @@ export default function Item(props: IProps) {
const hasChildren = orderedChildren.length > 0
const [showChildren, setShowChildren] = useState(hasChildren ? isDescendant(id, curDocId, docs) : false)
- function toggleShowChildren(e: React.MouseEvent) {
+ function toggleShowChildren(e: React.MouseEvent) {
e.stopPropagation()
setShowChildren(!showChildren)
}
@@ -90,25 +90,30 @@ export default function Item(props: IProps) {
{/* icon 显示/隐藏 children */}
{hasChildren && (
-
{showChildren && }
{!showChildren && }
-
+
)}
{/* 标题链接 */}
@@ -116,9 +121,17 @@ export default function Item(props: IProps) {
ref={titleRef}
data-testid={`directory-drag-title-${id}`}
onClick={onClickTitle}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onClickTitle()
+ }
+ }}
+ role="link"
+ tabIndex={0}
className={cn(
'flex-auto overflow-hidden py-1.5 px-0.5 flex items-center',
- sortBy === 'default' ? 'cursor-grab touch-none active:cursor-grabbing' : 'cursor-pointer'
+ sortBy === 'default' ? 'cursor-grab touch-pan-y active:cursor-grabbing' : 'cursor-pointer'
)}
>
{/* no icon */}
@@ -141,17 +154,19 @@ export default function Item(props: IProps) {
{/* 操作按钮 */}
-
+
{/* 创建文档 */}
-
createDocHandler(id)}
- className="cursor-pointer rounded-full p-1 hover:bg-active invisible group-hover:visible"
+ className="invisible cursor-pointer rounded-full p-1 hover:bg-primary-soft group-hover:visible group-focus-within:visible"
>
-
+
{instruction && instruction.operation !== 'combine' && !instruction.blocked && (
)}
diff --git a/src/app/[locale]/work/[id]/@directory/list.tsx b/src/app/[locale]/work/[id]/@directory/list.tsx
index 9f55036..6e43403 100644
--- a/src/app/[locale]/work/[id]/@directory/list.tsx
+++ b/src/app/[locale]/work/[id]/@directory/list.tsx
@@ -56,7 +56,7 @@ export default function List({ defaultParamId, defaultList, defaultPubDocs }: Li
// show or hide list
const [showChildren, setShowChildren] = useState(false)
- function toggleShowChildren(e: React.MouseEvent
) {
+ function toggleShowChildren(e: React.MouseEvent) {
e.stopPropagation()
setShowChildren(!showChildren)
}
@@ -87,27 +87,29 @@ export default function List({ defaultParamId, defaultList, defaultPubDocs }: Li
if (loading)
return (
-
-
-
+
+
+
)
return (
//
- <>
+
-
-
+
{showChildren && }
{!showChildren && }
-
+
{t('title')}
-
+
@@ -132,7 +134,7 @@ export default function List({ defaultParamId, defaultList, defaultPubDocs }: Li
{/* create button */}
- >
+
)
}
diff --git a/src/app/[locale]/work/[id]/@directory/other-list.tsx b/src/app/[locale]/work/[id]/@directory/other-list.tsx
index 217f48f..dff76e2 100644
--- a/src/app/[locale]/work/[id]/@directory/other-list.tsx
+++ b/src/app/[locale]/work/[id]/@directory/other-list.tsx
@@ -72,7 +72,7 @@ export default function OtherList(props: IProps) {
{t('title')}
{noticeCount > 0 && (
-
+
{noticeCount}
)}
@@ -132,7 +132,7 @@ function AuthorList({ author }: { author: IShareRelationUser }) {
{noticeCount > 0 && (
-
+
{noticeCount}
)}
@@ -183,7 +183,7 @@ function OtherDocItem({ doc }: { doc: ShareRelationDocAndNoticeType }) {
{!icon && }
{noticeType !== 'NONE' && (
-
+
{noticeType}
)}
diff --git a/src/app/[locale]/work/[id]/@directory/util.ts b/src/app/[locale]/work/[id]/@directory/util.ts
index e396715..51ce56c 100644
--- a/src/app/[locale]/work/[id]/@directory/util.ts
+++ b/src/app/[locale]/work/[id]/@directory/util.ts
@@ -15,7 +15,9 @@ export function isDescendant(id: string, descendantId: string, list: IDoc[]) {
// 跳转链接 切换文档
export function nav(id: string) {
- const url = `/work/${id}`
+ const locale = window.location.pathname.split('/')[1]
+ const localePrefix = locale === 'en' || locale === 'zh-cn' ? `/${locale}` : ''
+ const url = `${localePrefix}/work/${id}`
// 切换文档前异步触发一次版本保存,不阻塞后续跳转。
flushCurrentDocVersion()
emitter.emit(EVENT_KEY_NAV_DOC, { id })
@@ -23,7 +25,8 @@ export function nav(id: string) {
}
if (typeof window !== 'undefined') {
const handlePopState = (event: PopStateEvent) => {
- emitter.emit(EVENT_KEY_NAV_DOC, { id: event.state.docId })
+ const id = event.state?.docId || window.location.pathname.split('/').at(-1)
+ if (id) emitter.emit(EVENT_KEY_NAV_DOC, { id })
}
window.addEventListener('popstate', handlePopState) // 只能绑定一次
}
diff --git a/src/app/[locale]/work/[id]/bottom-bar.tsx b/src/app/[locale]/work/[id]/bottom-bar.tsx
index af10687..5ef23ce 100644
--- a/src/app/[locale]/work/[id]/bottom-bar.tsx
+++ b/src/app/[locale]/work/[id]/bottom-bar.tsx
@@ -14,8 +14,8 @@ export default function BottomBar() {
const doc = useMemo(() => docs.find((d) => d.id === id), [docs, id])
return (
-
-
+
)
}
diff --git a/src/app/[locale]/work/[id]/layout.tsx b/src/app/[locale]/work/[id]/layout.tsx
index 7d2a61c..6f1d25d 100644
--- a/src/app/[locale]/work/[id]/layout.tsx
+++ b/src/app/[locale]/work/[id]/layout.tsx
@@ -1,17 +1,8 @@
-import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import TopBar from './top-bar'
import BottomBar from './bottom-bar'
-import { Separator } from '@/components/ui/separator'
-import { LogOut } from 'lucide-react'
-import UserSettingButton from '@/components/user-setting-button'
-import SignOutButton from '@/components/sign-out-button'
-import Trash from '../(dialog-pages)/trash'
-import StarList from '../(dialog-pages)/star-list'
-import SearchComp from '../(dialog-pages)/search'
-import WorkHomeLink from '@/components/work-home-link'
-import ShareList from '../(dialog-pages)/share-list'
-import PubDocList from '../(dialog-pages)/pub-list'
+import WorkSidebar from './work-sidebar'
import { getTranslations } from 'next-intl/server'
+import ResponsiveWorkspace from '@/components/responsive-workspace'
export default async function Layout({
params,
@@ -23,42 +14,33 @@ export default async function Layout({
directory: React.ReactNode
}>) {
const userInfoTrans = await getTranslations('userInfo')
+ const commonTrans = await getTranslations('common')
return (
-
-
-
-
-
-
{directory}
-
-
-
-
-
- {userInfoTrans('logout')}
-
-
-
-
-
-
-
- {/* top bar */}
-
- {/* content */}
-
{children}
- {/* bottom bar */}
-
-
-
-
+
+ }
+ >
+
+ {/* top bar */}
+
+ {/* content */}
+
+ {children}
+
+ {/* bottom bar */}
+
+
+
)
}
diff --git a/src/app/[locale]/work/[id]/top-bar.tsx b/src/app/[locale]/work/[id]/top-bar.tsx
index b526627..86866cd 100644
--- a/src/app/[locale]/work/[id]/top-bar.tsx
+++ b/src/app/[locale]/work/[id]/top-bar.tsx
@@ -21,61 +21,113 @@ import VersionDialog from '@/components/doc-version/version-dialog'
import VersionEntryButton from '@/components/doc-version/version-entry-button'
import { useDocsStore } from '@/stores/docs-store'
import { useUserStore } from '@/stores/user-store'
+import { Topbar } from '@fullstack-ai-infra/ui'
+import { useTranslations } from 'next-intl'
+import { useCompactWorkspace } from '@/hooks/use-compact-workspace'
export default function TopBar() {
+ const t = useTranslations('common')
+ const isCompact = useCompactWorkspace()
const docs = useDocsStore((s) => s.docs)
const id = useDocsStore((s) => s.curDocId)
const doc = useMemo(() => docs.find((d) => d.id === id), [docs, id])
const userInfo = useUserStore((s) => s.userInfo)
return (
-
-
-
-
-
-
- {/* 后续再拆分组件 */}
-
+ }
+ actions={
+ <>
{id !== '0' && (
<>
-
-
-
-
+ {isCompact ? (
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
>
)}
-
-
-
+ >
+ }
+ />
+ )
+}
+
+function CompactDocumentActions(props: { id: string; disabled?: boolean }) {
+ const { id, disabled = false } = props
+ const t = useTranslations('common')
+ const [open, setOpen] = useState(false)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ setOpen(false)} />
+
+
+
+ >
)
}
function TopBarHandlers(props: { id: string; disabled?: boolean }) {
const { id, disabled = false } = props
const [open, setOpen] = useState(false)
+ const t = useTranslations('common')
return (
<>
-
-
-
-
- setOpen(false)} />
-
-
+ setOpen(false)} />
>
)
}
+
+function TopBarHandlerItems(props: { id: string; onVersionEntry?: () => void }) {
+ const { id, onVersionEntry } = props
+ return (
+ <>
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/[locale]/work/[id]/work-sidebar.tsx b/src/app/[locale]/work/[id]/work-sidebar.tsx
new file mode 100644
index 0000000..47315b8
--- /dev/null
+++ b/src/app/[locale]/work/[id]/work-sidebar.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import { Sidebar, SidebarSection } from '@fullstack-ai-infra/ui'
+import { LogOut } from 'lucide-react'
+import UserSettingButton from '@/components/user-setting-button'
+import SignOutButton from '@/components/sign-out-button'
+import Trash from '../(dialog-pages)/trash'
+import StarList from '../(dialog-pages)/star-list'
+import SearchComp from '../(dialog-pages)/search'
+import WorkHomeLink from '@/components/work-home-link'
+import ShareList from '../(dialog-pages)/share-list'
+import PubDocList from '../(dialog-pages)/pub-list'
+
+// The shared-UI Sidebar pulls antd (module-scope createContext), which cannot be
+// evaluated in Next's react-server graph. The async work layout is a server
+// component, so the Sidebar is rendered behind this client boundary instead of
+// being imported by the layout directly.
+interface WorkSidebarProps {
+ navigationLabel: string
+ logoutLabel: string
+ directory: React.ReactNode
+}
+
+export default function WorkSidebar({ navigationLabel, logoutLabel, directory }: WorkSidebarProps) {
+ return (
+
}
+ footer={
+
+
+
+
+ {logoutLabel}
+
+
+ }
+ >
+
+
+
+
+
+
+
+
{directory}
+
+ )
+}
diff --git a/src/app/[locale]/work/layout.tsx b/src/app/[locale]/work/layout.tsx
index 8d5dfd7..9144605 100644
--- a/src/app/[locale]/work/layout.tsx
+++ b/src/app/[locale]/work/layout.tsx
@@ -1,32 +1,20 @@
-import { Link, redirect } from '@/i18n/routing'
+import { redirect } from '@/i18n/routing'
import { getUserInfo } from '@/lib/session'
-import { isMobileDevice } from '@/lib/isMobileDevice'
-import { getLocale, getTranslations } from 'next-intl/server'
+import { getLocale } from 'next-intl/server'
export default async function Layout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
- const isMobile = await isMobileDevice()
const locale = await getLocale()
- const t = await getTranslations('common')
-
- if (isMobile) {
- return (
-
-
-
- {t('brandName')}
-
- , {t('notSupportMobile')}
-
-
- )
- }
const user = await getUserInfo()
if (user == null) {
+ redirect({ href: '/signin?callbackUrl=/work', locale })
+ return null
+ }
+ if (!user.name?.trim()) {
redirect({ href: '/user-info', locale })
return null
}
diff --git a/src/app/admin/components/admin-doc-detail-dialog.tsx b/src/app/admin/components/admin-doc-detail-dialog.tsx
index 8f00c40..2ff2d6d 100644
--- a/src/app/admin/components/admin-doc-detail-dialog.tsx
+++ b/src/app/admin/components/admin-doc-detail-dialog.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
+import { Button, buttonVariants } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PubDocStatusValue } from '@/lib/pub-doc-status'
import AdminPubStatusSelect from './admin-pub-status-select'
@@ -94,11 +94,13 @@ export default function AdminDocDetailDialog({ doc }: Props) {
{doc.latestPubDoc?.publishId && (
-
-
- 查看发布页
-
-
+
+ 查看发布页
+
)}
diff --git a/src/app/globals.css b/src/app/globals.css
index f5dbf72..51e7801 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,96 +1,12 @@
+@import '@fullstack-ai-infra/ui/styles.css';
+
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './editor.css';
-/*
- * doc design tokens
- * Dark-first, restrained and geometric. The palette intentionally shares the
- * same visual family as fullstack-ai-infra/mem so the two products read as one
- * infrastructure suite.
- */
@layer base {
- :root,
- .dark {
- --background: 228 20% 5%;
- --foreground: 225 21% 93%;
-
- --card: 222 22% 9%;
- --card-foreground: 225 21% 93%;
-
- --popover: 222 22% 9%;
- --popover-foreground: 225 21% 93%;
-
- --primary: 234 89% 74%;
- --primary-foreground: 228 20% 5%;
-
- --secondary: 222 22% 12%;
- --secondary-foreground: 225 21% 93%;
-
- --muted: 222 22% 12%;
- --muted-foreground: 219 10% 65%;
-
- --accent: 222 22% 12%;
- --accent-foreground: 225 21% 93%;
-
- --destructive: 0 91% 71%;
- --destructive-foreground: 228 20% 5%;
-
- --success: 160 84% 39%;
- --success-foreground: 228 20% 5%;
-
- --warning: 43 96% 56%;
- --warning-foreground: 228 20% 5%;
-
- --border: 221 21% 15%;
- --input: 222 20% 24%;
- --ring: 234 89% 74%;
- --active: 234 89% 74% / 0.09;
- --radius: 0.5rem;
-
- color-scheme: dark;
- }
-
- .light {
- --background: 240 25% 98%;
- --foreground: 221 39% 11%;
-
- --card: 0 0% 100%;
- --card-foreground: 221 39% 11%;
-
- --popover: 0 0% 100%;
- --popover-foreground: 221 39% 11%;
-
- --primary: 244 75% 59%;
- --primary-foreground: 0 0% 100%;
-
- --secondary: 225 29% 95%;
- --secondary-foreground: 221 39% 11%;
-
- --muted: 225 29% 95%;
- --muted-foreground: 220 9% 46%;
-
- --accent: 225 29% 95%;
- --accent-foreground: 221 39% 11%;
-
- --destructive: 0 72% 51%;
- --destructive-foreground: 0 0% 100%;
-
- --success: 160 84% 31%;
- --success-foreground: 0 0% 100%;
-
- --warning: 32 95% 44%;
- --warning-foreground: 0 0% 100%;
-
- --border: 220 20% 90%;
- --input: 214 20% 82%;
- --ring: 244 75% 59%;
- --active: 244 75% 59% / 0.08;
-
- color-scheme: light;
- }
-
* {
@apply border-border;
}
@@ -120,12 +36,12 @@
}
::selection {
- background: hsl(var(--primary) / 0.35);
- color: hsl(var(--foreground));
+ background: var(--ui-selection);
+ color: var(--ui-foreground);
}
:focus-visible {
- @apply outline-none ring-2 ring-ring/60 ring-offset-2 ring-offset-background;
+ @apply outline-none ring-2 ring-focus ring-offset-2 ring-offset-background;
}
::-webkit-scrollbar {
@@ -140,7 +56,7 @@
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
- background: hsl(var(--input) / 0.7);
+ background: var(--ui-border-strong);
background-clip: content-box;
}
}
@@ -148,9 +64,7 @@
@layer components {
.surface {
@apply rounded-lg border border-border bg-card;
- box-shadow:
- inset 0 1px 0 hsl(0 0% 100% / 0.03),
- 0 1px 2px hsl(0 0% 0% / 0.4);
+ box-shadow: var(--ui-shadow-sm);
}
.surface-inset {
@@ -158,7 +72,7 @@
}
.doc-grid {
- background-image: radial-gradient(circle at 1px 1px, hsl(var(--input) / 0.42) 1px, transparent 0);
+ background-image: radial-gradient(circle at 1px 1px, var(--ui-border) 1px, transparent 0);
background-size: 24px 24px;
}
diff --git a/src/components/ai-panel-button.tsx b/src/components/ai-panel-button.tsx
index 6e6fd8d..f468910 100644
--- a/src/components/ai-panel-button.tsx
+++ b/src/components/ai-panel-button.tsx
@@ -10,9 +10,16 @@ export default function AIPanelButton() {
const setAIPanelOpen = useDialogStore((s) => s.setAIPanelOpen)
const t = useTranslations('AIInput')
return (
-
setAIPanelOpen(!AIPanelOpen)}>
-
- {t('AIWriting')}
+ setAIPanelOpen(!AIPanelOpen)}
+ >
+
+ {t('AIWriting')}
)
}
diff --git a/src/components/ai-panel/ai-input.tsx b/src/components/ai-panel/ai-input.tsx
index 693599c..5dcb22f 100644
--- a/src/components/ai-panel/ai-input.tsx
+++ b/src/components/ai-panel/ai-input.tsx
@@ -88,12 +88,12 @@ const AIInput = forwardRef((props: IProps, inputRef: ForwardedRef
- {delay > 0 && {delay}s}
+ {delay > 0 && {delay}s}
@@ -124,6 +125,7 @@ const AIInput = forwardRef((props: IProps, inputRef: ForwardedRef
diff --git a/src/components/ai-panel/chat-item/chat-item-ai-generating.tsx b/src/components/ai-panel/chat-item/chat-item-ai-generating.tsx
index 456365f..26a2c9d 100644
--- a/src/components/ai-panel/chat-item/chat-item-ai-generating.tsx
+++ b/src/components/ai-panel/chat-item/chat-item-ai-generating.tsx
@@ -24,11 +24,11 @@ export default function ChatItemAIGenerating(props: IProps) {
}, [content])
return (
-
+
-
+
-
+
{!content && (
{t('AIgenerating')}
diff --git a/src/components/ai-panel/chat-item/chat-item-ai.tsx b/src/components/ai-panel/chat-item/chat-item-ai.tsx
index 0db0271..3cc0ffe 100644
--- a/src/components/ai-panel/chat-item/chat-item-ai.tsx
+++ b/src/components/ai-panel/chat-item/chat-item-ai.tsx
@@ -16,11 +16,11 @@ export default function ChatItemAI(props: IProps) {
const [viewMore, setViewMore] = useState(false)
return (
-
+
-
+
-
+
diff --git a/src/components/ai-panel/chat-item/chat-item-user.tsx b/src/components/ai-panel/chat-item/chat-item-user.tsx
index 22625ef..a127383 100644
--- a/src/components/ai-panel/chat-item/chat-item-user.tsx
+++ b/src/components/ai-panel/chat-item/chat-item-user.tsx
@@ -8,12 +8,10 @@ export default function ChatItemUser(props: IProps) {
const { content } = props
return (
-
-
- {content}
-
+
)
diff --git a/src/components/ai-panel/chat-item/item-menus.tsx b/src/components/ai-panel/chat-item/item-menus.tsx
index 0d5e3b4..bdbb1c9 100644
--- a/src/components/ai-panel/chat-item/item-menus.tsx
+++ b/src/components/ai-panel/chat-item/item-menus.tsx
@@ -69,7 +69,7 @@ export default function ItemMenus(props: IProps) {
}
return (
-
+
@@ -78,10 +78,10 @@ export default function ItemMenus(props: IProps) {
disabled={loading}
variant="ghost"
size="sm"
- className="p-2 h-6 hover:bg-inherit hover:text-blue-400"
+ className="h-6 p-2 hover:bg-inherit hover:text-ai"
tabIndex={-1}
>
- {copied ? : }
+ {copied ? : }
@@ -98,7 +98,7 @@ export default function ItemMenus(props: IProps) {
disabled={loading}
variant="ghost"
size="sm"
- className="p-2 h-6 hover:bg-inherit hover:text-blue-400"
+ className="h-6 p-2 hover:bg-inherit hover:text-ai"
tabIndex={-1}
>
@@ -116,7 +116,7 @@ export default function ItemMenus(props: IProps) {
disabled={loading || isSelectionEmpty}
variant="ghost"
size="sm"
- className="p-2 h-6 hover:bg-inherit hover:text-blue-400"
+ className="h-6 p-2 hover:bg-inherit hover:text-ai"
tabIndex={-1}
>
@@ -134,7 +134,7 @@ export default function ItemMenus(props: IProps) {
disabled={loading || !reRequestAI}
variant="ghost"
size="sm"
- className="p-2 h-6 hover:bg-inherit hover:text-blue-400"
+ className="h-6 p-2 hover:bg-inherit hover:text-ai"
tabIndex={-1}
>
diff --git a/src/components/ai-panel/index.tsx b/src/components/ai-panel/index.tsx
index c84e330..2382e83 100644
--- a/src/components/ai-panel/index.tsx
+++ b/src/components/ai-panel/index.tsx
@@ -29,6 +29,7 @@ import SummaryMenu from './shortcut-menus/summary-menu'
import { MessagesType } from './hooks/useGenMessages'
import ClearChatButton from './clear-chat-button'
import { buildHistoryMessages } from './util'
+import { AIStatus } from '@fullstack-ai-infra/ui'
interface IChatItem {
id: string
@@ -198,24 +199,31 @@ export default function AiPanel() {
}
return (
- <>
-
-
{t('AIWritingChat')}
-
+
+
+
+
{t('AIWritingChat')}
+
+
+
- setAIPanelOpen(false)} className="p-1 m-0 h-6 w-6">
+ setAIPanelOpen(false)}>
{/* 无聊天记录时,input 居中显示 */}
{chatList.length === 0 && !loading && (
-
-
-
+
+
+
-
{t('useAIWritingChat')}
+
{t('useAIWritingChat')}
0 || loading) && (
<>
-
+
{chatList.map((chatItem, index) => {
const { from, content } = chatItem
if (from === 'user') {
@@ -270,7 +278,7 @@ export default function AiPanel() {
{loading && }
{loading && }
-
+
)
}
diff --git a/src/components/ai-panel/mermaid-preview-card.tsx b/src/components/ai-panel/mermaid-preview-card.tsx
index ae9f6e0..c40fa49 100644
--- a/src/components/ai-panel/mermaid-preview-card.tsx
+++ b/src/components/ai-panel/mermaid-preview-card.tsx
@@ -33,7 +33,7 @@ export default function MermaidPreviewCard(props: MermaidPreviewCardProps) {
{t('mermaidInsertToDoc')}
-
diff --git a/src/components/ai-panel/shortcut-menus/brain-storm-menu.tsx b/src/components/ai-panel/shortcut-menus/brain-storm-menu.tsx
index 7b236c7..421a3c5 100644
--- a/src/components/ai-panel/shortcut-menus/brain-storm-menu.tsx
+++ b/src/components/ai-panel/shortcut-menus/brain-storm-menu.tsx
@@ -33,7 +33,7 @@ export default function BrainStormMenu(props: IProps) {
}
return (
-
+
{t('brainstorm')}
diff --git a/src/components/ai-panel/shortcut-menus/continue-menu.tsx b/src/components/ai-panel/shortcut-menus/continue-menu.tsx
index 9504f92..0248069 100644
--- a/src/components/ai-panel/shortcut-menus/continue-menu.tsx
+++ b/src/components/ai-panel/shortcut-menus/continue-menu.tsx
@@ -49,7 +49,7 @@ export default function ContinueMenu(props: IProps) {
}
return (
-
+
{t('continue')}
diff --git a/src/components/ai-panel/shortcut-menus/outline-menu.tsx b/src/components/ai-panel/shortcut-menus/outline-menu.tsx
index 85a0b77..66a98db 100644
--- a/src/components/ai-panel/shortcut-menus/outline-menu.tsx
+++ b/src/components/ai-panel/shortcut-menus/outline-menu.tsx
@@ -33,7 +33,7 @@ export default function OutlineMenu(props: IProps) {
}
return (
-
+
{t('outline')}
diff --git a/src/components/ai-panel/shortcut-menus/summary-menu.tsx b/src/components/ai-panel/shortcut-menus/summary-menu.tsx
index 27afd08..e3db550 100644
--- a/src/components/ai-panel/shortcut-menus/summary-menu.tsx
+++ b/src/components/ai-panel/shortcut-menus/summary-menu.tsx
@@ -70,7 +70,7 @@ export default function SummaryMenu(props: IProps) {
}
return (
-
+
{t('summary')}
diff --git a/src/components/ai-token-info.tsx b/src/components/ai-token-info.tsx
index ef35ade..d89fbaa 100644
--- a/src/components/ai-token-info.tsx
+++ b/src/components/ai-token-info.tsx
@@ -31,7 +31,7 @@ export default function AITokenInfo(props: IProps) {
}, [AITokenLimit, setAITokenLimit])
return (
-
+
{t('mistakeTip')}.
{t.rich('limitTip', {
limit: (chunks: any) => (
@@ -50,9 +50,9 @@ export default function AITokenInfo(props: IProps) {
function TokenLimitSpan({ tokenLimit }: { tokenLimit: number | null }) {
if (tokenLimit === null) return ---
- let color = 'text-green-500'
- if (tokenLimit < 3000) color = 'text-orange-500'
- if (tokenLimit < 1000) color = 'text-red-500'
+ let color = 'text-success'
+ if (tokenLimit < 3000) color = 'text-warning-strong'
+ if (tokenLimit < 1000) color = 'text-danger'
if (tokenLimit < 0) return ---
return {tokenLimit}
}
diff --git a/src/components/auto-growing-title.tsx b/src/components/auto-growing-title.tsx
new file mode 100644
index 0000000..49d04d0
--- /dev/null
+++ b/src/components/auto-growing-title.tsx
@@ -0,0 +1,36 @@
+'use client'
+
+import { useEffect, useRef, type TextareaHTMLAttributes } from 'react'
+import { cn } from '@/lib/utils'
+
+export type AutoGrowingTitleProps = TextareaHTMLAttributes
+
+export default function AutoGrowingTitle({ className, value, ...props }: AutoGrowingTitleProps) {
+ const textareaRef = useRef(null)
+
+ useEffect(() => {
+ const textarea = textareaRef.current
+ if (!textarea) return
+ const resize = () => {
+ textarea.style.height = 'auto'
+ textarea.style.height = `${textarea.scrollHeight}px`
+ }
+ resize()
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize)
+ observer?.observe(textarea)
+ return () => observer?.disconnect()
+ }, [value])
+
+ return (
+
+ )
+}
diff --git a/src/components/change-locale.tsx b/src/components/change-locale.tsx
index f4ebe7b..5ad19ac 100644
--- a/src/components/change-locale.tsx
+++ b/src/components/change-locale.tsx
@@ -3,11 +3,12 @@
import { useTransition } from 'react'
import { Languages } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { useLocale } from 'next-intl'
+import { useLocale, useTranslations } from 'next-intl'
import { Locale, usePathname, useRouter } from '@/i18n/routing'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
export default function ChangeLocale() {
+ const t = useTranslations('common')
const curLocale = useLocale()
const router = useRouter()
const [isPending, startTransition] = useTransition()
@@ -24,8 +25,13 @@ export default function ChangeLocale() {
return (
-
-
+
+
diff --git a/src/components/change-theme.tsx b/src/components/change-theme.tsx
index 80be583..5a70f58 100644
--- a/src/components/change-theme.tsx
+++ b/src/components/change-theme.tsx
@@ -13,9 +13,15 @@ export default function ChangeTheme() {
return (
-
-
-
+
+
+
{t('title')}
diff --git a/src/components/doc-update-status.tsx b/src/components/doc-update-status.tsx
index 86a49fb..c0bdbec 100644
--- a/src/components/doc-update-status.tsx
+++ b/src/components/doc-update-status.tsx
@@ -1,12 +1,12 @@
'use client'
-import { memo } from 'react'
+import { memo, useEffect, useState } from 'react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Skeleton } from '@/components/ui/skeleton'
-import { cn } from '@/lib/utils'
import { useEditorStore, ICollabUser } from '@/stores/editor-store'
import { useLocale, useTranslations } from 'next-intl'
+import { SourceStatus, type SourceStatusState } from '@fullstack-ai-infra/ui'
interface IProps {
id: string
@@ -14,6 +14,8 @@ interface IProps {
export default function DocUpdateStatus(props: IProps) {
const { id } = props
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => setMounted(true), [])
const editorDocId = useEditorStore((s) => s.docId)
@@ -27,11 +29,13 @@ export default function DocUpdateStatus(props: IProps) {
// collaborative users
const collaborativeUsers = useEditorStore((s) => s.collaborativeUsers)
+ const sourceState: SourceStatusState =
+ collaborativeState === 'connected' ? 'available' : collaborativeState === 'connecting' ? 'syncing' : 'offline'
if (id === '0') return null
// 切换文档的瞬间,两者可能不一致
- if (id !== editorDocId) return
+ if (!mounted || id !== editorDocId) return
return (
<>
@@ -54,20 +58,15 @@ export default function DocUpdateStatus(props: IProps) {
})}
{/* collaborative state */}
-
-
- {/*
{collaborativeState} */}
-
+
{/* character count */}
-
+
{locale === 'zh-cn' && `共 ${characterCount >= 0 ? characterCount : '---'} 字`}
{locale !== 'zh-cn' &&
`Total ${wordCount >= 0 ? wordCount : '---'} words, ${characterCount >= 0 ? characterCount : '---'} characters`}
diff --git a/src/components/doc-version/block-view-styles.ts b/src/components/doc-version/block-view-styles.ts
index e6ac827..5f2fba6 100644
--- a/src/components/doc-version/block-view-styles.ts
+++ b/src/components/doc-version/block-view-styles.ts
@@ -6,7 +6,7 @@ export function getStaticTextBlockClass(kind: StaticTextBlockKind) {
return cn(
'w-full max-w-full min-w-0 px-3 text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]',
['listItem', 'taskItem'].includes(kind) ? 'py-1' : 'py-2',
- kind === 'blockquote' && 'border-l-2 border-muted-foreground/30 pl-3 italic'
+ kind === 'blockquote' && 'border-l-2 border-border-strong pl-3 italic'
)
}
@@ -17,11 +17,11 @@ export function getStaticHeadingClass(level: number) {
}
export function getStaticCodeBlockClass() {
- return 'rounded-md bg-black/80 px-3 py-2 text-xs text-white whitespace-pre-wrap [overflow-wrap:anywhere]'
+ return 'rounded-md bg-surface-inset px-3 py-2 text-xs text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]'
}
export function getStaticMermaidCodeClass() {
- return 'rounded-md bg-black/80 px-3 py-2 text-xs text-white whitespace-pre-wrap'
+ return 'rounded-md bg-surface-inset px-3 py-2 text-xs text-foreground whitespace-pre-wrap'
}
export function getStaticContainerBlockClass() {
@@ -41,7 +41,7 @@ export function getStaticColumnLayoutClass(layout: string) {
export function getStaticColumnClass(withBorder: boolean) {
if (!withBorder) return 'border-none px-0 py-0 overflow-auto'
- return 'rounded border-2 border-dotted border-black/10 px-3 py-1 overflow-auto dark:border-neutral-500'
+ return 'overflow-auto rounded border-2 border-dotted border-border-strong px-3 py-1'
}
export function getStaticTableWrapperClass() {
@@ -49,14 +49,11 @@ export function getStaticTableWrapperClass() {
}
export function getStaticTableClass() {
- return 'min-w-full w-full border-collapse box-border border-black/10 dark:border-white/20'
+ return 'min-w-full w-full border-collapse box-border border-border'
}
export function getStaticTableCellClass(isHeader: boolean) {
- return cn(
- 'border border-black/10 min-w-[100px] px-3 py-1.5 text-left align-top dark:border-white/20',
- isHeader && 'bg-muted/60 font-bold'
- )
+ return cn('min-w-[100px] border border-border px-3 py-1.5 text-left align-top', isHeader && 'bg-muted font-bold')
}
export function getStaticImageAlignClass(align?: string) {
@@ -74,6 +71,6 @@ export function getStaticImageClass() {
export function getStaticTaskCheckboxClass(checked: boolean) {
return cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border text-[11px] leading-none',
- checked ? 'border-[#1677ff] bg-[#1677ff] text-white' : 'border-muted-foreground/40 bg-background text-transparent'
+ checked ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-surface text-transparent'
)
}
diff --git a/src/components/doc-version/diff-block-renderer.tsx b/src/components/doc-version/diff-block-renderer.tsx
index 89eb111..02efa6f 100644
--- a/src/components/doc-version/diff-block-renderer.tsx
+++ b/src/components/doc-version/diff-block-renderer.tsx
@@ -26,25 +26,25 @@ import {
// 所有 diff 相关的颜色都通过这里管理,确保一致性
const diffStyles = {
added: {
- bg: 'bg-emerald-50',
- bgSubtle: 'bg-emerald-50/50',
- text: 'text-emerald-900',
- textSubtle: 'text-emerald-700',
- segment: 'bg-emerald-100 text-emerald-950',
+ bg: 'bg-success-soft',
+ bgSubtle: 'bg-success-soft',
+ text: 'text-success-strong',
+ textSubtle: 'text-success',
+ segment: 'bg-success-soft text-foreground',
},
removed: {
- bg: 'bg-red-50',
- bgSubtle: 'bg-red-50/50',
- text: 'text-red-900',
- textSubtle: 'text-red-700',
- segment: 'bg-red-100 text-red-900 line-through',
+ bg: 'bg-danger-soft',
+ bgSubtle: 'bg-danger-soft',
+ text: 'text-danger',
+ textSubtle: 'text-danger',
+ segment: 'bg-danger-soft text-danger line-through',
},
modified: {
- bg: 'bg-amber-50',
- bgSubtle: 'bg-amber-50/70',
- text: 'text-amber-900',
- textSubtle: 'text-amber-700',
- segment: 'bg-amber-100 text-amber-950',
+ bg: 'bg-warning-soft',
+ bgSubtle: 'bg-warning-soft',
+ text: 'text-warning-strong',
+ textSubtle: 'text-warning',
+ segment: 'bg-warning-soft text-foreground',
},
unchanged: {
bg: '',
@@ -134,7 +134,7 @@ export default function DiffBlockRenderer(props: { block: RenderBlock }) {
{imageChangeType === 'replaced' && 图片已替换
}
{imageChangeType === 'replaced' && previousSrc ? (
-
+
旧版本
-
+
新版本
- {delay > 0 &&
{delay}s}
+ {delay > 0 &&
{delay}s}
@@ -127,7 +128,7 @@ const CustomInput = forwardRef((props: IProps, inputRef: ForwardedRef}
{loading && (
-
+
)}
diff --git a/src/components/editor/ai-island/info.tsx b/src/components/editor/ai-island/info.tsx
index be3ebfc..94a88e6 100644
--- a/src/components/editor/ai-island/info.tsx
+++ b/src/components/editor/ai-island/info.tsx
@@ -50,9 +50,9 @@ export default function Info(props: IProps) {
function TokenLimitSpan({ tokenLimit }: { tokenLimit: number | null }) {
if (tokenLimit === null) return ---
- let color = 'text-green-500'
- if (tokenLimit < 3000) color = 'text-orange-500'
- if (tokenLimit < 1000) color = 'text-red-500'
+ let color = 'text-success'
+ if (tokenLimit < 3000) color = 'text-warning'
+ if (tokenLimit < 1000) color = 'text-danger'
if (tokenLimit < 0) return ---
return {tokenLimit}
}
diff --git a/src/components/editor/ai-island/menus/brain-storm-menu.tsx b/src/components/editor/ai-island/menus/brain-storm-menu.tsx
index cdd40bc..c3649fb 100644
--- a/src/components/editor/ai-island/menus/brain-storm-menu.tsx
+++ b/src/components/editor/ai-island/menus/brain-storm-menu.tsx
@@ -38,7 +38,7 @@ export default function BrainStormMenu(props: IProps) {
}
return (
-
+
{t('brainstorm')}
diff --git a/src/components/editor/ai-island/menus/change-tone-menu.tsx b/src/components/editor/ai-island/menus/change-tone-menu.tsx
index 459d4a0..34be657 100644
--- a/src/components/editor/ai-island/menus/change-tone-menu.tsx
+++ b/src/components/editor/ai-island/menus/change-tone-menu.tsx
@@ -52,7 +52,7 @@ export default function ChangeToneMenu(props: IProps) {
return (
-
+
{t('tone')}
@@ -61,7 +61,7 @@ export default function ChangeToneMenu(props: IProps) {
handleClick(t('professional'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('professional')}
@@ -69,7 +69,7 @@ export default function ChangeToneMenu(props: IProps) {
handleClick(t('casual'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('casual')}
@@ -77,7 +77,7 @@ export default function ChangeToneMenu(props: IProps) {
handleClick(t('neutral'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('neutral')}
@@ -85,7 +85,7 @@ export default function ChangeToneMenu(props: IProps) {
handleClick(t('formal'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('formal')}
@@ -93,7 +93,7 @@ export default function ChangeToneMenu(props: IProps) {
handleClick(t('friendly'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('friendly')}
diff --git a/src/components/editor/ai-island/menus/continue-menu.tsx b/src/components/editor/ai-island/menus/continue-menu.tsx
index 329724c..b3251c4 100644
--- a/src/components/editor/ai-island/menus/continue-menu.tsx
+++ b/src/components/editor/ai-island/menus/continue-menu.tsx
@@ -55,7 +55,7 @@ export default function ContinueMenu(props: IProps) {
if (editor == null) return null
return (
-
+
{t('continue')}
diff --git a/src/components/editor/ai-island/menus/explain-menu.tsx b/src/components/editor/ai-island/menus/explain-menu.tsx
index 0ab082c..d66e8a9 100644
--- a/src/components/editor/ai-island/menus/explain-menu.tsx
+++ b/src/components/editor/ai-island/menus/explain-menu.tsx
@@ -49,7 +49,7 @@ export default function ExplainMenu(props: IProps) {
if (editor == null) return null
return (
-
+
{t('explain')}
diff --git a/src/components/editor/ai-island/menus/make-longer-menu.tsx b/src/components/editor/ai-island/menus/make-longer-menu.tsx
index 58181c1..0745bbc 100644
--- a/src/components/editor/ai-island/menus/make-longer-menu.tsx
+++ b/src/components/editor/ai-island/menus/make-longer-menu.tsx
@@ -49,7 +49,7 @@ export default function MakeLongerMenu(props: IProps) {
if (editor == null) return null
return (
-
+
{t('expand')}
diff --git a/src/components/editor/ai-island/menus/make-shorter-menu.tsx b/src/components/editor/ai-island/menus/make-shorter-menu.tsx
index f11aa38..6863ffb 100644
--- a/src/components/editor/ai-island/menus/make-shorter-menu.tsx
+++ b/src/components/editor/ai-island/menus/make-shorter-menu.tsx
@@ -49,7 +49,7 @@ export default function MakeShorterMenu(props: IProps) {
if (editor == null) return null
return (
-
+
{t('simply')}
diff --git a/src/components/editor/ai-island/menus/outline-menu.tsx b/src/components/editor/ai-island/menus/outline-menu.tsx
index d484942..4a1ca79 100644
--- a/src/components/editor/ai-island/menus/outline-menu.tsx
+++ b/src/components/editor/ai-island/menus/outline-menu.tsx
@@ -38,7 +38,7 @@ export default function OutlineMenu(props: IProps) {
}
return (
-
+
{t('outline')}
diff --git a/src/components/editor/ai-island/menus/summary-menu.tsx b/src/components/editor/ai-island/menus/summary-menu.tsx
index 1f2447d..510fba8 100644
--- a/src/components/editor/ai-island/menus/summary-menu.tsx
+++ b/src/components/editor/ai-island/menus/summary-menu.tsx
@@ -78,7 +78,7 @@ export default function SummaryMenu(props: IProps) {
if (editor == null) return null
return (
-
+
{t('summary')}
diff --git a/src/components/editor/ai-island/menus/translate-menu.tsx b/src/components/editor/ai-island/menus/translate-menu.tsx
index 9efa88d..effc35a 100644
--- a/src/components/editor/ai-island/menus/translate-menu.tsx
+++ b/src/components/editor/ai-island/menus/translate-menu.tsx
@@ -52,7 +52,7 @@ export default function TranslateMenu(props: IProps) {
return (
-
+
{t('translate')}
@@ -61,7 +61,7 @@ export default function TranslateMenu(props: IProps) {
handleClick(t('English'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('English')}
@@ -69,7 +69,7 @@ export default function TranslateMenu(props: IProps) {
handleClick(t('Japanese'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('Japanese')}
@@ -77,7 +77,7 @@ export default function TranslateMenu(props: IProps) {
handleClick(t('Chinese'))}
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
>
{t('Chinese')}
diff --git a/src/components/editor/ai-island/result-panel.tsx b/src/components/editor/ai-island/result-panel.tsx
index d814388..4c5d2b9 100644
--- a/src/components/editor/ai-island/result-panel.tsx
+++ b/src/components/editor/ai-island/result-panel.tsx
@@ -76,7 +76,7 @@ export default function ResultPanel(props: IProps) {
if (!result && !loading) return null
return (
-
+
{!result && (
@@ -91,7 +91,7 @@ export default function ResultPanel(props: IProps) {
onClick={onReplace}
disabled={loading || isSelectionEmpty}
variant="ghost"
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
tabIndex={-1}
>
@@ -101,7 +101,7 @@ export default function ResultPanel(props: IProps) {
onClick={onInsert}
disabled={loading}
variant="ghost"
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
tabIndex={-1}
>
@@ -111,7 +111,7 @@ export default function ResultPanel(props: IProps) {
onClick={reRequestAI}
disabled={loading}
variant="ghost"
- className="p-2 text-blue-500 hover:bg-inherit hover:text-blue-400"
+ className="p-2 text-ai-strong hover:bg-inherit hover:text-ai-hover"
tabIndex={-1}
>
@@ -121,7 +121,7 @@ export default function ResultPanel(props: IProps) {
onClick={onClose}
disabled={loading}
variant="ghost"
- className="p-2 text-red-500 hover:bg-inherit hover:text-red-400"
+ className="p-2 text-danger hover:bg-inherit"
tabIndex={-1}
>
diff --git a/src/components/editor/doc-template/index.tsx b/src/components/editor/doc-template/index.tsx
index 518c9c6..b8ce257 100644
--- a/src/components/editor/doc-template/index.tsx
+++ b/src/components/editor/doc-template/index.tsx
@@ -8,10 +8,10 @@ import TodosTemplate from './templates/todos'
export default function DocTemplate() {
return (
-
+
diff --git a/src/components/editor/doc-template/templates/project-highlight.tsx b/src/components/editor/doc-template/templates/project-highlight.tsx
index 9868cc2..092269e 100644
--- a/src/components/editor/doc-template/templates/project-highlight.tsx
+++ b/src/components/editor/doc-template/templates/project-highlight.tsx
@@ -24,7 +24,7 @@ export default function ProjectHighLightTemplate() {
{t('projectHighlight')}
-
+
{t('projectHighlightDesc')}
diff --git a/src/components/editor/doc-template/templates/resume.tsx b/src/components/editor/doc-template/templates/resume.tsx
index fc40238..bc98c52 100644
--- a/src/components/editor/doc-template/templates/resume.tsx
+++ b/src/components/editor/doc-template/templates/resume.tsx
@@ -24,7 +24,7 @@ export default function ResumeTemplate() {
{t('resume')}
-
+
{t('resumeDesc')}
diff --git a/src/components/editor/doc-template/templates/todos.tsx b/src/components/editor/doc-template/templates/todos.tsx
index 0f03097..2301426 100644
--- a/src/components/editor/doc-template/templates/todos.tsx
+++ b/src/components/editor/doc-template/templates/todos.tsx
@@ -23,7 +23,7 @@ export default function TodosTemplate() {
{t('todos')}
-
+
{t('todosDesc')}
diff --git a/src/components/editor/extensions/image-upload/image-uploader.tsx b/src/components/editor/extensions/image-upload/image-uploader.tsx
index 894dd2f..969a250 100644
--- a/src/components/editor/extensions/image-upload/image-uploader.tsx
+++ b/src/components/editor/extensions/image-upload/image-uploader.tsx
@@ -69,14 +69,14 @@ export default function ImageUploadView(props: ImageUploadViewProps) {
const wrapperClass = cn(
'flex flex-col items-center justify-center px-8 py-10 rounded-lg bg-opacity-80',
- draggedInside && 'bg-neutral-100'
+ draggedInside && 'bg-active'
)
return (
-
+
-
+
{draggedInside ? t('putImageHere') : t('dragImageHere')}
{
SlashCommands,
Dropcursor.configure({
width: 2,
- class: 'ProseMirror-dropcursor border-black',
+ class: 'ProseMirror-dropcursor border-primary',
}),
Selection,
SearchAndReplace.configure({
diff --git a/src/components/editor/extensions/mermaid/mermaid-block-view.tsx b/src/components/editor/extensions/mermaid/mermaid-block-view.tsx
index 73e8c6e..634a087 100644
--- a/src/components/editor/extensions/mermaid/mermaid-block-view.tsx
+++ b/src/components/editor/extensions/mermaid/mermaid-block-view.tsx
@@ -27,7 +27,7 @@ export default function MermaidBlockView(props: ReactNodeViewProps) {
const editorPanel = (