From 7d059894708d0653a741b8ba11f106a8cc7970e1 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:50:43 +0200 Subject: [PATCH 001/102] =?UTF-8?q?docs(logbook):=20=D0=B7=D0=B0=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LOGBOOK.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/LOGBOOK.md b/LOGBOOK.md index 4b8df1d..cca2abf 100644 --- a/LOGBOOK.md +++ b/LOGBOOK.md @@ -2,16 +2,31 @@ ## Решения -- +- В проекте уже используется алгоритм SM-2, оставил его, т.к он соответствует цели пользователя; +- Вопрос и ответ в карточке показаны одновременно, что неинтутивно для цели пользователя - скрою ответ до клика; +- После оценки карточки пользователем пересчитываю ее новое состояние: интервал, легкость и дату ее следующего показа; +- Новое состояние карточки буду сохранять в БД; +- Добавлю отдельную страницу с общими статистиками за всё время и за неделю; +- В публичной странице не показаны заметки, теги и другие личные данные, как требует заказчик; +- Обложки книг вполне реализуемы и полезны, их можно получать с помощью API OpenLibrary - добавлю в течение работы над проектом; +- Добавляю авторизацию, чтобы можно было безопасно подключаться к сервису с разных устройств; +- Решил сделать регистрацию доступной для нескольких аккаунтов - то есть, приложение не однопользовательское; +- Каждая заметка, карточка, и запись повторения принадлежит конкретному пользователю; +- При переходе на страницу другого пользователя показаны только его публичный профиль. ## Допущения -- +- Под требованием "сделать красиво" понимаю современный, но не перегруженный дизайн - выполню; +- Требование заказчика о "напоминаниях" понял как простое появление карточек в очереди, т.е без уведомлений вне приложения. ## С чем поспорил / что отклонил -- +- Решил не добавлять ИИ-конспекты из-за высокого шанса галлюцинаций при работе с большими объемами данных книг, а также лишней сложности/зависимости от сторонних ИИ-сервисов; +- Считаю, что автоматическое удаление заметок после 30 дней может привести к неожиданной потере собранной базы знаний, поэтому отклоняю; +- Интеграция со словарём требует очередной интеграции со сторонним API, что раздувает функционал приложения и не необходимо к основному сценарию использования; +- Цитата дня не относится к цели приложения - отклоняю. ## Мнение -- +- Изначально в приложении вообще нет авторизации - в таком виде при развертывании сервиса заметки будут открыты для всех (если не использовать Tailscale и т.п); +- Добавление авторизации значительно увеличивает объем работы, но она необходима для безопасного использования приложения. From c22067f5f42c5c5799f42dbcddf2eb4e8bd4e11e Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:06:58 +0200 Subject: [PATCH 002/102] =?UTF-8?q?fix(reviews):=20=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=8F=D0=B5=D0=BC=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/src/Http/Controller/ReviewsController.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/backend/src/Http/Controller/ReviewsController.php b/app/backend/src/Http/Controller/ReviewsController.php index 10c9351..6f09642 100644 --- a/app/backend/src/Http/Controller/ReviewsController.php +++ b/app/backend/src/Http/Controller/ReviewsController.php @@ -67,9 +67,7 @@ public function grade(Request $request, Response $response, array $args): Respon $review = $card->grade($grade, $this->now); - // TODO: повторение пока не доделано. Оценка сохраняется, но карточка не - // сохраняется с новым интервалом и датой, поэтому не уходит из очереди - // на сегодня. Допишите сохранение карточки ($this->cards->save($card)). + $this->cards->save($card); $this->reviews->save($review); return Json::write($response, $this->serializer->serialize($review), 201); From f1b9ae6c0c4274c519beb9793659ab7a85f9298b Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:26:25 +0200 Subject: [PATCH 003/102] =?UTF-8?q?feat(stats):=20=D1=81=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B8=D1=8E=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 2 +- .../src/Http/Controller/StatsController.php | 35 +++++++++++++++---- .../Persistence/ReviewRepository.php | 14 ++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 7cd1cd0..7495ca5 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -46,7 +46,7 @@ $notes = new NotesController($noteRepo, $serializer, $now); $cards = new CardsController($cardRepo, $noteRepo, $serializer, $now); $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); -$stats = new StatsController($cardRepo, $serializer); +$stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); $app = AppFactory::create(); $app->addBodyParsingMiddleware(); diff --git a/app/backend/src/Http/Controller/StatsController.php b/app/backend/src/Http/Controller/StatsController.php index 4c4804c..44965e2 100644 --- a/app/backend/src/Http/Controller/StatsController.php +++ b/app/backend/src/Http/Controller/StatsController.php @@ -4,29 +4,52 @@ namespace Recall\Http\Controller; +use DateTimeImmutable; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\Stats; +use Recall\Domain\ValueObject\Day; use Recall\Http\Json; use Recall\Http\Serializer; use Recall\Infrastructure\Persistence\CardRepository; +use Recall\Infrastructure\Persistence\ReviewRepository; final readonly class StatsController { public function __construct( private CardRepository $cards, + private ReviewRepository $reviews, private Serializer $serializer, + private DateTimeImmutable $now, ) {} public function index(Request $request, Response $response): Response { - $total = $this->cards->count(); + $today = Day::today($this->now); + $weekEnd = new Day($this->now->modify('+6 days')->format('Y-m-d')); + $dueToday = 0; + $dueWeek = 0; + foreach ($this->cards->all() as $card) { + if ($card->isDue($today)) { + ++$dueToday; + } + if ($card->due->isOnOrBefore($weekEnd)) { + ++$dueWeek; + } + } - // TODO: заглушка. Считает все карточки как «на сегодня» и «за неделю», - // игнорируя даты, а серию всегда отдаёт нулём. Определите настоящие - // правила: сколько повторить сегодня, сколько за неделю и как считать - // серию дней. - $stats = new Stats(dueToday: $total, dueWeek: $total, streak: 0); + $reviewDays = []; + foreach ($this->reviews->all() as $review) { + $reviewDays[$review->createdAt()->format('Y-m-d')] = true; + } + $streak = 0; + $streakDay = $this->now; + while (isset($reviewDays[$streakDay->format('Y-m-d')])) { + ++$streak; + $streakDay = $streakDay->modify('-1 day'); + } + + $stats = new Stats(dueToday: $dueToday, dueWeek: $dueWeek, streak: $streak); return Json::write($response, $this->serializer->serialize($stats)); } diff --git a/app/backend/src/Infrastructure/Persistence/ReviewRepository.php b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php index fb0cf00..dd55dcc 100644 --- a/app/backend/src/Infrastructure/Persistence/ReviewRepository.php +++ b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php @@ -6,6 +6,7 @@ use Cycle\ORM\EntityManager; use Cycle\ORM\ORMInterface; +use Cycle\ORM\Select; use Recall\Domain\Review; /** Доступ к повторениям через Cycle ORM. */ @@ -13,6 +14,19 @@ { public function __construct(private ORMInterface $orm) {} + /** @return list */ + public function all(): array + { + $reviews = []; + foreach ((new Select($this->orm, Review::class))->orderBy('id')->fetchAll() as $review) { + if ($review instanceof Review) { + $reviews[] = $review; + } + } + + return $reviews; + } + public function save(Review $review): void { (new EntityManager($this->orm))->persist($review)->run(); From 07128f3c86ef4cd9441e94f2fc0d7eb480c92347 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:28:29 +0200 Subject: [PATCH 004/102] =?UTF-8?q?feat(review):=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/main.ts | 13 ++++++++++++- app/frontend/src/style.css | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index af5088c..ef64e86 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -83,6 +83,17 @@ function cardItem(card: Card): HTMLDivElement { const back = document.createElement("p"); back.className = "back"; back.textContent = card.back; + back.hidden = true; + + const reveal = document.createElement("button"); + reveal.type = "button"; + reveal.className = "reveal-answer"; + reveal.textContent = "Показать ответ"; + reveal.dataset.testid = "reveal-answer"; + reveal.addEventListener("click", () => { + back.hidden = false; + reveal.hidden = true; + }); const buttons = document.createElement("div"); buttons.className = "grade-buttons"; @@ -99,7 +110,7 @@ function cardItem(card: Card): HTMLDivElement { buttons.append(button); } - wrap.append(front, back, buttons); + wrap.append(front, reveal, back, buttons); return wrap; } diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index 08d178d..0b3bb69 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -88,6 +88,10 @@ button:hover { color: #555; } +.card .reveal-answer { + margin-top: 0.5rem; +} + .card .grade-buttons { display: flex; gap: 0.5rem; From c43be8bf88b8d6ad8885895baf4464d11c34a1e6 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:33:02 +0200 Subject: [PATCH 005/102] =?UTF-8?q?feat(cards):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 22 +++++++++++++++ app/frontend/src/api.ts | 2 ++ app/frontend/src/main.ts | 55 ++++++++++++++++++++++++++++++++++++++ app/frontend/src/style.css | 3 ++- 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index 986fc13..44871cb 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -41,6 +41,28 @@

Заметки

+
+

Новая карточка

+
+ + + + +
+
+

Очередь повторения

diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index c03be32..7a2aa4d 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -82,6 +82,8 @@ export const api = { http(`/notes${tag ? `?tag=${encodeURIComponent(tag)}` : ""}`), createNote: (input: { title: string; body: string; tags: string[] }) => http("/notes", { method: "POST", body: JSON.stringify(input) }), + createCard: (input: { note_id: string; front: string; back: string }) => + http("/cards", { method: "POST", body: JSON.stringify(input) }), deleteNote: async (id: string): Promise => { await http(`/notes/${id}`, { method: "DELETE" }); }, diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index ef64e86..b378b32 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -70,6 +70,29 @@ function noteItem(note: Note): HTMLLIElement { return item; } +function updateCardNoteOptions(notes: Note[]): void { + const select = document.querySelector( + "#card-form select[name='note_id']", + ); + if (!select) { + throw new Error("нет списка заметок для карточки"); + } + const options = notes.map((note) => { + const option = document.createElement("option"); + option.value = note.id; + option.textContent = note.title; + return option; + }); + select.replaceChildren( + Object.assign(document.createElement("option"), { + value: "", + textContent: "Выберите заметку", + }), + ...options, + ); + select.disabled = notes.length === 0; +} + function cardItem(card: Card): HTMLDivElement { const wrap = document.createElement("div"); wrap.className = "card"; @@ -125,6 +148,7 @@ async function refreshNotes(): Promise { const notes = await api.listNotes(); const list = need("#note-list"); list.replaceChildren(...notes.map(noteItem)); + updateCardNoteOptions(notes); } async function refreshQueue(): Promise { @@ -173,5 +197,36 @@ form.addEventListener("submit", (event) => { .finally(() => (submit.disabled = false)); }); +const cardForm = need("#card-form"); +if (!(cardForm instanceof HTMLFormElement)) { + throw new Error("нет формы #card-form"); +} +cardForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = cardForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const data = new FormData(cardForm); + const input = { + note_id: field(data, "note_id"), + front: field(data, "front"), + back: field(data, "back"), + }; + + submit.disabled = true; + clearStatus(); + void api + .createCard(input) + .then(() => { + cardForm.reset(); + return Promise.all([refreshStats(), refreshQueue()]); + }) + .catch(showError) + .finally(() => (submit.disabled = false)); +}); + statusBar.textContent = "Загрузка…"; void refreshAll().then(clearStatus, showError); diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index 0b3bb69..7723b8b 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -34,7 +34,8 @@ form { } input, -textarea { +textarea, +select { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; From d536e7320a70bb4a145f612dc8e7a9db78669118 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:35:49 +0200 Subject: [PATCH 006/102] =?UTF-8?q?feat(notes):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=82=D0=B5=D0=B3=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 9 +++++++++ app/frontend/src/main.ts | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index 44871cb..7da4c93 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -38,6 +38,15 @@

Заметки

> +
+ + + +
    diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index b378b32..fbd13f8 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -18,6 +18,7 @@ function field(form: FormData, name: string): string { } const statusBar = need("#status"); +let activeTag: string | undefined; function showError(error: unknown): void { statusBar.textContent = @@ -145,7 +146,7 @@ async function refreshStats(): Promise { } async function refreshNotes(): Promise { - const notes = await api.listNotes(); + const notes = await api.listNotes(activeTag); const list = need("#note-list"); list.replaceChildren(...notes.map(noteItem)); updateCardNoteOptions(notes); @@ -228,5 +229,28 @@ cardForm.addEventListener("submit", (event) => { .finally(() => (submit.disabled = false)); }); +const tagFilterForm = need("#tag-filter-form"); +if (!(tagFilterForm instanceof HTMLFormElement)) { + throw new Error("нет формы фильтра тегов"); +} +tagFilterForm.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(tagFilterForm); + const tag = field(data, "tag").trim(); + activeTag = tag === "" ? undefined : tag; + clearStatus(); + void refreshNotes().catch(showError); +}); + +const clearTagFilter = need("#clear-tag-filter"); +onClick(clearTagFilter as HTMLButtonElement, async () => { + const input = tagFilterForm.elements.namedItem("tag"); + if (input instanceof HTMLInputElement) { + input.value = ""; + } + activeTag = undefined; + await refreshNotes(); +}); + statusBar.textContent = "Загрузка…"; void refreshAll().then(clearStatus, showError); From 17cb7dceda15b0694fbda267557451a2a63188e4 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:38:02 +0200 Subject: [PATCH 007/102] =?UTF-8?q?feat(notes):=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/api.ts | 8 +++++ app/frontend/src/main.ts | 65 +++++++++++++++++++++++++++++++++++++- app/frontend/src/style.css | 10 ++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index 7a2aa4d..bc0c0a9 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -82,6 +82,14 @@ export const api = { http(`/notes${tag ? `?tag=${encodeURIComponent(tag)}` : ""}`), createNote: (input: { title: string; body: string; tags: string[] }) => http("/notes", { method: "POST", body: JSON.stringify(input) }), + updateNote: ( + id: string, + input: { title: string; body: string; tags: string[]; links: string[] }, + ) => + http(`/notes/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }), createCard: (input: { note_id: string; front: string; back: string }) => http("/cards", { method: "POST", body: JSON.stringify(input) }), deleteNote: async (id: string): Promise => { diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index fbd13f8..3331cab 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -57,6 +57,15 @@ function noteItem(note: Note): HTMLLIElement { tags.className = "note-tags"; tags.textContent = tagLabel(note.tags); + const edit = document.createElement("button"); + edit.type = "button"; + edit.textContent = "изменить"; + edit.dataset.testid = "edit-note"; + edit.setAttribute("aria-label", `Изменить заметку: ${note.title}`); + edit.addEventListener("click", () => { + item.replaceChildren(noteEditForm(note)); + }); + const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "удалить"; @@ -67,10 +76,64 @@ function noteItem(note: Note): HTMLLIElement { await refreshNotes(); }); - item.append(title, tags, remove); + item.append(title, tags, edit, remove); return item; } +function noteEditForm(note: Note): HTMLFormElement { + const form = document.createElement("form"); + form.className = "note-edit"; + form.dataset.testid = "edit-note-form"; + + const title = document.createElement("input"); + title.name = "title"; + title.value = note.title; + title.required = true; + title.setAttribute("aria-label", "Заголовок заметки"); + + const tags = document.createElement("input"); + tags.name = "tags"; + tags.value = note.tags.join(", "); + tags.setAttribute("aria-label", "Теги через запятую"); + + const body = document.createElement("textarea"); + body.name = "body"; + body.value = note.body; + body.setAttribute("aria-label", "Текст заметки"); + + const actions = document.createElement("div"); + actions.className = "note-edit-actions"; + const save = document.createElement("button"); + save.type = "submit"; + save.textContent = "сохранить"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.textContent = "отмена"; + cancel.addEventListener("click", () => { + void refreshNotes().catch(showError); + }); + actions.append(save, cancel); + + form.append(title, tags, body, actions); + form.addEventListener("submit", (event) => { + event.preventDefault(); + save.disabled = true; + clearStatus(); + void api + .updateNote(note.id, { + title: title.value, + body: body.value, + tags: parseTags(tags.value), + links: note.links, + }) + .then(() => refreshNotes()) + .catch(showError) + .finally(() => (save.disabled = false)); + }); + + return form; +} + function updateCardNoteOptions(notes: Note[]): void { const select = document.querySelector( "#card-form select[name='note_id']", diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index 7723b8b..a7081b8 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -68,6 +68,16 @@ button:hover { gap: 0.5rem; } +.note-edit { + width: 100%; + margin: 0; +} + +.note-edit-actions { + display: flex; + gap: 0.5rem; +} + .note-tags { color: #595959; font-size: 0.85rem; From fc414cf0f969a191633970daf3385c5318196a4d Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:22:27 +0200 Subject: [PATCH 008/102] =?UTF-8?q?feat(auth):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/src/Domain/Session.php | 39 +++++++++++ app/backend/src/Domain/SessionCredentials.php | 16 +++++ app/backend/src/Domain/User.php | 45 ++++++++++++ .../src/Domain/ValueObject/SessionId.php | 13 ++++ .../src/Domain/ValueObject/SessionToken.php | 28 ++++++++ app/backend/src/Domain/ValueObject/UserId.php | 13 ++++ .../src/Domain/ValueObject/Username.php | 29 ++++++++ .../src/Infrastructure/Persistence/Orm.php | 70 ++++++++++++++++++- .../Persistence/SessionRepository.php | 45 ++++++++++++ .../Persistence/UserRepository.php | 49 +++++++++++++ .../Persistence/ValueObjectTypecast.php | 15 ++++ app/backend/tests/SessionRepositoryTest.php | 44 ++++++++++++ app/backend/tests/UserTest.php | 24 +++++++ 13 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/Domain/Session.php create mode 100644 app/backend/src/Domain/SessionCredentials.php create mode 100644 app/backend/src/Domain/User.php create mode 100644 app/backend/src/Domain/ValueObject/SessionId.php create mode 100644 app/backend/src/Domain/ValueObject/SessionToken.php create mode 100644 app/backend/src/Domain/ValueObject/UserId.php create mode 100644 app/backend/src/Domain/ValueObject/Username.php create mode 100644 app/backend/src/Infrastructure/Persistence/SessionRepository.php create mode 100644 app/backend/src/Infrastructure/Persistence/UserRepository.php create mode 100644 app/backend/tests/SessionRepositoryTest.php create mode 100644 app/backend/tests/UserTest.php diff --git a/app/backend/src/Domain/Session.php b/app/backend/src/Domain/Session.php new file mode 100644 index 0000000..3d15d2d --- /dev/null +++ b/app/backend/src/Domain/Session.php @@ -0,0 +1,39 @@ +hash(), + $now->modify('+30 days'), + ); + + return new SessionCredentials($session, $token); + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $this->expiresAt <= $now; + } +} diff --git a/app/backend/src/Domain/SessionCredentials.php b/app/backend/src/Domain/SessionCredentials.php new file mode 100644 index 0000000..b9bd09f --- /dev/null +++ b/app/backend/src/Domain/SessionCredentials.php @@ -0,0 +1,16 @@ +id->createdAt(); + } + + public function verifiesPassword(string $password): bool + { + return password_verify($password, $this->passwordHash); + } + + private static function validatePassword(string $password): void + { + $length = strlen($password); + if ($length < 12 || $length > 72) { + throw new InvalidArgumentException('пароль должен содержать от 12 до 72 байт'); + } + } +} diff --git a/app/backend/src/Domain/ValueObject/SessionId.php b/app/backend/src/Domain/ValueObject/SessionId.php new file mode 100644 index 0000000..72ade2e --- /dev/null +++ b/app/backend/src/Domain/ValueObject/SessionId.php @@ -0,0 +1,13 @@ +value); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/app/backend/src/Domain/ValueObject/UserId.php b/app/backend/src/Domain/ValueObject/UserId.php new file mode 100644 index 0000000..d9289af --- /dev/null +++ b/app/backend/src/Domain/ValueObject/UserId.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/app/backend/src/Infrastructure/Persistence/Orm.php b/app/backend/src/Infrastructure/Persistence/Orm.php index a4bfeb5..047b17b 100644 --- a/app/backend/src/Infrastructure/Persistence/Orm.php +++ b/app/backend/src/Infrastructure/Persistence/Orm.php @@ -21,6 +21,8 @@ use Recall\Domain\Grade; use Recall\Domain\Note; use Recall\Domain\Review; +use Recall\Domain\Session; +use Recall\Domain\User; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\CardText; use Recall\Domain\ValueObject\Day; @@ -29,8 +31,11 @@ use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\ReviewId; +use Recall\Domain\ValueObject\SessionId; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; +use Recall\Domain\ValueObject\UserId; +use Recall\Domain\ValueObject\Username; /** * Сборка Cycle ORM: подключение к SQLite, схема (заданная массивом, без @@ -76,6 +81,21 @@ public function entityManager(): EntityManager private static function migrate(DatabaseInterface $db): void { + $db->execute( + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL + )", + ); + $db->execute( + "CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL + )", + ); $db->execute( "CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, @@ -114,7 +134,7 @@ private static function map(): array { $handler = [ValueObjectTypecast::class, Typecast::class]; - return [ + return self::authenticationMap($handler) + [ Note::class => [ SchemaInterface::ROLE => 'note', SchemaInterface::DATABASE => 'default', @@ -193,4 +213,52 @@ private static function map(): array ], ]; } + + /** + * @param list $handler + * @return array> + */ + private static function authenticationMap(array $handler): array + { + return [ + User::class => [ + SchemaInterface::ROLE => 'user', + SchemaInterface::DATABASE => 'default', + SchemaInterface::TABLE => 'users', + SchemaInterface::PRIMARY_KEY => 'id', + SchemaInterface::TYPECAST_HANDLER => $handler, + SchemaInterface::COLUMNS => [ + 'id' => 'id', + 'username' => 'username', + 'passwordHash' => 'password_hash', + ], + SchemaInterface::TYPECAST => [ + 'id' => UserId::class, + 'username' => Username::class, + ], + SchemaInterface::SCHEMA => [], + SchemaInterface::RELATIONS => [], + ], + Session::class => [ + SchemaInterface::ROLE => 'session', + SchemaInterface::DATABASE => 'default', + SchemaInterface::TABLE => 'sessions', + SchemaInterface::PRIMARY_KEY => 'id', + SchemaInterface::TYPECAST_HANDLER => $handler, + SchemaInterface::COLUMNS => [ + 'id' => 'id', + 'userId' => 'user_id', + 'tokenHash' => 'token_hash', + 'expiresAt' => 'expires_at', + ], + SchemaInterface::TYPECAST => [ + 'id' => SessionId::class, + 'userId' => UserId::class, + 'expiresAt' => 'datetime', + ], + SchemaInterface::SCHEMA => [], + SchemaInterface::RELATIONS => [], + ], + ]; + } } diff --git a/app/backend/src/Infrastructure/Persistence/SessionRepository.php b/app/backend/src/Infrastructure/Persistence/SessionRepository.php new file mode 100644 index 0000000..eee7dca --- /dev/null +++ b/app/backend/src/Infrastructure/Persistence/SessionRepository.php @@ -0,0 +1,45 @@ +orm, Session::class)) + ->where('tokenHash', $token->hash()) + ->fetchOne(); + if (!$session instanceof Session) { + return null; + } + if ($session->isExpired($now)) { + $this->delete($session); + + return null; + } + + return $session; + } + + public function save(Session $session): void + { + (new EntityManager($this->orm))->persist($session)->run(); + } + + public function delete(Session $session): void + { + (new EntityManager($this->orm))->delete($session)->run(); + } +} diff --git a/app/backend/src/Infrastructure/Persistence/UserRepository.php b/app/backend/src/Infrastructure/Persistence/UserRepository.php new file mode 100644 index 0000000..77eddd7 --- /dev/null +++ b/app/backend/src/Infrastructure/Persistence/UserRepository.php @@ -0,0 +1,49 @@ +first( + (new Select($this->orm, User::class))->where('id', $id->toString())->fetchAll(), + ); + } + + public function findByUsername(Username $username): ?User + { + return $this->first( + (new Select($this->orm, User::class))->where('username', $username->value)->fetchAll(), + ); + } + + public function save(User $user): void + { + (new EntityManager($this->orm))->persist($user)->run(); + } + + /** @param iterable $rows */ + private function first(iterable $rows): ?User + { + foreach ($rows as $user) { + if ($user instanceof User) { + return $user; + } + } + + return null; + } +} diff --git a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php index 8a5b09e..266f6a3 100644 --- a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php +++ b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php @@ -14,8 +14,11 @@ use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\ReviewId; +use Recall\Domain\ValueObject\SessionId; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; +use Recall\Domain\ValueObject\UserId; +use Recall\Domain\ValueObject\Username; /** * Единая точка перевода значений между БД и доменными value-объектами. @@ -96,6 +99,18 @@ private function pair(string $rule): ?array static fn(mixed $v): ReviewId => ReviewId::fromString(self::str($v)), static fn(mixed $v): string => $v instanceof ReviewId ? $v->toString() : self::str($v), ], + UserId::class => [ + static fn(mixed $v): UserId => UserId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof UserId ? $v->toString() : self::str($v), + ], + SessionId::class => [ + static fn(mixed $v): SessionId => SessionId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof SessionId ? $v->toString() : self::str($v), + ], + Username::class => [ + static fn(mixed $v): Username => new Username(self::str($v)), + static fn(mixed $v): string => $v instanceof Username ? $v->value : self::str($v), + ], Title::class => [ static fn(mixed $v): Title => new Title(self::str($v)), static fn(mixed $v): string => $v instanceof Title ? $v->value : self::str($v), diff --git a/app/backend/tests/SessionRepositoryTest.php b/app/backend/tests/SessionRepositoryTest.php new file mode 100644 index 0000000..ea0dfa6 --- /dev/null +++ b/app/backend/tests/SessionRepositoryTest.php @@ -0,0 +1,44 @@ +orm); + $now = new DateTimeImmutable('2026-07-18T12:00:00+00:00'); + $credentials = Session::start(UserId::generate(), $now); + $repo->save($credentials->session); + + $found = $repo->findByToken($credentials->token, $now); + + Assert::notNull($found); + Assert::same($found->userId->toString(), $credentials->session->userId->toString()); + Assert::false($found->tokenHash === (string) $credentials->token); + } + + #[Test] + public function removesExpiredSessions(): void + { + $repo = new SessionRepository(Orm::boot(':memory:')->orm); + $now = new DateTimeImmutable('2026-07-18T12:00:00+00:00'); + $credentials = Session::start(UserId::generate(), $now); + $repo->save($credentials->session); + + $found = $repo->findByToken($credentials->token, $now->modify('+31 days')); + + Assert::null($found); + } +} diff --git a/app/backend/tests/UserTest.php b/app/backend/tests/UserTest.php new file mode 100644 index 0000000..352a6ec --- /dev/null +++ b/app/backend/tests/UserTest.php @@ -0,0 +1,24 @@ +verifiesPassword($password)); + Assert::false($user->verifiesPassword('another-correct-password')); + Assert::false($user->passwordHash === $password); + } +} From ca6259077969c820a847d7b6e2eeced994a81cc3 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:44:33 +0200 Subject: [PATCH 009/102] =?UTF-8?q?feat(auth):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 6 +++ .../src/Http/Controller/AuthController.php | 49 +++++++++++++++++++ .../src/Http/Input/RegistrationInput.php | 41 ++++++++++++++++ outdatty.lock | 2 +- spec/acceptance/auth.hurl | 34 +++++++++++++ spec/api/openapi.yaml | 40 +++++++++++++++ 6 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/Http/Controller/AuthController.php create mode 100644 app/backend/src/Http/Input/RegistrationInput.php create mode 100644 spec/acceptance/auth.hurl diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 7495ca5..fe62ef3 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -6,6 +6,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerAwareInterface; +use Recall\Http\Controller\AuthController; use Recall\Http\Controller\CardsController; use Recall\Http\Controller\NotesController; use Recall\Http\Controller\ReviewsController; @@ -19,6 +20,7 @@ use Recall\Infrastructure\Persistence\QueryCounter; use Recall\Infrastructure\Persistence\ReviewRepository; use Recall\Infrastructure\Persistence\Seeder; +use Recall\Infrastructure\Persistence\UserRepository; use Slim\Exception\HttpMethodNotAllowedException; use Slim\Exception\HttpNotFoundException; use Slim\Factory\AppFactory; @@ -39,6 +41,7 @@ $noteRepo = new NoteRepository($boot->orm); $cardRepo = new CardRepository($boot->orm); $reviewRepo = new ReviewRepository($boot->orm); +$userRepo = new UserRepository($boot->orm); (new Seeder($noteRepo, $cardRepo))->seedIfEmpty($now); @@ -47,6 +50,7 @@ $cards = new CardsController($cardRepo, $noteRepo, $serializer, $now); $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); $stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); +$auth = new AuthController($userRepo); $app = AppFactory::create(); $app->addBodyParsingMiddleware(); @@ -83,6 +87,8 @@ $app->get('/stats', $stats->index(...)); +$app->post('/auth/register', $auth->register(...)); + $app->options('/{routes:.+}', static fn(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface => $response->withStatus(204)); $app->addRoutingMiddleware(); diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php new file mode 100644 index 0000000..960ee50 --- /dev/null +++ b/app/backend/src/Http/Controller/AuthController.php @@ -0,0 +1,49 @@ +body($request)); + if ($this->users->findByUsername($input->username) !== null) { + return Json::error($response, 'логин уже занят', 409, ['username' => 'выберите другой логин']); + } + + $user = User::register($input->username, $input->password); + $this->users->save($user); + + return Json::write($response, $this->profile($user), 201); + } + + /** @return array */ + private function body(Request $request): array + { + $body = $request->getParsedBody(); + + return is_array($body) ? $body : []; + } + + /** @return array{id: string, username: string, created_at: string} */ + private function profile(User $user): array + { + return [ + 'id' => $user->id->toString(), + 'username' => $user->username->value, + 'created_at' => $user->createdAt()->format(DateTimeImmutable::ATOM), + ]; + } +} diff --git a/app/backend/src/Http/Input/RegistrationInput.php b/app/backend/src/Http/Input/RegistrationInput.php new file mode 100644 index 0000000..b492348 --- /dev/null +++ b/app/backend/src/Http/Input/RegistrationInput.php @@ -0,0 +1,41 @@ + $data */ + public static function fromArray(array $data): self + { + $password = $data['password'] ?? null; + if (!is_string($password) || $password === '') { + throw new ValidationException('проверьте поля регистрации', ['password' => 'укажите пароль']); + } + + return new self(self::username($data['username'] ?? null), $password); + } + + private static function username(mixed $raw): Username + { + if (!is_string($raw)) { + throw new ValidationException('проверьте поля регистрации', ['username' => 'укажите логин']); + } + try { + return Username::fromString($raw); + } catch (InvalidArgumentException $exception) { + throw new ValidationException('проверьте поля регистрации', ['username' => $exception->getMessage()]); + } + } +} diff --git a/outdatty.lock b/outdatty.lock index a6d8419..220906a 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: ab9c8ba2dbd1cd1a692a5e4bd62e5e4c71a076c08be9ef5a8316f7e068fe637e + spec/api/openapi.yaml: 42966134b003d6109548b0bf8f0e43638ff0e9fd5b98e8996aa3745eff4cb96d dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 diff --git a/spec/acceptance/auth.hurl b/spec/acceptance/auth.hurl new file mode 100644 index 0000000..632c1e3 --- /dev/null +++ b/spec/acceptance/auth.hurl @@ -0,0 +1,34 @@ +# Registration creates a user without exposing a password hash. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "reader_01", + "password": "correct-horse-battery-staple" +} +HTTP 201 +[Captures] +user_id: jsonpath "$.id" +[Asserts] +jsonpath "$.id" exists +jsonpath "$.username" == "reader_01" +jsonpath "$.created_at" exists +jsonpath "$.password_hash" not exists + +# Logins are unique. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "reader_01", + "password": "another-correct-password" +} +HTTP 409 + +# Invalid registration input is rejected. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "No spaces", + "password": "short" +} +HTTP 422 diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index 87e48a4..2a27f67 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -16,12 +16,35 @@ servers: description: Local backend (php -S / compose) tags: + - name: auth - name: notes - name: cards - name: reviews - name: stats paths: + /auth/register: + post: + tags: [auth] + summary: Register a user + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RegistrationInput" } + responses: + "201": + description: Registered user + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "409": + description: Username already exists + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/ValidationError" } + /notes: get: tags: [notes] @@ -212,6 +235,23 @@ components: schema: { $ref: "#/components/schemas/Error" } schemas: + User: + type: object + required: [id, username, created_at] + properties: + id: { type: string, format: uuid } + username: { type: string } + created_at: { type: string, format: date-time } + + RegistrationInput: + type: object + required: [username, password] + properties: + username: + type: string + pattern: "^[a-z0-9][a-z0-9_-]{2,31}$" + password: { type: string, minLength: 12, maxLength: 72 } + Note: type: object required: [id, title, body, tags, links, created_at, updated_at] From 112469a6b606203f414f506cfc8d86b6bbcd8a74 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:51:11 +0200 Subject: [PATCH 010/102] =?UTF-8?q?feat(auth):=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Justfile | 2 +- app/backend/public/index.php | 5 ++- .../src/Http/Controller/AuthController.php | 14 ++++++-- app/backend/src/Http/SessionCookie.php | 35 +++++++++++++++++++ outdatty.lock | 6 ++-- spec/acceptance/auth.hurl | 9 +++-- spec/api/openapi.yaml | 4 +++ 7 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 app/backend/src/Http/SessionCookie.php diff --git a/Justfile b/Justfile index 2fc8d4b..ebab200 100644 --- a/Justfile +++ b/Justfile @@ -38,7 +38,7 @@ down: # API-приёмка — приложение уже должно быть запущено (см. `just up`). acceptance: - hurl --variable base={{ base }} --test spec/acceptance/*.hurl + hurl --variable base={{ base }} --variable username=acceptance-$(date +%s%N) --test spec/acceptance/*.hurl # Браузерные сценарии — приложение уже должно быть запущено (см. `just up`). e2e: diff --git a/app/backend/public/index.php b/app/backend/public/index.php index fe62ef3..cfd0895 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -13,6 +13,7 @@ use Recall\Http\Controller\StatsController; use Recall\Http\Json; use Recall\Http\Serializer; +use Recall\Http\SessionCookie; use Recall\Http\ValidationException; use Recall\Infrastructure\Persistence\CardRepository; use Recall\Infrastructure\Persistence\NoteRepository; @@ -20,6 +21,7 @@ use Recall\Infrastructure\Persistence\QueryCounter; use Recall\Infrastructure\Persistence\ReviewRepository; use Recall\Infrastructure\Persistence\Seeder; +use Recall\Infrastructure\Persistence\SessionRepository; use Recall\Infrastructure\Persistence\UserRepository; use Slim\Exception\HttpMethodNotAllowedException; use Slim\Exception\HttpNotFoundException; @@ -41,6 +43,7 @@ $noteRepo = new NoteRepository($boot->orm); $cardRepo = new CardRepository($boot->orm); $reviewRepo = new ReviewRepository($boot->orm); +$sessionRepo = new SessionRepository($boot->orm); $userRepo = new UserRepository($boot->orm); (new Seeder($noteRepo, $cardRepo))->seedIfEmpty($now); @@ -50,7 +53,7 @@ $cards = new CardsController($cardRepo, $noteRepo, $serializer, $now); $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); $stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); -$auth = new AuthController($userRepo); +$auth = new AuthController($userRepo, $sessionRepo, new SessionCookie(), $now); $app = AppFactory::create(); $app->addBodyParsingMiddleware(); diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php index 960ee50..5697471 100644 --- a/app/backend/src/Http/Controller/AuthController.php +++ b/app/backend/src/Http/Controller/AuthController.php @@ -7,14 +7,22 @@ use DateTimeImmutable; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use Recall\Domain\Session; use Recall\Domain\User; use Recall\Http\Input\RegistrationInput; use Recall\Http\Json; +use Recall\Http\SessionCookie; +use Recall\Infrastructure\Persistence\SessionRepository; use Recall\Infrastructure\Persistence\UserRepository; final readonly class AuthController { - public function __construct(private UserRepository $users) {} + public function __construct( + private UserRepository $users, + private SessionRepository $sessions, + private SessionCookie $cookie, + private DateTimeImmutable $now, + ) {} public function register(Request $request, Response $response): Response { @@ -25,8 +33,10 @@ public function register(Request $request, Response $response): Response $user = User::register($input->username, $input->password); $this->users->save($user); + $credentials = Session::start($user->id, $this->now); + $this->sessions->save($credentials->session); - return Json::write($response, $this->profile($user), 201); + return $this->cookie->add(Json::write($response, $this->profile($user), 201), $credentials); } /** @return array */ diff --git a/app/backend/src/Http/SessionCookie.php b/app/backend/src/Http/SessionCookie.php new file mode 100644 index 0000000..32efced --- /dev/null +++ b/app/backend/src/Http/SessionCookie.php @@ -0,0 +1,35 @@ +set(self::NAME, [ + 'value' => (string) $credentials->token, + 'path' => '/', + 'expires' => $credentials->session->expiresAt->getTimestamp(), + 'httponly' => true, + 'samesite' => 'lax', + ]); + + foreach ($cookies->toHeaders() as $header) { + if (is_string($header)) { + $response = $response->withAddedHeader('Set-Cookie', $header); + } + } + + return $response; + } +} diff --git a/outdatty.lock b/outdatty.lock index 220906a..9ce38e4 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: 42966134b003d6109548b0bf8f0e43638ff0e9fd5b98e8996aa3745eff4cb96d + spec/api/openapi.yaml: 76e8166e40785b6429f253872a5d4cae5545f6b3bbfbf76988462b05e7e5bde0 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 @@ -16,7 +16,7 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d commands: source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a + Justfile: 66f6b078f9d6b26ab2d5c72107137ef331145c544b63fda5b4d20a0f8d8b1646 dependents: www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d @@ -41,7 +41,7 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d repo-layout: source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a + Justfile: 66f6b078f9d6b26ab2d5c72107137ef331145c544b63fda5b4d20a0f8d8b1646 compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 dependents: diff --git a/spec/acceptance/auth.hurl b/spec/acceptance/auth.hurl index 632c1e3..3c2c29c 100644 --- a/spec/acceptance/auth.hurl +++ b/spec/acceptance/auth.hurl @@ -3,7 +3,7 @@ POST {{base}}/auth/register Content-Type: application/json { - "username": "reader_01", + "username": "{{username}}", "password": "correct-horse-battery-staple" } HTTP 201 @@ -11,15 +11,18 @@ HTTP 201 user_id: jsonpath "$.id" [Asserts] jsonpath "$.id" exists -jsonpath "$.username" == "reader_01" +jsonpath "$.username" == "{{username}}" jsonpath "$.created_at" exists jsonpath "$.password_hash" not exists +header "Set-Cookie" contains "recall_session=" +header "Set-Cookie" contains "HttpOnly" +header "Set-Cookie" contains "SameSite=lax" # Logins are unique. POST {{base}}/auth/register Content-Type: application/json { - "username": "reader_01", + "username": "{{username}}", "password": "another-correct-password" } HTTP 409 diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index 2a27f67..c6627f1 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -35,6 +35,10 @@ paths: responses: "201": description: Registered user + headers: + Set-Cookie: + description: HttpOnly session cookie. + schema: { type: string } content: application/json: schema: { $ref: "#/components/schemas/User" } From c18a7f392f23aefe3cef54b9982b2a68d1c75ca9 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:53:30 +0200 Subject: [PATCH 011/102] =?UTF-8?q?feat(auth):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 1 + .../src/Http/Controller/AuthController.php | 15 +++++++ app/backend/src/Http/Input/LoginInput.php | 41 +++++++++++++++++++ outdatty.lock | 2 +- spec/acceptance/auth.hurl | 26 ++++++++++++ spec/api/openapi.yaml | 35 ++++++++++++++++ 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/Http/Input/LoginInput.php diff --git a/app/backend/public/index.php b/app/backend/public/index.php index cfd0895..47e35d5 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -91,6 +91,7 @@ $app->get('/stats', $stats->index(...)); $app->post('/auth/register', $auth->register(...)); +$app->post('/auth/login', $auth->login(...)); $app->options('/{routes:.+}', static fn(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface => $response->withStatus(204)); diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php index 5697471..53b320f 100644 --- a/app/backend/src/Http/Controller/AuthController.php +++ b/app/backend/src/Http/Controller/AuthController.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\Session; use Recall\Domain\User; +use Recall\Http\Input\LoginInput; use Recall\Http\Input\RegistrationInput; use Recall\Http\Json; use Recall\Http\SessionCookie; @@ -39,6 +40,20 @@ public function register(Request $request, Response $response): Response return $this->cookie->add(Json::write($response, $this->profile($user), 201), $credentials); } + public function login(Request $request, Response $response): Response + { + $input = LoginInput::fromArray($this->body($request)); + $user = $this->users->findByUsername($input->username); + if ($user === null || !$user->verifiesPassword($input->password)) { + return Json::error($response, 'неверный логин или пароль', 401); + } + + $credentials = Session::start($user->id, $this->now); + $this->sessions->save($credentials->session); + + return $this->cookie->add(Json::write($response, $this->profile($user)), $credentials); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/Input/LoginInput.php b/app/backend/src/Http/Input/LoginInput.php new file mode 100644 index 0000000..d5f1292 --- /dev/null +++ b/app/backend/src/Http/Input/LoginInput.php @@ -0,0 +1,41 @@ + $data */ + public static function fromArray(array $data): self + { + $password = $data['password'] ?? null; + if (!is_string($password) || $password === '') { + throw new ValidationException('проверьте данные для входа', ['password' => 'укажите пароль']); + } + + return new self(self::username($data['username'] ?? null), $password); + } + + private static function username(mixed $raw): Username + { + if (!is_string($raw)) { + throw new ValidationException('проверьте данные для входа', ['username' => 'укажите логин']); + } + try { + return Username::fromString($raw); + } catch (InvalidArgumentException $exception) { + throw new ValidationException('проверьте данные для входа', ['username' => $exception->getMessage()]); + } + } +} diff --git a/outdatty.lock b/outdatty.lock index 9ce38e4..1a421ad 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: 76e8166e40785b6429f253872a5d4cae5545f6b3bbfbf76988462b05e7e5bde0 + spec/api/openapi.yaml: 8fd94149919dcfb20f8814bd11e102ec878b4eb7e46e79e34877ff454037c8d9 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 diff --git a/spec/acceptance/auth.hurl b/spec/acceptance/auth.hurl index 3c2c29c..8c4c31d 100644 --- a/spec/acceptance/auth.hurl +++ b/spec/acceptance/auth.hurl @@ -18,6 +18,32 @@ header "Set-Cookie" contains "recall_session=" header "Set-Cookie" contains "HttpOnly" header "Set-Cookie" contains "SameSite=lax" +# A registered user can obtain a fresh session by logging in. +POST {{base}}/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "correct-horse-battery-staple" +} +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{user_id}}" +jsonpath "$.username" == "{{username}}" +header "Set-Cookie" contains "recall_session=" +header "Set-Cookie" contains "HttpOnly" +header "Set-Cookie" contains "SameSite=lax" + +# A wrong password never creates a session. +POST {{base}}/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "wrong-password" +} +HTTP 401 +[Asserts] +header "Set-Cookie" not exists + # Logins are unique. POST {{base}}/auth/register Content-Type: application/json diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index c6627f1..60d92e5 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -49,6 +49,32 @@ paths: schema: { $ref: "#/components/schemas/Error" } "422": { $ref: "#/components/responses/ValidationError" } + /auth/login: + post: + tags: [auth] + summary: Log in with a username and password + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/LoginInput" } + responses: + "200": + description: Logged-in user + headers: + Set-Cookie: + description: HttpOnly session cookie. + schema: { type: string } + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "401": + description: Invalid username or password + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/ValidationError" } + /notes: get: tags: [notes] @@ -256,6 +282,15 @@ components: pattern: "^[a-z0-9][a-z0-9_-]{2,31}$" password: { type: string, minLength: 12, maxLength: 72 } + LoginInput: + type: object + required: [username, password] + properties: + username: + type: string + pattern: "^[a-z0-9][a-z0-9_-]{2,31}$" + password: { type: string, minLength: 1, maxLength: 72 } + Note: type: object required: [id, title, body, tags, links, created_at, updated_at] From 4afe2c1fac2345483e6303459c6914c7e46b45e6 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:02:30 +0200 Subject: [PATCH 012/102] =?UTF-8?q?feat(auth):=20=D0=BF=D0=BE=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=83=D1=89?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 1 + .../src/Domain/ValueObject/SessionToken.php | 10 ++++++++++ .../src/Http/Controller/AuthController.php | 20 +++++++++++++++++++ app/backend/src/Http/SessionCookie.php | 17 ++++++++++++++++ outdatty.lock | 2 +- spec/acceptance/auth-me.hurl | 8 ++++++++ spec/acceptance/auth.hurl | 7 +++++++ spec/api/openapi.yaml | 16 +++++++++++++++ 8 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 spec/acceptance/auth-me.hurl diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 47e35d5..c0bcd89 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -92,6 +92,7 @@ $app->post('/auth/register', $auth->register(...)); $app->post('/auth/login', $auth->login(...)); +$app->get('/auth/me', $auth->currentUser(...)); $app->options('/{routes:.+}', static fn(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface => $response->withStatus(204)); diff --git a/app/backend/src/Domain/ValueObject/SessionToken.php b/app/backend/src/Domain/ValueObject/SessionToken.php index 0e55393..32e5916 100644 --- a/app/backend/src/Domain/ValueObject/SessionToken.php +++ b/app/backend/src/Domain/ValueObject/SessionToken.php @@ -4,6 +4,7 @@ namespace Recall\Domain\ValueObject; +use InvalidArgumentException; use Stringable; /** Непрозрачный токен сессии, пригодный для значения cookie. */ @@ -16,6 +17,15 @@ public static function generate(): self return new self(rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=')); } + public static function fromString(string $value): self + { + if (preg_match('/^[A-Za-z0-9_-]{43}$/', $value) !== 1) { + throw new InvalidArgumentException('некорректный токен сессии'); + } + + return new self($value); + } + public function hash(): string { return hash('sha256', $this->value); diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php index 53b320f..b91af1c 100644 --- a/app/backend/src/Http/Controller/AuthController.php +++ b/app/backend/src/Http/Controller/AuthController.php @@ -54,6 +54,26 @@ public function login(Request $request, Response $response): Response return $this->cookie->add(Json::write($response, $this->profile($user)), $credentials); } + public function currentUser(Request $request, Response $response): Response + { + $token = $this->cookie->token($request); + if ($token === null) { + return Json::error($response, 'необходима авторизация', 401); + } + + $session = $this->sessions->findByToken($token, $this->now); + if ($session === null) { + return Json::error($response, 'необходима авторизация', 401); + } + + $user = $this->users->find($session->userId); + if ($user === null) { + return Json::error($response, 'необходима авторизация', 401); + } + + return Json::write($response, $this->profile($user)); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/SessionCookie.php b/app/backend/src/Http/SessionCookie.php index 32efced..6a6a05f 100644 --- a/app/backend/src/Http/SessionCookie.php +++ b/app/backend/src/Http/SessionCookie.php @@ -4,8 +4,11 @@ namespace Recall\Http; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\SessionCredentials; +use Recall\Domain\ValueObject\SessionToken; use Slim\Psr7\Cookies; /** Устанавливает непрозрачный токен сессии в безопасной browser-cookie. */ @@ -32,4 +35,18 @@ public function add(Response $response, SessionCredentials $credentials): Respon return $response; } + + public function token(Request $request): ?SessionToken + { + $value = $request->getCookieParams()[self::NAME] ?? null; + if (!is_string($value)) { + return null; + } + + try { + return SessionToken::fromString($value); + } catch (InvalidArgumentException) { + return null; + } + } } diff --git a/outdatty.lock b/outdatty.lock index 1a421ad..edcb7a4 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: 8fd94149919dcfb20f8814bd11e102ec878b4eb7e46e79e34877ff454037c8d9 + spec/api/openapi.yaml: 99023705f0b495939a8fb1ee0df5afc228924c5b13d64b22e7b32a083fc3d42e dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 diff --git a/spec/acceptance/auth-me.hurl b/spec/acceptance/auth-me.hurl new file mode 100644 index 0000000..7a620da --- /dev/null +++ b/spec/acceptance/auth-me.hurl @@ -0,0 +1,8 @@ +# A current user requires a valid session cookie. + +GET {{base}}/auth/me +HTTP 401 + +GET {{base}}/auth/me +Cookie: recall_session=invalid +HTTP 401 diff --git a/spec/acceptance/auth.hurl b/spec/acceptance/auth.hurl index 8c4c31d..6d6a5c6 100644 --- a/spec/acceptance/auth.hurl +++ b/spec/acceptance/auth.hurl @@ -18,6 +18,13 @@ header "Set-Cookie" contains "recall_session=" header "Set-Cookie" contains "HttpOnly" header "Set-Cookie" contains "SameSite=lax" +# The cookie identifies the current user. +GET {{base}}/auth/me +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{user_id}}" +jsonpath "$.username" == "{{username}}" + # A registered user can obtain a fresh session by logging in. POST {{base}}/auth/login Content-Type: application/json diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index 60d92e5..94c4dc0 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -75,6 +75,22 @@ paths: schema: { $ref: "#/components/schemas/Error" } "422": { $ref: "#/components/responses/ValidationError" } + /auth/me: + get: + tags: [auth] + summary: Return the user of the current session + responses: + "200": + description: Current user + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "401": + description: Missing, expired, or invalid session + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /notes: get: tags: [notes] From 9727505e984018eb51f029dbaf313221793134fe Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:01:26 +0200 Subject: [PATCH 013/102] =?UTF-8?q?feat(auth):=20=D0=B2=D1=8B=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B8=D0=B7=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 1 + .../src/Http/Controller/AuthController.php | 13 ++++++++ app/backend/src/Http/SessionCookie.php | 31 +++++++++++++++---- outdatty.lock | 2 +- spec/acceptance/auth-me.hurl | 6 ++++ spec/acceptance/auth.hurl | 12 +++++++ spec/api/openapi.yaml | 12 +++++++ 7 files changed, 70 insertions(+), 7 deletions(-) diff --git a/app/backend/public/index.php b/app/backend/public/index.php index c0bcd89..f4fd321 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -93,6 +93,7 @@ $app->post('/auth/register', $auth->register(...)); $app->post('/auth/login', $auth->login(...)); $app->get('/auth/me', $auth->currentUser(...)); +$app->post('/auth/logout', $auth->logout(...)); $app->options('/{routes:.+}', static fn(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface => $response->withStatus(204)); diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php index b91af1c..18fcb33 100644 --- a/app/backend/src/Http/Controller/AuthController.php +++ b/app/backend/src/Http/Controller/AuthController.php @@ -74,6 +74,19 @@ public function currentUser(Request $request, Response $response): Response return Json::write($response, $this->profile($user)); } + public function logout(Request $request, Response $response): Response + { + $token = $this->cookie->token($request); + if ($token !== null) { + $session = $this->sessions->findByToken($token, $this->now); + if ($session !== null) { + $this->sessions->delete($session); + } + } + + return $this->cookie->clear($response->withStatus(204)); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/SessionCookie.php b/app/backend/src/Http/SessionCookie.php index 6a6a05f..6c1f2a9 100644 --- a/app/backend/src/Http/SessionCookie.php +++ b/app/backend/src/Http/SessionCookie.php @@ -27,13 +27,21 @@ public function add(Response $response, SessionCredentials $credentials): Respon 'samesite' => 'lax', ]); - foreach ($cookies->toHeaders() as $header) { - if (is_string($header)) { - $response = $response->withAddedHeader('Set-Cookie', $header); - } - } + return $this->attach($response, $cookies); + } - return $response; + public function clear(Response $response): Response + { + $cookies = new Cookies(); + $cookies->set(self::NAME, [ + 'value' => '', + 'path' => '/', + 'expires' => 1, + 'httponly' => true, + 'samesite' => 'lax', + ]); + + return $this->attach($response, $cookies); } public function token(Request $request): ?SessionToken @@ -49,4 +57,15 @@ public function token(Request $request): ?SessionToken return null; } } + + private function attach(Response $response, Cookies $cookies): Response + { + foreach ($cookies->toHeaders() as $header) { + if (is_string($header)) { + $response = $response->withAddedHeader('Set-Cookie', $header); + } + } + + return $response; + } } diff --git a/outdatty.lock b/outdatty.lock index edcb7a4..34f44b4 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: 99023705f0b495939a8fb1ee0df5afc228924c5b13d64b22e7b32a083fc3d42e + spec/api/openapi.yaml: 19ebf602052bcd98f9f8b982bbe689c71876fb8f75bd6879a1b747da16505aad dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 diff --git a/spec/acceptance/auth-me.hurl b/spec/acceptance/auth-me.hurl index 7a620da..5bf8e78 100644 --- a/spec/acceptance/auth-me.hurl +++ b/spec/acceptance/auth-me.hurl @@ -6,3 +6,9 @@ HTTP 401 GET {{base}}/auth/me Cookie: recall_session=invalid HTTP 401 + +# Logout is safe even without a session. +POST {{base}}/auth/logout +HTTP 204 +[Asserts] +header "Set-Cookie" contains "recall_session=" diff --git a/spec/acceptance/auth.hurl b/spec/acceptance/auth.hurl index 6d6a5c6..454e5b8 100644 --- a/spec/acceptance/auth.hurl +++ b/spec/acceptance/auth.hurl @@ -40,6 +40,18 @@ header "Set-Cookie" contains "recall_session=" header "Set-Cookie" contains "HttpOnly" header "Set-Cookie" contains "SameSite=lax" +# Logging out invalidates the session and clears the browser cookie. +POST {{base}}/auth/logout +HTTP 204 +[Asserts] +header "Set-Cookie" contains "recall_session=" +header "Set-Cookie" contains "expires=" +header "Set-Cookie" contains "HttpOnly" +header "Set-Cookie" contains "SameSite=lax" + +GET {{base}}/auth/me +HTTP 401 + # A wrong password never creates a session. POST {{base}}/auth/login Content-Type: application/json diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index 94c4dc0..fbb4023 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -91,6 +91,18 @@ paths: application/json: schema: { $ref: "#/components/schemas/Error" } + /auth/logout: + post: + tags: [auth] + summary: End the current session + responses: + "204": + description: Session ended and browser cookie cleared + headers: + Set-Cookie: + description: An expired HttpOnly session cookie. + schema: { type: string } + /notes: get: tags: [notes] From 60f578fb347dfce1f98df3832c1161950ea3590e Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:05:19 +0200 Subject: [PATCH 014/102] =?UTF-8?q?chore(auth):=20=D1=80=D0=B0=D0=B7=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=20cookie=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?CORS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Justfile | 2 +- app/backend/public/index.php | 21 +++++++++++++++++---- compose.yaml | 2 ++ outdatty.lock | 8 ++++---- spec/acceptance/cors.hurl | 20 ++++++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 spec/acceptance/cors.hurl diff --git a/Justfile b/Justfile index ebab200..653f82c 100644 --- a/Justfile +++ b/Justfile @@ -38,7 +38,7 @@ down: # API-приёмка — приложение уже должно быть запущено (см. `just up`). acceptance: - hurl --variable base={{ base }} --variable username=acceptance-$(date +%s%N) --test spec/acceptance/*.hurl + hurl --variable base={{ base }} --variable frontend={{ front }} --variable username=acceptance-$(date +%s%N) --test spec/acceptance/*.hurl # Браузерные сценарии — приложение уже должно быть запущено (см. `just up`). e2e: diff --git a/app/backend/public/index.php b/app/backend/public/index.php index f4fd321..4c5d757 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -59,10 +59,23 @@ $app->addBodyParsingMiddleware(); // CORS — фронтенд-дев-сервер ходит к нам с другого источника. -$app->add(static fn(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface => $handler->handle($request) - ->withHeader('Access-Control-Allow-Origin', '*') - ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') - ->withHeader('Access-Control-Allow-Headers', 'Content-Type')); +$configuredOrigin = getenv('RECALL_FRONTEND_ORIGIN'); +$frontendOrigin = is_string($configuredOrigin) && $configuredOrigin !== '' + ? $configuredOrigin + : 'http://localhost:5173'; +$app->add(static function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($frontendOrigin): ResponseInterface { + $response = $handler->handle($request); + if ($request->getHeaderLine('Origin') !== $frontendOrigin) { + return $response; + } + + return $response + ->withHeader('Access-Control-Allow-Origin', $frontendOrigin) + ->withHeader('Access-Control-Allow-Credentials', 'true') + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + ->withHeader('Access-Control-Allow-Headers', 'Content-Type') + ->withHeader('Vary', 'Origin'); +}); // Метрики: время запроса и число запросов к БД (наблюдение, не гейт). $app->add(static function (ServerRequestInterface $request, RequestHandlerInterface $handler) use ($queries): ResponseInterface { diff --git a/compose.yaml b/compose.yaml index 4b6b7a6..1c181f9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,8 @@ services: environment: # Ephemeral DB inside the container: a fresh, seeded database on every `up`. RECALL_DB: /tmp/recall.sqlite + # Credentialed CORS accepts only the browser frontend's origin. + RECALL_FRONTEND_ORIGIN: ${RECALL_FRONTEND_ORIGIN:-http://localhost:${RECALL_FRONTEND_PORT}} volumes: - ./app/backend:/app - backend_vendor:/app/vendor diff --git a/outdatty.lock b/outdatty.lock index 34f44b4..201f26c 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -10,13 +10,13 @@ groups: backend-stack: source: app/backend/composer.json: affae8dc08e919c50426844bbdd6b4ab76797d4e7957e210c445ff4a670ccdc0 - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + compose.yaml: 2800c785d79ecbdc534aa9a69d1111a1829c5c035d00bd3e466322f419cd244f dependents: www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d commands: source: - Justfile: 66f6b078f9d6b26ab2d5c72107137ef331145c544b63fda5b4d20a0f8d8b1646 + Justfile: 8dceb0c1f7c43df7c8f489d065d1f1eb1b346adf1cd7464b384c4b4029f9182c dependents: www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d @@ -41,8 +41,8 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d repo-layout: source: - Justfile: 66f6b078f9d6b26ab2d5c72107137ef331145c544b63fda5b4d20a0f8d8b1646 - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + Justfile: 8dceb0c1f7c43df7c8f489d065d1f1eb1b346adf1cd7464b384c4b4029f9182c + compose.yaml: 2800c785d79ecbdc534aa9a69d1111a1829c5c035d00bd3e466322f419cd244f devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d diff --git a/spec/acceptance/cors.hurl b/spec/acceptance/cors.hurl new file mode 100644 index 0000000..d1ad328 --- /dev/null +++ b/spec/acceptance/cors.hurl @@ -0,0 +1,20 @@ +# Browser cookies require an explicit, trusted frontend origin. + +OPTIONS {{base}}/auth/me +Origin: {{frontend}} +Access-Control-Request-Method: GET +HTTP 204 +[Asserts] +header "Access-Control-Allow-Origin" == "{{frontend}}" +header "Access-Control-Allow-Credentials" == "true" +header "Access-Control-Allow-Methods" contains "GET" +header "Vary" contains "Origin" + +# Other origins do not receive CORS permission. +OPTIONS {{base}}/auth/me +Origin: https://untrusted.example +Access-Control-Request-Method: GET +HTTP 204 +[Asserts] +header "Access-Control-Allow-Origin" not exists +header "Access-Control-Allow-Credentials" not exists From f169d278bd07a604e331e902dc88c5d8cc4c2e6e Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:07:46 +0200 Subject: [PATCH 015/102] =?UTF-8?q?feat(users):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=B4=D0=BB=D0=B5=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/src/Domain/Card.php | 2 + app/backend/src/Domain/Note.php | 2 + app/backend/src/Domain/Review.php | 2 + .../src/Infrastructure/Persistence/Orm.php | 29 ++++++++++-- app/backend/tests/OwnershipSchemaTest.php | 44 +++++++++++++++++++ outdatty.lock | 4 +- 6 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 app/backend/tests/OwnershipSchemaTest.php diff --git a/app/backend/src/Domain/Card.php b/app/backend/src/Domain/Card.php index 7b516ce..1c6bb16 100644 --- a/app/backend/src/Domain/Card.php +++ b/app/backend/src/Domain/Card.php @@ -12,6 +12,7 @@ use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\ReviewId; +use Recall\Domain\ValueObject\UserId; /** Карточка с текущим расписанием повторения. */ class Card @@ -24,6 +25,7 @@ public function __construct( public Ease $ease, public Interval $interval, public Day $due, + public ?UserId $userId = null, ) {} public static function create(NoteId $noteId, CardText $front, CardText $back, DateTimeImmutable $now): self diff --git a/app/backend/src/Domain/Note.php b/app/backend/src/Domain/Note.php index d242d71..7e55507 100644 --- a/app/backend/src/Domain/Note.php +++ b/app/backend/src/Domain/Note.php @@ -9,6 +9,7 @@ use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; +use Recall\Domain\ValueObject\UserId; /** Заметка: заголовок, текст, теги и ссылки на другие заметки. */ class Note @@ -20,6 +21,7 @@ public function __construct( public TagList $tags, public NoteIdList $links, public DateTimeImmutable $updatedAt, + public ?UserId $userId = null, ) {} public static function create( diff --git a/app/backend/src/Domain/Review.php b/app/backend/src/Domain/Review.php index afa9a74..e87e143 100644 --- a/app/backend/src/Domain/Review.php +++ b/app/backend/src/Domain/Review.php @@ -10,6 +10,7 @@ use Recall\Domain\ValueObject\Ease; use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\ReviewId; +use Recall\Domain\ValueObject\UserId; /** Зафиксированная оценка карточки и полученное расписание. */ class Review @@ -21,6 +22,7 @@ public function __construct( public Interval $interval, public Ease $ease, public Day $nextDue, + public ?UserId $userId = null, ) {} public function createdAt(): DateTimeImmutable diff --git a/app/backend/src/Infrastructure/Persistence/Orm.php b/app/backend/src/Infrastructure/Persistence/Orm.php index 047b17b..ce8aad2 100644 --- a/app/backend/src/Infrastructure/Persistence/Orm.php +++ b/app/backend/src/Infrastructure/Persistence/Orm.php @@ -103,7 +103,8 @@ private static function migrate(DatabaseInterface $db): void body TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', links TEXT NOT NULL DEFAULT '[]', - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) )", ); $db->execute( @@ -114,7 +115,8 @@ private static function migrate(DatabaseInterface $db): void back TEXT NOT NULL, ease REAL NOT NULL, interval INTEGER NOT NULL, - due TEXT NOT NULL + due TEXT NOT NULL, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) )", ); $db->execute( @@ -124,9 +126,24 @@ private static function migrate(DatabaseInterface $db): void grade TEXT NOT NULL, interval INTEGER NOT NULL, ease REAL NOT NULL, - next_due TEXT NOT NULL + next_due TEXT NOT NULL, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) )", ); + self::addUserOwnershipColumns($db); + } + + /** Добавляет ownership-поля без пересборки уже выданной SQLite-базы. */ + private static function addUserOwnershipColumns(DatabaseInterface $db): void + { + foreach (['notes', 'cards', 'reviews'] as $table) { + if (!$db->table($table)->hasColumn('user_id')) { + $db->execute( + "ALTER TABLE $table ADD COLUMN user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36)", + ); + } + $db->execute("CREATE INDEX IF NOT EXISTS {$table}_user_id_idx ON $table (user_id)"); + } } /** @return array> */ @@ -148,6 +165,7 @@ private static function map(): array 'tags' => 'tags', 'links' => 'links', 'updatedAt' => 'updated_at', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => NoteId::class, @@ -155,6 +173,7 @@ private static function map(): array 'tags' => TagList::class, 'links' => NoteIdList::class, 'updatedAt' => 'datetime', + 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], SchemaInterface::RELATIONS => [], @@ -173,6 +192,7 @@ private static function map(): array 'ease' => 'ease', 'interval' => 'interval', 'due' => 'due', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => CardId::class, @@ -182,6 +202,7 @@ private static function map(): array 'ease' => Ease::class, 'interval' => Interval::class, 'due' => Day::class, + 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], SchemaInterface::RELATIONS => [], @@ -199,6 +220,7 @@ private static function map(): array 'interval' => 'interval', 'ease' => 'ease', 'nextDue' => 'next_due', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => ReviewId::class, @@ -207,6 +229,7 @@ private static function map(): array 'interval' => Interval::class, 'ease' => Ease::class, 'nextDue' => Day::class, + 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], SchemaInterface::RELATIONS => [], diff --git a/app/backend/tests/OwnershipSchemaTest.php b/app/backend/tests/OwnershipSchemaTest.php new file mode 100644 index 0000000..a2f72ac --- /dev/null +++ b/app/backend/tests/OwnershipSchemaTest.php @@ -0,0 +1,44 @@ +createLegacySchema($path); + $database = Orm::boot($path)->dbal->database('default'); + + foreach (['notes', 'cards', 'reviews'] as $table) { + $schema = $database->table($table); + Assert::true($schema->hasColumn('user_id')); + Assert::true($schema->hasIndex(['user_id'])); + } + } finally { + unlink($path); + } + } + + private function createLegacySchema(string $path): void + { + $database = new PDO('sqlite:' . $path); + $database->exec('CREATE TABLE notes (id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, tags TEXT NOT NULL, links TEXT NOT NULL, updated_at TEXT NOT NULL)'); + $database->exec('CREATE TABLE cards (id TEXT PRIMARY KEY, note_id TEXT NOT NULL, front TEXT NOT NULL, back TEXT NOT NULL, ease REAL NOT NULL, interval INTEGER NOT NULL, due TEXT NOT NULL)'); + $database->exec('CREATE TABLE reviews (id TEXT PRIMARY KEY, card_id TEXT NOT NULL, grade TEXT NOT NULL, interval INTEGER NOT NULL, ease REAL NOT NULL, next_due TEXT NOT NULL)'); + } +} diff --git a/outdatty.lock b/outdatty.lock index 201f26c..e44068d 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -22,9 +22,9 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d domain-model: source: - app/backend/src/Domain/Card.php: e0677882247a9f8df2aa5d221e0993eb5fbf549e437a66cecacc13ed19fdcf82 + app/backend/src/Domain/Card.php: cef3fa7357313b74bb211a757dfefad1da4014d033807263946358e7adf405e5 app/backend/src/Domain/Grade.php: ef3238e498d5c52d921ac4c953034db833f085d64cbc170932d5b96eeb95f68a - app/backend/src/Domain/Note.php: 7fe1af898b3fb83f065e18b588834a5cb4a5821d4066a1d33d41d5a93fa34d48 + app/backend/src/Domain/Note.php: 314c584e47a6eccd036cb4fd181b50873bc476792430e8222bc5d308ccb38c20 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 From 812a9dc3da4fbff4dc87112d8f3915021f3015fa Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:13:19 +0200 Subject: [PATCH 016/102] =?UTF-8?q?feat(auth):=20=D0=B0=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Justfile | 2 +- app/backend/public/index.php | 11 +++ .../src/Http/AuthenticationMiddleware.php | 74 +++++++++++++++++++ .../src/Http/Controller/AuthController.php | 15 +--- .../src/Http/Controller/HealthController.php | 28 +++++++ outdatty.lock | 6 +- spec/acceptance/auth-middleware.hurl | 13 ++++ spec/acceptance/cards.hurl | 8 ++ spec/acceptance/health.hurl | 7 ++ spec/acceptance/notes.hurl | 8 ++ spec/acceptance/reviews.hurl | 8 ++ spec/acceptance/stats.hurl | 8 ++ spec/api/openapi.yaml | 24 ++++++ 13 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 app/backend/src/Http/AuthenticationMiddleware.php create mode 100644 app/backend/src/Http/Controller/HealthController.php create mode 100644 spec/acceptance/auth-middleware.hurl create mode 100644 spec/acceptance/health.hurl diff --git a/Justfile b/Justfile index 653f82c..9e47cdc 100644 --- a/Justfile +++ b/Justfile @@ -28,7 +28,7 @@ dev: _env up: _env {{ compose }} up -d @echo "ожидание бэкенда {{ base }}" - @for i in $(seq 1 90); do curl -sf {{ base }}/stats >/dev/null 2>&1 && break || sleep 1; done + @for i in $(seq 1 90); do curl -sf {{ base }}/health >/dev/null 2>&1 && break || sleep 1; done @echo "ожидание фронтенда {{ front }}" @for i in $(seq 1 90); do curl -sf {{ front }} >/dev/null 2>&1 && break || sleep 1; done diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 4c5d757..bc6e30c 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -6,8 +6,10 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerAwareInterface; +use Recall\Http\AuthenticationMiddleware; use Recall\Http\Controller\AuthController; use Recall\Http\Controller\CardsController; +use Recall\Http\Controller\HealthController; use Recall\Http\Controller\NotesController; use Recall\Http\Controller\ReviewsController; use Recall\Http\Controller\StatsController; @@ -54,9 +56,17 @@ $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); $stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); $auth = new AuthController($userRepo, $sessionRepo, new SessionCookie(), $now); +$health = new HealthController($boot->dbal->database('default')); $app = AppFactory::create(); $app->addBodyParsingMiddleware(); +$app->add(new AuthenticationMiddleware( + new SessionCookie(), + $sessionRepo, + $userRepo, + $now, + $app->getResponseFactory(), +)); // CORS — фронтенд-дев-сервер ходит к нам с другого источника. $configuredOrigin = getenv('RECALL_FRONTEND_ORIGIN'); @@ -102,6 +112,7 @@ $app->post('/reviews/{id}', $reviews->grade(...)); $app->get('/stats', $stats->index(...)); +$app->get('/health', $health->show(...)); $app->post('/auth/register', $auth->register(...)); $app->post('/auth/login', $auth->login(...)); diff --git a/app/backend/src/Http/AuthenticationMiddleware.php b/app/backend/src/Http/AuthenticationMiddleware.php new file mode 100644 index 0000000..ce6263f --- /dev/null +++ b/app/backend/src/Http/AuthenticationMiddleware.php @@ -0,0 +1,74 @@ +requiresAuthentication($request)) { + return $handler->handle($request); + } + + $token = $this->cookie->token($request); + if ($token === null) { + return $this->unauthorized(); + } + + $session = $this->sessions->findByToken($token, $this->now); + if ($session === null) { + return $this->unauthorized(); + } + + $user = $this->users->find($session->userId); + if ($user === null) { + return $this->unauthorized(); + } + + return $handler->handle($request->withAttribute(self::USER_ATTRIBUTE, $user)); + } + + private function requiresAuthentication(ServerRequestInterface $request): bool + { + if ($request->getMethod() === 'OPTIONS') { + return false; + } + + $path = $request->getUri()->getPath(); + if (in_array($path, ['/stats', '/auth/me'], true)) { + return true; + } + + return array_any( + ['/notes', '/cards', '/reviews'], + fn(string $prefix): bool => $path === $prefix || str_starts_with($path, "$prefix/"), + ); + } + + private function unauthorized(): ResponseInterface + { + return Json::error($this->responses->createResponse(), 'необходима авторизация', 401); + } +} diff --git a/app/backend/src/Http/Controller/AuthController.php b/app/backend/src/Http/Controller/AuthController.php index 18fcb33..9e856ae 100644 --- a/app/backend/src/Http/Controller/AuthController.php +++ b/app/backend/src/Http/Controller/AuthController.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\Session; use Recall\Domain\User; +use Recall\Http\AuthenticationMiddleware; use Recall\Http\Input\LoginInput; use Recall\Http\Input\RegistrationInput; use Recall\Http\Json; @@ -56,18 +57,8 @@ public function login(Request $request, Response $response): Response public function currentUser(Request $request, Response $response): Response { - $token = $this->cookie->token($request); - if ($token === null) { - return Json::error($response, 'необходима авторизация', 401); - } - - $session = $this->sessions->findByToken($token, $this->now); - if ($session === null) { - return Json::error($response, 'необходима авторизация', 401); - } - - $user = $this->users->find($session->userId); - if ($user === null) { + $user = $request->getAttribute(AuthenticationMiddleware::USER_ATTRIBUTE); + if (!$user instanceof User) { return Json::error($response, 'необходима авторизация', 401); } diff --git a/app/backend/src/Http/Controller/HealthController.php b/app/backend/src/Http/Controller/HealthController.php new file mode 100644 index 0000000..d698a99 --- /dev/null +++ b/app/backend/src/Http/Controller/HealthController.php @@ -0,0 +1,28 @@ +database->query('SELECT 1')->fetchColumn(); + } catch (Throwable) { + return Json::error($response, 'database unavailable', 503); + } + + return Json::write($response, ['status' => 'ok', 'database' => 'ok']); + } +} diff --git a/outdatty.lock b/outdatty.lock index e44068d..db03300 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: 19ebf602052bcd98f9f8b982bbe689c71876fb8f75bd6879a1b747da16505aad + spec/api/openapi.yaml: a6f7bd08f624f6e78ff4ef8bfb4ebaa3a5954f799d0f4c3fede4d728e16d9a42 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 @@ -16,7 +16,7 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d commands: source: - Justfile: 8dceb0c1f7c43df7c8f489d065d1f1eb1b346adf1cd7464b384c4b4029f9182c + Justfile: ffa4bea01dcd958845f65e7eab80a0704d1ae1c5ce7608050f678968ded6ac5b dependents: www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d @@ -41,7 +41,7 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d repo-layout: source: - Justfile: 8dceb0c1f7c43df7c8f489d065d1f1eb1b346adf1cd7464b384c4b4029f9182c + Justfile: ffa4bea01dcd958845f65e7eab80a0704d1ae1c5ce7608050f678968ded6ac5b compose.yaml: 2800c785d79ecbdc534aa9a69d1111a1829c5c035d00bd3e466322f419cd244f devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 dependents: diff --git a/spec/acceptance/auth-middleware.hurl b/spec/acceptance/auth-middleware.hurl new file mode 100644 index 0000000..e2faeb0 --- /dev/null +++ b/spec/acceptance/auth-middleware.hurl @@ -0,0 +1,13 @@ +# Data endpoints require a current session. + +GET {{base}}/notes +HTTP 401 + +GET {{base}}/cards +HTTP 401 + +GET {{base}}/reviews/queue +HTTP 401 + +GET {{base}}/stats +HTTP 401 diff --git a/spec/acceptance/cards.hurl b/spec/acceptance/cards.hurl index 0cc55e8..8b56670 100644 --- a/spec/acceptance/cards.hurl +++ b/spec/acceptance/cards.hurl @@ -1,5 +1,13 @@ # Cards: created from a note, then read and listed. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-c", + "password": "correct-horse-battery-staple" +} +HTTP 201 + # A card needs a note. POST {{base}}/notes Content-Type: application/json diff --git a/spec/acceptance/health.hurl b/spec/acceptance/health.hurl new file mode 100644 index 0000000..2043075 --- /dev/null +++ b/spec/acceptance/health.hurl @@ -0,0 +1,7 @@ +# Health is public and includes a database probe. + +GET {{base}}/health +HTTP 200 +[Asserts] +jsonpath "$.status" == "ok" +jsonpath "$.database" == "ok" diff --git a/spec/acceptance/notes.hurl b/spec/acceptance/notes.hurl index 3c85689..c2feb31 100644 --- a/spec/acceptance/notes.hurl +++ b/spec/acceptance/notes.hurl @@ -1,6 +1,14 @@ # Notes: create, read, update, filter by tag, delete. # Loose by design — asserts observable behaviour, not internal representation. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-n", + "password": "correct-horse-battery-staple" +} +HTTP 201 + # Create a note. POST {{base}}/notes Content-Type: application/json diff --git a/spec/acceptance/reviews.hurl b/spec/acceptance/reviews.hurl index a04edf2..0b5996c 100644 --- a/spec/acceptance/reviews.hurl +++ b/spec/acceptance/reviews.hurl @@ -1,6 +1,14 @@ # Reviews: a freshly created card is due now, can be graded, and gets rescheduled. # Intervals are intentionally not pinned here — only that grading moves a card forward. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-r", + "password": "correct-horse-battery-staple" +} +HTTP 201 + POST {{base}}/notes Content-Type: application/json { diff --git a/spec/acceptance/stats.hurl b/spec/acceptance/stats.hurl index f36d332..e17d718 100644 --- a/spec/acceptance/stats.hurl +++ b/spec/acceptance/stats.hurl @@ -1,6 +1,14 @@ # Stats: shape and basic monotonicity only. # The exact counting rules are part of the task, so they are not pinned here. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-s", + "password": "correct-horse-battery-staple" +} +HTTP 201 + GET {{base}}/stats HTTP 200 [Asserts] diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index fbb4023..ca7656d 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -17,12 +17,29 @@ servers: tags: - name: auth + - name: health - name: notes - name: cards - name: reviews - name: stats paths: + /health: + get: + tags: [health] + summary: Check API and database availability + responses: + "200": + description: API and database are available + content: + application/json: + schema: { $ref: "#/components/schemas/Health" } + "503": + description: Database is unavailable + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /auth/register: post: tags: [auth] @@ -293,6 +310,13 @@ components: schema: { $ref: "#/components/schemas/Error" } schemas: + Health: + type: object + required: [status, database] + properties: + status: { type: string, enum: [ok] } + database: { type: string, enum: [ok] } + User: type: object required: [id, username, created_at] From bb60a568408f6cb546ad64e7a78dbfc378d58823 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:20:54 +0200 Subject: [PATCH 017/102] =?UTF-8?q?feat(data):=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B8=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Http/Controller/CardsController.php | 23 +++++--- .../src/Http/Controller/NotesController.php | 25 +++++--- .../src/Http/Controller/ReviewsController.php | 10 ++-- .../src/Http/Controller/StatsController.php | 6 +- app/backend/src/Http/CurrentUser.php | 24 ++++++++ .../Persistence/CardRepository.php | 57 ++++++++++++++++--- .../Persistence/NoteRepository.php | 48 ++++++++++++++-- .../Persistence/ReviewRepository.php | 17 +++++- .../src/Infrastructure/Persistence/Seeder.php | 12 ++-- app/backend/tests/NoteRepositoryTest.php | 7 ++- app/backend/tests/OwnershipRepositoryTest.php | 54 ++++++++++++++++++ 11 files changed, 234 insertions(+), 49 deletions(-) create mode 100644 app/backend/src/Http/CurrentUser.php create mode 100644 app/backend/tests/OwnershipRepositoryTest.php diff --git a/app/backend/src/Http/Controller/CardsController.php b/app/backend/src/Http/Controller/CardsController.php index 5d9e57b..cb46138 100644 --- a/app/backend/src/Http/Controller/CardsController.php +++ b/app/backend/src/Http/Controller/CardsController.php @@ -11,6 +11,8 @@ use Recall\Domain\Card; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\NoteId; +use Recall\Domain\ValueObject\UserId; +use Recall\Http\CurrentUser; use Recall\Http\Input\CardInput; use Recall\Http\Json; use Recall\Http\Serializer; @@ -28,13 +30,16 @@ public function __construct( public function index(Request $request, Response $response): Response { - return Json::write($response, array_map($this->serializer->serialize(...), $this->cards->all())); + return Json::write( + $response, + array_map($this->serializer->serialize(...), $this->cards->all(CurrentUser::userId($request))), + ); } /** @param array $args */ public function show(Request $request, Response $response, array $args): Response { - $card = $this->lookup($args); + $card = $this->lookup(CurrentUser::userId($request), $args); return $card === null ? Json::error($response, 'card not found', 404) @@ -44,18 +49,19 @@ public function show(Request $request, Response $response, array $args): Respons public function create(Request $request, Response $response): Response { $input = CardInput::fromArray($this->body($request)); + $userId = CurrentUser::userId($request); try { $noteId = NoteId::fromString($input->noteId); } catch (InvalidArgumentException) { return Json::error($response, 'note not found', 404); } - if ($this->notes->find($noteId) === null) { + if ($this->notes->find($userId, $noteId) === null) { return Json::error($response, 'note not found', 404); } $card = Card::create($noteId, $input->front, $input->back, $this->now); - $this->cards->save($card); + $this->cards->save($userId, $card); return Json::write($response, $this->serializer->serialize($card), 201); } @@ -63,25 +69,26 @@ public function create(Request $request, Response $response): Response /** @param array $args */ public function delete(Request $request, Response $response, array $args): Response { - $card = $this->lookup($args); + $userId = CurrentUser::userId($request); + $card = $this->lookup($userId, $args); if ($card === null) { return Json::error($response, 'card not found', 404); } - $this->cards->delete($card); + $this->cards->delete($userId, $card); return $response->withStatus(204); } /** @param array $args */ - private function lookup(array $args): ?Card + private function lookup(UserId $userId, array $args): ?Card { $raw = $args['id'] ?? null; if (!is_string($raw)) { return null; } try { - return $this->cards->find(CardId::fromString($raw)); + return $this->cards->find($userId, CardId::fromString($raw)); } catch (InvalidArgumentException) { return null; } diff --git a/app/backend/src/Http/Controller/NotesController.php b/app/backend/src/Http/Controller/NotesController.php index 9e06f01..735ee31 100644 --- a/app/backend/src/Http/Controller/NotesController.php +++ b/app/backend/src/Http/Controller/NotesController.php @@ -11,6 +11,8 @@ use Recall\Domain\Note; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\Tag; +use Recall\Domain\ValueObject\UserId; +use Recall\Http\CurrentUser; use Recall\Http\Input\NoteInput; use Recall\Http\Json; use Recall\Http\Serializer; @@ -29,13 +31,16 @@ public function index(Request $request, Response $response): Response $tagParam = $request->getQueryParams()['tag'] ?? null; $tag = is_string($tagParam) && $tagParam !== '' ? Tag::fromString($tagParam) : null; - return Json::write($response, array_map($this->serializer->serialize(...), $this->notes->all($tag))); + return Json::write( + $response, + array_map($this->serializer->serialize(...), $this->notes->all(CurrentUser::userId($request), $tag)), + ); } /** @param array $args */ public function show(Request $request, Response $response, array $args): Response { - $note = $this->lookup($args); + $note = $this->lookup(CurrentUser::userId($request), $args); return $note === null ? Json::error($response, 'note not found', 404) @@ -46,7 +51,7 @@ public function create(Request $request, Response $response): Response { $input = NoteInput::fromArray($this->body($request)); $note = Note::create($input->title, $input->body, $input->tags, $input->links, $this->now); - $this->notes->save($note); + $this->notes->save(CurrentUser::userId($request), $note); return Json::write($response, $this->serializer->serialize($note), 201); } @@ -54,14 +59,15 @@ public function create(Request $request, Response $response): Response /** @param array $args */ public function update(Request $request, Response $response, array $args): Response { - $note = $this->lookup($args); + $userId = CurrentUser::userId($request); + $note = $this->lookup($userId, $args); if ($note === null) { return Json::error($response, 'note not found', 404); } $input = NoteInput::fromArray($this->body($request)); $note->revise($input->title, $input->body, $input->tags, $input->links, $this->now); - $this->notes->save($note); + $this->notes->save($userId, $note); return Json::write($response, $this->serializer->serialize($note)); } @@ -69,25 +75,26 @@ public function update(Request $request, Response $response, array $args): Respo /** @param array $args */ public function delete(Request $request, Response $response, array $args): Response { - $note = $this->lookup($args); + $userId = CurrentUser::userId($request); + $note = $this->lookup($userId, $args); if ($note === null) { return Json::error($response, 'note not found', 404); } - $this->notes->delete($note); + $this->notes->delete($userId, $note); return $response->withStatus(204); } /** @param array $args */ - private function lookup(array $args): ?Note + private function lookup(UserId $userId, array $args): ?Note { $raw = $args['id'] ?? null; if (!is_string($raw)) { return null; } try { - return $this->notes->find(NoteId::fromString($raw)); + return $this->notes->find($userId, NoteId::fromString($raw)); } catch (InvalidArgumentException) { return null; } diff --git a/app/backend/src/Http/Controller/ReviewsController.php b/app/backend/src/Http/Controller/ReviewsController.php index 6f09642..99eb589 100644 --- a/app/backend/src/Http/Controller/ReviewsController.php +++ b/app/backend/src/Http/Controller/ReviewsController.php @@ -11,6 +11,7 @@ use Recall\Domain\Grade; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\Day; +use Recall\Http\CurrentUser; use Recall\Http\Json; use Recall\Http\Serializer; use Recall\Infrastructure\Persistence\CardRepository; @@ -28,7 +29,7 @@ public function __construct( /** Карточки, которые пора повторить. */ public function queue(Request $request, Response $response): Response { - $due = $this->cards->dueOn(Day::today($this->now)); + $due = $this->cards->dueOn(CurrentUser::userId($request), Day::today($this->now)); return Json::write($response, array_map($this->serializer->serialize(...), $due)); } @@ -41,10 +42,11 @@ public function queue(Request $request, Response $response): Response public function grade(Request $request, Response $response, array $args): Response { $raw = $args['id'] ?? null; + $userId = CurrentUser::userId($request); $card = null; if (is_string($raw)) { try { - $card = $this->cards->find(CardId::fromString($raw)); + $card = $this->cards->find($userId, CardId::fromString($raw)); } catch (InvalidArgumentException) { $card = null; } @@ -67,8 +69,8 @@ public function grade(Request $request, Response $response, array $args): Respon $review = $card->grade($grade, $this->now); - $this->cards->save($card); - $this->reviews->save($review); + $this->cards->save($userId, $card); + $this->reviews->save($userId, $review); return Json::write($response, $this->serializer->serialize($review), 201); } diff --git a/app/backend/src/Http/Controller/StatsController.php b/app/backend/src/Http/Controller/StatsController.php index 44965e2..8bfc931 100644 --- a/app/backend/src/Http/Controller/StatsController.php +++ b/app/backend/src/Http/Controller/StatsController.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\Stats; use Recall\Domain\ValueObject\Day; +use Recall\Http\CurrentUser; use Recall\Http\Json; use Recall\Http\Serializer; use Recall\Infrastructure\Persistence\CardRepository; @@ -25,11 +26,12 @@ public function __construct( public function index(Request $request, Response $response): Response { + $userId = CurrentUser::userId($request); $today = Day::today($this->now); $weekEnd = new Day($this->now->modify('+6 days')->format('Y-m-d')); $dueToday = 0; $dueWeek = 0; - foreach ($this->cards->all() as $card) { + foreach ($this->cards->all($userId) as $card) { if ($card->isDue($today)) { ++$dueToday; } @@ -39,7 +41,7 @@ public function index(Request $request, Response $response): Response } $reviewDays = []; - foreach ($this->reviews->all() as $review) { + foreach ($this->reviews->all($userId) as $review) { $reviewDays[$review->createdAt()->format('Y-m-d')] = true; } $streak = 0; diff --git a/app/backend/src/Http/CurrentUser.php b/app/backend/src/Http/CurrentUser.php new file mode 100644 index 0000000..dc551cb --- /dev/null +++ b/app/backend/src/Http/CurrentUser.php @@ -0,0 +1,24 @@ +getAttribute(AuthenticationMiddleware::USER_ATTRIBUTE); + if (!$user instanceof User) { + throw new LogicException('пользователь отсутствует в контексте запроса'); + } + + return $user->id; + } +} diff --git a/app/backend/src/Infrastructure/Persistence/CardRepository.php b/app/backend/src/Infrastructure/Persistence/CardRepository.php index c0d33fd..c0cd556 100644 --- a/app/backend/src/Infrastructure/Persistence/CardRepository.php +++ b/app/backend/src/Infrastructure/Persistence/CardRepository.php @@ -7,9 +7,11 @@ use Cycle\ORM\EntityManager; use Cycle\ORM\ORMInterface; use Cycle\ORM\Select; +use LogicException; use Recall\Domain\Card; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\Day; +use Recall\Domain\ValueObject\UserId; /** Доступ к карточкам через Cycle ORM. */ final readonly class CardRepository @@ -17,22 +19,31 @@ public function __construct(private ORMInterface $orm) {} /** @return list */ - public function all(): array + public function all(UserId $userId): array { - return $this->collect((new Select($this->orm, Card::class))->orderBy('id')->fetchAll()); + return $this->collect( + (new Select($this->orm, Card::class))->where('userId', $userId->toString())->orderBy('id')->fetchAll(), + ); } /** @return list */ - public function dueOn(Day $day): array + public function dueOn(UserId $userId, Day $day): array { return $this->collect( - (new Select($this->orm, Card::class))->where('due', '<=', $day->value)->orderBy('id')->fetchAll(), + (new Select($this->orm, Card::class)) + ->where('userId', $userId->toString()) + ->where('due', '<=', $day->value) + ->orderBy('id') + ->fetchAll(), ); } - public function find(CardId $id): ?Card + public function find(UserId $userId, CardId $id): ?Card { - foreach ((new Select($this->orm, Card::class))->where('id', $id->toString())->fetchAll() as $card) { + foreach ((new Select($this->orm, Card::class)) + ->where('id', $id->toString()) + ->where('userId', $userId->toString()) + ->fetchAll() as $card) { if ($card instanceof Card) { return $card; } @@ -41,21 +52,49 @@ public function find(CardId $id): ?Card return null; } - public function count(): int + public function countAll(): int { return (new Select($this->orm, Card::class))->count(); } - public function save(Card $card): void + public function save(UserId $userId, Card $card): void { + $this->assignOwner($userId, $card); + (new EntityManager($this->orm))->persist($card)->run(); } - public function delete(Card $card): void + /** Сохраняет стартовые данные без владельца, чтобы они не стали данными случайного пользователя. */ + public function saveLegacy(Card $card): void { + (new EntityManager($this->orm))->persist($card)->run(); + } + + public function delete(UserId $userId, Card $card): void + { + $this->assertOwner($userId, $card); + (new EntityManager($this->orm))->delete($card)->run(); } + private function assignOwner(UserId $userId, Card $card): void + { + if ($card->userId === null) { + $card->userId = $userId; + + return; + } + + $this->assertOwner($userId, $card); + } + + private function assertOwner(UserId $userId, Card $card): void + { + if ($card->userId?->toString() !== $userId->toString()) { + throw new LogicException('карточка принадлежит другому пользователю'); + } + } + /** * @param iterable $rows * @return list diff --git a/app/backend/src/Infrastructure/Persistence/NoteRepository.php b/app/backend/src/Infrastructure/Persistence/NoteRepository.php index 673a063..1e11acb 100644 --- a/app/backend/src/Infrastructure/Persistence/NoteRepository.php +++ b/app/backend/src/Infrastructure/Persistence/NoteRepository.php @@ -7,9 +7,11 @@ use Cycle\ORM\EntityManager; use Cycle\ORM\ORMInterface; use Cycle\ORM\Select; +use LogicException; use Recall\Domain\Note; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\Tag; +use Recall\Domain\ValueObject\UserId; /** Доступ к заметкам через Cycle ORM. */ final readonly class NoteRepository @@ -17,10 +19,13 @@ public function __construct(private ORMInterface $orm) {} /** @return list */ - public function all(?Tag $tag): array + public function all(UserId $userId, ?Tag $tag): array { $notes = []; - foreach ((new Select($this->orm, Note::class))->orderBy('id')->fetchAll() as $note) { + foreach ((new Select($this->orm, Note::class)) + ->where('userId', $userId->toString()) + ->orderBy('id') + ->fetchAll() as $note) { if ($note instanceof Note && ($tag === null || $note->tags->contains($tag))) { $notes[] = $note; } @@ -29,9 +34,12 @@ public function all(?Tag $tag): array return $notes; } - public function find(NoteId $id): ?Note + public function find(UserId $userId, NoteId $id): ?Note { - foreach ((new Select($this->orm, Note::class))->where('id', $id->toString())->fetchAll() as $note) { + foreach ((new Select($this->orm, Note::class)) + ->where('id', $id->toString()) + ->where('userId', $userId->toString()) + ->fetchAll() as $note) { if ($note instanceof Note) { return $note; } @@ -40,13 +48,41 @@ public function find(NoteId $id): ?Note return null; } - public function save(Note $note): void + public function save(UserId $userId, Note $note): void + { + $this->assignOwner($userId, $note); + + (new EntityManager($this->orm))->persist($note)->run(); + } + + /** Сохраняет стартовые данные без владельца, чтобы они не стали данными случайного пользователя. */ + public function saveLegacy(Note $note): void { (new EntityManager($this->orm))->persist($note)->run(); } - public function delete(Note $note): void + public function delete(UserId $userId, Note $note): void { + $this->assertOwner($userId, $note); + (new EntityManager($this->orm))->delete($note)->run(); } + + private function assignOwner(UserId $userId, Note $note): void + { + if ($note->userId === null) { + $note->userId = $userId; + + return; + } + + $this->assertOwner($userId, $note); + } + + private function assertOwner(UserId $userId, Note $note): void + { + if ($note->userId?->toString() !== $userId->toString()) { + throw new LogicException('заметка принадлежит другому пользователю'); + } + } } diff --git a/app/backend/src/Infrastructure/Persistence/ReviewRepository.php b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php index dd55dcc..da76a13 100644 --- a/app/backend/src/Infrastructure/Persistence/ReviewRepository.php +++ b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php @@ -7,7 +7,9 @@ use Cycle\ORM\EntityManager; use Cycle\ORM\ORMInterface; use Cycle\ORM\Select; +use LogicException; use Recall\Domain\Review; +use Recall\Domain\ValueObject\UserId; /** Доступ к повторениям через Cycle ORM. */ final readonly class ReviewRepository @@ -15,10 +17,13 @@ public function __construct(private ORMInterface $orm) {} /** @return list */ - public function all(): array + public function all(UserId $userId): array { $reviews = []; - foreach ((new Select($this->orm, Review::class))->orderBy('id')->fetchAll() as $review) { + foreach ((new Select($this->orm, Review::class)) + ->where('userId', $userId->toString()) + ->orderBy('id') + ->fetchAll() as $review) { if ($review instanceof Review) { $reviews[] = $review; } @@ -27,8 +32,14 @@ public function all(): array return $reviews; } - public function save(Review $review): void + public function save(UserId $userId, Review $review): void { + if ($review->userId === null) { + $review->userId = $userId; + } elseif ($review->userId->toString() !== $userId->toString()) { + throw new LogicException('повторение принадлежит другому пользователю'); + } + (new EntityManager($this->orm))->persist($review)->run(); } } diff --git a/app/backend/src/Infrastructure/Persistence/Seeder.php b/app/backend/src/Infrastructure/Persistence/Seeder.php index cc18f30..cdfc365 100644 --- a/app/backend/src/Infrastructure/Persistence/Seeder.php +++ b/app/backend/src/Infrastructure/Persistence/Seeder.php @@ -24,7 +24,7 @@ public function __construct( public function seedIfEmpty(DateTimeImmutable $now): void { - if ($this->cards->count() > 0) { + if ($this->cards->countAll() > 0) { return; } @@ -42,18 +42,18 @@ public function seedIfEmpty(DateTimeImmutable $now): void new NoteIdList(), $now, ); - $this->notes->save($spacedRepetition); - $this->notes->save($rust); + $this->notes->saveLegacy($spacedRepetition); + $this->notes->saveLegacy($rust); // Две карточки на сегодня, одна — через три дня. $today = Day::today($now); $later = $today->plusDays(Interval::ofDays(3)); - $this->cards->save(Card::create($spacedRepetition->id, CardText::fromString('Что такое интервальное повторение?'), CardText::fromString('Повторение прямо перед забыванием.'), $now)); - $this->cards->save(Card::create($rust->id, CardText::fromString('Сколько владельцев у значения в Rust?'), CardText::fromString('Ровно один.'), $now)); + $this->cards->saveLegacy(Card::create($spacedRepetition->id, CardText::fromString('Что такое интервальное повторение?'), CardText::fromString('Повторение прямо перед забыванием.'), $now)); + $this->cards->saveLegacy(Card::create($rust->id, CardText::fromString('Сколько владельцев у значения в Rust?'), CardText::fromString('Ровно один.'), $now)); $future = Card::create($spacedRepetition->id, CardText::fromString('Почему интервалы помогают?'), CardText::fromString('Они борются с кривой забывания.'), $now); $future->due = $later; - $this->cards->save($future); + $this->cards->saveLegacy($future); } } diff --git a/app/backend/tests/NoteRepositoryTest.php b/app/backend/tests/NoteRepositoryTest.php index 097024e..3a66e2c 100644 --- a/app/backend/tests/NoteRepositoryTest.php +++ b/app/backend/tests/NoteRepositoryTest.php @@ -9,6 +9,7 @@ use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; +use Recall\Domain\ValueObject\UserId; use Recall\Infrastructure\Persistence\NoteRepository; use Recall\Infrastructure\Persistence\Orm; use Testo\Assert; @@ -25,6 +26,7 @@ final class NoteRepositoryTest public function createsAndReadsBackANote(): void { $repo = new NoteRepository(Orm::boot(':memory:')->orm); + $ownerId = UserId::generate(); $note = Note::create( Title::fromString('Working memory'), @@ -33,13 +35,14 @@ public function createsAndReadsBackANote(): void new NoteIdList(), new DateTimeImmutable(), ); - $repo->save($note); + $repo->save($ownerId, $note); - $found = $repo->find($note->id); + $found = $repo->find($ownerId, $note->id); Assert::notNull($found); Assert::same($found->title->value, 'Working memory'); // Теги хранятся канонично: в нижнем регистре и без повторов. Assert::same($found->tags->toStrings(), ['memory', 'cognition']); Assert::same($found->body, 'Holds a handful of items at once.'); + Assert::same($found->userId?->toString(), $ownerId->toString()); } } diff --git a/app/backend/tests/OwnershipRepositoryTest.php b/app/backend/tests/OwnershipRepositoryTest.php new file mode 100644 index 0000000..c1ab97c --- /dev/null +++ b/app/backend/tests/OwnershipRepositoryTest.php @@ -0,0 +1,54 @@ +orm; + $notes = new NoteRepository($orm); + $cards = new CardRepository($orm); + $reviews = new ReviewRepository($orm); + $owner = UserId::generate(); + $other = UserId::generate(); + $now = new DateTimeImmutable('2026-07-19T12:00:00+00:00'); + + $note = Note::create( + Title::fromString('Private note'), + '', + new TagList(), + new NoteIdList(), + $now, + ); + $notes->save($owner, $note); + $card = Card::create($note->id, CardText::fromString('Question'), CardText::fromString('Answer'), $now); + $cards->save($owner, $card); + $reviews->save($owner, $card->grade(Grade::Good, $now)); + + Assert::same($notes->all($other, null), []); + Assert::null($notes->find($other, $note->id)); + Assert::same($cards->all($other), []); + Assert::null($cards->find($other, $card->id)); + Assert::same($reviews->all($other), []); + } +} From 1c43e85318d0ec3edc9d6c57e173b511d43e6614 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:24:53 +0200 Subject: [PATCH 018/102] =?UTF-8?q?feat(auth):=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20?= =?UTF-8?q?=D0=BA=20=D1=87=D1=83=D0=B6=D0=B8=D0=BC=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Http/Controller/CardsController.php | 29 +++++++- .../src/Http/Controller/NotesController.php | 27 ++++++- .../src/Http/Controller/ReviewsController.php | 8 +- .../Persistence/CardRepository.php | 9 +++ .../Persistence/NoteRepository.php | 9 +++ app/backend/tests/OwnershipRepositoryTest.php | 4 + outdatty.lock | 2 +- spec/acceptance/auth-isolation.hurl | 74 +++++++++++++++++++ spec/api/openapi.yaml | 12 +++ 9 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 spec/acceptance/auth-isolation.hurl diff --git a/app/backend/src/Http/Controller/CardsController.php b/app/backend/src/Http/Controller/CardsController.php index cb46138..9898a31 100644 --- a/app/backend/src/Http/Controller/CardsController.php +++ b/app/backend/src/Http/Controller/CardsController.php @@ -39,10 +39,11 @@ public function index(Request $request, Response $response): Response /** @param array $args */ public function show(Request $request, Response $response, array $args): Response { - $card = $this->lookup(CurrentUser::userId($request), $args); + $userId = CurrentUser::userId($request); + $card = $this->lookup($userId, $args); return $card === null - ? Json::error($response, 'card not found', 404) + ? $this->notFoundOrForbidden($response, $userId, $args) : Json::write($response, $this->serializer->serialize($card)); } @@ -57,7 +58,9 @@ public function create(Request $request, Response $response): Response return Json::error($response, 'note not found', 404); } if ($this->notes->find($userId, $noteId) === null) { - return Json::error($response, 'note not found', 404); + return $this->notes->belongsToAnotherUser($userId, $noteId) + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'note not found', 404); } $card = Card::create($noteId, $input->front, $input->back, $this->now); @@ -72,7 +75,7 @@ public function delete(Request $request, Response $response, array $args): Respo $userId = CurrentUser::userId($request); $card = $this->lookup($userId, $args); if ($card === null) { - return Json::error($response, 'card not found', 404); + return $this->notFoundOrForbidden($response, $userId, $args); } $this->cards->delete($userId, $card); @@ -94,6 +97,24 @@ private function lookup(UserId $userId, array $args): ?Card } } + /** @param array $args */ + private function notFoundOrForbidden(Response $response, UserId $userId, array $args): Response + { + $raw = $args['id'] ?? null; + if (!is_string($raw)) { + return Json::error($response, 'card not found', 404); + } + try { + $otherUsersCard = $this->cards->belongsToAnotherUser($userId, CardId::fromString($raw)); + } catch (InvalidArgumentException) { + return Json::error($response, 'card not found', 404); + } + + return $otherUsersCard + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'card not found', 404); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/Controller/NotesController.php b/app/backend/src/Http/Controller/NotesController.php index 735ee31..548e911 100644 --- a/app/backend/src/Http/Controller/NotesController.php +++ b/app/backend/src/Http/Controller/NotesController.php @@ -40,10 +40,11 @@ public function index(Request $request, Response $response): Response /** @param array $args */ public function show(Request $request, Response $response, array $args): Response { - $note = $this->lookup(CurrentUser::userId($request), $args); + $userId = CurrentUser::userId($request); + $note = $this->lookup($userId, $args); return $note === null - ? Json::error($response, 'note not found', 404) + ? $this->notFoundOrForbidden($response, $userId, $args) : Json::write($response, $this->serializer->serialize($note)); } @@ -62,7 +63,7 @@ public function update(Request $request, Response $response, array $args): Respo $userId = CurrentUser::userId($request); $note = $this->lookup($userId, $args); if ($note === null) { - return Json::error($response, 'note not found', 404); + return $this->notFoundOrForbidden($response, $userId, $args); } $input = NoteInput::fromArray($this->body($request)); @@ -78,7 +79,7 @@ public function delete(Request $request, Response $response, array $args): Respo $userId = CurrentUser::userId($request); $note = $this->lookup($userId, $args); if ($note === null) { - return Json::error($response, 'note not found', 404); + return $this->notFoundOrForbidden($response, $userId, $args); } $this->notes->delete($userId, $note); @@ -100,6 +101,24 @@ private function lookup(UserId $userId, array $args): ?Note } } + /** @param array $args */ + private function notFoundOrForbidden(Response $response, UserId $userId, array $args): Response + { + $raw = $args['id'] ?? null; + if (!is_string($raw)) { + return Json::error($response, 'note not found', 404); + } + try { + $otherUsersNote = $this->notes->belongsToAnotherUser($userId, NoteId::fromString($raw)); + } catch (InvalidArgumentException) { + return Json::error($response, 'note not found', 404); + } + + return $otherUsersNote + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'note not found', 404); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/Controller/ReviewsController.php b/app/backend/src/Http/Controller/ReviewsController.php index 99eb589..504e1cc 100644 --- a/app/backend/src/Http/Controller/ReviewsController.php +++ b/app/backend/src/Http/Controller/ReviewsController.php @@ -43,15 +43,21 @@ public function grade(Request $request, Response $response, array $args): Respon { $raw = $args['id'] ?? null; $userId = CurrentUser::userId($request); + $cardId = null; $card = null; if (is_string($raw)) { try { - $card = $this->cards->find($userId, CardId::fromString($raw)); + $cardId = CardId::fromString($raw); + $card = $this->cards->find($userId, $cardId); } catch (InvalidArgumentException) { $card = null; } } if ($card === null) { + if ($cardId !== null && $this->cards->belongsToAnotherUser($userId, $cardId)) { + return Json::error($response, 'forbidden', 403); + } + return Json::error($response, 'card not found', 404); } diff --git a/app/backend/src/Infrastructure/Persistence/CardRepository.php b/app/backend/src/Infrastructure/Persistence/CardRepository.php index c0cd556..09c2d16 100644 --- a/app/backend/src/Infrastructure/Persistence/CardRepository.php +++ b/app/backend/src/Infrastructure/Persistence/CardRepository.php @@ -52,6 +52,15 @@ public function find(UserId $userId, CardId $id): ?Card return null; } + public function belongsToAnotherUser(UserId $userId, CardId $id): bool + { + foreach ((new Select($this->orm, Card::class))->where('id', $id->toString())->fetchAll() as $card) { + return $card instanceof Card && $card->userId?->toString() !== $userId->toString(); + } + + return false; + } + public function countAll(): int { return (new Select($this->orm, Card::class))->count(); diff --git a/app/backend/src/Infrastructure/Persistence/NoteRepository.php b/app/backend/src/Infrastructure/Persistence/NoteRepository.php index 1e11acb..3ae9248 100644 --- a/app/backend/src/Infrastructure/Persistence/NoteRepository.php +++ b/app/backend/src/Infrastructure/Persistence/NoteRepository.php @@ -48,6 +48,15 @@ public function find(UserId $userId, NoteId $id): ?Note return null; } + public function belongsToAnotherUser(UserId $userId, NoteId $id): bool + { + foreach ((new Select($this->orm, Note::class))->where('id', $id->toString())->fetchAll() as $note) { + return $note instanceof Note && $note->userId?->toString() !== $userId->toString(); + } + + return false; + } + public function save(UserId $userId, Note $note): void { $this->assignOwner($userId, $note); diff --git a/app/backend/tests/OwnershipRepositoryTest.php b/app/backend/tests/OwnershipRepositoryTest.php index c1ab97c..9a54314 100644 --- a/app/backend/tests/OwnershipRepositoryTest.php +++ b/app/backend/tests/OwnershipRepositoryTest.php @@ -45,8 +45,12 @@ public function readsOnlyTheCurrentUsersData(): void $cards->save($owner, $card); $reviews->save($owner, $card->grade(Grade::Good, $now)); + Assert::true($notes->belongsToAnotherUser($other, $note->id)); + Assert::false($notes->belongsToAnotherUser($owner, $note->id)); Assert::same($notes->all($other, null), []); Assert::null($notes->find($other, $note->id)); + Assert::true($cards->belongsToAnotherUser($other, $card->id)); + Assert::false($cards->belongsToAnotherUser($owner, $card->id)); Assert::same($cards->all($other), []); Assert::null($cards->find($other, $card->id)); Assert::same($reviews->all($other), []); diff --git a/outdatty.lock b/outdatty.lock index db03300..3109e2b 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,7 +3,7 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: a6f7bd08f624f6e78ff4ef8bfb4ebaa3a5954f799d0f4c3fede4d728e16d9a42 + spec/api/openapi.yaml: 045b342933aad0bc3beeeffad4210d6b7b068f6686dc1467f6e283ce431aaf75 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 diff --git a/spec/acceptance/auth-isolation.hurl b/spec/acceptance/auth-isolation.hurl new file mode 100644 index 0000000..e534d04 --- /dev/null +++ b/spec/acceptance/auth-isolation.hurl @@ -0,0 +1,74 @@ +# Resources of another account are explicitly forbidden. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-a", + "password": "correct-horse-battery-staple" +} +HTTP 201 + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Alice private note", + "body": "Only Alice can use it." +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" + +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "Private question", + "back": "Private answer" +} +HTTP 201 +[Captures] +card_id: jsonpath "$.id" + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-b", + "password": "correct-horse-battery-staple" +} +HTTP 201 + +GET {{base}}/notes/{{note_id}} +HTTP 403 + +PUT {{base}}/notes/{{note_id}} +Content-Type: application/json +{ + "title": "Bob cannot edit this", + "body": "" +} +HTTP 403 + +DELETE {{base}}/notes/{{note_id}} +HTTP 403 + +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "Bob cannot create this", + "back": "No" +} +HTTP 403 + +GET {{base}}/cards/{{card_id}} +HTTP 403 + +DELETE {{base}}/cards/{{card_id}} +HTTP 403 + +POST {{base}}/reviews/{{card_id}} +Content-Type: application/json +{ + "grade": "good" +} +HTTP 403 diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index ca7656d..72e41c9 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -166,6 +166,7 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Note" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } put: tags: [notes] @@ -181,6 +182,7 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Note" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } delete: @@ -188,6 +190,7 @@ paths: summary: Delete a note responses: "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /cards: @@ -216,6 +219,7 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Card" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } @@ -231,12 +235,14 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Card" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [cards] summary: Delete a card responses: "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /reviews/queue: @@ -275,6 +281,7 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Review" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/ValidationError" } @@ -298,6 +305,11 @@ components: schema: { type: string, format: uuid } responses: + Forbidden: + description: Resource belongs to another user + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } NotFound: description: Resource not found content: From 7c30340dbbedbe115fcfcec242a3927cae3f8f82 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:30:25 +0200 Subject: [PATCH 019/102] =?UTF-8?q?chore(deploy):=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B2=20doc?= =?UTF-8?q?ker=20volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.yaml | 6 ++++-- outdatty.lock | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 1c181f9..8a3f440 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,13 +22,14 @@ services: # Limits come from .env.example (single source; the guide renders them too). command: php -d memory_limit=${RECALL_PHP_MEMORY_LIMIT:-96M} -S 0.0.0.0:8080 -t public public/index.php environment: - # Ephemeral DB inside the container: a fresh, seeded database on every `up`. - RECALL_DB: /tmp/recall.sqlite + # Persistent SQLite database, retained when the backend container is recreated. + RECALL_DB: /data/recall.sqlite # Credentialed CORS accepts only the browser frontend's origin. RECALL_FRONTEND_ORIGIN: ${RECALL_FRONTEND_ORIGIN:-http://localhost:${RECALL_FRONTEND_PORT}} volumes: - ./app/backend:/app - backend_vendor:/app/vendor + - backend_data:/data ports: - "${RECALL_BACKEND_PORT}:8080" mem_limit: ${RECALL_CONTAINER_MEMORY_LIMIT:-256M} @@ -51,5 +52,6 @@ services: - backend volumes: + backend_data: backend_vendor: frontend_node_modules: diff --git a/outdatty.lock b/outdatty.lock index 3109e2b..00a3eb6 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -10,7 +10,7 @@ groups: backend-stack: source: app/backend/composer.json: affae8dc08e919c50426844bbdd6b4ab76797d4e7957e210c445ff4a670ccdc0 - compose.yaml: 2800c785d79ecbdc534aa9a69d1111a1829c5c035d00bd3e466322f419cd244f + compose.yaml: 9be8443048e2b2278dbf39f366417c5a11646cddaeb9da37e06185221f84cb6b dependents: www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d @@ -42,7 +42,7 @@ groups: repo-layout: source: Justfile: ffa4bea01dcd958845f65e7eab80a0704d1ae1c5ce7608050f678968ded6ac5b - compose.yaml: 2800c785d79ecbdc534aa9a69d1111a1829c5c035d00bd3e466322f419cd244f + compose.yaml: 9be8443048e2b2278dbf39f366417c5a11646cddaeb9da37e06185221f84cb6b devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d From ab272629964b916581312b5a8a7da432714e94bc Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:44:19 +0200 Subject: [PATCH 020/102] =?UTF-8?q?feat(frontend):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 136 ++++++++++++++++++++--------------- app/frontend/src/api.test.ts | 23 ++++++ app/frontend/src/api.ts | 17 ++++- app/frontend/src/main.ts | 38 +++++++++- e2e/tests/auth.ts | 13 ++++ e2e/tests/notes.spec.ts | 2 + e2e/tests/review.spec.ts | 12 ++++ 7 files changed, 181 insertions(+), 60 deletions(-) create mode 100644 e2e/tests/auth.ts diff --git a/app/frontend/index.html b/app/frontend/index.html index 7da4c93..a161482 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -11,71 +11,95 @@

    Recall

    -
    - сегодня: … - за неделю: … - серия: … -
    - -
    -

    Заметки

    -
    +
    +

    Создать аккаунт

    + - - - -
    - - - -
    -
      -
      - -
      -

      Новая карточка

      -
      - - - - +
      -
      -

      Очередь повторения

      -
      -
      + diff --git a/app/frontend/src/api.test.ts b/app/frontend/src/api.test.ts index 513ba43..3c3bfb1 100644 --- a/app/frontend/src/api.test.ts +++ b/app/frontend/src/api.test.ts @@ -90,5 +90,28 @@ describe("api client", () => { "application/json", ); expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + }); + + it("регистрирует пользователя через API", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse( + { id: "1", username: "reader_01", created_at: "2026-07-19" }, + 201, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.register({ + username: "reader_01", + password: "correct-horse-battery-staple", + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/auth/register"); + expect((fetchMock.mock.calls[0]?.[1] as RequestInit).credentials).toBe( + "include", + ); }); }); diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index bc0c0a9..e11bb6b 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -31,6 +31,12 @@ export interface Stats { streak: number; } +export interface User { + id: string; + username: string; + created_at: string; +} + export type Grade = "again" | "hard" | "good" | "easy"; // Ошибка обращения к API с понятным пользователю текстом. @@ -61,7 +67,11 @@ async function http(path: string, init?: RequestInit): Promise { headers.set("Content-Type", "application/json"); } try { - response = await fetch(`${BASE}${path}`, { ...init, headers }); + response = await fetch(`${BASE}${path}`, { + ...init, + credentials: "include", + headers, + }); } catch { throw new ApiError("Сервер недоступен", 0); } @@ -78,6 +88,11 @@ async function http(path: string, init?: RequestInit): Promise { } export const api = { + register: (input: { username: string; password: string }) => + http("/auth/register", { + method: "POST", + body: JSON.stringify(input), + }), listNotes: (tag?: string) => http(`/notes${tag ? `?tag=${encodeURIComponent(tag)}` : ""}`), createNote: (input: { title: string; body: string; tags: string[] }) => diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index 3331cab..b11998d 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -18,6 +18,8 @@ function field(form: FormData, name: string): string { } const statusBar = need("#status"); +const registration = need("#registration"); +const workspace = need("#workspace"); let activeTag: string | undefined; function showError(error: unknown): void { @@ -232,6 +234,39 @@ async function refreshAll(): Promise { await Promise.all([refreshStats(), refreshNotes(), refreshQueue()]); } +function showWorkspace(): void { + registration.hidden = true; + workspace.hidden = false; + statusBar.textContent = "Загрузка…"; + void refreshAll().then(clearStatus, showError); +} + +const registerForm = need("#register-form"); +if (!(registerForm instanceof HTMLFormElement)) { + throw new Error("нет формы регистрации"); +} +registerForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = registerForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const data = new FormData(registerForm); + + submit.disabled = true; + clearStatus(); + void api + .register({ + username: field(data, "username"), + password: field(data, "password"), + }) + .then(showWorkspace) + .catch(showError) + .finally(() => (submit.disabled = false)); +}); + const form = need("#note-form"); if (!(form instanceof HTMLFormElement)) { throw new Error("нет формы #note-form"); @@ -314,6 +349,3 @@ onClick(clearTagFilter as HTMLButtonElement, async () => { activeTag = undefined; await refreshNotes(); }); - -statusBar.textContent = "Загрузка…"; -void refreshAll().then(clearStatus, showError); diff --git a/e2e/tests/auth.ts b/e2e/tests/auth.ts new file mode 100644 index 0000000..ecd4727 --- /dev/null +++ b/e2e/tests/auth.ts @@ -0,0 +1,13 @@ +import { expect, type Page } from "@playwright/test"; + +export async function register(page: Page): Promise { + const username = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await page.fill("#register-form input[name='username']", username); + await page.fill( + "#register-form input[name='password']", + "correct-horse-battery-staple", + ); + await page.click("#register-form button[type='submit']"); + + await expect(page.locator("#workspace")).toBeVisible(); +} diff --git a/e2e/tests/notes.spec.ts b/e2e/tests/notes.spec.ts index 07c2dee..58a5efc 100644 --- a/e2e/tests/notes.spec.ts +++ b/e2e/tests/notes.spec.ts @@ -1,7 +1,9 @@ import { test, expect } from "@playwright/test"; +import { register } from "./auth"; test("a created note appears in the list", async ({ page }) => { await page.goto("/"); + await register(page); const title = `E2E note ${Date.now()}`; await page.fill("#note-form input[name='title']", title); diff --git a/e2e/tests/review.spec.ts b/e2e/tests/review.spec.ts index 62dbd1c..c12b53a 100644 --- a/e2e/tests/review.spec.ts +++ b/e2e/tests/review.spec.ts @@ -1,7 +1,19 @@ import { test, expect } from "@playwright/test"; +import { register } from "./auth"; test("grading a due card removes it from the queue", async ({ page }) => { await page.goto("/"); + await register(page); + + const title = `E2E review ${Date.now()}`; + await page.fill("#note-form input[name='title']", title); + await page.click("#note-form button[type='submit']"); + await expect(page.locator("[data-testid='note']", { hasText: title })).toBeVisible(); + + await page.selectOption("#card-form select[name='note_id']", { label: title }); + await page.fill("#card-form input[name='front']", "E2E question"); + await page.fill("#card-form textarea[name='back']", "E2E answer"); + await page.click("#card-form button[type='submit']"); const cards = page.locator("[data-testid='queue-card']"); await expect(cards.first()).toBeVisible(); From 5523e6c9cfc5214982416f9f34f1ad984ebe7cea Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:52:21 +0200 Subject: [PATCH 021/102] =?UTF-8?q?feat(frontend):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 24 ++++++++++++++++++ app/frontend/src/api.test.ts | 28 +++++++++++++++++++++ app/frontend/src/api.ts | 5 ++++ app/frontend/src/main.ts | 48 ++++++++++++++++++++++++++++++++++++ e2e/tests/auth.ts | 33 ++++++++++++++++++++++--- e2e/tests/login.spec.ts | 25 +++++++++++++++++++ 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 e2e/tests/login.spec.ts diff --git a/app/frontend/index.html b/app/frontend/index.html index a161482..5e014f8 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -31,6 +31,30 @@

      Создать аккаунт

      /> + +
      + +
      diff --git a/app/frontend/src/card-edit.ts b/app/frontend/src/card-edit.ts new file mode 100644 index 0000000..25b3a41 --- /dev/null +++ b/app/frontend/src/card-edit.ts @@ -0,0 +1,90 @@ +import { api, type Card } from "./api"; +import { createAnimatedDisclosure } from "./disclosure"; +import type { UiActions } from "./ui"; + +const CARD_EDIT_ANIMATION_DURATION = 220; + +export interface CardEditView { + form: HTMLFormElement; + open: () => void; +} + +interface CardEditCallbacks { + onClosed: () => Promise | void; + onSaved: () => Promise; +} + +function waitForCardEditClose(): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, CARD_EDIT_ANIMATION_DURATION); + }); +} + +export function createCardEdit( + card: Card, + actions: UiActions, + callbacks: CardEditCallbacks, +): CardEditView { + const form = document.createElement("form"); + form.className = "card-edit"; + form.dataset.testid = "edit-card-form"; + const setOpen = createAnimatedDisclosure(form, "is-open", false); + + const front = document.createElement("input"); + front.name = "front"; + front.value = card.front; + front.required = true; + front.setAttribute("aria-label", "Вопрос карточки"); + + const back = document.createElement("textarea"); + back.name = "back"; + back.value = card.back; + back.required = true; + back.setAttribute("aria-label", "Ответ карточки"); + + const actionsBox = document.createElement("div"); + actionsBox.className = "note-edit-actions"; + const save = document.createElement("button"); + save.type = "submit"; + save.textContent = "Сохранить"; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.textContent = "Отмена"; + actionsBox.append(save, cancel); + + function close(): void { + setOpen(false); + void waitForCardEditClose() + .then(callbacks.onClosed) + .catch(actions.showError); + } + + cancel.addEventListener("click", close); + form.append(front, back, actionsBox); + form.addEventListener("submit", (event) => { + event.preventDefault(); + save.disabled = true; + actions.clearStatus(); + void api + .updateCard(card.id, { + note_id: card.note_id, + front: front.value, + back: back.value, + }) + .then(() => { + setOpen(false); + return waitForCardEditClose(); + }) + .then(callbacks.onClosed) + .then(callbacks.onSaved) + .catch(actions.showError) + .finally(() => (save.disabled = false)); + }); + + return { + form, + open: () => { + setOpen(true); + }, + }; +} diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css new file mode 100644 index 0000000..4f56358 --- /dev/null +++ b/app/frontend/src/cards.css @@ -0,0 +1,169 @@ +.card { + display: grid; + grid-template-columns: minmax(0, 1fr); + margin-bottom: 1rem; + padding: 1.25rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.75rem; + background: var(--pico-background-color); +} + +.card:last-child { + margin-bottom: 0; +} + +.card-view, +.card-edit { + grid-column: 1; + grid-row: 1; +} + +.card-view { + min-width: 0; + transition: opacity 0.18s ease; +} + +.card-view.is-editing { + opacity: 0; + pointer-events: none; +} + +.card-review { + max-height: var(--card-review-height, 100rem); + overflow: hidden; + opacity: 1; + transition: + max-height 0.22s ease, + opacity 0.18s ease; +} + +.card.is-card-edit-mode .card-review { + max-height: 0; + opacity: 0; + pointer-events: none; +} + +.card-management { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + max-height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + margin-top 0.22s ease; +} + +.card.is-card-edit-mode .card-management { + max-height: 3rem; + margin-top: 0.5rem; + opacity: 1; + pointer-events: auto; +} + +.card-management button { + display: grid; + place-items: center; + width: 2.4rem; + height: 2.4rem; + min-height: 2.4rem; + margin: 0; + padding: 0; + border: 0; + border-radius: 50%; +} + +.card-management button svg, +.card-edit-toggle svg { + width: 1.15rem; + height: 1.15rem; +} + +.card-management button[data-testid="edit-card"] { + background: var(--app-highlight-background); + color: var(--app-highlight-color); +} + +.card-management button[data-testid="edit-card"]:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.card-management button[data-testid="delete-card"] { + background: var(--app-danger-background); + color: var(--app-danger-color); + transition: filter 0.15s ease; +} + +.card-management button[data-testid="delete-card"]:hover { + filter: brightness(0.9); +} + +.card-management button[data-testid="delete-card"]:active { + filter: brightness(0.86); +} + +.card-edit { + max-height: 0; + margin: 0; + overflow: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + opacity 0.18s ease; +} + +.card-edit.is-open { + max-height: var(--disclosure-height); + opacity: 1; +} + +.card-edit textarea { + min-height: calc(1.6rem + 1.5rem + 2px); + max-height: calc(6.4rem + 1.5rem + 2px); + padding: 0.75rem 1rem; + resize: vertical; +} + +.card .front { + font-weight: 700; +} + +.card .back { + margin-bottom: 0; + color: var(--pico-muted-color); +} + +.card .reveal-answer, +.card .grade-buttons button { + border: 0; + color: var(--pico-color); +} + +.card .reveal-answer:hover, +.card .grade-buttons button:hover { + border: 0; + background: var(--pico-primary-hover-background); + color: var(--pico-primary-inverse); +} + +.card .reveal-answer { + margin-top: 0.5rem; +} + +.card .grade-buttons { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; + margin-top: 1rem; +} + +@media (max-width: 576px) { + .card .grade-buttons { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index a08f1a2..5503808 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -1,6 +1,7 @@ import "./style.css"; import "./profile.css"; import "./workspace.css"; +import "./cards.css"; import "./notes.css"; import "./create.css"; import { setupAuth } from "./auth"; @@ -114,6 +115,7 @@ if (publicUsername !== null) { streak: need("[data-stat='streak']"), queueCount: need("#queue-count"), queue: need("#queue"), + toggleCardEdit: needButton("#toggle-card-edit"), cardForm, onCreated: closeCreateDialog, }, diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts index 1f11fc7..dfc72d2 100644 --- a/app/frontend/src/reviews.ts +++ b/app/frontend/src/reviews.ts @@ -1,4 +1,6 @@ import { api, type Card, type Grade } from "./api"; +import { createCardEdit, type CardEditView } from "./card-edit"; +import { pencilIcon, trashIcon } from "./icons"; import type { UiActions } from "./ui"; const GRADES: Grade[] = ["again", "hard", "good", "easy"]; @@ -9,6 +11,7 @@ export interface ReviewsElements { streak: HTMLElement; queueCount: HTMLElement; queue: HTMLElement; + toggleCardEdit: HTMLButtonElement; cardForm: HTMLFormElement; onCreated: () => void; } @@ -22,21 +25,36 @@ export function setupReviews( setupCardForm(refreshAll: () => Promise): void; } { let refreshAll = (): Promise => Promise.resolve(); + let cardEditMode = false; function cardItem(card: Card): HTMLDivElement { const wrap = document.createElement("div"); wrap.className = "card"; + wrap.classList.toggle("is-card-edit-mode", cardEditMode); wrap.dataset.testid = "queue-card"; wrap.dataset.id = card.id; + const view = document.createElement("div"); + view.className = "card-view"; + const front = document.createElement("p"); front.className = "front"; front.textContent = card.front; + const review = document.createElement("div"); + review.className = "card-review"; + const syncReviewHeight = (): void => { + review.style.setProperty( + "--card-review-height", + `${review.scrollHeight}px`, + ); + }; + const back = document.createElement("p"); back.className = "back"; back.textContent = card.back; back.hidden = true; + back.setAttribute("aria-hidden", "true"); const reveal = document.createElement("button"); reveal.type = "button"; @@ -45,7 +63,9 @@ export function setupReviews( reveal.dataset.testid = "reveal-answer"; reveal.addEventListener("click", () => { back.hidden = false; + back.setAttribute("aria-hidden", "false"); reveal.hidden = true; + window.requestAnimationFrame(syncReviewHeight); }); const buttons = document.createElement("div"); @@ -62,11 +82,73 @@ export function setupReviews( }); buttons.append(button); } + review.append(reveal, back, buttons); + + const management = document.createElement("div"); + management.className = "card-management"; + management.dataset.testid = "card-management"; + + const edit = document.createElement("button"); + edit.type = "button"; + edit.append(pencilIcon()); + edit.dataset.testid = "edit-card"; + edit.setAttribute("aria-label", `Изменить карточку: ${card.front}`); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.append(trashIcon()); + remove.dataset.testid = "delete-card"; + remove.setAttribute("aria-label", `Удалить карточку: ${card.front}`); + actions.onClick(remove, async () => { + await api.deleteCard(card.id); + await refreshAll(); + }); + management.append(edit, remove); + + view.append(front, review, management); + wrap.append(view); + + let editView: CardEditView; + edit.addEventListener("click", () => { + view.classList.add("is-editing"); + editView = createCardEdit(card, actions, { + onClosed: () => { + editView.form.remove(); + view.classList.remove("is-editing"); + }, + onSaved: refreshAll, + }); + wrap.append(editView.form); + editView.open(); + }); - wrap.append(front, reveal, back, buttons); + window.requestAnimationFrame(() => { + syncReviewHeight(); + }); return wrap; } + function setCardEditMode(enabled: boolean): void { + cardEditMode = enabled; + elements.toggleCardEdit.setAttribute("aria-expanded", String(enabled)); + elements.toggleCardEdit.setAttribute("aria-pressed", String(enabled)); + elements.queue.querySelectorAll(".card").forEach((card) => { + card.classList.toggle("is-card-edit-mode", enabled); + if (!enabled) { + return; + } + const back = card.querySelector(".back"); + const reveal = card.querySelector(".reveal-answer"); + back?.setAttribute("aria-hidden", "true"); + if (back) { + back.hidden = true; + } + if (reveal) { + reveal.hidden = false; + } + }); + } + async function refreshStats(): Promise { const stats = await api.stats(); elements.dueToday.textContent = `сегодня: ${stats.due_today}`; @@ -118,5 +200,9 @@ export function setupReviews( }); } + elements.toggleCardEdit.addEventListener("click", () => { + setCardEditMode(!cardEditMode); + }); + return { refreshStats, refreshQueue, setupCardForm }; } diff --git a/app/frontend/src/workspace.css b/app/frontend/src/workspace.css index 981491d..98ba1b3 100644 --- a/app/frontend/src/workspace.css +++ b/app/frontend/src/workspace.css @@ -136,51 +136,6 @@ color: var(--app-highlight-color); } -.card { - margin-bottom: 1rem; - padding: 1.25rem; - border: 1px solid var(--pico-muted-border-color); - border-radius: 0.75rem; - background: var(--pico-background-color); -} - -.card:last-child { - margin-bottom: 0; -} - -.card .front { - font-weight: 700; -} - -.card .back { - margin-bottom: 0; - color: var(--pico-muted-color); -} - -.card .reveal-answer, -.card .grade-buttons button { - border: 0; - color: var(--pico-color); -} - -.card .reveal-answer:hover, -.card .grade-buttons button:hover { - border: 0; - background: var(--pico-primary-hover-background); - color: var(--pico-primary-inverse); -} - -.card .reveal-answer { - margin-top: 0.5rem; -} - -.card .grade-buttons { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.5rem; - margin-top: 1rem; -} - [data-testid="queue-empty"] { margin: 0; color: var(--pico-muted-color); @@ -210,8 +165,4 @@ #workspace > section:not(#stats) { padding: 1.25rem; } - - .card .grade-buttons { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } diff --git a/e2e/tests/review.spec.ts b/e2e/tests/review.spec.ts index 757d640..1093db1 100644 --- a/e2e/tests/review.spec.ts +++ b/e2e/tests/review.spec.ts @@ -9,10 +9,14 @@ test("grading a due card removes it from the queue", async ({ page }) => { await openCreateForm(page, "note"); await page.fill("#note-form input[name='title']", title); await page.click("#note-form button[type='submit']"); - await expect(page.locator("[data-testid='note']", { hasText: title })).toBeVisible(); + await expect( + page.locator("[data-testid='note']", { hasText: title }), + ).toBeVisible(); await openCreateForm(page, "card"); - await page.selectOption("#card-form select[name='note_id']", { label: title }); + await page.selectOption("#card-form select[name='note_id']", { + label: title, + }); await page.fill("#card-form input[name='front']", "E2E question"); await page.fill("#card-form textarea[name='back']", "E2E answer"); await page.click("#card-form button[type='submit']"); @@ -24,7 +28,9 @@ test("grading a due card removes it from the queue", async ({ page }) => { const firstId = await cards.first().getAttribute("data-id"); const before = await cards.count(); - await page.locator("[data-testid='queue-card']").first() + await page + .locator("[data-testid='queue-card']") + .first() .locator("[data-testid='grade-good']") .click(); @@ -37,3 +43,52 @@ test("grading a due card removes it from the queue", async ({ page }) => { const after = await page.locator("[data-testid='queue-card']").count(); expect(after).toBeLessThan(before); }); + +test("cards can be edited and deleted from management mode", async ({ + page, +}) => { + await page.goto("/"); + await register(page); + + const title = `E2E card management ${Date.now()}`; + await openCreateForm(page, "note"); + await page.fill("#note-form input[name='title']", title); + await page.click("#note-form button[type='submit']"); + await expect( + page.locator("[data-testid='note']", { hasText: title }), + ).toBeVisible(); + + await openCreateForm(page, "card"); + await page.selectOption("#card-form select[name='note_id']", { + label: title, + }); + await page.fill("#card-form input[name='front']", "Management question"); + await page.fill("#card-form textarea[name='back']", "Management answer"); + await page.click("#card-form button[type='submit']"); + + const card = page.locator("[data-testid='queue-card']").first(); + await expect(card).toBeVisible(); + await card.locator("[data-testid='reveal-answer']").click(); + await page.click("#toggle-card-edit"); + await expect(page.locator("#toggle-card-edit")).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect(card.locator("[data-testid='card-management']")).toBeVisible(); + await expect(card.locator(".card-review")).toHaveCSS("opacity", "0"); + await expect(card.locator(".back")).toBeHidden(); + + await card.locator("[data-testid='edit-card']").click(); + const editForm = card.locator("[data-testid='edit-card-form']"); + await expect(editForm).toBeVisible(); + await editForm.locator("input[name='front']").fill("Updated question"); + await editForm.locator("textarea[name='back']").fill("Updated answer"); + await editForm.locator("button[type='submit']").click(); + await expect(page.locator("[data-testid='queue-card'] .front")).toHaveText( + "Updated question", + ); + + await page.locator("[data-testid='delete-card']").click(); + await expect(page.locator("[data-testid='queue-empty']")).toBeVisible(); + await expect(page.locator("#queue-count")).toHaveText("0"); +}); From bcdec6766bbdb378f05f47c964455db4d4afa194 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:54:16 +0200 Subject: [PATCH 057/102] =?UTF-8?q?fix(frontend):=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 19 +++++++++++++++++-- app/frontend/src/reviews.ts | 8 ++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index 4f56358..9f787f0 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -32,15 +32,22 @@ max-height: var(--card-review-height, 100rem); overflow: hidden; opacity: 1; + visibility: visible; transition: max-height 0.22s ease, - opacity 0.18s ease; + opacity 0.18s ease, + visibility 0s linear; } .card.is-card-edit-mode .card-review { max-height: 0; opacity: 0; pointer-events: none; + visibility: hidden; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + visibility 0s linear 0.22s; } .card-management { @@ -51,10 +58,12 @@ overflow: hidden; opacity: 0; pointer-events: none; + visibility: hidden; transition: max-height 0.22s ease, opacity 0.18s ease, - margin-top 0.22s ease; + margin-top 0.22s ease, + visibility 0s linear 0.22s; } .card.is-card-edit-mode .card-management { @@ -62,6 +71,12 @@ margin-top: 0.5rem; opacity: 1; pointer-events: auto; + visibility: visible; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + margin-top 0.22s ease, + visibility 0s linear; } .card-management button { diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts index dfc72d2..d24994f 100644 --- a/app/frontend/src/reviews.ts +++ b/app/frontend/src/reviews.ts @@ -43,6 +43,7 @@ export function setupReviews( const review = document.createElement("div"); review.className = "card-review"; + review.setAttribute("aria-hidden", String(cardEditMode)); const syncReviewHeight = (): void => { review.style.setProperty( "--card-review-height", @@ -87,6 +88,7 @@ export function setupReviews( const management = document.createElement("div"); management.className = "card-management"; management.dataset.testid = "card-management"; + management.setAttribute("aria-hidden", String(!cardEditMode)); const edit = document.createElement("button"); edit.type = "button"; @@ -134,6 +136,12 @@ export function setupReviews( elements.toggleCardEdit.setAttribute("aria-pressed", String(enabled)); elements.queue.querySelectorAll(".card").forEach((card) => { card.classList.toggle("is-card-edit-mode", enabled); + card + .querySelector(".card-review") + ?.setAttribute("aria-hidden", String(enabled)); + card + .querySelector(".card-management") + ?.setAttribute("aria-hidden", String(!enabled)); if (!enabled) { return; } From dd830554331dbd6d0dd5cbb6bd690c553902c9c8 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:56:41 +0200 Subject: [PATCH 058/102] =?UTF-8?q?test(frontend):=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D1=8B=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/api.test.ts | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/frontend/src/api.test.ts b/app/frontend/src/api.test.ts index 9c684f2..23b7e70 100644 --- a/app/frontend/src/api.test.ts +++ b/app/frontend/src/api.test.ts @@ -41,6 +41,47 @@ describe("api client", () => { await expect(api.deleteNote("1")).resolves.toBeUndefined(); }); + it("изменяет карточку через PUT", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: "card-1", + note_id: "note-1", + front: "new question", + back: "new answer", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.updateCard("card-1", { + note_id: "note-1", + front: "new question", + back: "new answer", + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/cards/card-1"); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("PUT"); + expect(init.body).toBe( + JSON.stringify({ + note_id: "note-1", + front: "new question", + back: "new answer", + }), + ); + }); + + it("удаляет карточку через DELETE", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.deleteCard("card-1")).resolves.toBeUndefined(); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/cards/card-1"); + expect((fetchMock.mock.calls[0]?.[1] as RequestInit).method).toBe("DELETE"); + }); + it("превращает ошибку сервера с полем error в ApiError", async () => { const fetchMock = vi .fn() From 52d2e5287c3bacd1427f4fd63161bcf20ed8f6ae Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:05:47 +0200 Subject: [PATCH 059/102] =?UTF-8?q?refactor(frontend):=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D0=BD=D0=BE=20=D0=B2=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D0=BC=D1=83=D1=8E=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 45 ++++++++---------- app/frontend/src/create.css | 91 ++++--------------------------------- app/frontend/src/create.ts | 76 ++++++------------------------- app/frontend/src/main.ts | 16 ++----- e2e/tests/notes.spec.ts | 2 +- 5 files changed, 45 insertions(+), 185 deletions(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index 9d6af68..c693db3 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -108,33 +108,6 @@

      Публичный профиль

      серия: … - -
      -

      Новая карточка

      - -
      - -
      -

      Заметки

      @@ -302,6 +275,24 @@

      +
      diff --git a/app/frontend/src/create.css b/app/frontend/src/create.css index 3abd932..3546918 100644 --- a/app/frontend/src/create.css +++ b/app/frontend/src/create.css @@ -69,7 +69,8 @@ transform: translateY(-0.05rem); } -#note-form { +#note-form, +#card-form { gap: 0.35rem; max-height: 0; margin-bottom: 0; @@ -81,98 +82,22 @@ opacity 0.18s ease; } -#note-form.is-open { +#note-form.is-open, +#card-form.is-open { max-height: var(--disclosure-height); margin-bottom: 1rem; opacity: 1; } -#note-form.is-resizing { +#note-form.is-resizing, +#card-form.is-resizing { transition: none; } -#note-form textarea { +#note-form textarea, +#card-form textarea { min-height: calc(1.6rem + 1.5rem + 2px); max-height: calc(6.4rem + 1.5rem + 2px); padding: 0.75rem 1rem; resize: vertical; } - -#create-panel { - display: block; - position: fixed; - inset: 50% auto auto 50%; - transform: translate(-50%, -46%) scale(0.98); - width: min(36rem, calc(100vw - 2rem)); - min-width: 0; - height: auto; - max-height: calc(100vh - 2rem); - min-height: 0; - margin: 0; - padding: 1.5rem; - border: 1px solid var(--pico-muted-border-color); - border-radius: 1rem; - background: var(--pico-card-background-color); - backdrop-filter: none; - box-shadow: 0 1rem 2rem rgba(37, 35, 35, 0.2); - color: var(--pico-color); - opacity: 0; - transition: - opacity 0.18s ease, - transform 0.18s ease; -} - -#create-panel:not([open]) { - display: none; -} - -#create-panel::backdrop { - background: transparent; - transition: background-color 0.18s ease; -} - -#create-panel.is-open { - transform: translate(-50%, -50%) scale(1); - opacity: 1; -} - -#create-panel.is-open::backdrop { - background: rgba(37, 35, 35, 0.45); -} - -#create-panel.is-closing::backdrop { - background: transparent; -} - -.create-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; -} - -.create-panel-header h2 { - margin: 0; -} - -#create-panel #create-close { - width: 2.5rem; - margin: 0; - padding: 0; - border: 0; - background: transparent; - color: var(--pico-muted-color); - font-size: 1.5rem; - line-height: 1; -} - -#create-panel #create-close:hover { - border: 0; - background: var(--app-highlight-background); - color: var(--app-highlight-color); -} - -#create-panel form { - margin: 0; -} diff --git a/app/frontend/src/create.ts b/app/frontend/src/create.ts index b966a90..57277e3 100644 --- a/app/frontend/src/create.ts +++ b/app/frontend/src/create.ts @@ -1,8 +1,6 @@ import { createAnimatedDisclosure } from "./disclosure"; export interface CreateElements { - panel: HTMLDialogElement; - closeButton: HTMLButtonElement; noteButton: HTMLButtonElement; cardButton: HTMLButtonElement; noteForm: HTMLFormElement; @@ -11,86 +9,42 @@ export interface CreateElements { export function setupCreateActions(elements: CreateElements): () => void { const setNoteOpen = createAnimatedDisclosure(elements.noteForm, "is-open"); - let closeTimer: number | undefined; - - function finishClose(): void { - if (closeTimer !== undefined) { - window.clearTimeout(closeTimer); - closeTimer = undefined; - } - elements.panel.classList.remove("is-open", "is-closing"); - if (elements.panel.open) { - elements.panel.close(); - } - } + const setCardOpen = createAnimatedDisclosure(elements.cardForm, "is-open"); function closeNoteForm(): void { setNoteOpen(false); elements.noteButton.setAttribute("aria-expanded", "false"); } - function close(): void { - if ( - !elements.panel.open || - elements.panel.classList.contains("is-closing") - ) { - return; - } - elements.panel.classList.remove("is-open"); - elements.panel.classList.add("is-closing"); - - const onTransitionEnd = (event: TransitionEvent): void => { - if (event.target !== elements.panel) { - return; - } - elements.panel.removeEventListener("transitionend", onTransitionEnd); - finishClose(); - }; - elements.panel.addEventListener("transitionend", onTransitionEnd); - closeTimer = window.setTimeout(finishClose, 220); + function closeCardForm(): void { + setCardOpen(false); + elements.cardButton.setAttribute("aria-expanded", "false"); } function openCardForm(): void { + const isOpen = elements.cardForm.classList.contains("is-open"); closeNoteForm(); - elements.cardForm.hidden = false; - if (!elements.panel.open) { - elements.panel.showModal(); - window.requestAnimationFrame(() => { - if (elements.panel.open) { - elements.panel.classList.add("is-open"); - } - }); + setCardOpen(!isOpen); + elements.cardButton.setAttribute("aria-expanded", String(!isOpen)); + + if (!isOpen) { + const field = elements.cardForm.querySelector( + "input:not(:disabled), select:not(:disabled), textarea:not(:disabled)", + ); + field?.focus(); } - - const field = elements.cardForm.querySelector( - "input:not(:disabled), select:not(:disabled), textarea:not(:disabled)", - ); - field?.focus(); } elements.noteButton.addEventListener("click", () => { const isOpen = elements.noteForm.classList.contains("is-open"); + closeCardForm(); setNoteOpen(!isOpen); elements.noteButton.setAttribute("aria-expanded", String(!isOpen)); }); elements.cardButton.addEventListener("click", openCardForm); - elements.closeButton.addEventListener("click", close); - elements.panel.addEventListener("cancel", (event) => { - event.preventDefault(); - close(); - }); - elements.panel.addEventListener("close", () => { - elements.panel.classList.remove("is-open", "is-closing"); - elements.cardForm.hidden = true; - }); - elements.panel.addEventListener("click", (event) => { - if (event.target === elements.panel) { - close(); - } - }); return () => { closeNoteForm(); - close(); + closeCardForm(); }; } diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index 5503808..23692ef 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -35,14 +35,6 @@ function needButton(selector: string): HTMLButtonElement { return button; } -function needDialog(selector: string): HTMLDialogElement { - const dialog = need(selector); - if (!(dialog instanceof HTMLDialogElement)) { - throw new Error(`нет диалога: ${selector}`); - } - return dialog; -} - function needSelect(selector: string): HTMLSelectElement { const select = need(selector); if (!(select instanceof HTMLSelectElement)) { @@ -81,9 +73,7 @@ if (publicUsername !== null) { const noteForm = needForm("#note-form"); const cardForm = needForm("#card-form"); - const closeCreateDialog = setupCreateActions({ - panel: needDialog("#create-panel"), - closeButton: needButton("#create-close"), + const closeCreateForms = setupCreateActions({ noteButton: needButton("#create-note"), cardButton: needButton("#create-card"), noteForm, @@ -103,7 +93,7 @@ if (publicUsername !== null) { notesNext: needButton("#notes-next"), notesPage: need("#notes-page"), cardNoteSelect: needSelect("#card-form select[name='note_id']"), - onCreated: closeCreateDialog, + onCreated: closeCreateForms, }, actions, ); @@ -117,7 +107,7 @@ if (publicUsername !== null) { queue: need("#queue"), toggleCardEdit: needButton("#toggle-card-edit"), cardForm, - onCreated: closeCreateDialog, + onCreated: closeCreateForms, }, actions, ); diff --git a/e2e/tests/notes.spec.ts b/e2e/tests/notes.spec.ts index a9b2772..1e1778f 100644 --- a/e2e/tests/notes.spec.ts +++ b/e2e/tests/notes.spec.ts @@ -62,7 +62,7 @@ test("notes truncate long text and use three-note pages", async ({ page }) => { expect(toggleOffset).toBe(0); for (const title of ["Page note 2", "Page note 3", "Page note 4"]) { - await expect(page.locator("#create-panel")).toHaveJSProperty("open", false); + await expect(page.locator("#card-form")).toBeHidden(); await openCreateForm(page, "note"); await page.fill("#note-form input[name='title']", title); await page.click("#note-form button[type='submit']"); From f7fa3f7b0b9a6f969f6e1bb24aa9343e4f69f3c9 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:07:05 +0200 Subject: [PATCH 060/102] =?UTF-8?q?fix(frontend):=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/workspace.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/frontend/src/workspace.css b/app/frontend/src/workspace.css index 98ba1b3..4a6f448 100644 --- a/app/frontend/src/workspace.css +++ b/app/frontend/src/workspace.css @@ -139,6 +139,7 @@ [data-testid="queue-empty"] { margin: 0; color: var(--pico-muted-color); + text-align: center; } @media (min-width: 900px) and (orientation: landscape) { From e638a8d14dc80bd08d8e8bfd4b4067820aaeaa11 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:17:43 +0200 Subject: [PATCH 061/102] =?UTF-8?q?feat(frontend):=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 51 ++++++++++++++++++++++++++++++++----- app/frontend/src/reviews.ts | 39 ++++++++++++++-------------- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index 9f787f0..cacbc50 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -29,23 +29,30 @@ } .card-review { - max-height: var(--card-review-height, 100rem); + display: grid; + grid-template-rows: 1fr; + min-height: 0; overflow: hidden; opacity: 1; visibility: visible; transition: - max-height 0.22s ease, + grid-template-rows 0.22s ease, opacity 0.18s ease, visibility 0s linear; } +.card-review-content { + min-height: 0; + overflow: hidden; +} + .card.is-card-edit-mode .card-review { - max-height: 0; + grid-template-rows: 0fr; opacity: 0; pointer-events: none; visibility: hidden; transition: - max-height 0.22s ease, + grid-template-rows 0.22s ease, opacity 0.18s ease, visibility 0s linear 0.22s; } @@ -149,8 +156,26 @@ } .card .back { - margin-bottom: 0; + max-height: 0; + margin: 0; + overflow: hidden; color: var(--pico-muted-color); + visibility: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + visibility 0s linear 0.22s; +} + +.card .back.is-visible { + max-height: var(--card-answer-height, 100rem); + visibility: visible; + opacity: 1; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + visibility 0s linear; } .card .reveal-answer, @@ -167,7 +192,21 @@ } .card .reveal-answer { - margin-top: 0.5rem; + min-height: 0; + margin: 0.35rem 0 0; + padding: 0; + border: 0; + background: transparent; + color: var(--pico-muted-color); + font-size: 0.8rem; + line-height: 1.2; +} + +.card .reveal-answer:hover { + border: 0; + background: transparent; + color: var(--pico-primary-hover); + text-decoration: underline; } .card .grade-buttons { diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts index d24994f..583cff5 100644 --- a/app/frontend/src/reviews.ts +++ b/app/frontend/src/reviews.ts @@ -44,29 +44,31 @@ export function setupReviews( const review = document.createElement("div"); review.className = "card-review"; review.setAttribute("aria-hidden", String(cardEditMode)); - const syncReviewHeight = (): void => { - review.style.setProperty( - "--card-review-height", - `${review.scrollHeight}px`, - ); - }; + const reviewContent = document.createElement("div"); + reviewContent.className = "card-review-content"; const back = document.createElement("p"); back.className = "back"; back.textContent = card.back; - back.hidden = true; back.setAttribute("aria-hidden", "true"); + const syncAnswerHeight = (): void => { + back.style.setProperty("--card-answer-height", `${back.scrollHeight}px`); + }; + const reveal = document.createElement("button"); reveal.type = "button"; reveal.className = "reveal-answer"; - reveal.textContent = "Показать ответ"; + reveal.textContent = "(Показать ответ)"; reveal.dataset.testid = "reveal-answer"; reveal.addEventListener("click", () => { - back.hidden = false; - back.setAttribute("aria-hidden", "false"); - reveal.hidden = true; - window.requestAnimationFrame(syncReviewHeight); + const visible = !back.classList.contains("is-visible"); + back.classList.toggle("is-visible", visible); + back.setAttribute("aria-hidden", String(!visible)); + reveal.textContent = visible ? "(Скрыть ответ)" : "(Показать ответ)"; + if (visible) { + window.requestAnimationFrame(syncAnswerHeight); + } }); const buttons = document.createElement("div"); @@ -83,7 +85,8 @@ export function setupReviews( }); buttons.append(button); } - review.append(reveal, back, buttons); + reviewContent.append(reveal, back, buttons); + review.append(reviewContent); const management = document.createElement("div"); management.className = "card-management"; @@ -124,9 +127,7 @@ export function setupReviews( editView.open(); }); - window.requestAnimationFrame(() => { - syncReviewHeight(); - }); + window.requestAnimationFrame(syncAnswerHeight); return wrap; } @@ -147,12 +148,10 @@ export function setupReviews( } const back = card.querySelector(".back"); const reveal = card.querySelector(".reveal-answer"); + back?.classList.remove("is-visible"); back?.setAttribute("aria-hidden", "true"); - if (back) { - back.hidden = true; - } if (reveal) { - reveal.hidden = false; + reveal.textContent = "(Показать ответ)"; } }); } From 36e04c135582fe706a2037b2c3192627755c5250 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:32:15 +0200 Subject: [PATCH 062/102] =?UTF-8?q?feat(frontend):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D0=BE=D0=B4=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 23 ++++++++++++++++++++++- app/frontend/src/icons.ts | 6 ++++++ app/frontend/src/reviews.ts | 18 +++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index cacbc50..ff25436 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -184,6 +184,27 @@ color: var(--pico-color); } +.card .grade-buttons button { + display: flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.card .grade-buttons button[data-testid="grade-again"] { + width: 3rem; + min-width: 3rem; + height: 3rem; + min-height: 3rem; + padding: 0; + justify-self: start; +} + +.card .grade-buttons button svg { + width: 1.15rem; + height: 1.15rem; +} + .card .reveal-answer:hover, .card .grade-buttons button:hover { border: 0; @@ -211,7 +232,7 @@ .card .grade-buttons { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: auto repeat(3, minmax(0, 1fr)); gap: 0.5rem; margin-top: 1rem; } diff --git a/app/frontend/src/icons.ts b/app/frontend/src/icons.ts index 4bc99bf..adf824f 100644 --- a/app/frontend/src/icons.ts +++ b/app/frontend/src/icons.ts @@ -31,3 +31,9 @@ export function pencilIcon(): SVGSVGElement { export function chevronIcon(): SVGSVGElement { return strokeIcon("M6 9l6 6 6-6"); } + +export function refreshIcon(): SVGSVGElement { + return strokeIcon( + "M20 11a8 8 0 0 0-14.9-3M4 5v4h4M4 13a8 8 0 0 0 14.9 3M20 19v-4h-4", + ); +} diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts index 583cff5..99ab06e 100644 --- a/app/frontend/src/reviews.ts +++ b/app/frontend/src/reviews.ts @@ -1,9 +1,15 @@ import { api, type Card, type Grade } from "./api"; import { createCardEdit, type CardEditView } from "./card-edit"; -import { pencilIcon, trashIcon } from "./icons"; +import { pencilIcon, refreshIcon, trashIcon } from "./icons"; import type { UiActions } from "./ui"; const GRADES: Grade[] = ["again", "hard", "good", "easy"]; +const GRADE_LABELS: Record = { + again: "Снова", + hard: "Сложно", + good: "Хорошо", + easy: "Легко", +}; export interface ReviewsElements { dueToday: HTMLElement; @@ -76,9 +82,15 @@ export function setupReviews( for (const grade of GRADES) { const button = document.createElement("button"); button.type = "button"; - button.textContent = grade; + if (grade === "again") { + button.append(refreshIcon()); + } else { + const label = document.createElement("span"); + label.textContent = GRADE_LABELS[grade]; + button.append(label); + } button.dataset.testid = `grade-${grade}`; - button.setAttribute("aria-label", `Оценить: ${grade}`); + button.setAttribute("aria-label", `Оценить: ${GRADE_LABELS[grade]}`); actions.onClick(button, async () => { await api.grade(card.id, grade); await refreshAll(); From 80acb06a31431fca16967e19aa6e4275fb4702f9 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:33:59 +0200 Subject: [PATCH 063/102] =?UTF-8?q?fix(frontend):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BE?= =?UTF-8?q?=D1=82=D1=81=D1=82=D1=83=D0=BF=20=D1=83=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index ff25436..ad3f4b9 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -188,6 +188,7 @@ display: flex; align-items: center; justify-content: center; + margin: 0; text-align: center; } From 4e3adb3b92d8e4e72954b6e60931a1e0e679d3f4 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:59:51 +0200 Subject: [PATCH 064/102] =?UTF-8?q?feat(frontend):=20=D1=81=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=81=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 4 ++ app/frontend/src/api.test.ts | 5 +- app/frontend/src/api.ts | 8 ++- app/frontend/src/create.css | 58 ++++++++++++++++++++++ app/frontend/src/main.ts | 1 + app/frontend/src/note-edit.ts | 20 +++++++- app/frontend/src/note-options.ts | 75 ++++++++++++++++++++++++++++ app/frontend/src/notes.ts | 85 +++++++++++++++++--------------- 8 files changed, 210 insertions(+), 46 deletions(-) create mode 100644 app/frontend/src/note-options.ts diff --git a/app/frontend/index.html b/app/frontend/index.html index c693db3..1698fa7 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -174,6 +174,10 @@

      Заметки

      placeholder="Текст заметки" aria-label="Текст заметки" > +
        diff --git a/app/frontend/src/api.test.ts b/app/frontend/src/api.test.ts index 23b7e70..fbad143 100644 --- a/app/frontend/src/api.test.ts +++ b/app/frontend/src/api.test.ts @@ -124,7 +124,7 @@ describe("api client", () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "1" }, 201)); vi.stubGlobal("fetch", fetchMock); - await api.createNote({ title: "t", body: "b", tags: [] }); + await api.createNote({ title: "t", body: "b", tags: [], links: [] }); const init = fetchMock.mock.calls[0]?.[1] as RequestInit; expect((init.headers as Headers).get("Content-Type")).toBe( @@ -132,6 +132,9 @@ describe("api client", () => { ); expect(init.method).toBe("POST"); expect(init.credentials).toBe("include"); + expect(init.body).toBe( + JSON.stringify({ title: "t", body: "b", tags: [], links: [] }), + ); }); it("регистрирует пользователя через API", async () => { diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index 744c023..6056d4d 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -114,8 +114,12 @@ export const api = { }, listNotes: (tag?: string) => http(`/notes${tag ? `?tag=${encodeURIComponent(tag)}` : ""}`), - createNote: (input: { title: string; body: string; tags: string[] }) => - http("/notes", { method: "POST", body: JSON.stringify(input) }), + createNote: (input: { + title: string; + body: string; + tags: string[]; + links: string[]; + }) => http("/notes", { method: "POST", body: JSON.stringify(input) }), updateNote: ( id: string, input: { title: string; body: string; tags: string[]; links: string[] }, diff --git a/app/frontend/src/create.css b/app/frontend/src/create.css index 3546918..8babc2d 100644 --- a/app/frontend/src/create.css +++ b/app/frontend/src/create.css @@ -101,3 +101,61 @@ padding: 0.75rem 1rem; resize: vertical; } + +#note-links, +.note-links-fieldset { + margin: 0; + padding: 0.5rem 0.75rem; +} + +#note-links legend, +.note-links-fieldset legend { + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +.note-link-options { + display: grid; + gap: 0.25rem; + max-height: 7rem; + overflow-y: auto; +} + +.note-link-option { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + min-height: 2.25rem; + margin: 0; + padding: 0.35rem 0.5rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--pico-color); + font-size: 0.9rem; + text-align: left; +} + +.note-link-option:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.note-link-option[data-selected="true"] { + background: var(--app-highlight-background); + color: var(--app-highlight-color); +} + +.note-link-option[data-selected="true"]:hover { + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.note-links-empty { + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index 23692ef..91786d7 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -83,6 +83,7 @@ if (publicUsername !== null) { const notes = setupNotes( { noteForm, + noteLinksContainer: need("#note-links"), tagFilterForm: needForm("#tag-filter-form"), toggleTagFilter: needButton("#toggle-tag-filter"), clearTagFilter: needButton("#clear-tag-filter"), diff --git a/app/frontend/src/note-edit.ts b/app/frontend/src/note-edit.ts index 7b8e7d5..0e6ad4f 100644 --- a/app/frontend/src/note-edit.ts +++ b/app/frontend/src/note-edit.ts @@ -1,6 +1,7 @@ import { api, type Note } from "./api"; import { createAnimatedDisclosure } from "./disclosure"; import { parseTags } from "./format"; +import { populateLinkOptions, selectedLinkIds } from "./note-options"; import type { UiActions } from "./ui"; const EDIT_ANIMATION_DURATION = 220; @@ -26,6 +27,7 @@ export function createNoteEdit( note: Note, actions: UiActions, callbacks: NoteEditCallbacks, + linkedNotes: Note[], ): NoteEditView { const form = document.createElement("form"); form.className = "note-edit"; @@ -48,6 +50,20 @@ export function createNoteEdit( body.value = note.body; body.setAttribute("aria-label", "Текст заметки"); + const linksFieldset = document.createElement("fieldset"); + linksFieldset.className = "note-links-fieldset"; + linksFieldset.setAttribute("aria-label", "Связанные заметки"); + const linksLegend = document.createElement("legend"); + linksLegend.textContent = "Связанные заметки"; + const linksContainer = document.createElement("div"); + linksContainer.className = "note-link-options"; + populateLinkOptions( + linksContainer, + linkedNotes.filter((linkedNote) => linkedNote.id !== note.id), + note.links, + ); + linksFieldset.append(linksLegend, linksContainer); + const actionsBox = document.createElement("div"); actionsBox.className = "note-edit-actions"; const save = document.createElement("button"); @@ -64,7 +80,7 @@ export function createNoteEdit( cancel.addEventListener("click", close); actionsBox.append(save, cancel); - form.append(title, tags, body, actionsBox); + form.append(title, tags, body, linksFieldset, actionsBox); form.addEventListener("submit", (event) => { event.preventDefault(); save.disabled = true; @@ -74,7 +90,7 @@ export function createNoteEdit( title: title.value, body: body.value, tags: parseTags(tags.value), - links: note.links, + links: selectedLinkIds(linksContainer), }) .then(() => { callbacks.onCloseStart(form); diff --git a/app/frontend/src/note-options.ts b/app/frontend/src/note-options.ts new file mode 100644 index 0000000..c7ddffb --- /dev/null +++ b/app/frontend/src/note-options.ts @@ -0,0 +1,75 @@ +import type { Note } from "./api"; + +export function populateNoteOptions( + select: HTMLSelectElement, + notes: Note[], + placeholder?: string, +): void { + const options = notes.map((note) => { + const option = document.createElement("option"); + option.value = note.id; + option.textContent = note.title; + return option; + }); + if (placeholder !== undefined) { + options.unshift( + Object.assign(document.createElement("option"), { + value: "", + textContent: placeholder, + }), + ); + } + if (notes.length === 0) { + const empty = document.createElement("option"); + empty.textContent = "Нет доступных заметок"; + empty.disabled = true; + options.push(empty); + } + select.replaceChildren(...options); + select.disabled = notes.length === 0; +} + +export function populateLinkOptions( + container: HTMLElement, + notes: Note[], + selected: string[] = [], +): void { + if (notes.length === 0) { + const empty = document.createElement("p"); + empty.className = "note-links-empty"; + empty.textContent = "Нет других заметок"; + container.replaceChildren(empty); + return; + } + + const selectedIds = new Set(selected); + const options = notes.map((note) => { + const option = document.createElement("button"); + option.type = "button"; + option.className = "note-link-option"; + option.dataset.noteLink = note.id; + const isSelected = selectedIds.has(note.id); + option.dataset.selected = String(isSelected); + option.setAttribute("aria-pressed", String(isSelected)); + option.setAttribute("aria-label", `Связать с заметкой: ${note.title}`); + option.textContent = note.title; + option.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const next = option.dataset.selected !== "true"; + option.dataset.selected = String(next); + option.setAttribute("aria-pressed", String(next)); + }); + return option; + }); + container.replaceChildren(...options); +} + +export function selectedLinkIds(container: HTMLElement): string[] { + return Array.from( + container.querySelectorAll( + "button[data-note-link][data-selected='true']", + ), + (button) => button.dataset.noteLink ?? "", + ).filter((id) => id !== ""); +} diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index edb5b23..d919108 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -5,12 +5,18 @@ import { pencilIcon, trashIcon } from "./icons"; import { animateListHeight } from "./notes-animation"; import { createNoteBody } from "./note-body"; import { createNoteEdit, type NoteEditView } from "./note-edit"; +import { + populateLinkOptions, + populateNoteOptions, + selectedLinkIds, +} from "./note-options"; import { truncateNoteTags, truncateNoteTitle } from "./note-text"; import { NOTES_PER_PAGE, setupNotesPagination } from "./notes-pagination"; import type { UiActions } from "./ui"; export interface NotesElements { noteForm: HTMLFormElement; + noteLinksContainer: HTMLElement; tagFilterForm: HTMLFormElement; toggleTagFilter: HTMLButtonElement; clearTagFilter: HTMLButtonElement; @@ -30,23 +36,7 @@ export function setupNotes( ): () => Promise { let activeTag: string | undefined; let notes: Note[] = []; - - function updateCardNoteOptions(notes: Note[]): void { - const options = notes.map((note) => { - const option = document.createElement("option"); - option.value = note.id; - option.textContent = note.title; - return option; - }); - elements.cardNoteSelect.replaceChildren( - Object.assign(document.createElement("option"), { - value: "", - textContent: "Выберите заметку", - }), - ...options, - ); - elements.cardNoteSelect.disabled = notes.length === 0; - } + let linkableNotes: Note[] = []; function noteItem(note: Note): HTMLLIElement { const item = document.createElement("li"); @@ -93,30 +83,35 @@ export function setupNotes( let editView: NoteEditView; edit.addEventListener("click", () => { view.classList.add("is-editing"); - editView = createNoteEdit(note, actions, { - onCloseStart: (form) => { - const closeHeight = Math.min( - view.offsetHeight, - form.getBoundingClientRect().height, - ); - form.style.setProperty( - "--note-edit-close-height", - `${closeHeight}px`, - ); - form.classList.add("is-closing"); - }, - onClosed: () => { - editView.form.classList.remove("is-closing"); - editView.form.remove(); - return new Promise((resolve) => { - window.requestAnimationFrame(() => { - view.classList.remove("is-editing"); - window.setTimeout(resolve, 180); + editView = createNoteEdit( + note, + actions, + { + onCloseStart: (form) => { + const closeHeight = Math.min( + view.offsetHeight, + form.getBoundingClientRect().height, + ); + form.style.setProperty( + "--note-edit-close-height", + `${closeHeight}px`, + ); + form.classList.add("is-closing"); + }, + onClosed: () => { + editView.form.classList.remove("is-closing"); + editView.form.remove(); + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + view.classList.remove("is-editing"); + window.setTimeout(resolve, 180); + }); }); - }); + }, + onSaved: refreshNotes, }, - onSaved: refreshNotes, - }); + linkableNotes, + ); item.append(editView.form); editView.open(); }); @@ -161,12 +156,19 @@ export function setupNotes( ); async function refreshNotes(animate = false): Promise { - notes = await api.listNotes(activeTag); + const allNotes = await api.listNotes(); + notes = activeTag === undefined ? allNotes : await api.listNotes(activeTag); + linkableNotes = allNotes; const page = pagination.update(notes.length); renderPage(page, animate, animate); elements.noteList.hidden = notes.length === 0; elements.notesEmpty.hidden = notes.length > 0; - updateCardNoteOptions(notes); + populateNoteOptions( + elements.cardNoteSelect, + linkableNotes, + "Выберите заметку", + ); + populateLinkOptions(elements.noteLinksContainer, linkableNotes); } elements.noteForm.addEventListener("submit", (event) => { @@ -182,6 +184,7 @@ export function setupNotes( title: actions.field(data, "title"), body: actions.field(data, "body"), tags: parseTags(actions.field(data, "tags")), + links: selectedLinkIds(elements.noteLinksContainer), }; submit.disabled = true; From 34f3b117fa6faa1b14857ca541b9ba05e8e3a198 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:04:24 +0200 Subject: [PATCH 065/102] =?UTF-8?q?feat(frontend):=20=D1=81=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B8=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D1=8E=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/note-body.ts | 36 +++++++++++++++++++++++++++++++---- app/frontend/src/notes.css | 32 ++++++++++++++++++++++++++++++- app/frontend/src/notes.ts | 2 +- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/frontend/src/note-body.ts b/app/frontend/src/note-body.ts index 62854f3..04ffb6c 100644 --- a/app/frontend/src/note-body.ts +++ b/app/frontend/src/note-body.ts @@ -3,17 +3,45 @@ import { createAnimatedDisclosure } from "./disclosure"; import { chevronIcon } from "./icons"; export interface NoteBodyElements { - body: HTMLParagraphElement; + body: HTMLDivElement; toggle: HTMLButtonElement; } -export function createNoteBody(note: Note): NoteBodyElements { - const body = document.createElement("p"); +export function createNoteBody( + note: Note, + linkedNotes: Note[] = [], +): NoteBodyElements { + const body = document.createElement("div"); body.className = "note-body"; body.dataset.testid = "note-body"; body.id = `note-body-${note.id}`; body.setAttribute("aria-hidden", "true"); - body.textContent = note.body || "Содержимое отсутствует."; + + const text = document.createElement("p"); + text.className = "note-body-text"; + text.textContent = note.body || "Содержимое отсутствует."; + body.append(text); + + const relatedNotes = note.links + .map((id) => linkedNotes.find((linkedNote) => linkedNote.id === id)) + .filter((linkedNote): linkedNote is Note => linkedNote !== undefined); + if (relatedNotes.length > 0) { + const related = document.createElement("div"); + related.className = "note-related"; + const label = document.createElement("span"); + label.className = "note-related-label"; + label.textContent = "Связанные заметки:"; + const list = document.createElement("div"); + list.className = "note-related-list"; + for (const linkedNote of relatedNotes) { + const title = document.createElement("span"); + title.className = "note-related-item"; + title.textContent = linkedNote.title; + list.append(title); + } + related.append(label, list); + body.append(related); + } const toggle = document.createElement("button"); toggle.type = "button"; diff --git a/app/frontend/src/notes.css b/app/frontend/src/notes.css index 55b8776..cb4485e 100644 --- a/app/frontend/src/notes.css +++ b/app/frontend/src/notes.css @@ -191,7 +191,7 @@ margin: 0; overflow-wrap: anywhere; overflow: hidden; - white-space: pre-wrap; + white-space: normal; color: var(--pico-muted-color); visibility: hidden; opacity: 0; @@ -208,6 +208,36 @@ opacity: 1; } +.note-body-text { + margin: 0; + white-space: pre-wrap; +} + +.note-related { + display: grid; + gap: 0.35rem; + margin-top: 0.75rem; +} + +.note-related-label { + font-size: 0.8rem; + font-weight: 700; +} + +.note-related-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.note-related-item { + padding: 0.2rem 0.5rem; + border-radius: 999px; + background: var(--app-highlight-background); + color: var(--app-highlight-color); + font-size: 0.8rem; +} + .notes-pagination { display: grid; grid-template-columns: 2rem 1fr 2rem; diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index d919108..0e61b14 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -53,7 +53,7 @@ export function setupNotes( tags.textContent = truncateNoteTags(note.tags); tags.title = note.tags.join(", "); - const { body, toggle: bodyToggle } = createNoteBody(note); + const { body, toggle: bodyToggle } = createNoteBody(note, linkableNotes); const edit = document.createElement("button"); edit.type = "button"; From a33a67697b38ac004ed07552fd7a8ccadea85e85 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:22:58 +0200 Subject: [PATCH 066/102] =?UTF-8?q?feat(frontend):=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D1=8F=D0=BC=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/main.ts | 1 + app/frontend/src/note-body.ts | 5 ++++- app/frontend/src/note-highlight.css | 14 ++++++++++++++ app/frontend/src/notes-animation.ts | 10 ++++++++++ app/frontend/src/notes-pagination.ts | 26 ++++++++++++++++---------- app/frontend/src/notes.css | 10 ++++++++++ app/frontend/src/notes.ts | 23 +++++++++++++++++++++-- 7 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 app/frontend/src/note-highlight.css diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index 91786d7..b60a7b8 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -3,6 +3,7 @@ import "./profile.css"; import "./workspace.css"; import "./cards.css"; import "./notes.css"; +import "./note-highlight.css"; import "./create.css"; import { setupAuth } from "./auth"; import { setupCreateActions } from "./create"; diff --git a/app/frontend/src/note-body.ts b/app/frontend/src/note-body.ts index 04ffb6c..16ffff9 100644 --- a/app/frontend/src/note-body.ts +++ b/app/frontend/src/note-body.ts @@ -10,6 +10,7 @@ export interface NoteBodyElements { export function createNoteBody( note: Note, linkedNotes: Note[] = [], + onLinkedNote?: (id: string) => void, ): NoteBodyElements { const body = document.createElement("div"); body.className = "note-body"; @@ -34,9 +35,11 @@ export function createNoteBody( const list = document.createElement("div"); list.className = "note-related-list"; for (const linkedNote of relatedNotes) { - const title = document.createElement("span"); + const title = document.createElement("button"); + title.type = "button"; title.className = "note-related-item"; title.textContent = linkedNote.title; + title.addEventListener("click", () => onLinkedNote?.(linkedNote.id)); list.append(title); } related.append(label, list); diff --git a/app/frontend/src/note-highlight.css b/app/frontend/src/note-highlight.css new file mode 100644 index 0000000..d46f0e3 --- /dev/null +++ b/app/frontend/src/note-highlight.css @@ -0,0 +1,14 @@ +#note-list li.is-linked-target { + animation: linked-note-highlight 1.1s ease; +} + +@keyframes linked-note-highlight { + 0%, + 100% { + box-shadow: inset 0 0 0 0 transparent; + } + 35%, + 70% { + box-shadow: inset 0 0 0 0.35rem rgba(51, 65, 85, 0.5); + } +} diff --git a/app/frontend/src/notes-animation.ts b/app/frontend/src/notes-animation.ts index 2086ce6..0ebedd6 100644 --- a/app/frontend/src/notes-animation.ts +++ b/app/frontend/src/notes-animation.ts @@ -19,3 +19,13 @@ export function animateListHeight(list: HTMLElement, render: () => void): void { }); }); } + +export function highlightNote(target: HTMLElement): void { + target.classList.remove("is-linked-target"); + void target.offsetWidth; + target.classList.add("is-linked-target"); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + window.setTimeout(() => { + target.classList.remove("is-linked-target"); + }, 1100); +} diff --git a/app/frontend/src/notes-pagination.ts b/app/frontend/src/notes-pagination.ts index e87b1ec..404f798 100644 --- a/app/frontend/src/notes-pagination.ts +++ b/app/frontend/src/notes-pagination.ts @@ -10,7 +10,11 @@ export interface NotesPaginationElements { export function setupNotesPagination( elements: NotesPaginationElements, onPageChange: (page: number) => void, -): { reset: () => void; update: (total: number) => number } { +): { + reset: () => void; + update: (total: number) => number; + goTo: (page: number) => void; +} { let currentPage = 0; let totalPages = 0; @@ -23,21 +27,22 @@ export function setupNotesPagination( elements.next.disabled = totalPages === 0 || currentPage === lastPage; } - elements.previous.addEventListener("click", () => { - if (currentPage === 0) { + function goTo(page: number): void { + const lastPage = Math.max(totalPages - 1, 0); + const nextPage = Math.min(Math.max(page, 0), lastPage); + if (nextPage === currentPage) { return; } - currentPage -= 1; + currentPage = nextPage; sync(); onPageChange(currentPage); + } + + elements.previous.addEventListener("click", () => { + goTo(currentPage - 1); }); elements.next.addEventListener("click", () => { - if (currentPage >= totalPages - 1) { - return; - } - currentPage += 1; - sync(); - onPageChange(currentPage); + goTo(currentPage + 1); }); return { @@ -49,5 +54,6 @@ export function setupNotesPagination( sync(); return currentPage; }, + goTo, }; } diff --git a/app/frontend/src/notes.css b/app/frontend/src/notes.css index cb4485e..2cdb172 100644 --- a/app/frontend/src/notes.css +++ b/app/frontend/src/notes.css @@ -231,13 +231,23 @@ } .note-related-item { + min-height: 0; + margin: 0; padding: 0.2rem 0.5rem; + border: 0; border-radius: 999px; background: var(--app-highlight-background); color: var(--app-highlight-color); + cursor: pointer; font-size: 0.8rem; } +.note-related-item:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + .notes-pagination { display: grid; grid-template-columns: 2rem 1fr 2rem; diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index 0e61b14..5bc044c 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -2,7 +2,7 @@ import { api, type Note } from "./api"; import { createAnimatedDisclosure } from "./disclosure"; import { parseTags } from "./format"; import { pencilIcon, trashIcon } from "./icons"; -import { animateListHeight } from "./notes-animation"; +import { animateListHeight, highlightNote } from "./notes-animation"; import { createNoteBody } from "./note-body"; import { createNoteEdit, type NoteEditView } from "./note-edit"; import { @@ -37,6 +37,21 @@ export function setupNotes( let activeTag: string | undefined; let notes: Note[] = []; let linkableNotes: Note[] = []; + function openLinkedNote(id: string): void { + const index = notes.findIndex((note) => note.id === id); + if (index < 0) { + return; + } + pagination.goTo(Math.floor(index / NOTES_PER_PAGE)); + window.setTimeout(() => { + const target = Array.from(elements.noteList.children).find( + (element): element is HTMLElement => + element instanceof HTMLElement && element.dataset.id === id, + ); + if (!target) return; + highlightNote(target); + }, 240); + } function noteItem(note: Note): HTMLLIElement { const item = document.createElement("li"); @@ -53,7 +68,11 @@ export function setupNotes( tags.textContent = truncateNoteTags(note.tags); tags.title = note.tags.join(", "); - const { body, toggle: bodyToggle } = createNoteBody(note, linkableNotes); + const { body, toggle: bodyToggle } = createNoteBody( + note, + linkableNotes, + openLinkedNote, + ); const edit = document.createElement("button"); edit.type = "button"; From d4d63fd9a0202b225a9b52ff160a96dc475e5c80 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:24:44 +0200 Subject: [PATCH 067/102] =?UTF-8?q?feat(frontend):=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0=D0=BD=D1=8B=20=D0=BE=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/note-body.ts | 58 +++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/app/frontend/src/note-body.ts b/app/frontend/src/note-body.ts index 16ffff9..38fea86 100644 --- a/app/frontend/src/note-body.ts +++ b/app/frontend/src/note-body.ts @@ -7,6 +7,35 @@ export interface NoteBodyElements { toggle: HTMLButtonElement; } +function appendRelatedNotes( + body: HTMLDivElement, + labelText: string, + notes: Note[], + onLinkedNote?: (id: string) => void, +): void { + if (notes.length === 0) { + return; + } + + const related = document.createElement("div"); + related.className = "note-related"; + const label = document.createElement("span"); + label.className = "note-related-label"; + label.textContent = labelText; + const list = document.createElement("div"); + list.className = "note-related-list"; + for (const linkedNote of notes) { + const title = document.createElement("button"); + title.type = "button"; + title.className = "note-related-item"; + title.textContent = linkedNote.title; + title.addEventListener("click", () => onLinkedNote?.(linkedNote.id)); + list.append(title); + } + related.append(label, list); + body.append(related); +} + export function createNoteBody( note: Note, linkedNotes: Note[] = [], @@ -26,25 +55,16 @@ export function createNoteBody( const relatedNotes = note.links .map((id) => linkedNotes.find((linkedNote) => linkedNote.id === id)) .filter((linkedNote): linkedNote is Note => linkedNote !== undefined); - if (relatedNotes.length > 0) { - const related = document.createElement("div"); - related.className = "note-related"; - const label = document.createElement("span"); - label.className = "note-related-label"; - label.textContent = "Связанные заметки:"; - const list = document.createElement("div"); - list.className = "note-related-list"; - for (const linkedNote of relatedNotes) { - const title = document.createElement("button"); - title.type = "button"; - title.className = "note-related-item"; - title.textContent = linkedNote.title; - title.addEventListener("click", () => onLinkedNote?.(linkedNote.id)); - list.append(title); - } - related.append(label, list); - body.append(related); - } + appendRelatedNotes(body, "Связанные заметки:", relatedNotes, onLinkedNote); + appendRelatedNotes( + body, + "Ссылаются на эту заметку:", + linkedNotes.filter( + (sourceNote) => + sourceNote.id !== note.id && sourceNote.links.includes(note.id), + ), + onLinkedNote, + ); const toggle = document.createElement("button"); toggle.type = "button"; From 60332f82fc5a1e25a2fd8e5a029e821c252dba84 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:43:51 +0200 Subject: [PATCH 068/102] =?UTF-8?q?feat(frontend):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=B4=20=D0=BA=20=D0=B8=D1=81=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 21 +++++++++++++++++++++ app/frontend/src/main.ts | 3 ++- app/frontend/src/notes.ts | 7 ++----- app/frontend/src/reviews.ts | 18 ++++++++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index ad3f4b9..42102d1 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -214,6 +214,7 @@ } .card .reveal-answer { + display: block; min-height: 0; margin: 0.35rem 0 0; padding: 0; @@ -224,6 +225,26 @@ line-height: 1.2; } +.card .open-source-note { + display: block; + min-height: 0; + margin: 0.35rem 0 0; + padding: 0; + border: 0; + background: transparent; + color: var(--pico-muted-color); + font-size: 0.8rem; + line-height: 1.2; + text-align: left; +} + +.card .open-source-note:hover { + border: 0; + background: transparent; + color: var(--pico-primary-hover); + text-decoration: underline; +} + .card .reveal-answer:hover { border: 0; background: transparent; diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index b60a7b8..781da82 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -110,6 +110,7 @@ if (publicUsername !== null) { toggleCardEdit: needButton("#toggle-card-edit"), cardForm, onCreated: closeCreateForms, + onOpenNote: notes.open, }, actions, ); @@ -117,7 +118,7 @@ if (publicUsername !== null) { const refreshAll = async (): Promise => { await Promise.all([ reviews.refreshStats(), - notes(), + notes.refresh(), reviews.refreshQueue(), ]); }; diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index 5bc044c..e80be47 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -30,10 +30,7 @@ export interface NotesElements { onCreated: () => void; } -export function setupNotes( - elements: NotesElements, - actions: UiActions, -): () => Promise { +export function setupNotes(elements: NotesElements, actions: UiActions) { let activeTag: string | undefined; let notes: Note[] = []; let linkableNotes: Note[] = []; @@ -246,5 +243,5 @@ export function setupNotes( await refreshNotes(); }); - return refreshNotes; + return { refresh: refreshNotes, open: openLinkedNote }; } diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts index 99ab06e..6842d30 100644 --- a/app/frontend/src/reviews.ts +++ b/app/frontend/src/reviews.ts @@ -20,6 +20,7 @@ export interface ReviewsElements { toggleCardEdit: HTMLButtonElement; cardForm: HTMLFormElement; onCreated: () => void; + onOpenNote: (id: string) => void; } export function setupReviews( @@ -53,6 +54,15 @@ export function setupReviews( const reviewContent = document.createElement("div"); reviewContent.className = "card-review-content"; + const sourceNote = document.createElement("button"); + sourceNote.type = "button"; + sourceNote.className = "open-source-note"; + sourceNote.textContent = "К исходной заметке"; + sourceNote.dataset.testid = "open-source-note"; + sourceNote.addEventListener("click", () => { + elements.onOpenNote(card.note_id); + }); + const back = document.createElement("p"); back.className = "back"; back.textContent = card.back; @@ -65,13 +75,13 @@ export function setupReviews( const reveal = document.createElement("button"); reveal.type = "button"; reveal.className = "reveal-answer"; - reveal.textContent = "(Показать ответ)"; + reveal.textContent = "Показать ответ"; reveal.dataset.testid = "reveal-answer"; reveal.addEventListener("click", () => { const visible = !back.classList.contains("is-visible"); back.classList.toggle("is-visible", visible); back.setAttribute("aria-hidden", String(!visible)); - reveal.textContent = visible ? "(Скрыть ответ)" : "(Показать ответ)"; + reveal.textContent = visible ? "Скрыть ответ" : "Показать ответ"; if (visible) { window.requestAnimationFrame(syncAnswerHeight); } @@ -97,7 +107,7 @@ export function setupReviews( }); buttons.append(button); } - reviewContent.append(reveal, back, buttons); + reviewContent.append(sourceNote, reveal, back, buttons); review.append(reviewContent); const management = document.createElement("div"); @@ -163,7 +173,7 @@ export function setupReviews( back?.classList.remove("is-visible"); back?.setAttribute("aria-hidden", "true"); if (reveal) { - reveal.textContent = "(Показать ответ)"; + reveal.textContent = "Показать ответ"; } }); } From bc6f847d7bcacc5ad25956f60610102005900ef3 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:51:52 +0200 Subject: [PATCH 069/102] =?UTF-8?q?feat(books):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=B8=D1=85=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8C=20?= =?UTF-8?q?=D1=81=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 11 +- app/backend/src/Domain/Book.php | 87 ++++++++++++++ app/backend/src/Domain/Note.php | 16 ++- app/backend/src/Domain/ValueObject/BookId.php | 13 +++ .../src/Http/AuthenticationMiddleware.php | 2 +- .../src/Http/Controller/BooksController.php | 110 ++++++++++++++++++ .../src/Http/Controller/NotesController.php | 31 ++++- app/backend/src/Http/Input/BookInput.php | 81 +++++++++++++ app/backend/src/Http/Input/NoteInput.php | 21 +++- .../Persistence/BookRepository.php | 86 ++++++++++++++ .../Persistence/DatabaseMigrator.php | 23 +++- .../Infrastructure/Persistence/OrmSchema.php | 38 +++++- .../Persistence/ValueObjectTypecast.php | 58 +++++---- app/backend/tests/BookRepositoryTest.php | 38 ++++++ app/backend/tests/OwnershipSchemaTest.php | 4 +- spec/acceptance/books.hurl | 61 ++++++++++ 16 files changed, 648 insertions(+), 32 deletions(-) create mode 100644 app/backend/src/Domain/Book.php create mode 100644 app/backend/src/Domain/ValueObject/BookId.php create mode 100644 app/backend/src/Http/Controller/BooksController.php create mode 100644 app/backend/src/Http/Input/BookInput.php create mode 100644 app/backend/src/Infrastructure/Persistence/BookRepository.php create mode 100644 app/backend/tests/BookRepositoryTest.php create mode 100644 spec/acceptance/books.hurl diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 0d2c252..2a5673d 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -8,6 +8,7 @@ use Psr\Log\LoggerAwareInterface; use Recall\Http\AuthenticationMiddleware; use Recall\Http\Controller\AuthController; +use Recall\Http\Controller\BooksController; use Recall\Http\Controller\CardsController; use Recall\Http\Controller\HealthController; use Recall\Http\Controller\NotesController; @@ -18,6 +19,7 @@ use Recall\Http\Serializer; use Recall\Http\SessionCookie; use Recall\Http\ValidationException; +use Recall\Infrastructure\Persistence\BookRepository; use Recall\Infrastructure\Persistence\CardRepository; use Recall\Infrastructure\Persistence\DatabaseContext; use Recall\Infrastructure\Persistence\NoteRepository; @@ -44,6 +46,7 @@ } $noteRepo = new NoteRepository($boot->orm); +$bookRepo = new BookRepository($boot->orm); $cardRepo = new CardRepository($boot->orm); $reviewRepo = new ReviewRepository($boot->orm); $sessionRepo = new SessionRepository($boot->orm); @@ -52,7 +55,8 @@ (new Seeder($noteRepo, $cardRepo))->seedIfEmpty($now); $serializer = new Serializer(); -$notes = new NotesController($noteRepo, $serializer, $now); +$notes = new NotesController($noteRepo, $bookRepo, $serializer, $now); +$books = new BooksController($bookRepo, $serializer); $cards = new CardsController($cardRepo, $noteRepo, $serializer, $now); $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); $stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); @@ -105,6 +109,11 @@ $app->put('/notes/{id}', $notes->update(...)); $app->delete('/notes/{id}', $notes->delete(...)); +$app->get('/books', $books->index(...)); +$app->post('/books', $books->create(...)); +$app->get('/books/{id}', $books->show(...)); +$app->delete('/books/{id}', $books->delete(...)); + $app->get('/cards', $cards->index(...)); $app->post('/cards', $cards->create(...)); $app->get('/cards/{id}', $cards->show(...)); diff --git a/app/backend/src/Domain/Book.php b/app/backend/src/Domain/Book.php new file mode 100644 index 0000000..34ec295 --- /dev/null +++ b/app/backend/src/Domain/Book.php @@ -0,0 +1,87 @@ +author = $this->author($author); + $this->openLibraryKey = $this->openLibraryKey($openLibraryKey); + $this->coverId = $this->coverId($coverId); + } + + public static function create( + Title $title, + string $author, + ?string $openLibraryKey, + ?int $coverId, + ): self { + return new self(BookId::generate(), $title, $author, $openLibraryKey, $coverId); + } + + /** Момент создания берётся из UUIDv7 — отдельного поля не держим. */ + public function createdAt(): DateTimeImmutable + { + return $this->id->createdAt(); + } + + private function author(string $author): string + { + $author = trim($author); + if (mb_strlen($author) > self::MAX_AUTHOR_LENGTH) { + throw new InvalidArgumentException('имя автора длиннее ' . self::MAX_AUTHOR_LENGTH . ' символов'); + } + + return $author; + } + + private function openLibraryKey(?string $key): ?string + { + if ($key === null) { + return null; + } + + $key = trim($key); + if ($key === '') { + return null; + } + if (mb_strlen($key) > self::MAX_OPEN_LIBRARY_KEY_LENGTH) { + throw new InvalidArgumentException( + 'идентификатор Open Library длиннее ' . self::MAX_OPEN_LIBRARY_KEY_LENGTH . ' символов', + ); + } + + return $key; + } + + private function coverId(?int $coverId): ?int + { + if ($coverId === null) { + return null; + } + if ($coverId < 1) { + throw new InvalidArgumentException('идентификатор обложки должен быть положительным'); + } + + return $coverId; + } +} diff --git a/app/backend/src/Domain/Note.php b/app/backend/src/Domain/Note.php index 7e55507..1bf1117 100644 --- a/app/backend/src/Domain/Note.php +++ b/app/backend/src/Domain/Note.php @@ -5,6 +5,7 @@ namespace Recall\Domain; use DateTimeImmutable; +use Recall\Domain\ValueObject\BookId; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; @@ -21,6 +22,7 @@ public function __construct( public TagList $tags, public NoteIdList $links, public DateTimeImmutable $updatedAt, + public ?BookId $bookId = null, public ?UserId $userId = null, ) {} @@ -30,8 +32,9 @@ public static function create( TagList $tags, NoteIdList $links, DateTimeImmutable $now, + ?BookId $bookId = null, ): self { - return new self(NoteId::generate(), $title, $body, $tags, $links, $now); + return new self(NoteId::generate(), $title, $body, $tags, $links, $now, $bookId); } /** Момент создания берётся из UUIDv7 — отдельного поля не держим. */ @@ -40,12 +43,19 @@ public function createdAt(): DateTimeImmutable return $this->id->createdAt(); } - public function revise(Title $title, string $body, TagList $tags, NoteIdList $links, DateTimeImmutable $now): void - { + public function revise( + Title $title, + string $body, + TagList $tags, + NoteIdList $links, + DateTimeImmutable $now, + ?BookId $bookId = null, + ): void { $this->title = $title; $this->body = $body; $this->tags = $tags; $this->links = $links; $this->updatedAt = $now; + $this->bookId = $bookId; } } diff --git a/app/backend/src/Domain/ValueObject/BookId.php b/app/backend/src/Domain/ValueObject/BookId.php new file mode 100644 index 0000000..66982a7 --- /dev/null +++ b/app/backend/src/Domain/ValueObject/BookId.php @@ -0,0 +1,13 @@ + $path === $prefix || str_starts_with($path, "$prefix/"), ); } diff --git a/app/backend/src/Http/Controller/BooksController.php b/app/backend/src/Http/Controller/BooksController.php new file mode 100644 index 0000000..d27f306 --- /dev/null +++ b/app/backend/src/Http/Controller/BooksController.php @@ -0,0 +1,110 @@ +serializer->serialize(...), $this->books->all(CurrentUser::userId($request))), + ); + } + + /** @param array $args */ + public function show(Request $request, Response $response, array $args): Response + { + $userId = CurrentUser::userId($request); + $book = $this->lookup($userId, $args); + + return $book === null + ? $this->notFoundOrForbidden($response, $userId, $args) + : Json::write($response, $this->serializer->serialize($book)); + } + + public function create(Request $request, Response $response): Response + { + $input = BookInput::fromArray($this->body($request)); + $book = Book::create($input->title, $input->author, $input->openLibraryKey, $input->coverId); + $this->books->save(CurrentUser::userId($request), $book); + + return Json::write($response, $this->serializer->serialize($book), 201); + } + + /** @param array $args */ + public function delete(Request $request, Response $response, array $args): Response + { + $userId = CurrentUser::userId($request); + $book = $this->lookup($userId, $args); + if ($book === null) { + return $this->notFoundOrForbidden($response, $userId, $args); + } + + $this->books->delete($userId, $book); + + return $response->withStatus(204); + } + + /** @param array $args */ + private function lookup(UserId $userId, array $args): ?Book + { + $raw = $args['id'] ?? null; + if (!is_string($raw)) { + return null; + } + try { + return $this->books->find($userId, BookId::fromString($raw)); + } catch (InvalidArgumentException) { + return null; + } + } + + /** @param array $args */ + private function notFoundOrForbidden( + Response $response, + UserId $userId, + array $args, + ): Response { + $raw = $args['id'] ?? null; + if (!is_string($raw)) { + return Json::error($response, 'book not found', 404); + } + try { + $otherUsersBook = $this->books->belongsToAnotherUser($userId, BookId::fromString($raw)); + } catch (InvalidArgumentException) { + return Json::error($response, 'book not found', 404); + } + + return $otherUsersBook + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'book not found', 404); + } + + /** @return array */ + private function body(Request $request): array + { + $body = $request->getParsedBody(); + + return is_array($body) ? $body : []; + } +} diff --git a/app/backend/src/Http/Controller/NotesController.php b/app/backend/src/Http/Controller/NotesController.php index 548e911..493a48a 100644 --- a/app/backend/src/Http/Controller/NotesController.php +++ b/app/backend/src/Http/Controller/NotesController.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Recall\Domain\Note; +use Recall\Domain\ValueObject\BookId; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\Tag; use Recall\Domain\ValueObject\UserId; @@ -16,12 +17,14 @@ use Recall\Http\Input\NoteInput; use Recall\Http\Json; use Recall\Http\Serializer; +use Recall\Infrastructure\Persistence\BookRepository; use Recall\Infrastructure\Persistence\NoteRepository; final readonly class NotesController { public function __construct( private NoteRepository $notes, + private BookRepository $books, private Serializer $serializer, private DateTimeImmutable $now, ) {} @@ -51,8 +54,14 @@ public function show(Request $request, Response $response, array $args): Respons public function create(Request $request, Response $response): Response { $input = NoteInput::fromArray($this->body($request)); - $note = Note::create($input->title, $input->body, $input->tags, $input->links, $this->now); - $this->notes->save(CurrentUser::userId($request), $note); + $userId = CurrentUser::userId($request); + $bookError = $this->bookError($response, $userId, $input->bookId); + if ($bookError !== null) { + return $bookError; + } + + $note = Note::create($input->title, $input->body, $input->tags, $input->links, $this->now, $input->bookId); + $this->notes->save($userId, $note); return Json::write($response, $this->serializer->serialize($note), 201); } @@ -67,7 +76,12 @@ public function update(Request $request, Response $response, array $args): Respo } $input = NoteInput::fromArray($this->body($request)); - $note->revise($input->title, $input->body, $input->tags, $input->links, $this->now); + $bookError = $this->bookError($response, $userId, $input->bookId); + if ($bookError !== null) { + return $bookError; + } + + $note->revise($input->title, $input->body, $input->tags, $input->links, $this->now, $input->bookId); $this->notes->save($userId, $note); return Json::write($response, $this->serializer->serialize($note)); @@ -119,6 +133,17 @@ private function notFoundOrForbidden(Response $response, UserId $userId, array $ : Json::error($response, 'note not found', 404); } + private function bookError(Response $response, UserId $userId, ?BookId $bookId): ?Response + { + if ($bookId === null || $this->books->find($userId, $bookId) !== null) { + return null; + } + + return $this->books->belongsToAnotherUser($userId, $bookId) + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'book not found', 404); + } + /** @return array */ private function body(Request $request): array { diff --git a/app/backend/src/Http/Input/BookInput.php b/app/backend/src/Http/Input/BookInput.php new file mode 100644 index 0000000..5a30d92 --- /dev/null +++ b/app/backend/src/Http/Input/BookInput.php @@ -0,0 +1,81 @@ + $data */ + public static function fromArray(array $data): self + { + $errors = []; + $title = null; + try { + $title = Title::fromString(self::str($data['title'] ?? '')); + } catch (InvalidArgumentException $e) { + $errors['title'] = $e->getMessage(); + $title = null; + } + + $author = trim(self::str($data['author'] ?? '')); + if (mb_strlen($author) > self::MAX_AUTHOR_LENGTH) { + $errors['author'] = 'имя автора длиннее ' . self::MAX_AUTHOR_LENGTH . ' символов'; + } + + $coverId = self::coverId($data['cover_id'] ?? null); + if ($coverId === false) { + $errors['cover_id'] = 'идентификатор обложки должен быть положительным числом'; + $coverId = null; + } + + if ($errors !== []) { + throw new ValidationException('проверьте поля книги', $errors); + } + + return new self($title, $author, self::nullableString($data['open_library_key'] ?? null), $coverId); + } + + private static function str(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + private static function nullableString(mixed $value): ?string + { + $value = trim(self::str($value)); + + return $value === '' ? null : $value; + } + + private static function coverId(mixed $value): int|false|null + { + if ($value === null || $value === '') { + return null; + } + if (is_int($value)) { + return $value > 0 ? $value : false; + } + if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) { + $number = (int) $value; + + return $number > 0 ? $number : false; + } + + return false; + } +} diff --git a/app/backend/src/Http/Input/NoteInput.php b/app/backend/src/Http/Input/NoteInput.php index b7d9bf7..01bfd86 100644 --- a/app/backend/src/Http/Input/NoteInput.php +++ b/app/backend/src/Http/Input/NoteInput.php @@ -5,6 +5,7 @@ namespace Recall\Http\Input; use InvalidArgumentException; +use Recall\Domain\ValueObject\BookId; use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; @@ -20,6 +21,7 @@ public function __construct( public string $body, public TagList $tags, public NoteIdList $links, + public ?BookId $bookId, ) {} /** @param array $data */ @@ -28,6 +30,7 @@ public static function fromArray(array $data): self $errors = []; $title = null; $links = null; + $bookId = null; try { $title = Title::fromString(self::str($data['title'] ?? '')); @@ -46,11 +49,20 @@ public static function fromArray(array $data): self $errors['links'] = $e->getMessage(); } + $rawBookId = self::nullableString($data['book_id'] ?? null); + if ($rawBookId !== null) { + try { + $bookId = BookId::fromString($rawBookId); + } catch (InvalidArgumentException $e) { + $errors['book_id'] = $e->getMessage(); + } + } + if ($errors !== [] || $title === null || $links === null) { throw new ValidationException('проверьте поля заметки', $errors); } - return new self($title, $body, TagList::fromStrings(self::list($data['tags'] ?? [])), $links); + return new self($title, $body, TagList::fromStrings(self::list($data['tags'] ?? [])), $links, $bookId); } private static function str(mixed $value): string @@ -58,6 +70,13 @@ private static function str(mixed $value): string return is_scalar($value) ? (string) $value : ''; } + private static function nullableString(mixed $value): ?string + { + $value = trim(self::str($value)); + + return $value === '' ? null : $value; + } + /** @return list */ private static function list(mixed $value): array { diff --git a/app/backend/src/Infrastructure/Persistence/BookRepository.php b/app/backend/src/Infrastructure/Persistence/BookRepository.php new file mode 100644 index 0000000..6e13bf6 --- /dev/null +++ b/app/backend/src/Infrastructure/Persistence/BookRepository.php @@ -0,0 +1,86 @@ + */ + public function all(UserId $userId): array + { + return $this->collect( + (new Select($this->orm, Book::class))->where('userId', $userId->toString())->orderBy('id')->fetchAll(), + ); + } + + public function find(UserId $userId, BookId $id): ?Book + { + foreach ((new Select($this->orm, Book::class)) + ->where('id', $id->toString()) + ->where('userId', $userId->toString()) + ->fetchAll() as $book) { + if ($book instanceof Book) { + return $book; + } + } + + return null; + } + + public function belongsToAnotherUser(UserId $userId, BookId $id): bool + { + foreach ((new Select($this->orm, Book::class))->where('id', $id->toString())->fetchAll() as $book) { + return $book instanceof Book && $book->userId?->toString() !== $userId->toString(); + } + + return false; + } + + public function save(UserId $userId, Book $book): void + { + if ($book->userId === null) { + $book->userId = $userId; + } elseif ($book->userId->toString() !== $userId->toString()) { + throw new LogicException('книга принадлежит другому пользователю'); + } + + (new EntityManager($this->orm))->persist($book)->run(); + } + + public function delete(UserId $userId, Book $book): void + { + if ($book->userId?->toString() !== $userId->toString()) { + throw new LogicException('книга принадлежит другому пользователю'); + } + + (new EntityManager($this->orm))->delete($book)->run(); + } + + /** + * @param iterable $rows + * @return list + */ + private function collect(iterable $rows): array + { + $books = []; + foreach ($rows as $book) { + if ($book instanceof Book) { + $books[] = $book; + } + } + + return $books; + } +} diff --git a/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php b/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php index 5b10a69..e1664cb 100644 --- a/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php +++ b/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php @@ -26,6 +26,16 @@ public static function migrate(DatabaseInterface $db): void expires_at TEXT NOT NULL )", ); + $db->execute( + "CREATE TABLE IF NOT EXISTS books ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + open_library_key TEXT NULL, + cover_id INTEGER NULL, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) + )", + ); $db->execute( "CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, @@ -34,6 +44,7 @@ public static function migrate(DatabaseInterface $db): void tags TEXT NOT NULL DEFAULT '[]', links TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL, + book_id TEXT NULL, user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) )", ); @@ -62,12 +73,13 @@ public static function migrate(DatabaseInterface $db): void ); self::addUserOwnershipColumns($db); + self::addNoteBookColumn($db); } /** Добавляет ownership-поля без пересборки уже выданной SQLite-базы. */ private static function addUserOwnershipColumns(DatabaseInterface $db): void { - foreach (['notes', 'cards', 'reviews'] as $table) { + foreach (['books', 'notes', 'cards', 'reviews'] as $table) { if (!$db->table($table)->hasColumn('user_id')) { $db->execute( "ALTER TABLE $table ADD COLUMN user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36)", @@ -76,4 +88,13 @@ private static function addUserOwnershipColumns(DatabaseInterface $db): void $db->execute("CREATE INDEX IF NOT EXISTS {$table}_user_id_idx ON $table (user_id)"); } } + + /** Добавляет связь заметки с книгой в уже выданную SQLite-базу. */ + private static function addNoteBookColumn(DatabaseInterface $db): void + { + if (!$db->table('notes')->hasColumn('book_id')) { + $db->execute('ALTER TABLE notes ADD COLUMN book_id TEXT NULL'); + } + $db->execute('CREATE INDEX IF NOT EXISTS notes_book_id_idx ON notes (book_id)'); + } } diff --git a/app/backend/src/Infrastructure/Persistence/OrmSchema.php b/app/backend/src/Infrastructure/Persistence/OrmSchema.php index 20c8752..a759e04 100644 --- a/app/backend/src/Infrastructure/Persistence/OrmSchema.php +++ b/app/backend/src/Infrastructure/Persistence/OrmSchema.php @@ -6,12 +6,14 @@ use Cycle\ORM\Parser\Typecast; use Cycle\ORM\SchemaInterface; +use Recall\Domain\Book; use Recall\Domain\Card; use Recall\Domain\Grade; use Recall\Domain\Note; use Recall\Domain\Review; use Recall\Domain\Session; use Recall\Domain\User; +use Recall\Domain\ValueObject\BookId; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\CardText; use Recall\Domain\ValueObject\Day; @@ -34,7 +36,7 @@ public static function map(): array { $handler = [ValueObjectTypecast::class, Typecast::class]; - return self::authenticationMap($handler) + [ + return self::authenticationMap($handler) + self::bookMap($handler) + [ Note::class => [ SchemaInterface::ROLE => 'note', SchemaInterface::DATABASE => 'default', @@ -48,6 +50,7 @@ public static function map(): array 'tags' => 'tags', 'links' => 'links', 'updatedAt' => 'updated_at', + 'bookId' => 'book_id', 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ @@ -56,6 +59,7 @@ public static function map(): array 'tags' => TagList::class, 'links' => NoteIdList::class, 'updatedAt' => 'datetime', + 'bookId' => BookId::class, 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], @@ -167,4 +171,36 @@ private static function authenticationMap(array $handler): array ], ]; } + + /** + * @param list $handler + * @return array> + */ + private static function bookMap(array $handler): array + { + return [ + Book::class => [ + SchemaInterface::ROLE => 'book', + SchemaInterface::DATABASE => 'default', + SchemaInterface::TABLE => 'books', + SchemaInterface::PRIMARY_KEY => 'id', + SchemaInterface::TYPECAST_HANDLER => $handler, + SchemaInterface::COLUMNS => [ + 'id' => 'id', + 'title' => 'title', + 'author' => 'author', + 'openLibraryKey' => 'open_library_key', + 'coverId' => 'cover_id', + 'userId' => 'user_id', + ], + SchemaInterface::TYPECAST => [ + 'id' => BookId::class, + 'title' => Title::class, + 'userId' => UserId::class, + ], + SchemaInterface::SCHEMA => [], + SchemaInterface::RELATIONS => [], + ], + ]; + } } diff --git a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php index 266f6a3..fbf9712 100644 --- a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php +++ b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php @@ -6,6 +6,7 @@ use Cycle\ORM\Parser\CastableInterface; use Cycle\ORM\Parser\UncastableInterface; +use Recall\Domain\ValueObject\BookId; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\CardText; use Recall\Domain\ValueObject\Day; @@ -86,27 +87,12 @@ public function uncast(array $data): array /** @return array{0: callable(mixed): mixed, 1: callable(mixed): mixed}|null */ private function pair(string $rule): ?array { + $identity = $this->identityPair($rule); + if ($identity !== null) { + return $identity; + } + return match ($rule) { - NoteId::class => [ - static fn(mixed $v): NoteId => NoteId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof NoteId ? $v->toString() : self::str($v), - ], - CardId::class => [ - static fn(mixed $v): CardId => CardId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof CardId ? $v->toString() : self::str($v), - ], - ReviewId::class => [ - static fn(mixed $v): ReviewId => ReviewId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof ReviewId ? $v->toString() : self::str($v), - ], - UserId::class => [ - static fn(mixed $v): UserId => UserId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof UserId ? $v->toString() : self::str($v), - ], - SessionId::class => [ - static fn(mixed $v): SessionId => SessionId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof SessionId ? $v->toString() : self::str($v), - ], Username::class => [ static fn(mixed $v): Username => new Username(self::str($v)), static fn(mixed $v): string => $v instanceof Username ? $v->value : self::str($v), @@ -143,6 +129,38 @@ private function pair(string $rule): ?array }; } + /** @return array{0: callable(mixed): mixed, 1: callable(mixed): mixed}|null */ + private function identityPair(string $rule): ?array + { + return match ($rule) { + BookId::class => [ + static fn(mixed $v): BookId => BookId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof BookId ? $v->toString() : self::str($v), + ], + CardId::class => [ + static fn(mixed $v): CardId => CardId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof CardId ? $v->toString() : self::str($v), + ], + NoteId::class => [ + static fn(mixed $v): NoteId => NoteId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof NoteId ? $v->toString() : self::str($v), + ], + ReviewId::class => [ + static fn(mixed $v): ReviewId => ReviewId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof ReviewId ? $v->toString() : self::str($v), + ], + SessionId::class => [ + static fn(mixed $v): SessionId => SessionId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof SessionId ? $v->toString() : self::str($v), + ], + UserId::class => [ + static fn(mixed $v): UserId => UserId::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof UserId ? $v->toString() : self::str($v), + ], + default => null, + }; + } + private static function str(mixed $value): string { return is_scalar($value) ? (string) $value : ''; diff --git a/app/backend/tests/BookRepositoryTest.php b/app/backend/tests/BookRepositoryTest.php new file mode 100644 index 0000000..46d5517 --- /dev/null +++ b/app/backend/tests/BookRepositoryTest.php @@ -0,0 +1,38 @@ +orm); + $ownerId = UserId::generate(); + $otherUserId = UserId::generate(); + $book = Book::create(Title::fromString('The Pragmatic Programmer'), 'Andrew Hunt', '/works/OL123W', 12345); + + $repo->save($ownerId, $book); + + $found = $repo->find($ownerId, $book->id); + Assert::notNull($found); + Assert::same($found->title->value, 'The Pragmatic Programmer'); + Assert::same($found->author, 'Andrew Hunt'); + Assert::same($found->openLibraryKey, '/works/OL123W'); + Assert::same($found->coverId, 12345); + Assert::same($found->createdAt()->format(DateTimeImmutable::ATOM), $book->createdAt()->format(DateTimeImmutable::ATOM)); + Assert::same($repo->find($otherUserId, $book->id), null); + Assert::true($repo->belongsToAnotherUser($otherUserId, $book->id)); + } +} diff --git a/app/backend/tests/OwnershipSchemaTest.php b/app/backend/tests/OwnershipSchemaTest.php index 4d66f42..5d62e38 100644 --- a/app/backend/tests/OwnershipSchemaTest.php +++ b/app/backend/tests/OwnershipSchemaTest.php @@ -24,11 +24,13 @@ public function addsOwnerColumnsAndIndexesToAnExistingDatabase(): void $this->createLegacySchema($path); $database = DatabaseContext::boot($path)->dbal->database('default'); - foreach (['notes', 'cards', 'reviews'] as $table) { + foreach (['books', 'notes', 'cards', 'reviews'] as $table) { $schema = $database->table($table); Assert::true($schema->hasColumn('user_id')); Assert::true($schema->hasIndex(['user_id'])); } + Assert::true($database->table('notes')->hasColumn('book_id')); + Assert::true($database->table('notes')->hasIndex(['book_id'])); } finally { unlink($path); } diff --git a/spec/acceptance/books.hurl b/spec/acceptance/books.hurl new file mode 100644 index 0000000..635c04c --- /dev/null +++ b/spec/acceptance/books.hurl @@ -0,0 +1,61 @@ +# Books: store a source and its Open Library cover identifier. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-b", + "password": "correct-horse-battery-staple" +} +HTTP 201 + +POST {{base}}/books +Content-Type: application/json +{ + "title": "The Pragmatic Programmer", + "author": "Andrew Hunt", + "open_library_key": "/works/OL123W", + "cover_id": 12345 +} +HTTP 201 +[Captures] +book_id: jsonpath "$.id" +[Asserts] +jsonpath "$.title" == "The Pragmatic Programmer" +jsonpath "$.author" == "Andrew Hunt" +jsonpath "$.open_library_key" == "/works/OL123W" +jsonpath "$.cover_id" == 12345 + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Ownership", + "body": "Each value has one owner.", + "book_id": "{{book_id}}" +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" +[Asserts] +jsonpath "$.book_id" == {{book_id}} + +GET {{base}}/books/{{book_id}} +HTTP 200 +[Asserts] +jsonpath "$.cover_id" == 12345 + +GET {{base}}/books +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].id" contains {{book_id}} + +DELETE {{base}}/books/{{book_id}} +HTTP 204 + +GET {{base}}/books/{{book_id}} +HTTP 404 + +GET {{base}}/notes/{{note_id}} +HTTP 200 +[Asserts] +jsonpath "$.book_id" == {{book_id}} From 99bed125f4125c789b83ce2bb5bfaf2e780d6b2a Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:03:12 +0200 Subject: [PATCH 070/102] =?UTF-8?q?feat(books):=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Open=20Library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/backend/public/index.php | 4 +- app/backend/src/Domain/BookSearchResult.php | 16 ++ .../src/Http/Controller/BooksController.php | 25 ++++ .../src/Infrastructure/OpenLibraryClient.php | 141 ++++++++++++++++++ app/backend/tests/OpenLibraryClientTest.php | 54 +++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/Domain/BookSearchResult.php create mode 100644 app/backend/src/Infrastructure/OpenLibraryClient.php create mode 100644 app/backend/tests/OpenLibraryClientTest.php diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 2a5673d..7c8b7a2 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -19,6 +19,7 @@ use Recall\Http\Serializer; use Recall\Http\SessionCookie; use Recall\Http\ValidationException; +use Recall\Infrastructure\OpenLibraryClient; use Recall\Infrastructure\Persistence\BookRepository; use Recall\Infrastructure\Persistence\CardRepository; use Recall\Infrastructure\Persistence\DatabaseContext; @@ -56,7 +57,7 @@ $serializer = new Serializer(); $notes = new NotesController($noteRepo, $bookRepo, $serializer, $now); -$books = new BooksController($bookRepo, $serializer); +$books = new BooksController($bookRepo, $serializer, new OpenLibraryClient()); $cards = new CardsController($cardRepo, $noteRepo, $serializer, $now); $reviews = new ReviewsController($cardRepo, $reviewRepo, $serializer, $now); $stats = new StatsController($cardRepo, $reviewRepo, $serializer, $now); @@ -109,6 +110,7 @@ $app->put('/notes/{id}', $notes->update(...)); $app->delete('/notes/{id}', $notes->delete(...)); +$app->get('/books/search', $books->search(...)); $app->get('/books', $books->index(...)); $app->post('/books', $books->create(...)); $app->get('/books/{id}', $books->show(...)); diff --git a/app/backend/src/Domain/BookSearchResult.php b/app/backend/src/Domain/BookSearchResult.php new file mode 100644 index 0000000..273d2f5 --- /dev/null +++ b/app/backend/src/Domain/BookSearchResult.php @@ -0,0 +1,16 @@ +getQueryParams()['q'] ?? null; + $query = is_string($rawQuery) ? trim($rawQuery) : ''; + if (mb_strlen($query) < 2) { + return Json::error( + $response, + 'поисковый запрос должен содержать минимум 2 символа', + 422, + ['q' => 'минимум 2 символа'], + ); + } + + try { + $results = $this->catalog->search($query); + } catch (RuntimeException) { + return Json::error($response, 'каталог книг недоступен', 502); + } + + return Json::write($response, array_map($this->serializer->serialize(...), $results)); + } + public function index(Request $request, Response $response): Response { return Json::write( diff --git a/app/backend/src/Infrastructure/OpenLibraryClient.php b/app/backend/src/Infrastructure/OpenLibraryClient.php new file mode 100644 index 0000000..5710a02 --- /dev/null +++ b/app/backend/src/Infrastructure/OpenLibraryClient.php @@ -0,0 +1,141 @@ +fetch = $fetch ?? static function (string $url): ?string { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 5, + 'header' => "Accept: application/json\r\nUser-Agent: Recall/1.0\r\n", + ], + ]); + set_error_handler(static fn(int $severity, string $message, string $file, int $line): bool => true); + try { + $body = file_get_contents($url, false, $context); + } finally { + restore_error_handler(); + } + + return is_string($body) ? $body : null; + }; + } + + /** @return list */ + public function search(string $query): array + { + $url = 'https://openlibrary.org/search.json?' . http_build_query( + [ + 'q' => $query, + 'limit' => self::RESULT_LIMIT, + 'fields' => 'key,title,author_name,cover_i', + ], + '', + '&', + PHP_QUERY_RFC3986, + ); + $body = ($this->fetch)($url); + if ($body === null) { + throw new RuntimeException('каталог книг недоступен'); + } + + try { + $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('каталог книг вернул некорректный ответ', 0, $e); + } + + $documents = is_array($payload) && is_array($payload['docs'] ?? null) + ? array_values($payload['docs']) + : []; + + return $this->results($documents); + } + + /** + * @param array $documents + * @return list + */ + private function results(array $documents): array + { + $results = []; + $seen = []; + foreach ($documents as $document) { + if (!is_array($document)) { + continue; + } + $key = $this->stringValue($document['key'] ?? null); + $title = trim($this->stringValue($document['title'] ?? null)); + if ($key === '') { + continue; + } + if ($title === '') { + continue; + } + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $results[] = new BookSearchResult( + $title, + $this->author($document['author_name'] ?? null), + $key, + $this->coverId($document['cover_i'] ?? null), + ); + } + + return $results; + } + + private function stringValue(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + private function author(mixed $value): string + { + if (!is_array($value)) { + return ''; + } + foreach ($value as $author) { + if (is_scalar($author) && trim((string) $author) !== '') { + return trim((string) $author); + } + } + + return ''; + } + + private function coverId(mixed $value): ?int + { + if (is_int($value) && $value > 0) { + return $value; + } + if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) { + $coverId = (int) $value; + + return $coverId > 0 ? $coverId : null; + } + + return null; + } +} diff --git a/app/backend/tests/OpenLibraryClientTest.php b/app/backend/tests/OpenLibraryClientTest.php new file mode 100644 index 0000000..c4df522 --- /dev/null +++ b/app/backend/tests/OpenLibraryClientTest.php @@ -0,0 +1,54 @@ + [ + [ + 'key' => '/works/OL123W', + 'title' => 'The Pragmatic Programmer', + 'author_name' => ['Andrew Hunt', 'David Thomas'], + 'cover_i' => 12345, + ], + [ + 'key' => '/works/OL123W', + 'title' => 'Duplicate', + ], + [ + 'key' => '/works/OL456W', + 'title' => 'No cover', + 'author_name' => [], + ], + ['title' => 'Missing key'], + ], + ], JSON_THROW_ON_ERROR); + }); + + $results = $client->search('rust & memory'); + + Assert::same(count($results), 2); + Assert::same($results[0]->title, 'The Pragmatic Programmer'); + Assert::same($results[0]->author, 'Andrew Hunt'); + Assert::same($results[0]->openLibraryKey, '/works/OL123W'); + Assert::same($results[0]->coverId, 12345); + Assert::same($results[1]->author, ''); + Assert::same($results[1]->coverId, null); + Assert::true(str_contains($requestedUrl, 'q=rust%20%26%20memory')); + Assert::true(str_contains($requestedUrl, 'limit=10')); + } +} From 76aa40f7ade336281254e05415df9a128b878ae6 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:36:24 +0200 Subject: [PATCH 071/102] =?UTF-8?q?feat(frontend):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 1 + app/frontend/src/api.ts | 37 +++++- app/frontend/src/book-picker.ts | 182 +++++++++++++++++++++++++++++ app/frontend/src/books-api.test.ts | 60 ++++++++++ app/frontend/src/create.css | 106 +++++++++++++++++ app/frontend/src/main.ts | 1 + app/frontend/src/note-edit.ts | 24 +++- app/frontend/src/note-form.ts | 31 +++++ app/frontend/src/notes.ts | 47 ++++---- 9 files changed, 464 insertions(+), 25 deletions(-) create mode 100644 app/frontend/src/book-picker.ts create mode 100644 app/frontend/src/books-api.test.ts create mode 100644 app/frontend/src/note-form.ts diff --git a/app/frontend/index.html b/app/frontend/index.html index 1698fa7..290589f 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -169,6 +169,7 @@

        Заметки

        placeholder="Теги (через запятую)" aria-label="Теги через запятую" /> +
        + - +
        + + +

          required > - +
          + + +
          diff --git a/app/frontend/src/create.ts b/app/frontend/src/create.ts index 2201fc1..cec0fd6 100644 --- a/app/frontend/src/create.ts +++ b/app/frontend/src/create.ts @@ -43,6 +43,13 @@ export function setupCreateActions(elements: CreateElements): () => void { elements.cardButton.setAttribute("aria-expanded", "false"); } + elements.noteForm + .querySelector("[data-create-cancel]") + ?.addEventListener("click", closeNoteForm); + elements.cardForm + .querySelector("[data-create-cancel]") + ?.addEventListener("click", closeCardForm); + async function openCardForm(): Promise { const request = ++openRequest; const isOpen = elements.cardForm.classList.contains("is-open"); diff --git a/app/frontend/src/workspace.css b/app/frontend/src/workspace.css index 4a6f448..f91f812 100644 --- a/app/frontend/src/workspace.css +++ b/app/frontend/src/workspace.css @@ -112,25 +112,29 @@ margin: 0; } -.note-edit-actions { +.note-edit-actions, +.create-actions { display: grid; grid-template-columns: minmax(0, 60fr) minmax(0, 40fr); gap: 0.35rem; } -.note-edit-actions button { +.note-edit-actions button, +.create-actions button { width: 100%; margin: 0; border: 0; font-weight: 700; } -.note-edit-actions button[type="button"] { +.note-edit-actions button[type="button"], +.create-actions button[type="button"] { background: var(--app-highlight-background); color: var(--app-highlight-color); } -.note-edit-actions button[type="button"]:hover { +.note-edit-actions button[type="button"]:hover, +.create-actions button[type="button"]:hover { border: 0; background: var(--app-highlight-hover-background); color: var(--app-highlight-color); diff --git a/e2e/tests/notes.spec.ts b/e2e/tests/notes.spec.ts index 025757e..45f3e49 100644 --- a/e2e/tests/notes.spec.ts +++ b/e2e/tests/notes.spec.ts @@ -62,6 +62,19 @@ test("keeps note editing and creation menus mutually exclusive", async ({ await expect(page.locator("#note-form")).toBeHidden(); }); +test("cancels note and card creation forms", async ({ page }) => { + await page.goto("/"); + await register(page); + + await openCreateForm(page, "note"); + await page.click("#note-form [data-create-cancel]"); + await expect(page.locator("#note-form")).toBeHidden(); + + await openCreateForm(page, "card"); + await page.click("#card-form [data-create-cancel]"); + await expect(page.locator("#card-form")).toBeHidden(); +}); + test("notes truncate long text and use three-note pages", async ({ page }) => { await page.goto("/"); await register(page); From 75f6287f075c46489c7659d8e3906c0eae2c0db8 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:17:55 +0200 Subject: [PATCH 080/102] =?UTF-8?q?fix(frontend):=20=D0=B2=D1=8B=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BD=D0=B5=D0=BD=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=A1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/cards.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/frontend/src/cards.css b/app/frontend/src/cards.css index 42102d1..668e38c 100644 --- a/app/frontend/src/cards.css +++ b/app/frontend/src/cards.css @@ -193,10 +193,10 @@ } .card .grade-buttons button[data-testid="grade-again"] { - width: 3rem; - min-width: 3rem; - height: 3rem; - min-height: 3rem; + width: 3.25rem; + min-width: 3.25rem; + height: 3.25rem; + min-height: 3.25rem; padding: 0; justify-self: start; } From f8b521762f7eb14728a3b3461f0f75727e2a365a Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:45:04 +0200 Subject: [PATCH 081/102] =?UTF-8?q?fix(frontend):=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B5=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC=D0=BB=D0=B5=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 6 ++++++ app/frontend/src/auth.ts | 38 ++++++++++++++++++++++++++++++++++++-- app/frontend/src/main.ts | 1 + app/frontend/src/style.css | 20 ++++++++++++++++++++ app/frontend/src/ui.ts | 11 +++++++---- e2e/tests/login.spec.ts | 4 +++- 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index c0c2c30..718b2f4 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -58,6 +58,12 @@

          Войти

          autocomplete="current-password" required /> + diff --git a/app/frontend/src/auth.ts b/app/frontend/src/auth.ts index 89cf1ad..6fab286 100644 --- a/app/frontend/src/auth.ts +++ b/app/frontend/src/auth.ts @@ -1,5 +1,5 @@ import { api, ApiError, type User } from "./api"; -import type { UiActions } from "./ui"; +import { errorMessage, type UiActions } from "./ui"; export interface AuthElements { registration: HTMLElement; @@ -9,6 +9,7 @@ export interface AuthElements { currentUserBar: HTMLElement; registerForm: HTMLFormElement; loginForm: HTMLFormElement; + loginError: HTMLElement; showLoginButton: HTMLButtonElement; showRegistrationButton: HTMLButtonElement; logoutButton: HTMLButtonElement; @@ -20,6 +21,36 @@ export function setupAuth( refreshAll: () => Promise, ): void { let currentUser: User | null = null; + let loginErrorAnimation = 0; + + function clearLoginError(): void { + loginErrorAnimation += 1; + elements.loginError.textContent = ""; + elements.loginError.classList.remove("is-visible"); + elements.loginError.setAttribute("aria-hidden", "true"); + } + + function showLoginError(error: unknown): void { + const message = errorMessage(error).replace(/^Ошибка:\s*/, ""); + const formattedMessage = message + ? `${message.charAt(0).toLocaleUpperCase("ru-RU")}${message.slice(1)}` + : message; + if ( + elements.loginError.classList.contains("is-visible") && + elements.loginError.textContent === formattedMessage + ) { + return; + } + + const animation = ++loginErrorAnimation; + elements.loginError.textContent = formattedMessage; + elements.loginError.setAttribute("aria-hidden", "false"); + window.requestAnimationFrame(() => { + if (animation === loginErrorAnimation) { + elements.loginError.classList.add("is-visible"); + } + }); + } function renderCurrentUser(): void { if (currentUser) { @@ -52,6 +83,7 @@ export function setupAuth( elements.workspace.hidden = true; elements.userBar.hidden = true; renderCurrentUser(); + clearLoginError(); actions.clearStatus(); } @@ -62,6 +94,7 @@ export function setupAuth( elements.workspace.hidden = true; elements.userBar.hidden = true; renderCurrentUser(); + clearLoginError(); actions.clearStatus(); } @@ -95,6 +128,7 @@ export function setupAuth( submit.disabled = true; actions.clearStatus(); + clearLoginError(); void api .register({ username: actions.field(data, "username"), @@ -123,7 +157,7 @@ export function setupAuth( password: actions.field(data, "password"), }) .then(showWorkspace) - .catch(actions.showError) + .catch(showLoginError) .finally(() => (submit.disabled = false)); }); diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index e8c5876..d51ecd7 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -140,6 +140,7 @@ if (publicUsername !== null) { currentUserBar: need("#current-user"), registerForm: needForm("#register-form"), loginForm: needForm("#login-form"), + loginError: need("#login-error"), showLoginButton: needButton("#show-login"), showRegistrationButton: needButton("#show-registration"), logoutButton: needButton("#logout"), diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index e95bad2..7c97144 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -218,6 +218,26 @@ a:hover { margin-bottom: 1.25rem; } +.auth-error { + max-height: 0; + margin: 0; + overflow: hidden; + opacity: 0; + color: #8f3028; + font-size: 0.9rem; + text-align: center; + transition: + max-height 0.22s ease, + margin 0.22s ease, + opacity 0.18s ease; +} + +.auth-error.is-visible { + max-height: 4rem; + margin: -0.5rem 0 1rem; + opacity: 1; +} + #registration form button, #login form button { margin-top: 0.25rem; diff --git a/app/frontend/src/ui.ts b/app/frontend/src/ui.ts index b1607a2..136f30f 100644 --- a/app/frontend/src/ui.ts +++ b/app/frontend/src/ui.ts @@ -7,6 +7,12 @@ export interface UiActions { onClick: (button: HTMLButtonElement, action: () => Promise) => void; } +export function errorMessage(error: unknown): string { + return error instanceof ApiError + ? `Ошибка: ${error.message}` + : "Что-то пошло не так"; +} + export function createUiActions(statusBar: HTMLElement): UiActions { function field(form: FormData, name: string): string { const value = form.get(name); @@ -14,10 +20,7 @@ export function createUiActions(statusBar: HTMLElement): UiActions { } function showError(error: unknown): void { - statusBar.textContent = - error instanceof ApiError - ? `Ошибка: ${error.message}` - : "Что-то пошло не так"; + statusBar.textContent = errorMessage(error); statusBar.dataset.state = "error"; } diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index 96b24dc..4404ad4 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -22,6 +22,8 @@ test("показывает ошибку при неверном пароле", a await page.fill("#login-form input[name='password']", "wrong-password"); await page.click("#login-form button[type='submit']"); - await expect(page.locator("#status")).toContainText("неверный логин или пароль"); + await expect(page.locator("#login-error")).toContainText( + "Неверный логин или пароль", + ); await expect(page.locator("#login")).toBeVisible(); }); From 0fc26176a94b0bce359e26cdb282b3c2796f7d8b Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:47:10 +0200 Subject: [PATCH 082/102] =?UTF-8?q?refactor(frontend):=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=B8=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/book-picker.css | 123 ++++++++++++++++++++++++++++++ app/frontend/src/create.css | 124 ------------------------------- app/frontend/src/main.ts | 1 + 3 files changed, 124 insertions(+), 124 deletions(-) create mode 100644 app/frontend/src/book-picker.css diff --git a/app/frontend/src/book-picker.css b/app/frontend/src/book-picker.css new file mode 100644 index 0000000..6108771 --- /dev/null +++ b/app/frontend/src/book-picker.css @@ -0,0 +1,123 @@ +.book-picker-label { + display: block; + min-width: 0; + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +.book-picker { + display: grid; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.book-picker-controls { + display: grid; + gap: 0.35rem; + min-width: 0; + padding: 0.35rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.75rem; + background: var(--pico-background-color); +} + +.book-picker-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + gap: 0.35rem; +} + +.book-picker-search input, +.book-picker-search button { + margin: 0; +} + +.book-picker-search button, +.book-picker-clear { + border: 0; + white-space: nowrap; +} + +.book-picker-search button:hover { + border: 0; + background: var(--pico-primary-hover-background); + color: var(--pico-primary-inverse); +} + +.book-picker-results { + display: grid; + gap: 0.25rem; + max-height: 10rem; + overflow-y: auto; +} + +.book-picker-result { + min-width: 0; + overflow: hidden; + min-height: 2.25rem; + margin: 0; + padding: 0.35rem 0.5rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--pico-color); + text-overflow: ellipsis; + text-align: left; + white-space: nowrap; +} + +.book-picker-result:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.book-picker-empty { + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +.book-picker-selected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; + padding: 0.4rem 0.5rem 0.4rem 0.75rem; + border-radius: 0.5rem; + background: var(--app-highlight-background); + color: var(--app-highlight-color); + font-size: 0.85rem; +} + +.book-picker-selected span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-picker-clear { + flex: 0 0 auto; + min-height: 0; + margin: 0; + padding: 0.15rem 0.35rem; + background: transparent; + color: inherit; + font-size: 1.15rem; + line-height: 1; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.book-picker-clear:hover, +.book-picker-clear:focus-visible { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} diff --git a/app/frontend/src/create.css b/app/frontend/src/create.css index 95712f6..a47156b 100644 --- a/app/frontend/src/create.css +++ b/app/frontend/src/create.css @@ -121,130 +121,6 @@ margin: 0; } -.book-picker-label { - display: block; - min-width: 0; - margin: 0; - color: var(--pico-muted-color); - font-size: 0.85rem; -} - -.book-picker { - display: grid; - gap: 0.35rem; - width: 100%; - min-width: 0; -} - -.book-picker-controls { - display: grid; - gap: 0.35rem; - min-width: 0; - padding: 0.35rem; - border: 1px solid var(--pico-muted-border-color); - border-radius: 0.75rem; - background: var(--pico-background-color); -} - -.book-picker-search { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - min-width: 0; - gap: 0.35rem; -} - -.book-picker-search input, -.book-picker-search button { - margin: 0; -} - -.book-picker-search button, -.book-picker-clear { - border: 0; - white-space: nowrap; -} - -.book-picker-search button:hover { - border: 0; - background: var(--pico-primary-hover-background); - color: var(--pico-primary-inverse); -} - -.book-picker-results { - display: grid; - gap: 0.25rem; - max-height: 10rem; - overflow-y: auto; -} - -.book-picker-result { - min-width: 0; - overflow: hidden; - min-height: 2.25rem; - margin: 0; - padding: 0.35rem 0.5rem; - border: 0; - border-radius: 0.5rem; - background: transparent; - color: var(--pico-color); - text-overflow: ellipsis; - text-align: left; - white-space: nowrap; -} - -.book-picker-result:hover { - border: 0; - background: var(--app-highlight-hover-background); - color: var(--app-highlight-color); -} - -.book-picker-empty { - margin: 0; - color: var(--pico-muted-color); - font-size: 0.85rem; -} - -.book-picker-selected { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - min-width: 0; - padding: 0.4rem 0.5rem 0.4rem 0.75rem; - border-radius: 0.5rem; - background: var(--app-highlight-background); - color: var(--app-highlight-color); - font-size: 0.85rem; -} - -.book-picker-selected span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.book-picker-clear { - flex: 0 0 auto; - min-height: 0; - margin: 0; - padding: 0.15rem 0.35rem; - background: transparent; - color: inherit; - font-size: 1.15rem; - line-height: 1; - transition: - background 0.15s ease, - color 0.15s ease; -} - -.book-picker-clear:hover, -.book-picker-clear:focus-visible { - border: 0; - background: var(--app-highlight-hover-background); - color: var(--app-highlight-color); -} - #note-links, .note-links-fieldset { display: grid; diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index d51ecd7..02b6157 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -4,6 +4,7 @@ import "./workspace.css"; import "./cards.css"; import "./notes.css"; import "./book-cover.css"; +import "./book-picker.css"; import "./note-highlight.css"; import "./create.css"; import { setupAuth } from "./auth"; From e7b5bd8045e46242f7d4f9b219e8b4a0e54887d2 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:51:17 +0200 Subject: [PATCH 083/102] =?UTF-8?q?refactor(frontend):=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/note-item.ts | 152 ++++++++++++++++++++++++++++++++++ app/frontend/src/notes.ts | 132 +++-------------------------- 2 files changed, 163 insertions(+), 121 deletions(-) create mode 100644 app/frontend/src/note-item.ts diff --git a/app/frontend/src/note-item.ts b/app/frontend/src/note-item.ts new file mode 100644 index 0000000..7d23da2 --- /dev/null +++ b/app/frontend/src/note-item.ts @@ -0,0 +1,152 @@ +import { api, type Book, type Note } from "./api"; +import { pencilIcon, trashIcon } from "./icons"; +import { createNoteBody } from "./note-body"; +import { createNoteEdit, type NoteEditView } from "./note-edit"; +import { truncateNoteTags, truncateNoteTitle } from "./note-text"; +import type { UiActions } from "./ui"; + +const SCROLL_TOP_OFFSET = 24; + +export interface NoteEditorState { + active: NoteEditView | undefined; + request: number; +} + +interface NoteItemOptions { + note: Note; + linkableNotes: Note[]; + books: Book[]; + actions: UiActions; + editorState: NoteEditorState; + openLinkedNote: (id: string) => void; + refreshNotes: () => Promise; + closeCreateForms: () => void; +} + +export function createNoteItem({ + note, + linkableNotes, + books, + actions, + editorState, + openLinkedNote, + refreshNotes, + closeCreateForms, +}: NoteItemOptions): HTMLLIElement { + const item = document.createElement("li"); + item.dataset.testid = "note"; + item.dataset.id = note.id; + + const title = document.createElement("span"); + title.className = "note-title"; + title.textContent = truncateNoteTitle(note.title); + title.title = note.title; + + const tags = document.createElement("span"); + tags.className = "note-tags"; + tags.textContent = truncateNoteTags(note.tags); + tags.title = note.tags.join(", "); + const { body, toggle: bodyToggle } = createNoteBody( + note, + linkableNotes, + openLinkedNote, + books, + ); + + const edit = document.createElement("button"); + edit.type = "button"; + edit.append(pencilIcon()); + edit.dataset.testid = "edit-note"; + edit.setAttribute("aria-label", `Изменить заметку: ${note.title}`); + const remove = document.createElement("button"); + remove.type = "button"; + remove.append(trashIcon()); + remove.dataset.testid = "delete-note"; + remove.setAttribute("aria-label", `Удалить заметку: ${note.title}`); + actions.onClick(remove, async () => { + await api.deleteNote(note.id); + await refreshNotes(); + }); + + const actionsBox = document.createElement("div"); + actionsBox.className = "note-actions"; + actionsBox.append(bodyToggle, edit, remove); + + const view = document.createElement("div"); + view.className = "note-view"; + view.append(title, tags, actionsBox, body); + item.append(view); + + let editView: NoteEditView; + edit.addEventListener("click", () => { + const request = ++editorState.request; + const previousEdit = editorState.active; + editorState.active = undefined; + void (previousEdit?.close() ?? Promise.resolve()) + .then(() => { + if (request !== editorState.request) { + return; + } + + closeCreateForms(); + view.classList.add("is-editing"); + editView = createNoteEdit( + note, + actions, + { + onCloseStart: (form) => { + const closeHeight = Math.min( + view.offsetHeight, + form.getBoundingClientRect().height, + ); + form.style.setProperty( + "--note-edit-close-height", + `${closeHeight}px`, + ); + form.classList.add("is-closing"); + }, + onClosed: () => { + if (editorState.active === editView) { + editorState.active = undefined; + } + editView.form.classList.remove("is-closing"); + editView.form.remove(); + return new Promise((resolve) => { + window.requestAnimationFrame(() => { + view.classList.remove("is-editing"); + window.setTimeout(resolve, 180); + }); + }); + }, + onSaved: refreshNotes, + }, + linkableNotes, + books, + ); + editorState.active = editView; + item.append(editView.form); + editView.open(); + const openedEdit = editView; + const scroll = (): void => { + if (!openedEdit.form.isConnected) { + return; + } + const { top } = openedEdit.form.getBoundingClientRect(); + if ( + top < 0 || + top + openedEdit.form.scrollHeight > window.innerHeight + ) { + window.scrollTo({ + top: Math.max(0, window.scrollY + top - SCROLL_TOP_OFFSET), + behavior: "smooth", + }); + } + }; + window.requestAnimationFrame(scroll); + window.setTimeout(scroll, 240); + }) + .catch(actions.showError); + }); + + return item; +} diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index 0a4be5d..67a99a6 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -1,18 +1,13 @@ import { api, type Book, type Note } from "./api"; import { createBookPicker, type BookPickerView } from "./book-picker"; import { createAnimatedDisclosure } from "./disclosure"; -import { pencilIcon, trashIcon } from "./icons"; +import { createNoteItem, type NoteEditorState } from "./note-item"; import { animateListHeight, highlightNote } from "./notes-animation"; -import { createNoteBody } from "./note-body"; -import { createNoteEdit, type NoteEditView } from "./note-edit"; import { populateLinkOptions, populateNoteOptions } from "./note-options"; import { readNoteForm } from "./note-form"; -import { truncateNoteTags, truncateNoteTitle } from "./note-text"; import { NOTES_PER_PAGE, setupNotesPagination } from "./notes-pagination"; import type { UiActions } from "./ui"; -const SCROLL_TOP_OFFSET = 24; - export interface NotesElements { noteForm: HTMLFormElement; noteBookPicker: HTMLElement; @@ -36,8 +31,7 @@ export function setupNotes(elements: NotesElements, actions: UiActions) { let notes: Note[] = []; let linkableNotes: Note[] = []; let books: Book[] = []; - let activeEdit: NoteEditView | undefined; - let editRequest = 0; + const editorState: NoteEditorState = { active: undefined, request: 0 }; const noteBookPicker: BookPickerView = createBookPicker( elements.noteBookPicker, actions, @@ -59,120 +53,16 @@ export function setupNotes(elements: NotesElements, actions: UiActions) { }, 240); } function noteItem(note: Note): HTMLLIElement { - const item = document.createElement("li"); - item.dataset.testid = "note"; - item.dataset.id = note.id; - - const title = document.createElement("span"); - title.className = "note-title"; - title.textContent = truncateNoteTitle(note.title); - title.title = note.title; - - const tags = document.createElement("span"); - tags.className = "note-tags"; - tags.textContent = truncateNoteTags(note.tags); - tags.title = note.tags.join(", "); - const { body, toggle: bodyToggle } = createNoteBody( + return createNoteItem({ note, linkableNotes, - openLinkedNote, books, - ); - - const edit = document.createElement("button"); - edit.type = "button"; - edit.append(pencilIcon()); - edit.dataset.testid = "edit-note"; - edit.setAttribute("aria-label", `Изменить заметку: ${note.title}`); - const remove = document.createElement("button"); - remove.type = "button"; - remove.append(trashIcon()); - remove.dataset.testid = "delete-note"; - remove.setAttribute("aria-label", `Удалить заметку: ${note.title}`); - actions.onClick(remove, async () => { - await api.deleteNote(note.id); - await refreshNotes(); - }); - - const actionsBox = document.createElement("div"); - actionsBox.className = "note-actions"; - actionsBox.append(bodyToggle, edit, remove); - - const view = document.createElement("div"); - view.className = "note-view"; - view.append(title, tags, actionsBox, body); - item.append(view); - let editView: NoteEditView; - edit.addEventListener("click", () => { - const request = ++editRequest; - const previousEdit = activeEdit; - activeEdit = undefined; - void (previousEdit?.close() ?? Promise.resolve()) - .then(() => { - if (request !== editRequest) { - return; - } - - elements.closeCreateForms(); - view.classList.add("is-editing"); - editView = createNoteEdit( - note, - actions, - { - onCloseStart: (form) => { - const closeHeight = Math.min( - view.offsetHeight, - form.getBoundingClientRect().height, - ); - form.style.setProperty( - "--note-edit-close-height", - `${closeHeight}px`, - ); - form.classList.add("is-closing"); - }, - onClosed: () => { - if (activeEdit === editView) { - activeEdit = undefined; - } - editView.form.classList.remove("is-closing"); - editView.form.remove(); - return new Promise((resolve) => { - window.requestAnimationFrame(() => { - view.classList.remove("is-editing"); - window.setTimeout(resolve, 180); - }); - }); - }, - onSaved: refreshNotes, - }, - linkableNotes, - books, - ); - activeEdit = editView; - item.append(editView.form); - editView.open(); - const openedEdit = editView; - const scroll = (): void => { - if (!openedEdit.form.isConnected) { - return; - } - const { top } = openedEdit.form.getBoundingClientRect(); - if ( - top < 0 || - top + openedEdit.form.scrollHeight > window.innerHeight - ) { - window.scrollTo({ - top: Math.max(0, window.scrollY + top - SCROLL_TOP_OFFSET), - behavior: "smooth", - }); - } - }; - window.requestAnimationFrame(scroll); - window.setTimeout(scroll, 240); - }) - .catch(actions.showError); + actions, + editorState, + openLinkedNote, + refreshNotes, + closeCreateForms: elements.closeCreateForms, }); - return item; } function renderPage( page: number, @@ -286,9 +176,9 @@ export function setupNotes(elements: NotesElements, actions: UiActions) { await refreshNotes(); }); function closeEditMenus(): Promise { - ++editRequest; - const editView = activeEdit; - activeEdit = undefined; + ++editorState.request; + const editView = editorState.active; + editorState.active = undefined; return editView?.close() ?? Promise.resolve(); } From 289b70752c15bf43056dc65709b71afe3257aa37 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:59:39 +0200 Subject: [PATCH 084/102] =?UTF-8?q?style(frontend):=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B0=D0=BB=D1=8B=20=D0=B2=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=D1=85=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/src/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index 7c97144..c16c38f 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -210,12 +210,14 @@ a:hover { #registration h2, #login h2 { margin-top: 0; + margin-bottom: 1rem; color: var(--pico-color); } #registration form, #login form { - margin-bottom: 1.25rem; + gap: 0; + margin-bottom: 0; } .auth-error { From 04dfd513da51b98088512f9d685a98002c387cd6 Mon Sep 17 00:00:00 2001 From: Sergey Bockanov <199808151+kyoug3n@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:03:15 +0200 Subject: [PATCH 085/102] =?UTF-8?q?feat(frontend):=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=84=D0=BE=D0=BB=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/frontend/index.html | 4 ++-- app/frontend/src/auth.ts | 4 ++-- e2e/tests/auth.ts | 1 + e2e/tests/login.spec.ts | 2 -- e2e/tests/logout.spec.ts | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index 718b2f4..5220f77 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -17,7 +17,7 @@

          Recall

          -
          +

          -