-
- {selectedNote ? formatDate(selectedNote.edit_time, locale) : t('noNoteSelected')}
- {saveState === 'saving' ? t('saving') : saveState === 'dirty' ? t('unsaved') : notice}
- {selectedNote ? (
-
- {draft.is_pinned ? t('pinned') : draft.is_favorite ? t('favorite') : draft.is_archived ? t('archived') : t('saved')}
-
- ) : null}
-
-
{selectedNote ? (
+
+
{draft.tags.length ? (
{draft.tags.map((tag) => (
@@ -1573,24 +1578,164 @@ function App() {
+
+
diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png
deleted file mode 100644
index 02251f4..0000000
Binary files a/frontend/src/assets/hero.png and /dev/null differ
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/frontend/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg
deleted file mode 100644
index 5101b67..0000000
--- a/frontend/src/assets/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/frontend/src/components/AuthScreen.tsx b/frontend/src/components/AuthScreen.tsx
new file mode 100644
index 0000000..a1c8fca
--- /dev/null
+++ b/frontend/src/components/AuthScreen.tsx
@@ -0,0 +1,88 @@
+import type { Dispatch, FormEventHandler, SetStateAction } from 'react'
+import { Languages } from 'lucide-react'
+
+import type { Locale, TranslationKey } from '../i18n'
+import type { AuthPayload } from '../types'
+
+type AuthMode = 'login' | 'register'
+type Translator = (
+ key: TranslationKey,
+ values?: Record
,
+) => string
+
+interface AuthScreenProps {
+ busy: boolean
+ error: string
+ form: AuthPayload
+ locale: Locale
+ mode: AuthMode
+ t: Translator
+ onFormChange: Dispatch>
+ onSubmit: FormEventHandler
+ onToggleLocale: () => void
+ onToggleMode: () => void
+}
+
+export function AuthScreen({
+ busy,
+ error,
+ form,
+ locale,
+ mode,
+ t,
+ onFormChange,
+ onSubmit,
+ onToggleLocale,
+ onToggleMode,
+}: AuthScreenProps) {
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/NotesSidebar.tsx b/frontend/src/components/NotesSidebar.tsx
new file mode 100644
index 0000000..0286400
--- /dev/null
+++ b/frontend/src/components/NotesSidebar.tsx
@@ -0,0 +1,105 @@
+import { SquarePen, X } from 'lucide-react'
+
+import type { TranslationKey } from '../i18n'
+import { htmlToPlainText } from '../lib/note-utils'
+import type { Note } from '../types'
+
+export type Shelf = 'all' | 'pinned' | 'favorites' | 'archived'
+
+type Translator = (
+ key: TranslationKey,
+ values?: Record,
+) => string
+
+interface NotesSidebarProps {
+ activeShelf: Shelf
+ busy: boolean
+ counts: Record
+ hidden: boolean
+ mobileOpen: boolean
+ notes: Note[]
+ selectedNoteId: number | null
+ t: Translator
+ onCreate: () => void
+ onClose: () => void
+ onSelect: (noteId: number) => void
+ onShelfChange: (shelf: Shelf) => void
+}
+
+const shelves: Shelf[] = ['all', 'pinned', 'favorites', 'archived']
+
+function shelfTranslationKey(shelf: Shelf): TranslationKey {
+ if (shelf === 'all') return 'notes'
+ if (shelf === 'archived') return 'archive'
+ return shelf
+}
+
+export function NotesSidebar({
+ activeShelf,
+ busy,
+ counts,
+ hidden,
+ mobileOpen,
+ notes,
+ selectedNoteId,
+ t,
+ onCreate,
+ onClose,
+ onSelect,
+ onShelfChange,
+}: NotesSidebarProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts
index dd7572c..53dab89 100644
--- a/frontend/src/i18n.ts
+++ b/frontend/src/i18n.ts
@@ -6,12 +6,12 @@ const translations = {
loading: 'Loading…', emptyNote: 'Empty note', noShelfNotes: 'No notes in this shelf.',
editorPlaceholder: 'Start writing your note…', title: 'Title', heading: 'Heading', text: 'Text', bodyText: 'Body text',
blockQuote: 'Block quote', enterLink: 'Enter a link', link: 'Link', createLink: 'Add or edit link', removeLink: 'Remove link', checklist: 'Checklist', table: 'Table',
- purpleText: 'Purple text', resetColor: 'Reset color', tags: 'Tags', search: 'Search',
+ purpleText: 'Purple text', resetColor: 'Reset color', tags: 'Tags', search: 'Search', close: 'Close', moreActions: 'More actions',
lightTheme: 'Light theme', darkTheme: 'Dark theme', switchLight: 'Switch to light theme', switchDark: 'Switch to dark theme',
signOut: 'Sign out', signingOut: 'Signing out…', noNoteSelected: 'No note selected', saving: 'Saving…', saved: 'Saved', unsaved: 'Unsaved',
favorite: 'Favorite', archived: 'Archived', dropImage: 'Drop image to insert', imageFormats: 'PNG, JPG, WEBP or GIF',
- words: '{count} words', minRead: '{count} min read', tagCount: '{count} tags', noTags: 'No tags',
- summaryChars: '{count} summary chars', noSummary: 'No summary', duplicate: 'Duplicate', save: 'Save', delete: 'Delete', deleting: 'Deleting…',
+ words: '{count} words', minRead: '{count} min read', tagCount: '{count} tags', noTags: 'No tags', savedAt: 'Saved {date}',
+ summaryChars: '{count} chars', noSummary: 'No summary', duplicate: 'Duplicate', save: 'Save', delete: 'Delete', deleting: 'Deleting…',
createFirst: 'Create a note to start writing.', newNote: 'New note',
opening: 'Opening notes…', cloudNotes: 'Cloud Notes', authHeading: 'Dark, focused notes with visual formatting.',
authCopy: 'Apple Notes inspired workspace with secure sign in, formatting buttons, note library and autosave.',
@@ -23,7 +23,7 @@ const translations = {
compactHeading: 'Compact heading block', emphasizedQuote: 'Block quote with emphasis', unorderedList: 'Unordered list',
orderedSequence: 'Ordered sequence', taskList: 'Trackable task list', preformattedBlock: 'Monospaced preformatted block',
sectionBreak: 'Visual section break', grid: '3 by 3 grid', uploadDevice: 'Upload from device',
- welcomeBack: 'Welcome back, {login}.', signInPrompt: 'Sign in to open your notes workspace.', autosaved: 'Autosaved.', savedManually: 'Saved manually.',
+ signInPrompt: 'Sign in to open your notes workspace.', autosaved: 'Autosaved.', savedManually: 'Saved manually.',
newNoteCreated: 'New note created.', newNoteToast: 'New note', deleted: 'Deleted', exported: 'Exported',
imageInserted: 'Image inserted into note.', imageAdded: 'Image added', deleteConfirm: 'Delete “{title}”?', untitledNote: 'Untitled note',
copySuffix: 'copy', noteDuplicated: 'Note duplicated.', duplicated: 'Duplicated', noteDeleted: 'Note deleted.', saveFailed: 'Save failed', noteConflict: 'This note was changed on another device. Your local text was kept and was not uploaded.', uploadFailed: 'Upload failed',
@@ -34,12 +34,12 @@ const translations = {
loading: 'Загрузка…', emptyNote: 'Пустая заметка', noShelfNotes: 'В этом разделе нет заметок.',
editorPlaceholder: 'Начните писать заметку…', title: 'Название', heading: 'Заголовок', text: 'Текст', bodyText: 'Основной текст',
blockQuote: 'Блок цитаты', enterLink: 'Введите ссылку', link: 'Ссылка', createLink: 'Добавить или изменить ссылку', removeLink: 'Удалить ссылку', checklist: 'Чек-лист', table: 'Таблица',
- purpleText: 'Фиолетовый текст', resetColor: 'Сбросить цвет', tags: 'Теги', search: 'Поиск',
+ purpleText: 'Фиолетовый текст', resetColor: 'Сбросить цвет', tags: 'Теги', search: 'Поиск', close: 'Закрыть', moreActions: 'Другие действия',
lightTheme: 'Светлая тема', darkTheme: 'Тёмная тема', switchLight: 'Включить светлую тему', switchDark: 'Включить тёмную тему',
signOut: 'Выйти', signingOut: 'Выход…', noNoteSelected: 'Заметка не выбрана', saving: 'Сохранение…', saved: 'Сохранено', unsaved: 'Не сохранено',
favorite: 'Избранное', archived: 'В архиве', dropImage: 'Перетащите изображение сюда', imageFormats: 'PNG, JPG, WEBP или GIF',
- words: 'Слов: {count}', minRead: 'Чтение: {count} мин', tagCount: 'Тегов: {count}', noTags: 'Нет тегов',
- summaryChars: 'Символов в описании: {count}', noSummary: 'Нет описания', duplicate: 'Дублировать', save: 'Сохранить', delete: 'Удалить', deleting: 'Удаление…',
+ words: 'Слов: {count}', minRead: 'Чтение: {count} мин', tagCount: 'Тегов: {count}', noTags: 'Нет тегов', savedAt: 'Сохранено {date}',
+ summaryChars: '{count} символов', noSummary: 'Нет краткого описания', duplicate: 'Дублировать', save: 'Сохранить', delete: 'Удалить', deleting: 'Удаление…',
createFirst: 'Создайте заметку, чтобы начать.', newNote: 'Новая заметка',
opening: 'Открываем заметки…', cloudNotes: 'Облачные заметки', authHeading: 'Сосредоточьтесь на заметках и форматировании.',
authCopy: 'Рабочее пространство в стиле Apple Notes с безопасным входом, форматированием, библиотекой заметок и автосохранением.',
@@ -51,7 +51,7 @@ const translations = {
compactHeading: 'Компактный заголовок', emphasizedQuote: 'Выделенный блок цитаты', unorderedList: 'Неупорядоченный список',
orderedSequence: 'Упорядоченная последовательность', taskList: 'Список задач', preformattedBlock: 'Моноширинный блок кода',
sectionBreak: 'Визуальный разделитель', grid: 'Сетка 3 на 3', uploadDevice: 'Загрузить с устройства',
- welcomeBack: 'С возвращением, {login}.', signInPrompt: 'Войдите, чтобы открыть заметки.', autosaved: 'Сохранено автоматически.', savedManually: 'Сохранено вручную.',
+ signInPrompt: 'Войдите, чтобы открыть заметки.', autosaved: 'Сохранено автоматически.', savedManually: 'Сохранено вручную.',
newNoteCreated: 'Новая заметка создана.', newNoteToast: 'Новая заметка', deleted: 'Удалено', exported: 'Экспортировано',
imageInserted: 'Изображение вставлено в заметку.', imageAdded: 'Изображение добавлено', deleteConfirm: 'Удалить «{title}»?', untitledNote: 'Без названия',
copySuffix: 'копия', noteDuplicated: 'Заметка продублирована.', duplicated: 'Продублировано', noteDeleted: 'Заметка удалена.', saveFailed: 'Ошибка сохранения', noteConflict: 'Заметка изменена на другом устройстве. Локальный текст сохранён и не был отправлен поверх новой версии.', uploadFailed: 'Ошибка загрузки',
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index e38f020..1643e0f 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -16,19 +16,57 @@ export class ApiError extends Error {
}
export type NoteEvent =
- | { type: 'note_created' | 'note_updated'; note: Note }
- | { type: 'note_deleted'; note_id: number }
+ | {
+ type: 'note_created' | 'note_updated'
+ note: Note
+ source_client_id?: string | null
+ }
+ | { type: 'note_deleted'; note_id: number; source_client_id?: string | null }
+
+const clientIdStorageKey = 'cloud-notes-client-id'
+
+function getClientId() {
+ const existing = window.localStorage.getItem(clientIdStorageKey)
+ if (existing) return existing
+
+ const clientId = window.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`
+ window.localStorage.setItem(clientIdStorageKey, clientId)
+ return clientId
+}
export function openNoteEvents() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return new WebSocket(`${protocol}//${window.location.host}${API_BASE_URL}/events`)
}
+function readErrorDetail(detail: unknown): string | null {
+ if (typeof detail === 'string' && detail.trim()) {
+ return detail
+ }
+
+ if (!Array.isArray(detail)) {
+ return null
+ }
+
+ const messages = detail.flatMap((item) => {
+ if (!item || typeof item !== 'object') return []
+ const error = item as { loc?: unknown; msg?: unknown }
+ if (typeof error.msg !== 'string') return []
+ const field = Array.isArray(error.loc)
+ ? error.loc.filter((value) => value !== 'body').join('.')
+ : ''
+ return [field ? `${field}: ${error.msg}` : error.msg]
+ })
+
+ return messages.length ? messages.join(' ') : null
+}
+
async function request(path: string, init?: RequestInit): Promise {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: 'include',
headers: {
Accept: 'application/json',
+ 'X-Client-Id': getClientId(),
...(init?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...init?.headers,
},
@@ -39,9 +77,10 @@ async function request(path: string, init?: RequestInit): Promise {
let message = 'Request failed.'
try {
- const data = (await response.json()) as { detail?: string }
- if (data.detail) {
- message = data.detail
+ const data = (await response.json()) as { detail?: unknown }
+ const detail = readErrorDetail(data.detail)
+ if (detail) {
+ message = detail
}
} catch {
message = response.statusText || message
diff --git a/frontend/src/lib/note-utils.ts b/frontend/src/lib/note-utils.ts
new file mode 100644
index 0000000..b80c63c
--- /dev/null
+++ b/frontend/src/lib/note-utils.ts
@@ -0,0 +1,63 @@
+import type { Note, NotePayload } from '../types'
+
+export const defaultDraft: NotePayload = {
+ title: '',
+ text: '',
+ summary: '',
+ tags: [],
+ is_pinned: false,
+ is_favorite: false,
+ is_archived: false,
+}
+
+export function formatDate(value: string) {
+ const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)
+ const date = new Date(hasTimeZone ? value : `${value}Z`)
+ const pad = (part: number) => String(part).padStart(2, '0')
+
+ return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}, ${pad(date.getHours())}:${pad(date.getMinutes())}`
+}
+
+export function sortNotes(items: Note[]) {
+ return [...items].sort(
+ (left, right) =>
+ new Date(right.edit_time).getTime() - new Date(left.edit_time).getTime(),
+ )
+}
+
+export function htmlToPlainText(value: string) {
+ if (!value) return ''
+
+ const document = new DOMParser().parseFromString(value, 'text/html')
+ return document.body.textContent?.replace(/\s+/g, ' ').trim() ?? ''
+}
+
+export function extractSummary(value: string) {
+ return htmlToPlainText(value).slice(0, 280)
+}
+
+export function estimateReadingTime(words: number) {
+ return Math.max(1, Math.ceil(words / 180))
+}
+
+export function noteToPayload(note: Note): NotePayload {
+ return {
+ title: note.title,
+ text: note.text ?? '',
+ summary: note.summary ?? '',
+ tags: note.tags,
+ is_pinned: note.is_pinned,
+ is_favorite: note.is_favorite,
+ is_archived: note.is_archived,
+ }
+}
+
+export function payloadEqualsNote(payload: NotePayload, note: Note | null) {
+ if (!note) return false
+
+ return JSON.stringify({
+ ...payload,
+ text: payload.text ?? '',
+ summary: payload.summary ?? '',
+ }) === JSON.stringify(noteToPayload(note))
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index a133858..d4bd91e 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -4,6 +4,21 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ if (!id.includes('node_modules')) return
+ if (id.includes('@tiptap') || id.includes('prosemirror')) {
+ return 'editor-vendor'
+ }
+ if (id.includes('lucide-react')) return 'icons-vendor'
+ if (id.includes('react')) return 'react-vendor'
+ return 'vendor'
+ },
+ },
+ },
+ },
server: {
host: '0.0.0.0',
port: 5173,