Server: - New POST /api/lint/check proxies to LanguageTool API (public free endpoint by default; override with LANGUAGETOOL_URL env var for self-hosted instance) - Returns trimmed match list: message, offset, length, replacements, kind (spelling|grammar), ruleId - Disables cosmetic rules that don't suit creative writing - 20-second timeout; falls back with 502 on error Frontend: - LintMark: transient TipTap Mark extension (never saved to DB — stripLintMarks() removes them from getJSON() before onChange fires) - buildTextMap(): walks ProseMirror doc tree, builds parallel plain- text string + position array so LanguageTool char offsets map back to exact PM positions even across paragraphs / headings - clearLintMarks() + runLint(): check → clear old marks → apply new marks in one transaction, no flicker - Click on underlined text → fixed-position popover showing the error message + up to 6 one-click replacement buttons + Ignore / ✕ - Applying a replacement triggers an automatic re-check after 600 ms - lintStatus: idle → checking → done → stale (when user edits) - ABC toolbar button shows count badge when errors found, ✓ when clean - Wavy red underline = spelling, wavy blue = grammar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
94 lines
3.7 KiB
JavaScript
94 lines
3.7 KiB
JavaScript
const TOKEN_KEY = 'sw-token'
|
|
|
|
export function getToken() { return localStorage.getItem(TOKEN_KEY) }
|
|
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t) }
|
|
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
|
|
|
|
export function getUser() {
|
|
const token = getToken()
|
|
if (!token) return null
|
|
try {
|
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
|
if (payload.exp * 1000 < Date.now()) { clearToken(); return null }
|
|
return payload
|
|
} catch { return null }
|
|
}
|
|
|
|
// Support tokens that still carry `email` from before the username migration
|
|
export function getUsername() {
|
|
const u = getUser()
|
|
return u?.username ?? u?.email ?? ''
|
|
}
|
|
|
|
async function req(method, url, body, extraHeaders = {}) {
|
|
const token = getToken()
|
|
const isForm = body instanceof FormData
|
|
const headers = {
|
|
...(!isForm && body ? { 'Content-Type': 'application/json' } : {}),
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
...extraHeaders,
|
|
}
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: isForm ? body : body ? JSON.stringify(body) : undefined,
|
|
})
|
|
const data = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
|
|
return data
|
|
}
|
|
|
|
async function download(url) {
|
|
const token = getToken()
|
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
if (!res.ok) throw new Error('Export failed')
|
|
const blob = await res.blob()
|
|
const fname = res.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] || 'export'
|
|
const a = document.createElement('a')
|
|
a.href = URL.createObjectURL(blob)
|
|
a.download = fname
|
|
a.click()
|
|
URL.revokeObjectURL(a.href)
|
|
}
|
|
|
|
export const api = {
|
|
login: async (username, password) => {
|
|
const data = await req('POST', '/api/auth/login', { username, password })
|
|
setToken(data.token)
|
|
return data.user
|
|
},
|
|
logout: clearToken,
|
|
changePassword: (current, newPassword) => req('PUT', '/api/auth/password', { current, newPassword }),
|
|
|
|
getStories: () => req('GET', '/api/stories'),
|
|
getStory: (id) => req('GET', `/api/stories/${id}`),
|
|
createStory: (data) => req('POST', '/api/stories', data),
|
|
updateStory: (id, data) => req('PUT', `/api/stories/${id}`, data),
|
|
deleteStory: (id) => req('DELETE', `/api/stories/${id}`),
|
|
|
|
uploadImage: async (file) => {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const data = await req('POST', '/api/images', form)
|
|
return data.url
|
|
},
|
|
|
|
getNotes: (storyId) => req('GET', `/api/stories/${storyId}/notes`),
|
|
createNote: (storyId) => req('POST', `/api/stories/${storyId}/notes`),
|
|
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
|
|
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
|
|
|
|
lintCheck: (text, language = 'en-US') => req('POST', '/api/lint/check', { text, language }),
|
|
|
|
getPrompt: () => req('GET', '/api/prompts'),
|
|
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
|
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
|
|
|
admin: {
|
|
getUsers: (pw) => req('GET', '/api/admin/users', null, { 'x-admin-password': pw }),
|
|
createUser: (pw, data) => req('POST', '/api/admin/users', data, { 'x-admin-password': pw }),
|
|
resetPassword: (pw, id, pass) => req('PUT', `/api/admin/users/${id}/password`, { password: pass }, { 'x-admin-password': pw }),
|
|
deleteUser: (pw, id) => req('DELETE', `/api/admin/users/${id}`, null, { 'x-admin-password': pw }),
|
|
},
|
|
}
|