Ready
This commit is contained in:
commit
0f45a51f1d
20 files changed
+3532
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>Заметки</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1848
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ai-notes-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { deleteNote, errorMessage, generateSummary, listNotes, saveNote } from './api';
|
||||
import type { Draft, Note } from './api';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import type { FieldErrors } from './NoteEditor';
|
||||
|
||||
const emptyDraft: Draft = { title: '', content: '' };
|
||||
const noteDate = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' });
|
||||
|
||||
type Generation =
|
||||
| { phase: 'running'; text: string }
|
||||
| { phase: 'failed' | 'insufficient'; message: string };
|
||||
|
||||
function AiPanel({
|
||||
note,
|
||||
generation,
|
||||
onGenerate,
|
||||
onEdit,
|
||||
disabled,
|
||||
}: {
|
||||
note: Note;
|
||||
generation?: Generation;
|
||||
onGenerate: () => void;
|
||||
onEdit: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const running = generation?.phase === 'running';
|
||||
const failed = generation?.phase === 'failed';
|
||||
const insufficient = generation?.phase === 'insufficient';
|
||||
const summary = running ? generation.text : insufficient ? null : note.summary;
|
||||
|
||||
return (
|
||||
<section className="ai-panel" aria-labelledby="ai-heading" aria-busy={running}>
|
||||
<div className="ai-header">
|
||||
<h2 id="ai-heading">Резюме</h2>
|
||||
<button className="button button-quiet ai-action" type="button" onClick={insufficient ? onEdit : onGenerate} disabled={running || disabled}>
|
||||
{insufficient ? 'Дополнить заметку' : running ? 'Генерация…' : failed ? 'Повторить' : note.summary ? 'Обновить' : 'Сгенерировать'}
|
||||
</button>
|
||||
</div>
|
||||
{failed && <p className="message error" role="alert">{generation.message}</p>}
|
||||
{insufficient && <p className="message" role="status">{generation.message}</p>}
|
||||
{summary && (
|
||||
<p className={`summary${running ? ' streaming' : ''}`}>{summary}</p>
|
||||
)}
|
||||
{!running && !insufficient && note.tags.length > 0 && (
|
||||
<ul className="tags" aria-label="Теги">
|
||||
{note.tags.map((tag) => <li key={tag}>{tag}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<span className="visually-hidden" role="status">
|
||||
{running ? 'Идёт генерация' : summary && !failed ? 'Резюме и теги готовы' : ''}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [active, setActive] = useState<number | 'new' | null>(null);
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<Draft>(emptyDraft);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [edit, setEdit] = useState<{ id: number; draft: Draft } | null>(null);
|
||||
const [editErrors, setEditErrors] = useState<FieldErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [generations, setGenerations] = useState<Partial<Record<number, Generation>>>({});
|
||||
const [deleteTarget, setDeleteTarget] = useState<Note | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const cancelDeleteRef = useRef<HTMLButtonElement>(null);
|
||||
const navToggleRef = useRef<HTMLButtonElement>(null);
|
||||
const newNoteRef = useRef<HTMLButtonElement>(null);
|
||||
const selectedHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editFieldRef = useRef<keyof Draft>('title');
|
||||
|
||||
const selected = active === 'new' ? undefined : notes.find((note) => note.id === active) ?? notes[0];
|
||||
const showLoading = !selected && active === null && loading;
|
||||
const isDraft = !selected && !showLoading;
|
||||
const currentEdit = edit && edit.id === selected?.id ? edit : null;
|
||||
const generating = selected !== undefined && generations[selected.id]?.phase === 'running';
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
|
||||
listNotes(controller.signal)
|
||||
.then((items) => {
|
||||
if (!controller.signal.aborted) setNotes(items);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setLoadError(errorMessage(error, 'Не удалось загрузить заметки.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [loadAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navOpen) return;
|
||||
newNoteRef.current?.focus();
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !deleteDialogRef.current?.open) closeNav();
|
||||
};
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [navOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = deleteDialogRef.current;
|
||||
if (deleteTarget && dialog && !dialog.open) {
|
||||
dialog.showModal();
|
||||
cancelDeleteRef.current?.focus();
|
||||
}
|
||||
}, [deleteTarget]);
|
||||
|
||||
function closeNav() {
|
||||
setNavOpen(false);
|
||||
navToggleRef.current?.focus();
|
||||
}
|
||||
|
||||
function selectNote(id: number) {
|
||||
if (currentEdit && currentEdit.id !== id && !leaveEdit()) return;
|
||||
setActive(id);
|
||||
setNavOpen(false);
|
||||
window.scrollTo(0, 0);
|
||||
if (navOpen) requestAnimationFrame(() => selectedHeadingRef.current?.focus());
|
||||
}
|
||||
|
||||
function openDraft() {
|
||||
if (!leaveEdit()) return;
|
||||
setActive('new');
|
||||
setNavOpen(false);
|
||||
window.scrollTo(0, 0);
|
||||
requestAnimationFrame(() => titleRef.current?.focus());
|
||||
}
|
||||
|
||||
function openDeleteDialog(note: Note) {
|
||||
setDeleteTarget(note);
|
||||
setDeleteError('');
|
||||
}
|
||||
|
||||
async function confirmDelete(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!deleteTarget || deleting) return;
|
||||
|
||||
const { id } = deleteTarget;
|
||||
setDeleting(true);
|
||||
setDeleteError('');
|
||||
try {
|
||||
await deleteNote(id);
|
||||
setNotes((current) => current.filter((note) => note.id !== id));
|
||||
setActive((current) => current === id ? null : current);
|
||||
setEdit((current) => current?.id === id ? null : current);
|
||||
deleteDialogRef.current?.close();
|
||||
requestAnimationFrame(() => newNoteRef.current?.focus());
|
||||
} catch (error) {
|
||||
setDeleteError(errorMessage(error, 'Не удалось удалить заметку. Попробуйте ещё раз.'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function beginEdit(note: Note, field: keyof Draft) {
|
||||
editFieldRef.current = field;
|
||||
setEdit({ id: note.id, draft: { title: note.title, content: note.content } });
|
||||
setEditErrors({});
|
||||
requestAnimationFrame(() => {
|
||||
const input = (field === 'title' ? titleRef : contentRef).current;
|
||||
input?.focus();
|
||||
if (input) input.setSelectionRange(input.value.length, input.value.length);
|
||||
});
|
||||
}
|
||||
|
||||
function leaveEdit() {
|
||||
if (!currentEdit || !selected) return true;
|
||||
const changed = currentEdit.draft.title !== selected.title || currentEdit.draft.content !== selected.content;
|
||||
if (changed && !window.confirm('Уйти без сохранения изменений?')) return false;
|
||||
setEdit(null);
|
||||
setEditErrors({});
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
const field = editFieldRef.current;
|
||||
setEdit(null);
|
||||
setEditErrors({});
|
||||
requestAnimationFrame(() => {
|
||||
selectedHeadingRef.current?.parentElement?.querySelector<HTMLButtonElement>(
|
||||
field === 'content' ? '[data-field="content"]' : '[data-field="title"]',
|
||||
)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function editDraft(field: keyof Draft, value: string) {
|
||||
if (currentEdit) {
|
||||
setEdit({ ...currentEdit, draft: { ...currentEdit.draft, [field]: value } });
|
||||
} else {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
const setFieldErrors = currentEdit ? setEditErrors : setErrors;
|
||||
setFieldErrors((current) => ({ ...current, [field]: undefined, request: undefined }));
|
||||
}
|
||||
|
||||
async function saveDraft(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (saving || loading) return;
|
||||
|
||||
const values = currentEdit?.draft ?? draft;
|
||||
const setFieldErrors = currentEdit ? setEditErrors : setErrors;
|
||||
const title = values.title.trim();
|
||||
const content = values.content.trim();
|
||||
if (!title || !content) {
|
||||
setFieldErrors({
|
||||
title: title ? undefined : 'Введите заголовок.',
|
||||
content: content ? undefined : 'Добавьте текст заметки.',
|
||||
});
|
||||
(title ? contentRef : titleRef).current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
const note = await saveNote({ title, content }, currentEdit?.id);
|
||||
if (currentEdit) {
|
||||
setNotes((current) => current.map((item) => item.id === note.id ? note : item));
|
||||
setGenerations((current) => ({ ...current, [note.id]: undefined }));
|
||||
cancelEdit();
|
||||
} else {
|
||||
setNotes((current) => [note, ...current]);
|
||||
setDraft(emptyDraft);
|
||||
selectNote(note.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setFieldErrors({ request: errorMessage(error, 'Не удалось сохранить заметку. Попробуйте ещё раз.') });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeNote(id: number) {
|
||||
setGenerations((current) => ({ ...current, [id]: { phase: 'running', text: '' } }));
|
||||
let summary = '';
|
||||
|
||||
try {
|
||||
const result = await generateSummary(id, (text) => {
|
||||
summary += text;
|
||||
const preview = summary;
|
||||
setGenerations((current) => ({ ...current, [id]: { phase: 'running', text: preview } }));
|
||||
});
|
||||
if (result.type === 'insufficient') {
|
||||
setNotes((current) => current.map((note) => note.id === id ? { ...note, summary: null, tags: [] } : note));
|
||||
setGenerations((current) => ({ ...current, [id]: { phase: 'insufficient', message: result.message } }));
|
||||
return;
|
||||
}
|
||||
setNotes((current) => current.map((note) => note.id === id ? result.note : note));
|
||||
setGenerations((current) => ({ ...current, [id]: undefined }));
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, 'Не удалось получить резюме. Попробуйте ещё раз.');
|
||||
setGenerations((current) => ({ ...current, [id]: { phase: 'failed', message } }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
{navOpen && <button className="nav-scrim" type="button" tabIndex={-1} aria-label="Закрыть список заметок" onClick={closeNav} />}
|
||||
<aside id="notes-sidebar" className={`sidebar${navOpen ? ' sidebar-open' : ''}`} aria-label="Заметки">
|
||||
<div className="brand">Заметки</div>
|
||||
<button ref={newNoteRef} className="button new-note" type="button" onClick={openDraft} disabled={saving} aria-current={isDraft ? 'page' : undefined}>
|
||||
<span className="plus" aria-hidden="true">+</span>
|
||||
Новая заметка
|
||||
</button>
|
||||
|
||||
<div className="sidebar-divider" aria-hidden="true" />
|
||||
{loadError && (
|
||||
<div className="sidebar-message">
|
||||
<p className="error" role="alert">{loadError}</p>
|
||||
<button className="button button-quiet" type="button" onClick={() => setLoadAttempt((count) => count + 1)}>Повторить</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <p className="sidebar-message" role="status">Загружаем заметки…</p>}
|
||||
{!loading && !loadError && notes.length === 0 && <p className="sidebar-message">Здесь появятся ваши заметки.</p>}
|
||||
{notes.length > 0 && (
|
||||
<nav className="notes-nav" aria-label="Список заметок">
|
||||
<ul className="note-list">
|
||||
{notes.map((note) => (
|
||||
<li key={note.id} className="note-row">
|
||||
<button
|
||||
type="button"
|
||||
className="note-link"
|
||||
aria-current={selected?.id === note.id ? 'page' : undefined}
|
||||
onClick={() => selectNote(note.id)}
|
||||
disabled={saving}
|
||||
>
|
||||
<span className="note-link-title">{note.title}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button note-delete"
|
||||
aria-label={`Удалить заметку «${note.title}»`}
|
||||
onClick={() => openDeleteDialog(note)}
|
||||
disabled={saving}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<dialog
|
||||
ref={deleteDialogRef}
|
||||
className="delete-dialog"
|
||||
aria-labelledby="delete-title"
|
||||
aria-describedby="delete-description"
|
||||
onCancel={(event) => {
|
||||
if (deleting) event.preventDefault();
|
||||
}}
|
||||
onClose={(event) => {
|
||||
if (!event.currentTarget.open) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<form onSubmit={confirmDelete}>
|
||||
<h2 id="delete-title">Удалить заметку?</h2>
|
||||
<p id="delete-description">«{deleteTarget?.title}» будет удалена.</p>
|
||||
{deleteError && <p className="message error" role="alert">{deleteError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button ref={cancelDeleteRef} type="button" className="button button-quiet" disabled={deleting} onClick={() => deleteDialogRef.current?.close()}>
|
||||
Отмена
|
||||
</button>
|
||||
<button className="button button-primary" type="submit" disabled={deleting}>
|
||||
{deleting ? 'Удаляем…' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<button ref={navToggleRef} className="button nav-toggle" type="button" onClick={() => setNavOpen((open) => !open)} aria-expanded={navOpen} aria-controls="notes-sidebar">
|
||||
Заметки
|
||||
</button>
|
||||
<span className="topbar-current">{selected?.title ?? (isDraft ? 'Новая заметка' : 'Загрузка…')}</span>
|
||||
</header>
|
||||
|
||||
<div className="workspace">
|
||||
{loadError && !navOpen && (
|
||||
<div className="mobile-load-error">
|
||||
<p className="message error" role="alert">{loadError}</p>
|
||||
<button className="button button-quiet" type="button" onClick={() => setLoadAttempt((count) => count + 1)}>Повторить</button>
|
||||
</div>
|
||||
)}
|
||||
{showLoading && <p className="workspace-message" role="status">Загружаем заметки…</p>}
|
||||
{(isDraft || currentEdit) && (
|
||||
<>
|
||||
<h1 className="visually-hidden">{currentEdit ? 'Редактирование заметки' : 'Новая заметка'}</h1>
|
||||
<NoteEditor
|
||||
key={currentEdit?.id ?? 'new'}
|
||||
draft={currentEdit?.draft ?? draft}
|
||||
errors={currentEdit ? editErrors : errors}
|
||||
saving={saving}
|
||||
loading={loading}
|
||||
titleRef={titleRef}
|
||||
contentRef={contentRef}
|
||||
onChange={editDraft}
|
||||
onSubmit={saveDraft}
|
||||
onCancel={currentEdit ? cancelEdit : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
{!currentEdit && (
|
||||
<article className="document">
|
||||
<h1 className="page-title" ref={selectedHeadingRef} tabIndex={-1}>
|
||||
<button type="button" className="editable-block" data-field="title" title="Изменить заголовок" disabled={generating} onClick={() => beginEdit(selected, 'title')}>{selected.title}</button>
|
||||
</h1>
|
||||
<time className="document-date" dateTime={selected.created_at}>{noteDate.format(new Date(selected.created_at))}</time>
|
||||
<button type="button" className="editable-block note-text" data-field="content" title="Изменить текст" disabled={generating} onClick={() => beginEdit(selected, 'content')}>{selected.content}</button>
|
||||
</article>
|
||||
)}
|
||||
<AiPanel
|
||||
note={selected}
|
||||
generation={generations[selected.id]}
|
||||
disabled={Boolean(currentEdit)}
|
||||
onGenerate={() => analyzeNote(selected.id)}
|
||||
onEdit={() => beginEdit(selected, 'content')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FormEventHandler, Ref } from 'react';
|
||||
import type { Draft } from './api';
|
||||
|
||||
const titleLimit = 120;
|
||||
const contentLimit = 12_000;
|
||||
|
||||
export type FieldErrors = Partial<Record<keyof Draft | 'request', string>>;
|
||||
|
||||
type EditorProps = {
|
||||
draft: Draft;
|
||||
errors: FieldErrors;
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
titleRef: Ref<HTMLInputElement>;
|
||||
contentRef: Ref<HTMLTextAreaElement>;
|
||||
onChange: (field: keyof Draft, value: string) => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export default function NoteEditor({
|
||||
draft, errors, saving, loading, titleRef, contentRef, onChange, onSubmit, onCancel,
|
||||
}: EditorProps) {
|
||||
return (
|
||||
<form className="editor" onSubmit={onSubmit} noValidate onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && onCancel && !saving) {
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
}}>
|
||||
<div className="editor-field">
|
||||
<label className="visually-hidden" htmlFor="note-title">Заголовок</label>
|
||||
<input
|
||||
id="note-title"
|
||||
className="page-title"
|
||||
ref={titleRef}
|
||||
maxLength={titleLimit}
|
||||
placeholder="Без названия"
|
||||
value={draft.title}
|
||||
onChange={(event) => onChange('title', event.target.value)}
|
||||
disabled={saving}
|
||||
required
|
||||
aria-invalid={Boolean(errors.title)}
|
||||
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||
/>
|
||||
{errors.title && <p className="field-error error" id="title-error">{errors.title}</p>}
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label className="visually-hidden" htmlFor="note-content">Текст заметки</label>
|
||||
<textarea
|
||||
id="note-content"
|
||||
ref={contentRef}
|
||||
rows={7}
|
||||
maxLength={contentLimit}
|
||||
placeholder="Начните писать…"
|
||||
value={draft.content}
|
||||
onChange={(event) => onChange('content', event.target.value)}
|
||||
disabled={saving}
|
||||
required
|
||||
aria-invalid={Boolean(errors.content)}
|
||||
aria-describedby={errors.content ? 'content-error note-count-info' : 'note-count-info'}
|
||||
/>
|
||||
{errors.content && <p className="field-error error" id="content-error">{errors.content}</p>}
|
||||
</div>
|
||||
{errors.request && <p className="message error" role="alert">{errors.request}</p>}
|
||||
<div className="editor-actions">
|
||||
<span id="note-count-info" className="character-count">{draft.content.length.toLocaleString('ru-RU')} / {contentLimit.toLocaleString('ru-RU')}</span>
|
||||
<div className="editor-buttons">
|
||||
{onCancel && <button type="button" className="button button-quiet" disabled={saving} onClick={onCancel}>Отмена</button>}
|
||||
<button className="button button-primary save-note" type="submit" disabled={saving || loading}>
|
||||
{saving ? 'Сохраняем…' : onCancel ? 'Сохранить изменения' : 'Сохранить заметку'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export type Note = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type Draft = Pick<Note, 'title' | 'content'>;
|
||||
|
||||
type StreamEvent =
|
||||
| { type: 'delta'; text: string }
|
||||
| { type: 'done'; note: Note }
|
||||
| { type: 'insufficient'; message: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
class ApiError extends Error {}
|
||||
|
||||
export function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof ApiError ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function checkResponse(response: Response, fallback: string) {
|
||||
if (response.ok) return;
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
const detail = typeof data?.detail === 'string' && data.detail.trim();
|
||||
throw new ApiError(detail || fallback);
|
||||
}
|
||||
|
||||
export async function listNotes(signal: AbortSignal): Promise<Note[]> {
|
||||
const response = await fetch('/api/notes', { signal });
|
||||
await checkResponse(response, 'Не удалось загрузить заметки.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function saveNote(draft: Draft, id?: number): Promise<Note> {
|
||||
const response = await fetch(id === undefined ? '/api/notes' : `/api/notes/${id}`, {
|
||||
method: id === undefined ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
await checkResponse(response, 'Не удалось сохранить заметку.');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function deleteNote(id: number): Promise<void> {
|
||||
const response = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
await checkResponse(response, 'Не удалось удалить заметку. Попробуйте ещё раз.');
|
||||
}
|
||||
|
||||
function readEvent(line: string): StreamEvent {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (
|
||||
(event?.type === 'delta' && typeof event.text === 'string') ||
|
||||
((event?.type === 'error' || event?.type === 'insufficient') && typeof event.message === 'string') ||
|
||||
(event?.type === 'done' && event.note && Array.isArray(event.note.tags))
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
} catch {
|
||||
// A non-JSON record is also an invalid stream event.
|
||||
}
|
||||
throw new ApiError('Сервер вернул неожиданный ответ. Попробуйте ещё раз.');
|
||||
}
|
||||
|
||||
export async function generateSummary(
|
||||
id: number,
|
||||
onDelta: (text: string) => void,
|
||||
): Promise<Extract<StreamEvent, { type: 'done' | 'insufficient' }>> {
|
||||
const response = await fetch(`/api/notes/${id}/generate`, { method: 'POST' });
|
||||
await checkResponse(response, 'Не удалось запустить генерацию.');
|
||||
if (!response.body) throw new ApiError('Ответ сервера пуст. Попробуйте ещё раз.');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
if (done && buffer) buffer += '\n';
|
||||
|
||||
let newline = buffer.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf('\n');
|
||||
if (!line) continue;
|
||||
|
||||
const event = readEvent(line);
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
onDelta(event.text);
|
||||
break;
|
||||
case 'done':
|
||||
case 'insufficient':
|
||||
return event;
|
||||
case 'error':
|
||||
throw new ApiError(event.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) throw new ApiError('Генерация прервалась. Попробуйте ещё раз.');
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/inter';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
:root {
|
||||
font-family: 'Inter Variable', Arial, sans-serif;
|
||||
color: #171717;
|
||||
background: #fff;
|
||||
font-synthesis: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--ink: #171717;
|
||||
--muted: #686868;
|
||||
--line: #e5e5e5;
|
||||
--control-line: #d6d6d6;
|
||||
--sidebar: #f7f7f7;
|
||||
--hover: #eeeeee;
|
||||
--active: #e9e9e9;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: default; opacity: .6; }
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 228px minmax(0, 1fr);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
padding: 13px 10px 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 8px 9px;
|
||||
font-size: 14px;
|
||||
font-weight: 660;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-divider { flex: none; height: 1px; margin: 10px 8px; background: var(--line); }
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) { background: var(--hover); }
|
||||
.button-primary { color: #fff; background: var(--ink); }
|
||||
.button-primary:hover:not(:disabled) { background: #333; }
|
||||
.button-quiet { border-color: var(--control-line); background: #fff; }
|
||||
|
||||
.new-note { justify-content: flex-start; padding: 0 8px; }
|
||||
.new-note[aria-current='page'] { background: var(--active); }
|
||||
.plus { font-size: 18px; font-weight: 400; line-height: 1; }
|
||||
.sidebar-message { margin: 2px 9px; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.sidebar-message p { margin: 0 0 8px; }
|
||||
|
||||
.notes-nav { min-height: 0; overflow: auto; }
|
||||
.note-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; margin: 0; padding: 3px; list-style: none; }
|
||||
.note-row { display: flex; align-items: center; gap: 2px; border-radius: 5px; }
|
||||
.note-row:has([aria-current='page']) { background: var(--active); }
|
||||
|
||||
.note-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 31px;
|
||||
padding: 5px 7px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
color: #454545;
|
||||
background: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.note-link:hover:not(:disabled) { background: var(--hover); }
|
||||
.note-link[aria-current='page'] { color: var(--ink); font-weight: 600; }
|
||||
.note-link:focus-visible { outline-offset: -2px; }
|
||||
.note-link-title { overflow: hidden; font-size: 13px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-delete { min-height: 28px; padding-inline: 5px; color: var(--muted); font-size: 11px; }
|
||||
.note-delete:focus-visible { outline-offset: -2px; }
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.note-delete { opacity: 0; }
|
||||
.note-row:hover .note-delete, .note-row:focus-within .note-delete { opacity: 1; }
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
width: min(100% - 32px, 380px);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--control-line);
|
||||
border-radius: 7px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.delete-dialog::backdrop { background: rgb(0 0 0 / 28%); }
|
||||
.delete-dialog h2 { margin: 0; font-size: 17px; }
|
||||
.delete-dialog p { margin: 10px 0 0; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-toggle, .nav-scrim, .mobile-load-error { display: none; }
|
||||
|
||||
.workspace {
|
||||
width: min(100% - 72px, 690px);
|
||||
margin: 0 auto;
|
||||
padding: 66px 0 64px;
|
||||
}
|
||||
|
||||
.workspace-message { color: var(--muted); font-size: 14px; }
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 3.5vw, 40px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -.035em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.editor { display: grid; gap: 14px; }
|
||||
.editor-field { display: grid; min-width: 0; gap: 6px; }
|
||||
.editor-field input, .editor-field textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.editor-field input { padding-bottom: 4px; }
|
||||
.editor-field textarea {
|
||||
display: block;
|
||||
min-height: 180px;
|
||||
padding: 3px 0;
|
||||
resize: vertical;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.editor-field input::placeholder, .editor-field textarea::placeholder { color: #737373; }
|
||||
.editor-field :is(input, textarea):focus-visible {
|
||||
border-bottom-color: var(--control-line);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-field [aria-invalid='true'] { border-bottom-color: var(--ink); }
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.character-count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.editor-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 8px; }
|
||||
.save-note { min-height: 36px; padding-inline: 12px; }
|
||||
.field-error { margin: 0; font-size: 13px; }
|
||||
.error { color: var(--ink); font-weight: 550; }
|
||||
.message { margin: 0; font-size: 13px; line-height: 1.55; }
|
||||
|
||||
.document-date { display: block; margin-top: 10px; color: var(--muted); font-size: 13px; }
|
||||
.editable-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.editable-block:not(:disabled) { cursor: text; }
|
||||
.editable-block:hover:not(:disabled) { background: var(--sidebar); }
|
||||
.editable-block:disabled { opacity: 1; }
|
||||
.note-text {
|
||||
margin: 24px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ai-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ai-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.ai-header h2 { margin: 0; font-size: 16px; font-weight: 650; }
|
||||
.summary { margin: 0; font-size: 15px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.streaming::after { display: inline-block; width: 1px; height: 1em; margin-left: 3px; vertical-align: -.14em; background: var(--ink); content: ''; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.tags li { max-width: 100%; padding: 3px 7px; border-radius: 4px; color: #333; background: #f1f1f1; font-size: 12px; overflow-wrap: anywhere; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.app-shell { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
z-index: 3;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(82vw, 258px);
|
||||
visibility: hidden;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.sidebar-open { visibility: visible; transform: none; }
|
||||
.nav-scrim { position: fixed; z-index: 2; inset: 0; display: block; border: 0; background: rgb(0 0 0 / 22%); }
|
||||
.nav-toggle { display: inline-flex; padding-inline: 7px; margin-left: -7px; }
|
||||
.note-delete { min-height: 34px; opacity: 1; }
|
||||
.topbar { padding-inline: 20px; }
|
||||
.topbar-current { padding-left: 12px; border-left: 1px solid var(--line); }
|
||||
.workspace { width: min(100% - 40px, 690px); padding-top: 43px; }
|
||||
.mobile-load-error { display: flex; align-items: center; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 20px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: { '/api': 'http://127.0.0.1:8000' },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user