This commit is contained in:
egostrik committed 2026-11-13 18:48:00 +03:00
commit 0f45a51f1d
20 files changed
+3532

No files matched your search

+13
View File
@@ -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>
+1848
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -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"
}
}
+408
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+113
View File
@@ -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();
}
}
+11
View File
@@ -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>,
);
+273
View File
@@ -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; }
}
+15
View File
@@ -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"]
}
+9
View File
@@ -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' },
},
});