diff --git a/Justfile b/Justfile index 2fc8d4b..c78f133 100644 --- a/Justfile +++ b/Justfile @@ -28,9 +28,9 @@ 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 if curl -sf {{ base }}/health >/dev/null 2>&1; then exit 0; fi; sleep 1; done; echo "бэкенд не ответил за 90 секунд" >&2; exit 1 @echo "ожидание фронтенда {{ front }}" - @for i in $(seq 1 90); do curl -sf {{ front }} >/dev/null 2>&1 && break || sleep 1; done + @for i in $(seq 1 90); do if curl -sf {{ front }} >/dev/null 2>&1; then exit 0; fi; sleep 1; done; echo "фронтенд не ответил за 90 секунд" >&2; exit 1 # Остановить приложение и удалить его тома. down: @@ -38,7 +38,7 @@ down: # API-приёмка — приложение уже должно быть запущено (см. `just up`). acceptance: - hurl --variable base={{ base }} --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/LOGBOOK.md b/LOGBOOK.md index 4b8df1d..b5783a9 100644 --- a/LOGBOOK.md +++ b/LOGBOOK.md @@ -2,16 +2,36 @@ ## Решения -- +- В проекте уже используется алгоритм SM-2, оставил его, т.к он соответствует цели пользователя; +- Вопрос и ответ в карточке показаны одновременно, что неинтутивно для цели пользователя - скрыл ответ до клика; +- После оценки карточки пользователем пересчитываю ее новое состояние: интервал, легкость и дату ее следующего показа; +- Новое состояние карточки сохраняется в БД; +- Добавил отдельную страницу с общими статистиками за всё время и за неделю; +- В публичной странице не показаны заметки, теги и другие личные данные, как требует заказчик; +- Обложки книг реализованы - их можно получать с помощью API OpenLibrary; +- Добавил авторизацию - без нее вовсе не секьюрно. Хотя добавлять ее намного увеличивает объем работы, это необходимо для подобного приложения; +- Решил сделать регистрацию доступной для нескольких аккаунтов - то есть, приложение не однопользовательское; +- Каждая заметка, карточка, и запись повторения принадлежит конкретному пользователю; +- При переходе на страницу другого пользователя показан только его публичный профиль; +- Добавил связи заметок для упрощенной навигации; +- Добавил постраничный просмотр заметок, чтобы не перегружать рабочее пространство; +- Поиск книг сделал необязательным при создании/редактировании заметки; +- Добавил ссылку на заметку, связанную с карточкой, чтобы пользователь мог повторить материал при необходимости. ## Допущения -- +- Под требованием "сделать красиво" понял современный, но не перегруженный дизайн - выполнил; +- Требование заказчика о "напоминаниях" понял как простое появление карточек в очереди, т.е без уведомлений вне приложения. ## С чем поспорил / что отклонил -- +- Решил не добавлять ИИ-конспекты из-за высокого шанса галлюцинаций при работе с большими объемами данных книг, а также лишней сложности/зависимости от сторонних ИИ-сервисов; +- Считаю, что автоматическое удаление заметок после 30 дней может привести к неожиданной потере собранной базы знаний, поэтому отклонил; +- Интеграция со словарём требует очередной интеграции со сторонним API, что раздувает функционал приложения и не необходимо к основному сценарию использования; +- Цитата дня не относится к цели приложения - отклонил. ## Мнение -- +- Пытался максимально отполировать реализацию, постарался уделить побольше внимания секьюрности - устранил возможные уязвимости и т.п; +- Проект очень интересный, задание более полезно, чем многое, чему меня учили в университете; +- Разрешение на пользование агентами очень ценю, т.к в текущих реалиях без них на самом деле никак. diff --git a/app/backend/public/index.php b/app/backend/public/index.php index 7cd1cd0..e2ca324 100644 --- a/app/backend/public/index.php +++ b/app/backend/public/index.php @@ -6,19 +6,30 @@ 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\BooksController; use Recall\Http\Controller\CardsController; +use Recall\Http\Controller\HealthController; use Recall\Http\Controller\NotesController; +use Recall\Http\Controller\ProfileController; use Recall\Http\Controller\ReviewsController; use Recall\Http\Controller\StatsController; use Recall\Http\Json; +use Recall\Http\OriginProtectionMiddleware; 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; use Recall\Infrastructure\Persistence\NoteRepository; -use Recall\Infrastructure\Persistence\Orm; 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; use Slim\Factory\AppFactory; @@ -29,7 +40,7 @@ $databasePath = $envDb === false || $envDb === '' ? dirname(__DIR__) . '/var/recall.sqlite' : $envDb; $now = new DateTimeImmutable('now'); -$boot = Orm::boot($databasePath); +$boot = DatabaseContext::boot($databasePath); $queries = new QueryCounter(); $driver = $boot->dbal->driver('sqlite'); if ($driver instanceof LoggerAwareInterface) { @@ -37,25 +48,53 @@ } $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); +$userRepo = new UserRepository($boot->orm); (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, new OpenLibraryClient()); $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); +$profiles = new ProfileController($userRepo, $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 — фронтенд-дев-сервер ходит к нам с другого источника. -$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(new OriginProtectionMiddleware(new SessionCookie(), $frontendOrigin, $app->getResponseFactory())); +$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 { @@ -73,15 +112,29 @@ $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(...)); +$app->delete('/books/{id}', $books->delete(...)); + $app->get('/cards', $cards->index(...)); $app->post('/cards', $cards->create(...)); $app->get('/cards/{id}', $cards->show(...)); +$app->put('/cards/{id}', $cards->update(...)); $app->delete('/cards/{id}', $cards->delete(...)); $app->get('/reviews/queue', $reviews->queue(...)); $app->post('/reviews/{id}', $reviews->grade(...)); $app->get('/stats', $stats->index(...)); +$app->get('/users/{username}', $profiles->show(...)); +$app->get('/health', $health->show(...)); + +$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)); @@ -89,16 +142,27 @@ $errors = $app->addErrorMiddleware(false, false, false); $errors->setDefaultErrorHandler( - static function (ServerRequestInterface $request, Throwable $exception) use ($app): ResponseInterface { + static function (ServerRequestInterface $request, Throwable $exception) use ($app, $frontendOrigin): ResponseInterface { $response = $app->getResponseFactory()->createResponse(); - return match (true) { + $response = match (true) { $exception instanceof HttpNotFoundException => Json::error($response, 'route not found', 404), $exception instanceof HttpMethodNotAllowedException => Json::error($response, 'method not allowed', 405), $exception instanceof ValidationException => Json::error($response, $exception->getMessage(), 422, $exception->details()), $exception instanceof InvalidArgumentException => Json::error($response, $exception->getMessage(), 422), default => Json::error($response, 'internal error', 500), }; + + 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'); }, ); 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/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 @@ +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/PublicProfile.php b/app/backend/src/Domain/PublicProfile.php new file mode 100644 index 0000000..4b512a7 --- /dev/null +++ b/app/backend/src/Domain/PublicProfile.php @@ -0,0 +1,22 @@ +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/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 @@ + */ public array $ids; @@ -20,8 +24,14 @@ public static function fromStrings(iterable $raw): self { $ids = []; foreach ($raw as $value) { - if (is_string($value) && $value !== '') { + if (!is_string($value) || $value === '') { + throw new InvalidArgumentException(self::INVALID_LINK); + } + + try { $ids[] = NoteId::fromString($value); + } catch (InvalidArgumentException $e) { + throw new InvalidArgumentException(self::INVALID_LINK, $e->getCode(), previous: $e); } } 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/Http/AuthenticationMiddleware.php b/app/backend/src/Http/AuthenticationMiddleware.php new file mode 100644 index 0000000..bc16094 --- /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', '/books', '/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 new file mode 100644 index 0000000..fba7407 --- /dev/null +++ b/app/backend/src/Http/Controller/AuthController.php @@ -0,0 +1,99 @@ +body($request)); + $user = User::register($input->username, $input->password); + try { + $this->users->save($user); + } catch (ConstrainException) { + return Json::error($response, 'логин уже занят', 409, ['username' => 'выберите другой логин']); + } + $credentials = Session::start($user->id, $this->now); + $this->sessions->save($credentials->session); + + 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); + } + + public function currentUser(Request $request, Response $response): Response + { + $user = $request->getAttribute(AuthenticationMiddleware::USER_ATTRIBUTE); + if (!$user instanceof User) { + return Json::error($response, 'необходима авторизация', 401); + } + + 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 + { + $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/Controller/BooksController.php b/app/backend/src/Http/Controller/BooksController.php new file mode 100644 index 0000000..8060af9 --- /dev/null +++ b/app/backend/src/Http/Controller/BooksController.php @@ -0,0 +1,135 @@ +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( + $response, + array_map($this->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/CardsController.php b/app/backend/src/Http/Controller/CardsController.php index 5d9e57b..7114835 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,65 +30,120 @@ 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); + $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)); } 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) { - return Json::error($response, 'note not found', 404); + if ($this->notes->find($userId, $noteId) === null) { + 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); - $this->cards->save($card); + $this->cards->save($userId, $card); return Json::write($response, $this->serializer->serialize($card), 201); } + /** @param array $args */ + public function update(Request $request, Response $response, array $args): Response + { + $userId = CurrentUser::userId($request); + $card = $this->lookup($userId, $args); + if ($card === null) { + return $this->notFoundOrForbidden($response, $userId, $args); + } + + $input = CardInput::fromArray($this->body($request)); + try { + $noteId = NoteId::fromString($input->noteId); + } catch (InvalidArgumentException) { + return Json::error($response, 'note not found', 404); + } + if ($this->notes->find($userId, $noteId) === null) { + return $this->notes->belongsToAnotherUser($userId, $noteId) + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'note not found', 404); + } + + $card->noteId = $noteId; + $card->front = $input->front; + $card->back = $input->back; + $this->cards->save($userId, $card); + + return Json::write($response, $this->serializer->serialize($card)); + } + /** @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); + return $this->notFoundOrForbidden($response, $userId, $args); } - $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; } } + /** @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/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/app/backend/src/Http/Controller/NotesController.php b/app/backend/src/Http/Controller/NotesController.php index 9e06f01..7132cec 100644 --- a/app/backend/src/Http/Controller/NotesController.php +++ b/app/backend/src/Http/Controller/NotesController.php @@ -9,17 +9,23 @@ 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\NoteIdList; 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; +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, ) {} @@ -29,24 +35,38 @@ 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); + $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)); } 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); + $userId = CurrentUser::userId($request); + $bookError = $this->bookError($response, $userId, $input->bookId); + if ($bookError !== null) { + return $bookError; + } + $linkError = $this->linkError($response, $userId, $input->links); + if ($linkError !== null) { + return $linkError; + } + + $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); } @@ -54,14 +74,24 @@ 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); + return $this->notFoundOrForbidden($response, $userId, $args); } $input = NoteInput::fromArray($this->body($request)); - $note->revise($input->title, $input->body, $input->tags, $input->links, $this->now); - $this->notes->save($note); + $bookError = $this->bookError($response, $userId, $input->bookId); + if ($bookError !== null) { + return $bookError; + } + $linkError = $this->linkError($response, $userId, $input->links); + if ($linkError !== null) { + return $linkError; + } + + $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)); } @@ -69,28 +99,73 @@ 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); + return $this->notFoundOrForbidden($response, $userId, $args); } - $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; + } + } + + /** @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); + } + + 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); + } + + private function linkError(Response $response, UserId $userId, NoteIdList $links): ?Response + { + foreach ($links->ids as $link) { + if ($this->notes->find($userId, $link) !== null) { + continue; + } + + return $this->notes->belongsToAnotherUser($userId, $link) + ? Json::error($response, 'forbidden', 403) + : Json::error($response, 'note not found', 404); + } + + return null; } /** @return array */ diff --git a/app/backend/src/Http/Controller/ProfileController.php b/app/backend/src/Http/Controller/ProfileController.php new file mode 100644 index 0000000..7ef3fa1 --- /dev/null +++ b/app/backend/src/Http/Controller/ProfileController.php @@ -0,0 +1,110 @@ + $args */ + public function show(Request $request, Response $response, array $args): Response + { + $rawUsername = $args['username'] ?? null; + if (!is_string($rawUsername)) { + return $this->notFound($response); + } + + try { + $username = Username::fromString($rawUsername); + } catch (InvalidArgumentException) { + return $this->notFound($response); + } + + $user = $this->users->findByUsername($username); + if ($user === null) { + return $this->notFound($response); + } + + $reviews = $this->reviews->all($user->id); + [$practiceDays, $currentStreak, $longestStreak] = $this->reviewStats($reviews); + $reviewsCount = count(array_filter( + $reviews, + static fn(Review $review): bool => $review->grade !== Grade::Again, + )); + $profile = new PublicProfile( + username: $user->username, + createdAt: $user->createdAt(), + cardsCount: $this->cards->countForUser($user->id), + reviewsCount: $reviewsCount, + practiceDays: $practiceDays, + currentStreak: $currentStreak, + longestStreak: $longestStreak, + ); + + return Json::write($response, $this->serializer->serialize($profile)); + } + + /** + * @param list $reviews + * @return array{0: int, 1: int, 2: int} + */ + private function reviewStats(array $reviews): array + { + $days = []; + $timezone = $this->now->getTimezone(); + foreach ($reviews as $review) { + $day = $review->createdAt()->setTimezone($timezone)->format('Y-m-d'); + $days[$day] = true; + } + + $dates = array_keys($days); + sort($dates); + $longest = 0; + $run = 0; + $previous = null; + foreach ($dates as $date) { + $isNextDay = $previous !== null + && (new DateTimeImmutable($date, $timezone))->modify('-1 day')->format('Y-m-d') === $previous; + $run = $isNextDay ? $run + 1 : 1; + $longest = max($longest, $run); + $previous = $date; + } + + $current = 0; + $day = $this->now; + while (isset($days[$day->format('Y-m-d')])) { + ++$current; + $day = $day->modify('-1 day'); + } + + return [count($days), $current, $longest]; + } + + private function notFound(Response $response): Response + { + return Json::error($response, 'профиль не найден', 404); + } +} diff --git a/app/backend/src/Http/Controller/ReviewsController.php b/app/backend/src/Http/Controller/ReviewsController.php index 10c9351..504e1cc 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,15 +42,22 @@ 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); + $cardId = null; $card = null; if (is_string($raw)) { try { - $card = $this->cards->find(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); } @@ -67,10 +75,8 @@ public function grade(Request $request, Response $response, array $args): Respon $review = $card->grade($grade, $this->now); - // TODO: повторение пока не доделано. Оценка сохраняется, но карточка не - // сохраняется с новым интервалом и датой, поэтому не уходит из очереди - // на сегодня. Допишите сохранение карточки ($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 4c4804c..f36276c 100644 --- a/app/backend/src/Http/Controller/StatsController.php +++ b/app/backend/src/Http/Controller/StatsController.php @@ -4,29 +4,55 @@ 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\CurrentUser; 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(); + $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($userId) as $card) { + if ($card->isDue($today)) { + ++$dueToday; + } + if ($card->due->isOnOrBefore($weekEnd)) { + ++$dueWeek; + } + } - // TODO: заглушка. Считает все карточки как «на сегодня» и «за неделю», - // игнорируя даты, а серию всегда отдаёт нулём. Определите настоящие - // правила: сколько повторить сегодня, сколько за неделю и как считать - // серию дней. - $stats = new Stats(dueToday: $total, dueWeek: $total, streak: 0); + $reviewDays = []; + $timezone = $this->now->getTimezone(); + foreach ($this->reviews->all($userId) as $review) { + $reviewDays[$review->createdAt()->setTimezone($timezone)->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/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/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/CardInput.php b/app/backend/src/Http/Input/CardInput.php index 7295d2a..b26e8d5 100644 --- a/app/backend/src/Http/Input/CardInput.php +++ b/app/backend/src/Http/Input/CardInput.php @@ -8,7 +8,7 @@ use Recall\Domain\ValueObject\CardText; use Recall\Http\ValidationException; -/** Разбор тела запроса для создания карточки. */ +/** Разбор тела запроса для создания/изменения карточки. */ final readonly class CardInput { public function __construct( 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/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/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/app/backend/src/Http/OriginProtectionMiddleware.php b/app/backend/src/Http/OriginProtectionMiddleware.php new file mode 100644 index 0000000..1d0b712 --- /dev/null +++ b/app/backend/src/Http/OriginProtectionMiddleware.php @@ -0,0 +1,43 @@ +isUntrustedMutation($request)) { + return Json::error($this->responses->createResponse(), 'недопустимый источник запроса', 403); + } + + return $handler->handle($request); + } + + private function isUntrustedMutation(ServerRequestInterface $request): bool + { + if (!in_array($request->getMethod(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return false; + } + + $origin = $request->getHeaderLine('Origin'); + + return $origin !== '' + && $origin !== $this->trustedOrigin + && $this->cookie->token($request) !== null; + } +} diff --git a/app/backend/src/Http/SessionCookie.php b/app/backend/src/Http/SessionCookie.php new file mode 100644 index 0000000..6c1f2a9 --- /dev/null +++ b/app/backend/src/Http/SessionCookie.php @@ -0,0 +1,71 @@ +set(self::NAME, [ + 'value' => (string) $credentials->token, + 'path' => '/', + 'expires' => $credentials->session->expiresAt->getTimestamp(), + 'httponly' => true, + 'samesite' => 'lax', + ]); + + return $this->attach($response, $cookies); + } + + 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 + { + $value = $request->getCookieParams()[self::NAME] ?? null; + if (!is_string($value)) { + return null; + } + + try { + return SessionToken::fromString($value); + } catch (InvalidArgumentException) { + 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/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/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/CardRepository.php b/app/backend/src/Infrastructure/Persistence/CardRepository.php index c0d33fd..1ac2249 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,65 @@ public function find(CardId $id): ?Card return null; } - public function count(): int + 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(); } - public function save(Card $card): void + public function countForUser(UserId $userId): int + { + return (new Select($this->orm, Card::class)) + ->where('userId', $userId->toString()) + ->count(); + } + + public function save(UserId $userId, Card $card): void + { + $this->assignOwner($userId, $card); + + (new EntityManager($this->orm))->persist($card)->run(); + } + + /** Сохраняет стартовые данные без владельца, чтобы они не стали данными случайного пользователя. */ + public function saveLegacy(Card $card): void { (new EntityManager($this->orm))->persist($card)->run(); } - public function delete(Card $card): void + 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/DatabaseContext.php b/app/backend/src/Infrastructure/Persistence/DatabaseContext.php new file mode 100644 index 0000000..c18ab34 --- /dev/null +++ b/app/backend/src/Infrastructure/Persistence/DatabaseContext.php @@ -0,0 +1,55 @@ + 'default', + 'databases' => ['default' => ['connection' => 'sqlite']], + 'connections' => ['sqlite' => new SQLiteDriverConfig(connection: $connection)], + ])); + + DatabaseMigrator::migrate($dbal->database('default')); + $orm = new CycleOrm(new Factory($dbal), new Schema(OrmSchema::map())); + + return new self($orm, $dbal); + } + + public function entityManager(): EntityManager + { + return new EntityManager($this->orm); + } +} diff --git a/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php b/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php new file mode 100644 index 0000000..e1664cb --- /dev/null +++ b/app/backend/src/Infrastructure/Persistence/DatabaseMigrator.php @@ -0,0 +1,100 @@ +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 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, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + 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) + )", + ); + $db->execute( + "CREATE TABLE IF NOT EXISTS 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, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) + )", + ); + $db->execute( + "CREATE TABLE IF NOT EXISTS 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, + user_id TEXT NULL CHECK (user_id IS NULL OR length(user_id) = 36) + )", + ); + + self::addUserOwnershipColumns($db); + self::addNoteBookColumn($db); + } + + /** Добавляет ownership-поля без пересборки уже выданной SQLite-базы. */ + private static function addUserOwnershipColumns(DatabaseInterface $db): void + { + 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)", + ); + } + $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/NoteRepository.php b/app/backend/src/Infrastructure/Persistence/NoteRepository.php index 673a063..3ae9248 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,50 @@ public function find(NoteId $id): ?Note return null; } - public function save(Note $note): void + 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); + + (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/Orm.php b/app/backend/src/Infrastructure/Persistence/OrmSchema.php similarity index 51% rename from app/backend/src/Infrastructure/Persistence/Orm.php rename to app/backend/src/Infrastructure/Persistence/OrmSchema.php index a4bfeb5..a759e04 100644 --- a/app/backend/src/Infrastructure/Persistence/Orm.php +++ b/app/backend/src/Infrastructure/Persistence/OrmSchema.php @@ -4,23 +4,16 @@ namespace Recall\Infrastructure\Persistence; -use Cycle\Database\Config\DatabaseConfig; -use Cycle\Database\Config\SQLite\FileConnectionConfig; -use Cycle\Database\Config\SQLite\MemoryConnectionConfig; -use Cycle\Database\Config\SQLiteDriverConfig; -use Cycle\Database\DatabaseInterface; -use Cycle\Database\DatabaseManager; -use Cycle\ORM\EntityManager; -use Cycle\ORM\Factory; -use Cycle\ORM\ORM as CycleOrm; -use Cycle\ORM\ORMInterface; use Cycle\ORM\Parser\Typecast; -use Cycle\ORM\Schema; 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; @@ -29,92 +22,21 @@ 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, схема (заданная массивом, без - * аннотаций в домене и без компиляции на каждый запрос) и точка входа ORM. - */ -final readonly class Orm +/** Карта доменных сущностей для Cycle ORM. */ +final class OrmSchema { - private function __construct( - public ORMInterface $orm, - public DatabaseManager $dbal, - ) {} - - public static function boot(string $databasePath): self - { - if ($databasePath !== ':memory:') { - $dir = dirname($databasePath); - if (!is_dir($dir)) { - mkdir($dir, 0o755, true); - } - } - - $connection = $databasePath === ':memory:' - ? new MemoryConnectionConfig() - : new FileConnectionConfig(database: $databasePath); - - $dbal = new DatabaseManager(new DatabaseConfig([ - 'default' => 'default', - 'databases' => ['default' => ['connection' => 'sqlite']], - 'connections' => ['sqlite' => new SQLiteDriverConfig(connection: $connection)], - ])); - - self::migrate($dbal->database('default')); - - $orm = new CycleOrm(new Factory($dbal), new Schema(self::map())); - - return new self($orm, $dbal); - } - - public function entityManager(): EntityManager - { - return new EntityManager($this->orm); - } - - private static function migrate(DatabaseInterface $db): void - { - $db->execute( - "CREATE TABLE IF NOT EXISTS notes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - body TEXT NOT NULL DEFAULT '', - tags TEXT NOT NULL DEFAULT '[]', - links TEXT NOT NULL DEFAULT '[]', - updated_at TEXT NOT NULL - )", - ); - $db->execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - ); - $db->execute( - "CREATE TABLE IF NOT EXISTS 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 - )", - ); - } - /** @return array> */ - private static function map(): array + public static function map(): array { $handler = [ValueObjectTypecast::class, Typecast::class]; - return [ + return self::authenticationMap($handler) + self::bookMap($handler) + [ Note::class => [ SchemaInterface::ROLE => 'note', SchemaInterface::DATABASE => 'default', @@ -128,6 +50,8 @@ private static function map(): array 'tags' => 'tags', 'links' => 'links', 'updatedAt' => 'updated_at', + 'bookId' => 'book_id', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => NoteId::class, @@ -135,6 +59,8 @@ private static function map(): array 'tags' => TagList::class, 'links' => NoteIdList::class, 'updatedAt' => 'datetime', + 'bookId' => BookId::class, + 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], SchemaInterface::RELATIONS => [], @@ -153,6 +79,7 @@ private static function map(): array 'ease' => 'ease', 'interval' => 'interval', 'due' => 'due', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => CardId::class, @@ -162,6 +89,7 @@ private static function map(): array 'ease' => Ease::class, 'interval' => Interval::class, 'due' => Day::class, + 'userId' => UserId::class, ], SchemaInterface::SCHEMA => [], SchemaInterface::RELATIONS => [], @@ -179,6 +107,7 @@ private static function map(): array 'interval' => 'interval', 'ease' => 'ease', 'nextDue' => 'next_due', + 'userId' => 'user_id', ], SchemaInterface::TYPECAST => [ 'id' => ReviewId::class, @@ -187,6 +116,87 @@ private static function map(): array 'interval' => Interval::class, 'ease' => Ease::class, 'nextDue' => Day::class, + 'userId' => UserId::class, + ], + SchemaInterface::SCHEMA => [], + SchemaInterface::RELATIONS => [], + ], + ]; + } + + /** + * @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 => [], + ], + ]; + } + + /** + * @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/ReviewRepository.php b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php index fb0cf00..da76a13 100644 --- a/app/backend/src/Infrastructure/Persistence/ReviewRepository.php +++ b/app/backend/src/Infrastructure/Persistence/ReviewRepository.php @@ -6,15 +6,40 @@ 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 { public function __construct(private ORMInterface $orm) {} - public function save(Review $review): void + /** @return list */ + public function all(UserId $userId): array { + $reviews = []; + foreach ((new Select($this->orm, Review::class)) + ->where('userId', $userId->toString()) + ->orderBy('id') + ->fetchAll() as $review) { + if ($review instanceof Review) { + $reviews[] = $review; + } + } + + return $reviews; + } + + 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/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..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; @@ -14,8 +15,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-объектами. @@ -83,18 +87,15 @@ 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), + 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)), @@ -128,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/NoteIdListTest.php b/app/backend/tests/NoteIdListTest.php new file mode 100644 index 0000000..0157c1a --- /dev/null +++ b/app/backend/tests/NoteIdListTest.php @@ -0,0 +1,41 @@ +assertInvalid($value); + } + } + + #[Test] + public function rejectsMalformedUuidLinks(): void + { + $this->assertInvalid('not-a-uuid'); + } + + private function assertInvalid(mixed $value): void + { + try { + NoteIdList::fromStrings([$value]); + } catch (InvalidArgumentException $e) { + Assert::same($e->getMessage(), 'ссылка на заметку должна быть UUID'); + + return; + } + + throw new RuntimeException('некорректная ссылка не была отклонена'); + } +} diff --git a/app/backend/tests/NoteRepositoryTest.php b/app/backend/tests/NoteRepositoryTest.php index 097024e..189b3e3 100644 --- a/app/backend/tests/NoteRepositoryTest.php +++ b/app/backend/tests/NoteRepositoryTest.php @@ -9,8 +9,9 @@ use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; use Recall\Domain\ValueObject\Title; +use Recall\Domain\ValueObject\UserId; +use Recall\Infrastructure\Persistence\DatabaseContext; use Recall\Infrastructure\Persistence\NoteRepository; -use Recall\Infrastructure\Persistence\Orm; use Testo\Assert; use Testo\Test; @@ -24,7 +25,8 @@ final class NoteRepositoryTest #[Test] public function createsAndReadsBackANote(): void { - $repo = new NoteRepository(Orm::boot(':memory:')->orm); + $repo = new NoteRepository(DatabaseContext::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/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')); + } +} diff --git a/app/backend/tests/OwnershipRepositoryTest.php b/app/backend/tests/OwnershipRepositoryTest.php new file mode 100644 index 0000000..2299883 --- /dev/null +++ b/app/backend/tests/OwnershipRepositoryTest.php @@ -0,0 +1,58 @@ +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::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/app/backend/tests/OwnershipSchemaTest.php b/app/backend/tests/OwnershipSchemaTest.php new file mode 100644 index 0000000..5d62e38 --- /dev/null +++ b/app/backend/tests/OwnershipSchemaTest.php @@ -0,0 +1,46 @@ +createLegacySchema($path); + $database = DatabaseContext::boot($path)->dbal->database('default'); + + 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); + } + } + + 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/app/backend/tests/SessionRepositoryTest.php b/app/backend/tests/SessionRepositoryTest.php new file mode 100644 index 0000000..e7c6bd2 --- /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(DatabaseContext::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/StatsControllerTest.php b/app/backend/tests/StatsControllerTest.php new file mode 100644 index 0000000..fe635ed --- /dev/null +++ b/app/backend/tests/StatsControllerTest.php @@ -0,0 +1,64 @@ +orm; + $cards = new CardRepository($orm); + $reviews = new ReviewRepository($orm); + $userId = UserId::generate(); + $timezone = new DateTimeZone('Europe/Amsterdam'); + $now = new DateTimeImmutable('2026-07-18T00:30:00', $timezone); + $reviewAt = new DateTimeImmutable('2026-07-17T22:30:00+00:00'); + $reviewId = new ReviewId(new UuidV7(UuidV7::generate($reviewAt))); + $review = new Review( + $reviewId, + CardId::generate(), + Grade::Good, + Interval::ofDays(1), + Ease::default(), + new Day('2026-07-19'), + ); + $reviews->save($userId, $review); + $user = new User($userId, Username::fromString('stats-user'), ''); + $request = (new ServerRequestFactory()) + ->createServerRequest('GET', '/stats') + ->withAttribute(AuthenticationMiddleware::USER_ATTRIBUTE, $user); + $response = (new StatsController($cards, $reviews, new Serializer(), $now)) + ->index($request, (new ResponseFactory())->createResponse()); + /** @var array{streak: int} $stats */ + $stats = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + Assert::same($stats['streak'], 1); + } +} 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); + } +} diff --git a/app/frontend/index.html b/app/frontend/index.html index 986fc13..3fac3eb 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -4,47 +4,376 @@ Recall +
-

Recall

+
+

Recall

+ +

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

Заметки

-
+
+ +
+

Войти

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

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

    -
    + + +
    diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 999ed12..76fadcd 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "recall-frontend", "version": "0.1.0", + "dependencies": { + "@picocss/pico": "^2.1.1" + }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.4.1", @@ -662,6 +665,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@picocss/pico": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", + "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index b829541..180c929 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -24,5 +24,8 @@ "typescript-eslint": "^8.60.1", "vite": "^6.0.0", "vitest": "^4.1.8" + }, + "dependencies": { + "@picocss/pico": "^2.1.1" } } diff --git a/app/frontend/src/api.test.ts b/app/frontend/src/api.test.ts index 513ba43..fbad143 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() @@ -83,12 +124,119 @@ 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( "application/json", ); 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 () => { + 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", + ); + }); + + it("выполняет вход пользователя через API", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse( + { id: "1", username: "reader_01", created_at: "2026-07-19" }, + 200, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.login({ + username: "reader_01", + password: "correct-horse-battery-staple", + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/auth/login"); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.body).toBe( + JSON.stringify({ + username: "reader_01", + password: "correct-horse-battery-staple", + }), + ); + expect(init.credentials).toBe("include"); + }); + + it("запрашивает текущего пользователя через API", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: "1", + username: "reader_01", + created_at: "2026-07-19", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.me(); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/auth/me"); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.credentials).toBe("include"); + }); + + it("завершает сессию через API", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.logout()).resolves.toBeUndefined(); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/auth/logout"); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + }); + + it("запрашивает публичный профиль с кодированным логином", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + username: "reader_01", + created_at: "2026-07-19T12:00:00+00:00", + cards_count: 4, + reviews_count: 12, + practice_days: 3, + current_streak: 2, + longest_streak: 4, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.profile("reader_01"); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/users/reader_01"); + 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 c03be32..e6bd13a 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -12,6 +12,23 @@ export interface Note { links: string[]; created_at: string; updated_at: string; + book_id?: string | null; +} + +export interface Book { + id: string; + title: string; + author: string; + open_library_key: string | null; + cover_id: number | null; + created_at: string; +} + +export interface BookSearchResult { + title: string; + author: string; + open_library_key: string; + cover_id: number | null; } export interface Card { @@ -31,6 +48,22 @@ export interface Stats { streak: number; } +export interface User { + id: string; + username: string; + created_at: string; +} + +export interface PublicProfile { + username: string; + created_at: string; + cards_count: number; + reviews_count: number; + practice_days: number; + current_streak: number; + longest_streak: number; +} + export type Grade = "again" | "hard" | "good" | "easy"; // Ошибка обращения к API с понятным пользователю текстом. @@ -61,7 +94,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,10 +115,67 @@ 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), + }), + login: (input: { username: string; password: string }) => + http("/auth/login", { + method: "POST", + body: JSON.stringify(input), + }), + me: () => http("/auth/me"), + logout: async (): Promise => { + await http("/auth/logout", { method: "POST" }); + }, 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) }), + listBooks: () => http("/books"), + searchBooks: (query: string) => + http( + `/books/search?q=${encodeURIComponent(query.trim())}`, + ), + createBook: (input: { + title: string; + author: string; + open_library_key?: string | null; + cover_id?: number | null; + }) => http("/books", { method: "POST", body: JSON.stringify(input) }), + createNote: (input: { + title: string; + body: string; + tags: string[]; + links: string[]; + book_id?: string; + }) => http("/notes", { method: "POST", body: JSON.stringify(input) }), + updateNote: ( + id: string, + input: { + title: string; + body: string; + tags: string[]; + links: string[]; + book_id?: 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) }), + updateCard: ( + id: string, + input: { note_id: string; front: string; back: string }, + ) => + http(`/cards/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }), + deleteCard: async (id: string): Promise => { + await http(`/cards/${id}`, { method: "DELETE" }); + }, deleteNote: async (id: string): Promise => { await http(`/notes/${id}`, { method: "DELETE" }); }, @@ -95,4 +189,6 @@ export const api = { }, stats: () => http("/stats"), + profile: (username: string) => + http(`/users/${encodeURIComponent(username)}`), }; diff --git a/app/frontend/src/auth.css b/app/frontend/src/auth.css new file mode 100644 index 0000000..3548554 --- /dev/null +++ b/app/frontend/src/auth.css @@ -0,0 +1,119 @@ +#registration, +#login { + max-width: 30rem; + margin: 2rem auto; + padding: 2rem; + background: var(--pico-card-background-color); + border: 1px solid var(--pico-muted-border-color); + border-radius: 1rem; + box-shadow: 0 1rem 2rem rgba(91, 48, 40, 0.08); +} + +#registration h2, +#login h2 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--pico-color); +} + +#registration form, +#login form { + gap: 0; + margin-bottom: 0; +} + +.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: 1.5rem 0 0.5rem; + opacity: 1; +} + +#registration form button:not(.password-toggle), +#login form button:not(.password-toggle) { + margin-top: var(--pico-spacing); +} + +#registration > button, +#login > button { + width: 100%; + margin-bottom: 0; + border: 0; + background: var(--app-highlight-background); + box-shadow: none; + color: var(--app-highlight-color); + font-weight: 700; +} + +#registration > button:hover, +#login > button:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.password-field { + position: relative; + min-width: 0; +} + +.password-field input { + width: 100%; + margin: 0; + padding-right: 5rem; +} + +.password-toggle { + position: absolute; + top: 50%; + right: 0.5rem; + width: 2rem; + height: 2rem; + margin: 0; + display: grid; + place-items: center; + min-height: 0; + padding: 0; + transform: translateY(-50%); + border: 0; + background: transparent; + color: var(--pico-muted-color); +} + +.password-toggle svg { + width: 1.2rem; + height: 1.2rem; +} + +.password-toggle:hover { + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.password-toggle:focus, +.password-toggle:focus-visible { + border: 0; + outline: none; + box-shadow: none; +} + +@media (max-width: 576px) { + #registration, + #login { + margin-top: 1rem; + padding: 1.5rem; + } +} diff --git a/app/frontend/src/auth.ts b/app/frontend/src/auth.ts new file mode 100644 index 0000000..77bf2b2 --- /dev/null +++ b/app/frontend/src/auth.ts @@ -0,0 +1,226 @@ +import { api, ApiError, type User } from "./api"; +import { eyeIcon, eyeOffIcon } from "./icons"; +import { errorMessage, type UiActions } from "./ui"; + +export interface AuthElements { + registration: HTMLElement; + login: HTMLElement; + workspace: HTMLElement; + userBar: HTMLElement; + currentUserBar: HTMLElement; + registerForm: HTMLFormElement; + loginForm: HTMLFormElement; + registrationError: HTMLElement; + loginError: HTMLElement; + showLoginButton: HTMLButtonElement; + showRegistrationButton: HTMLButtonElement; + logoutButton: HTMLButtonElement; +} + +export function setupAuth( + elements: AuthElements, + actions: UiActions, + refreshAll: () => Promise, +): void { + let currentUser: User | null = null; + let authErrorAnimation = 0; + + function setupPasswordToggle(form: HTMLFormElement): () => void { + const input = form.querySelector( + "input[name='password']", + ); + const toggle = form.querySelector(".password-toggle"); + if (!input || !toggle) { + throw new Error("нет переключателя видимости пароля"); + } + + const setVisibility = (isVisible: boolean): void => { + input.type = isVisible ? "text" : "password"; + toggle.replaceChildren(isVisible ? eyeOffIcon() : eyeIcon()); + toggle.setAttribute( + "aria-label", + isVisible ? "Скрыть пароль" : "Показать пароль", + ); + toggle.setAttribute("aria-pressed", String(isVisible)); + }; + + toggle.addEventListener("click", () => { + setVisibility(input.type !== "text"); + }); + setVisibility(false); + return () => { + setVisibility(false); + }; + } + + const resetRegistrationPassword = setupPasswordToggle(elements.registerForm); + const resetLoginPassword = setupPasswordToggle(elements.loginForm); + + function clearAuthError(element: HTMLElement): void { + authErrorAnimation += 1; + element.textContent = ""; + element.classList.remove("is-visible"); + element.setAttribute("aria-hidden", "true"); + } + + function clearAuthErrors(): void { + clearAuthError(elements.registrationError); + clearAuthError(elements.loginError); + } + + function showAuthError(element: HTMLElement, error: unknown): void { + const message = errorMessage(error).replace(/^Ошибка:\s*/, ""); + const formattedMessage = message + ? `${message.charAt(0).toLocaleUpperCase("ru-RU")}${message.slice(1)}` + : message; + if ( + element.classList.contains("is-visible") && + element.textContent === formattedMessage + ) { + return; + } + + const animation = ++authErrorAnimation; + element.textContent = formattedMessage; + element.setAttribute("aria-hidden", "false"); + window.requestAnimationFrame(() => { + if (animation === authErrorAnimation) { + element.classList.add("is-visible"); + } + }); + } + + function renderCurrentUser(): void { + if (currentUser) { + elements.currentUserBar.textContent = currentUser.username; + elements.currentUserBar.setAttribute( + "href", + `/users/${encodeURIComponent(currentUser.username)}`, + ); + return; + } + + elements.currentUserBar.textContent = ""; + elements.currentUserBar.removeAttribute("href"); + } + + async function showWorkspace(user: User): Promise { + currentUser = null; + elements.registration.hidden = true; + elements.login.hidden = true; + elements.workspace.hidden = true; + elements.userBar.hidden = true; + renderCurrentUser(); + try { + await refreshAll(); + } catch (error) { + showLogin(); + throw error; + } + currentUser = user; + renderCurrentUser(); + elements.workspace.hidden = false; + elements.userBar.hidden = false; + actions.clearStatus(); + } + + function showRegistration(): void { + currentUser = null; + elements.registration.hidden = false; + elements.login.hidden = true; + elements.workspace.hidden = true; + elements.userBar.hidden = true; + renderCurrentUser(); + resetRegistrationPassword(); + clearAuthErrors(); + actions.clearStatus(); + } + + function showLogin(): void { + currentUser = null; + elements.registration.hidden = true; + elements.login.hidden = false; + elements.workspace.hidden = true; + elements.userBar.hidden = true; + renderCurrentUser(); + resetLoginPassword(); + clearAuthErrors(); + actions.clearStatus(); + } + + async function restoreSession(): Promise { + elements.registration.hidden = true; + elements.login.hidden = true; + elements.workspace.hidden = true; + elements.userBar.hidden = true; + + try { + await showWorkspace(await api.me()); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + showLogin(); + return; + } + showLogin(); + actions.showError(error); + } + } + + elements.registerForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = elements.registerForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const data = new FormData(elements.registerForm); + + submit.disabled = true; + actions.clearStatus(); + clearAuthErrors(); + void api + .register({ + username: actions.field(data, "username"), + password: actions.field(data, "password"), + }) + .then(showWorkspace) + .catch((error: unknown) => { + showAuthError(elements.registrationError, error); + }) + .finally(() => (submit.disabled = false)); + }); + + elements.loginForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = elements.loginForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const data = new FormData(elements.loginForm); + + submit.disabled = true; + actions.clearStatus(); + void api + .login({ + username: actions.field(data, "username"), + password: actions.field(data, "password"), + }) + .then(showWorkspace) + .catch((error: unknown) => { + showAuthError(elements.loginError, error); + }) + .finally(() => (submit.disabled = false)); + }); + + elements.showLoginButton.addEventListener("click", showLogin); + elements.showRegistrationButton.addEventListener("click", showRegistration); + actions.onClick(elements.logoutButton, async () => { + await api.logout(); + showLogin(); + }); + + void restoreSession(); +} diff --git a/app/frontend/src/book-cover.css b/app/frontend/src/book-cover.css new file mode 100644 index 0000000..e244525 --- /dev/null +++ b/app/frontend/src/book-cover.css @@ -0,0 +1,57 @@ +.note-book-source { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 0.75rem; + min-width: 0; +} + +.note-book-cover { + display: grid; + place-items: center; + flex: 0 0 4rem; + width: 4rem; + height: 6rem; + overflow: hidden; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.4rem; + background: var(--app-highlight-background); + color: var(--app-highlight-color); + font-size: 0.65rem; + text-align: center; +} + +.note-book-cover img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.note-book-cover-fallback { + padding: 0.25rem; +} + +.note-book-metadata { + display: grid; + align-content: center; + gap: 0.15rem; + min-width: 0; + min-height: 6rem; +} + +.note-book-title, +.note-book-author { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-book-title { + color: var(--pico-color); +} + +.note-book-author { + color: var(--pico-muted-color); + font-size: 0.85rem; +} diff --git a/app/frontend/src/book-cover.test.ts b/app/frontend/src/book-cover.test.ts new file mode 100644 index 0000000..f72635f --- /dev/null +++ b/app/frontend/src/book-cover.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { bookCoverUrl } from "./book-cover"; + +describe("book covers", () => { + it("строит URL обложки Open Library среднего размера", () => { + expect(bookCoverUrl(12345)).toBe( + "https://covers.openlibrary.org/b/id/12345-M.jpg?default=false", + ); + }); +}); diff --git a/app/frontend/src/book-cover.ts b/app/frontend/src/book-cover.ts new file mode 100644 index 0000000..348360d --- /dev/null +++ b/app/frontend/src/book-cover.ts @@ -0,0 +1,3 @@ +export function bookCoverUrl(coverId: number): string { + return `https://covers.openlibrary.org/b/id/${coverId}-M.jpg?default=false`; +} 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/book-picker.ts b/app/frontend/src/book-picker.ts new file mode 100644 index 0000000..7c2ea5a --- /dev/null +++ b/app/frontend/src/book-picker.ts @@ -0,0 +1,186 @@ +import { api, ApiError, type Book, type BookSearchResult } from "./api"; +import type { UiActions } from "./ui"; + +export interface BookPickerView { + setBooks: (books: Book[]) => void; + selectedId: () => string | undefined; + clear: () => void; +} + +function bookLabel(book: { title: string; author: string }): string { + return book.author === "" ? book.title : `${book.title} — ${book.author}`; +} + +export function createBookPicker( + container: HTMLElement, + actions: UiActions, + initialBooks: Book[] = [], + initialBookId?: string | null, +): BookPickerView { + container.classList.add("book-picker"); + const label = document.createElement("label"); + label.className = "book-picker-label"; + label.textContent = "Книга (необязательно)"; + + const searchBox = document.createElement("div"); + searchBox.className = "book-picker-search"; + const query = document.createElement("input"); + query.type = "search"; + query.placeholder = "Найти книгу"; + query.setAttribute("aria-label", "Поиск книги"); + const search = document.createElement("button"); + search.type = "button"; + search.textContent = "Найти"; + searchBox.append(query, search); + + const pickerControls = document.createElement("div"); + pickerControls.className = "book-picker-controls"; + + const results = document.createElement("div"); + results.className = "book-picker-results"; + results.hidden = true; + results.setAttribute("role", "listbox"); + + const selected = document.createElement("div"); + selected.className = "book-picker-selected"; + selected.hidden = true; + const selectedText = document.createElement("span"); + const clear = document.createElement("button"); + clear.type = "button"; + clear.className = "book-picker-clear"; + clear.textContent = "×"; + clear.setAttribute("aria-label", "Сбросить выбранную книгу"); + clear.title = "Сбросить выбранную книгу"; + selected.append(selectedText, clear); + + pickerControls.append(searchBox, results, selected); + container.replaceChildren(label, pickerControls); + + let books = initialBooks; + let selectedBook: Book | undefined; + + function renderSelected(): void { + selected.hidden = selectedBook === undefined; + if (selectedBook !== undefined) { + selectedText.textContent = `Выбрано: ${bookLabel(selectedBook)}`; + } + } + + function selectBook(book: Book): void { + selectedBook = book; + renderSelected(); + results.hidden = true; + } + + function existingBook(result: BookSearchResult): Book | undefined { + return books.find( + (book) => + book.open_library_key !== null && + book.open_library_key === result.open_library_key, + ); + } + + function renderResults(items: BookSearchResult[]): void { + results.replaceChildren(); + if (items.length === 0) { + const empty = document.createElement("p"); + empty.className = "book-picker-empty"; + empty.textContent = "Книги не найдены"; + results.append(empty); + results.hidden = false; + return; + } + + for (const item of items) { + const option = document.createElement("button"); + option.type = "button"; + option.className = "book-picker-result"; + option.setAttribute("role", "option"); + option.textContent = bookLabel(item); + option.addEventListener("click", () => { + const saved = existingBook(item); + if (saved !== undefined) { + selectBook(saved); + return; + } + + option.disabled = true; + actions.clearStatus(); + void api + .createBook({ + title: item.title, + author: item.author, + open_library_key: item.open_library_key, + cover_id: item.cover_id, + }) + .then((book) => { + books = [...books, book]; + selectBook(book); + }) + .catch(actions.showError) + .finally(() => (option.disabled = false)); + }); + results.append(option); + } + results.hidden = false; + } + + async function findBooks(): Promise { + const text = query.value.trim(); + if (text.length < 2) { + actions.showError( + new ApiError("Введите минимум 2 символа для поиска", 422), + ); + return; + } + + actions.clearStatus(); + search.disabled = true; + try { + renderResults(await api.searchBooks(text)); + } finally { + search.disabled = false; + } + } + + function setBooks(nextBooks: Book[]): void { + books = nextBooks; + if (selectedBook !== undefined) { + const updated = books.find((book) => book.id === selectedBook?.id); + selectedBook = updated ?? selectedBook; + renderSelected(); + } + } + + search.addEventListener("click", () => { + void findBooks().catch(actions.showError); + }); + query.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void findBooks().catch(actions.showError); + } + }); + clear.addEventListener("click", () => { + selectedBook = undefined; + renderSelected(); + }); + + setBooks(initialBooks); + if (initialBookId !== undefined && initialBookId !== null) { + const initialBook = books.find((book) => book.id === initialBookId); + if (initialBook !== undefined) { + selectBook(initialBook); + } + } + + return { + setBooks, + selectedId: () => selectedBook?.id, + clear: () => { + selectedBook = undefined; + renderSelected(); + results.hidden = true; + }, + }; +} diff --git a/app/frontend/src/books-api.test.ts b/app/frontend/src/books-api.test.ts new file mode 100644 index 0000000..b5a6533 --- /dev/null +++ b/app/frontend/src/books-api.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("books api", () => { + it("ищет книги с кодированным запросом", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); + vi.stubGlobal("fetch", fetchMock); + + await api.searchBooks("rust & memory"); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "/books/search?q=rust%20%26%20memory", + ); + }); + + it("сохраняет выбранную книгу", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse( + { + id: "book-1", + title: "The Rust Book", + author: "Steve Klabnik", + open_library_key: "/works/OL1W", + cover_id: 42, + created_at: "2026-07-21T00:00:00+00:00", + }, + 201, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await api.createBook({ + title: "The Rust Book", + author: "Steve Klabnik", + open_library_key: "/works/OL1W", + cover_id: 42, + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/books"); + expect((fetchMock.mock.calls[0]?.[1] as RequestInit).body).toBe( + JSON.stringify({ + title: "The Rust Book", + author: "Steve Klabnik", + open_library_key: "/works/OL1W", + cover_id: 42, + }), + ); + }); +}); diff --git a/app/frontend/src/card-edit.ts b/app/frontend/src/card-edit.ts new file mode 100644 index 0000000..34dc265 --- /dev/null +++ b/app/frontend/src/card-edit.ts @@ -0,0 +1,107 @@ +import { api, type Card } from "./api"; +import { createAnimatedDisclosure } from "./disclosure"; +import type { UiActions } from "./ui"; + +const CARD_EDIT_ANIMATION_DURATION = 220; + +function labeledField( + labelText: string, + control: HTMLInputElement | HTMLTextAreaElement, +): HTMLLabelElement { + const field = document.createElement("label"); + field.className = "card-field"; + const label = document.createElement("span"); + label.textContent = labelText; + field.append(label, control); + + return field; +} + +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( + labeledField("Вопрос", front), + labeledField("Ответ", 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..668e38c --- /dev/null +++ b/app/frontend/src/cards.css @@ -0,0 +1,266 @@ +.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 { + display: grid; + grid-template-rows: 1fr; + min-height: 0; + overflow: hidden; + opacity: 1; + visibility: visible; + transition: + 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 { + grid-template-rows: 0fr; + opacity: 0; + pointer-events: none; + visibility: hidden; + transition: + grid-template-rows 0.22s ease, + opacity 0.18s ease, + visibility 0s linear 0.22s; +} + +.card-management { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + max-height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + visibility: hidden; + transition: + max-height 0.22s ease, + opacity 0.18s ease, + margin-top 0.22s ease, + visibility 0s linear 0.22s; +} + +.card.is-card-edit-mode .card-management { + max-height: 3rem; + 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 { + 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 { + 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, +.card .grade-buttons button { + border: 0; + color: var(--pico-color); +} + +.card .grade-buttons button { + display: flex; + align-items: center; + justify-content: center; + margin: 0; + text-align: center; +} + +.card .grade-buttons button[data-testid="grade-again"] { + width: 3.25rem; + min-width: 3.25rem; + height: 3.25rem; + min-height: 3.25rem; + 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; + background: var(--pico-primary-hover-background); + color: var(--pico-primary-inverse); +} + +.card .reveal-answer { + 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; +} + +.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; + color: var(--pico-primary-hover); + text-decoration: underline; +} + +.card .grade-buttons { + display: grid; + grid-template-columns: auto repeat(3, 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/create.css b/app/frontend/src/create.css new file mode 100644 index 0000000..a47156b --- /dev/null +++ b/app/frontend/src/create.css @@ -0,0 +1,216 @@ +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.section-heading h2 { + margin: 0; +} + +#workspace > section[aria-label="Заметки"] .section-heading h2 { + position: relative; + top: -0.65rem; +} + +#workspace > section[aria-label="Повторение"] .section-heading h2 { + position: relative; + top: -0.65rem; +} + +.section-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section-filter { + flex: 0 0 auto; +} + +.section-filter svg { + width: 1.25rem; + height: 1.25rem; +} + +.section-filter:hover, +.section-filter[aria-expanded="true"] { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.section-create { + display: grid; + place-items: center; + width: 2.4rem; + height: 2.4rem; + min-height: 2.4rem; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--app-highlight-background); + color: var(--app-highlight-color); + cursor: pointer; + font-size: 1.6rem; + font-weight: 400; + line-height: 1; +} + +.section-create:hover, +.section-create[aria-expanded="true"] { + border: 0; + background: var(--app-highlight-hover-background); +} + +.section-create > span { + transform: translateY(-0.05rem); +} + +#note-form, +#card-form { + gap: 0.5rem; + max-height: 0; + margin-bottom: 0; + overflow: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + margin-bottom 0.22s ease, + opacity 0.18s ease; +} + +#note-form.is-open, +#card-form.is-open { + max-height: var(--disclosure-height); + margin-bottom: 1rem; + opacity: 1; +} + +#note-form.is-resizing, +#card-form.is-resizing { + transition: none; +} + +#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; +} + +.note-field, +.card-field { + display: grid; + gap: 0.35rem; + min-width: 0; + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +.note-field input, +.note-field textarea, +.card-field input, +.card-field textarea, +.card-field select { + min-width: 0; + margin: 0; +} + +#note-links, +.note-links-fieldset { + display: grid; + gap: 0; + min-width: 0; + margin: 0; + padding: 0; +} + +#note-links legend, +.note-links-fieldset legend { + margin: 0; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +#note-links .note-links-label { + display: block; + padding: 0; +} + +.note-links-control { + display: grid; + gap: 0.25rem; + min-width: 0; + margin-top: 0.5rem; + padding: 0.65rem 0.75rem; + border: 1px solid var(--pico-form-element-border-color); + border-radius: 0.65rem; + background: var(--pico-form-element-background-color); +} + +.note-link-options { + display: grid; + gap: 0.25rem; + max-height: 7rem; + overflow-y: auto; +} + +.note-link-options.has-many { + padding-right: 0.5rem; +} + +#note-links .note-link-options { + min-height: 0; + max-height: 6rem; +} + +.note-link-option { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + min-width: 0; + 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 span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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/create.ts b/app/frontend/src/create.ts new file mode 100644 index 0000000..cec0fd6 --- /dev/null +++ b/app/frontend/src/create.ts @@ -0,0 +1,105 @@ +import { createAnimatedDisclosure } from "./disclosure"; + +const SCROLL_TOP_OFFSET = 24; + +export interface CreateElements { + noteButton: HTMLButtonElement; + cardButton: HTMLButtonElement; + noteForm: HTMLFormElement; + cardForm: HTMLFormElement; + onOpen?: () => Promise | void; +} + +export function setupCreateActions(elements: CreateElements): () => void { + const setNoteOpen = createAnimatedDisclosure(elements.noteForm, "is-open"); + const setCardOpen = createAnimatedDisclosure(elements.cardForm, "is-open"); + let openRequest = 0; + + function scrollToFormStart(form: HTMLElement): void { + const scroll = (): void => { + if (!form.isConnected || form.hidden) { + return; + } + const { top } = form.getBoundingClientRect(); + if (top >= 0 && top + form.scrollHeight <= window.innerHeight) { + return; + } + window.scrollTo({ + top: Math.max(0, window.scrollY + top - SCROLL_TOP_OFFSET), + behavior: "smooth", + }); + }; + window.requestAnimationFrame(scroll); + window.setTimeout(scroll, 240); + } + + function closeNoteForm(): void { + setNoteOpen(false); + elements.noteButton.setAttribute("aria-expanded", "false"); + } + + function closeCardForm(): void { + setCardOpen(false); + 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"); + closeNoteForm(); + if (isOpen) { + setCardOpen(false); + elements.cardButton.setAttribute("aria-expanded", "false"); + return; + } + + await elements.onOpen?.(); + if (request !== openRequest) { + return; + } + setCardOpen(true); + elements.cardButton.setAttribute("aria-expanded", "true"); + + const field = elements.cardForm.querySelector( + "input:not(:disabled), select:not(:disabled), textarea:not(:disabled)", + ); + field?.focus(); + scrollToFormStart(elements.cardForm); + } + + elements.noteButton.addEventListener("click", () => { + const request = ++openRequest; + const isOpen = elements.noteForm.classList.contains("is-open"); + closeCardForm(); + if (isOpen) { + setNoteOpen(false); + elements.noteButton.setAttribute("aria-expanded", "false"); + return; + } + + void Promise.resolve(elements.onOpen?.()).then(() => { + if (request !== openRequest) { + return; + } + setNoteOpen(true); + elements.noteButton.setAttribute("aria-expanded", "true"); + scrollToFormStart(elements.noteForm); + }); + }); + elements.cardButton.addEventListener("click", () => { + void openCardForm().catch(() => undefined); + }); + + return () => { + ++openRequest; + closeNoteForm(); + closeCardForm(); + }; +} diff --git a/app/frontend/src/disclosure.ts b/app/frontend/src/disclosure.ts new file mode 100644 index 0000000..be59610 --- /dev/null +++ b/app/frontend/src/disclosure.ts @@ -0,0 +1,75 @@ +export function createAnimatedDisclosure( + panel: HTMLElement, + openClass: string, + hideOnClose = true, +): (open: boolean) => void { + let isOpen = false; + let closeTimer: number | undefined; + let resizeEndTimer: number | undefined; + let resizeObserver: ResizeObserver | undefined; + let observedHeights = new WeakMap(); + + function updateHeight(): void { + panel.style.setProperty("--disclosure-height", `${panel.scrollHeight}px`); + } + + function observeContent(): void { + if (typeof ResizeObserver === "undefined") { + return; + } + resizeObserver ??= new ResizeObserver((entries) => { + const contentChanged = entries.some((entry) => { + const previousHeight = observedHeights.get(entry.target); + observedHeights.set(entry.target, entry.contentRect.height); + return ( + previousHeight !== undefined && + previousHeight !== entry.contentRect.height + ); + }); + if (isOpen && contentChanged) { + panel.classList.add("is-resizing"); + updateHeight(); + if (resizeEndTimer !== undefined) { + window.clearTimeout(resizeEndTimer); + } + resizeEndTimer = window.setTimeout(() => { + panel.classList.remove("is-resizing"); + resizeEndTimer = undefined; + }, 240); + } + }); + resizeObserver.disconnect(); + observedHeights = new WeakMap(); + panel.querySelectorAll("*").forEach((element) => { + resizeObserver?.observe(element); + }); + } + + return (open) => { + isOpen = open; + if (closeTimer !== undefined) { + window.clearTimeout(closeTimer); + closeTimer = undefined; + } + + if (open) { + panel.hidden = false; + observeContent(); + updateHeight(); + window.requestAnimationFrame(() => { + if (isOpen) { + panel.classList.add(openClass); + } + }); + return; + } + + panel.classList.remove(openClass); + closeTimer = window.setTimeout(() => { + if (!isOpen && hideOnClose) { + panel.hidden = true; + resizeObserver?.disconnect(); + } + }, 220); + }; +} diff --git a/app/frontend/src/icons.ts b/app/frontend/src/icons.ts new file mode 100644 index 0000000..8d75369 --- /dev/null +++ b/app/frontend/src/icons.ts @@ -0,0 +1,51 @@ +const SVG_NS = "http://www.w3.org/2000/svg"; + +function strokeIcon(pathData: string): SVGSVGElement { + const icon = document.createElementNS(SVG_NS, "svg"); + icon.setAttribute("viewBox", "0 0 24 24"); + icon.setAttribute("aria-hidden", "true"); + icon.setAttribute("focusable", "false"); + + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", pathData); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + path.setAttribute("stroke-width", "2"); + icon.append(path); + + return icon; +} + +export function trashIcon(): SVGSVGElement { + return strokeIcon("M5 7h14M9 7V5h6v2M8 10v7M12 10v7M16 10v7M7 7l1 13h8l1-13"); +} + +export function pencilIcon(): SVGSVGElement { + return strokeIcon( + "m4.75 16-.5 4 4-.5L19.75 8 16.25 4.5 4.75 16Zm9.5-9.5 3.5 3.5", + ); +} + +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", + ); +} + +export function eyeIcon(): SVGSVGElement { + return strokeIcon( + "M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12Zm10-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z", + ); +} + +export function eyeOffIcon(): SVGSVGElement { + return strokeIcon( + "M3 3l18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M6.6 6.6A13.5 13.5 0 0 0 2 12s3 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 5.2A10.5 10.5 0 0 1 12 5c7 0 10 7 10 7a13.2 13.2 0 0 1-1.7 2.7", + ); +} diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts index af5088c..40f588e 100644 --- a/app/frontend/src/main.ts +++ b/app/frontend/src/main.ts @@ -1,166 +1,157 @@ import "./style.css"; -import { api, ApiError, type Card, type Grade, type Note } from "./api"; -import { parseTags, tagLabel } from "./format"; - -const GRADES: Grade[] = ["again", "hard", "good", "easy"]; +import "./auth.css"; +import "./profile.css"; +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"; +import { setupCreateActions } from "./create"; +import { setupNotes } from "./notes"; +import { profileUsername, setupProfile } from "./profile"; +import { setupReviews } from "./reviews"; +import { createUiActions } from "./ui"; + +document.querySelector("#app")?.classList.add("app-ready"); +document.documentElement.classList.add("app-ready"); function need(selector: string): HTMLElement { - const el = document.querySelector(selector); - if (!el) { + const element = document.querySelector(selector); + if (!element) { throw new Error(`нет элемента: ${selector}`); } - return el; -} - -function field(form: FormData, name: string): string { - const value = form.get(name); - return typeof value === "string" ? value : ""; -} - -const statusBar = need("#status"); - -function showError(error: unknown): void { - statusBar.textContent = - error instanceof ApiError - ? `Ошибка: ${error.message}` - : "Что-то пошло не так"; - statusBar.dataset.state = "error"; -} - -function clearStatus(): void { - statusBar.textContent = ""; - delete statusBar.dataset.state; + return element; } -// Запустить действие по кнопке: блокируем её на время запроса и показываем ошибки. -function onClick(button: HTMLButtonElement, action: () => Promise): void { - button.addEventListener("click", () => { - button.disabled = true; - clearStatus(); - void action() - .catch(showError) - .finally(() => (button.disabled = false)); - }); -} - -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 = note.title; - - const tags = document.createElement("span"); - tags.className = "note-tags"; - tags.textContent = tagLabel(note.tags); - - const remove = document.createElement("button"); - remove.type = "button"; - remove.textContent = "удалить"; - remove.dataset.testid = "delete-note"; - remove.setAttribute("aria-label", `Удалить заметку: ${note.title}`); - onClick(remove, async () => { - await api.deleteNote(note.id); - await refreshNotes(); - }); - - item.append(title, tags, remove); - return item; -} - -function cardItem(card: Card): HTMLDivElement { - const wrap = document.createElement("div"); - wrap.className = "card"; - wrap.dataset.testid = "queue-card"; - wrap.dataset.id = card.id; - - const front = document.createElement("p"); - front.className = "front"; - front.textContent = card.front; - - const back = document.createElement("p"); - back.className = "back"; - back.textContent = card.back; - - const buttons = document.createElement("div"); - buttons.className = "grade-buttons"; - for (const grade of GRADES) { - const button = document.createElement("button"); - button.type = "button"; - button.textContent = grade; - button.dataset.testid = `grade-${grade}`; - button.setAttribute("aria-label", `Оценить: ${grade}`); - onClick(button, async () => { - await api.grade(card.id, grade); - await refreshAll(); - }); - buttons.append(button); +function needForm(selector: string): HTMLFormElement { + const form = need(selector); + if (!(form instanceof HTMLFormElement)) { + throw new Error(`нет формы: ${selector}`); } - - wrap.append(front, back, buttons); - return wrap; -} - -async function refreshStats(): Promise { - const stats = await api.stats(); - need("[data-stat='due_today']").textContent = `сегодня: ${stats.due_today}`; - need("[data-stat='due_week']").textContent = `за неделю: ${stats.due_week}`; - need("[data-stat='streak']").textContent = `серия: ${stats.streak}`; + return form; } -async function refreshNotes(): Promise { - const notes = await api.listNotes(); - const list = need("#note-list"); - list.replaceChildren(...notes.map(noteItem)); +function needButton(selector: string): HTMLButtonElement { + const button = need(selector); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`нет кнопки: ${selector}`); + } + return button; } -async function refreshQueue(): Promise { - const cards = await api.queue(); - const box = need("#queue"); - if (cards.length === 0) { - const done = document.createElement("p"); - done.dataset.testid = "queue-empty"; - done.textContent = "На сегодня всё повторено."; - box.replaceChildren(done); - return; +function needSelect(selector: string): HTMLSelectElement { + const select = need(selector); + if (!(select instanceof HTMLSelectElement)) { + throw new Error(`нет списка: ${selector}`); } - box.replaceChildren(...cards.map(cardItem)); + return select; } -async function refreshAll(): Promise { - await Promise.all([refreshStats(), refreshNotes(), refreshQueue()]); -} +const statusBar = need("#status"); +const actions = createUiActions(statusBar); + +const publicUsername = profileUsername(window.location.pathname); + +if (publicUsername !== null) { + need("#registration").hidden = true; + need("#login").hidden = true; + need("#workspace").hidden = true; + + setupProfile( + { + section: need("#profile"), + content: need("#profile-content"), + error: need("#profile-error"), + username: need("[data-profile='username']"), + createdAt: need("[data-profile='created_at']"), + cardsCount: need("[data-profile='cards_count']"), + reviewsCount: need("[data-profile='reviews_count']"), + practiceDays: need("[data-profile='practice_days']"), + currentStreak: need("[data-profile='current_streak']"), + longestStreak: need("[data-profile='longest_streak']"), + }, + actions, + publicUsername, + ); +} else { + const noteForm = needForm("#note-form"); + const cardForm = needForm("#card-form"); + + let closeEditMenus = (): Promise => Promise.resolve(); + const closeCreateForms = setupCreateActions({ + noteButton: needButton("#create-note"), + cardButton: needButton("#create-card"), + noteForm, + cardForm, + onOpen: () => closeEditMenus(), + }); -const form = need("#note-form"); -if (!(form instanceof HTMLFormElement)) { - throw new Error("нет формы #note-form"); -} -form.addEventListener("submit", (event) => { - event.preventDefault(); - const submit = form.querySelector("button[type='submit']"); - if (!submit) { - return; - } - const data = new FormData(form); - const input = { - title: field(data, "title"), - body: field(data, "body"), - tags: parseTags(field(data, "tags")), + const notes = setupNotes( + { + noteForm, + noteBookPicker: need("#note-book-picker"), + noteLinksContainer: need("#note-links .note-link-options"), + tagFilterForm: needForm("#tag-filter-form"), + toggleTagFilter: needButton("#toggle-tag-filter"), + clearTagFilter: needButton("#clear-tag-filter"), + noteList: need("#note-list"), + notesEmpty: need("#notes-empty"), + notesPagination: need("#notes-pagination"), + notesPrevious: needButton("#notes-previous"), + notesNext: needButton("#notes-next"), + notesPage: need("#notes-page"), + cardNoteSelect: needSelect("#card-form select[name='note_id']"), + onCreated: closeCreateForms, + closeCreateForms, + }, + actions, + ); + closeEditMenus = notes.closeEditMenus; + + const reviews = setupReviews( + { + dueToday: need("[data-stat='due_today']"), + dueWeek: need("[data-stat='due_week']"), + streak: need("[data-stat='streak']"), + queueCount: need("#queue-count"), + queue: need("#queue"), + toggleCardEdit: needButton("#toggle-card-edit"), + cardForm, + onCreated: closeCreateForms, + onOpenNote: notes.open, + }, + actions, + ); + + const refreshAll = async (): Promise => { + await Promise.all([ + reviews.refreshStats(), + notes.refresh(), + reviews.refreshQueue(), + ]); }; - submit.disabled = true; - clearStatus(); - void api - .createNote(input) - .then(() => { - form.reset(); - return refreshNotes(); - }) - .catch(showError) - .finally(() => (submit.disabled = false)); -}); - -statusBar.textContent = "Загрузка…"; -void refreshAll().then(clearStatus, showError); + reviews.setupCardForm(refreshAll); + + setupAuth( + { + registration: need("#registration"), + login: need("#login"), + workspace: need("#workspace"), + userBar: need("#app-userbar"), + currentUserBar: need("#current-user"), + registerForm: needForm("#register-form"), + loginForm: needForm("#login-form"), + registrationError: need("#registration-error"), + loginError: need("#login-error"), + showLoginButton: needButton("#show-login"), + showRegistrationButton: needButton("#show-registration"), + logoutButton: needButton("#logout"), + }, + actions, + refreshAll, + ); +} diff --git a/app/frontend/src/note-body.ts b/app/frontend/src/note-body.ts new file mode 100644 index 0000000..e4dec50 --- /dev/null +++ b/app/frontend/src/note-body.ts @@ -0,0 +1,147 @@ +import type { Book, Note } from "./api"; +import { bookCoverUrl } from "./book-cover"; +import { createAnimatedDisclosure } from "./disclosure"; +import { chevronIcon } from "./icons"; + +export interface NoteBodyElements { + body: HTMLDivElement; + 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); +} + +function appendBookSource(body: HTMLDivElement, book: Book | undefined): void { + if (book === undefined) { + return; + } + + const source = document.createElement("div"); + source.className = "note-book-source"; + source.setAttribute("aria-label", `Источник: ${book.title}`); + + const cover = document.createElement("div"); + cover.className = "note-book-cover"; + const fallback = document.createElement("span"); + fallback.className = "note-book-cover-fallback"; + fallback.textContent = "Нет обложки"; + fallback.hidden = book.cover_id !== null; + + if (book.cover_id !== null) { + const image = document.createElement("img"); + image.alt = `Обложка книги: ${book.title}`; + image.loading = "lazy"; + image.decoding = "async"; + image.src = bookCoverUrl(book.cover_id); + image.addEventListener("error", () => { + image.hidden = true; + fallback.hidden = false; + }); + cover.append(image); + } + cover.append(fallback); + + const metadata = document.createElement("div"); + metadata.className = "note-book-metadata"; + const title = document.createElement("strong"); + title.className = "note-book-title"; + title.textContent = book.title; + metadata.append(title); + if (book.author !== "") { + const author = document.createElement("span"); + author.className = "note-book-author"; + author.textContent = book.author; + metadata.append(author); + } + + source.append(cover, metadata); + body.append(source); +} + +export function createNoteBody( + note: Note, + linkedNotes: Note[] = [], + onLinkedNote?: (id: string) => void, + books: Book[] = [], +): 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"); + + appendBookSource( + body, + note.book_id === undefined || note.book_id === null + ? undefined + : books.find((book) => book.id === note.book_id), + ); + + 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); + 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"; + toggle.className = "note-body-toggle"; + toggle.dataset.testid = "toggle-note-body"; + toggle.append(chevronIcon()); + toggle.setAttribute("aria-controls", body.id); + toggle.setAttribute("aria-expanded", "false"); + toggle.setAttribute("aria-label", "Показать содержание"); + + const setOpen = createAnimatedDisclosure(body, "is-expanded", false); + toggle.addEventListener("click", () => { + const open = toggle.getAttribute("aria-expanded") !== "true"; + setOpen(open); + body.setAttribute("aria-hidden", String(!open)); + toggle.setAttribute("aria-expanded", String(open)); + toggle.setAttribute( + "aria-label", + open ? "Скрыть содержание" : "Показать содержание", + ); + }); + + return { body, toggle }; +} diff --git a/app/frontend/src/note-edit.ts b/app/frontend/src/note-edit.ts new file mode 100644 index 0000000..05e2372 --- /dev/null +++ b/app/frontend/src/note-edit.ts @@ -0,0 +1,158 @@ +import { api, type Book, type Note } from "./api"; +import { createBookPicker } from "./book-picker"; +import { createAnimatedDisclosure } from "./disclosure"; +import { parseTags } from "./format"; +import { populateLinkOptions, selectedLinkIds } from "./note-options"; +import type { UiActions } from "./ui"; + +const EDIT_ANIMATION_DURATION = 220; + +function labeledField( + labelText: string, + control: HTMLInputElement | HTMLTextAreaElement, +): HTMLLabelElement { + const field = document.createElement("label"); + field.className = "note-field"; + const label = document.createElement("span"); + label.textContent = labelText; + field.append(label, control); + + return field; +} + +export interface NoteEditView { + form: HTMLFormElement; + open: () => void; + close: () => Promise; +} + +interface NoteEditCallbacks { + onCloseStart: (form: HTMLFormElement) => void; + onClosed: () => Promise | void; + onSaved: () => Promise; +} + +function waitForEditClose(): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, EDIT_ANIMATION_DURATION); + }); +} + +export function createNoteEdit( + note: Note, + actions: UiActions, + callbacks: NoteEditCallbacks, + linkedNotes: Note[], + books: Book[], +): NoteEditView { + const form = document.createElement("form"); + form.className = "note-edit"; + form.dataset.testid = "edit-note-form"; + const setOpen = createAnimatedDisclosure(form, "is-open", false); + + 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 bookPickerContainer = document.createElement("div"); + const bookPicker = createBookPicker( + bookPickerContainer, + actions, + books, + note.book_id, + ); + + const body = document.createElement("textarea"); + body.name = "body"; + 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"; + const linksControl = document.createElement("div"); + linksControl.className = "note-links-control"; + linksControl.append(linksContainer); + populateLinkOptions( + linksContainer, + linkedNotes.filter((linkedNote) => linkedNote.id !== note.id), + note.links, + ); + linksFieldset.append(linksLegend, linksControl); + + 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 = "Отмена"; + let closePromise: Promise | undefined; + function close(): Promise { + if (closePromise !== undefined) { + return closePromise; + } + + callbacks.onCloseStart(form); + setOpen(false); + closePromise = waitForEditClose() + .then(callbacks.onClosed) + .catch(actions.showError); + + return closePromise; + } + cancel.addEventListener("click", () => { + void close(); + }); + actionsBox.append(save, cancel); + + form.append( + labeledField("Заголовок", title), + labeledField("Теги", tags), + bookPickerContainer, + labeledField("Содержание", body), + linksFieldset, + actionsBox, + ); + form.addEventListener("submit", (event) => { + event.preventDefault(); + save.disabled = true; + actions.clearStatus(); + void api + .updateNote(note.id, { + title: title.value, + body: body.value, + tags: parseTags(tags.value), + links: selectedLinkIds(linksContainer), + ...(bookPicker.selectedId() === undefined + ? {} + : { book_id: bookPicker.selectedId() }), + }) + .then(() => { + return close(); + }) + .then(callbacks.onSaved) + .catch(actions.showError) + .finally(() => (save.disabled = false)); + }); + + return { + form, + open: () => { + setOpen(true); + }, + close, + }; +} diff --git a/app/frontend/src/note-form.ts b/app/frontend/src/note-form.ts new file mode 100644 index 0000000..e099d27 --- /dev/null +++ b/app/frontend/src/note-form.ts @@ -0,0 +1,31 @@ +import { parseTags } from "./format"; +import { selectedLinkIds } from "./note-options"; +import type { UiActions } from "./ui"; + +export interface NoteFormInput { + title: string; + body: string; + tags: string[]; + links: string[]; + book_id?: string; +} + +export function readNoteForm( + form: HTMLFormElement, + linksContainer: HTMLElement, + actions: UiActions, + bookId?: string, +): NoteFormInput { + const data = new FormData(form); + const input: NoteFormInput = { + title: actions.field(data, "title"), + body: actions.field(data, "body"), + tags: parseTags(actions.field(data, "tags")), + links: selectedLinkIds(linksContainer), + }; + if (bookId !== undefined) { + input.book_id = bookId; + } + + return input; +} 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/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/note-options.ts b/app/frontend/src/note-options.ts new file mode 100644 index 0000000..8c9af5d --- /dev/null +++ b/app/frontend/src/note-options.ts @@ -0,0 +1,78 @@ +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 { + container.classList.toggle("has-many", notes.length > 2); + 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}`); + const title = document.createElement("span"); + title.textContent = note.title; + option.append(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/note-text.ts b/app/frontend/src/note-text.ts new file mode 100644 index 0000000..4a413d8 --- /dev/null +++ b/app/frontend/src/note-text.ts @@ -0,0 +1,28 @@ +import { tagLabel } from "./format"; + +const NOTE_TEXT_LIMIT = 35; + +export function truncateNoteTitle(value: string): string { + return value.length > NOTE_TEXT_LIMIT + ? `${value.slice(0, NOTE_TEXT_LIMIT - 3)}...` + : value; +} + +export function truncateNoteTags(tags: string[]): string { + const full = tagLabel(tags).trim(); + if (full.length <= NOTE_TEXT_LIMIT) { + return full; + } + + const visible: string[] = []; + for (const tag of tags) { + const candidate = tagLabel([...visible, tag]).trim(); + if (candidate.length + 3 > NOTE_TEXT_LIMIT) { + break; + } + visible.push(tag); + } + return visible.length > 0 + ? `${tagLabel(visible).trim()}...` + : `${full.slice(0, NOTE_TEXT_LIMIT - 3)}...`; +} diff --git a/app/frontend/src/notes-animation.ts b/app/frontend/src/notes-animation.ts new file mode 100644 index 0000000..0ebedd6 --- /dev/null +++ b/app/frontend/src/notes-animation.ts @@ -0,0 +1,31 @@ +export function animateListHeight(list: HTMLElement, render: () => void): void { + const previousHeight = list.offsetHeight; + list.style.height = `${previousHeight}px`; + list.classList.add("is-paging"); + + window.requestAnimationFrame(() => { + render(); + list.style.height = "auto"; + const nextHeight = list.scrollHeight; + list.style.height = `${previousHeight}px`; + void list.offsetHeight; + + window.requestAnimationFrame(() => { + list.style.height = `${nextHeight}px`; + window.setTimeout(() => { + list.style.height = ""; + list.classList.remove("is-paging"); + }, 220); + }); + }); +} + +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 new file mode 100644 index 0000000..404f798 --- /dev/null +++ b/app/frontend/src/notes-pagination.ts @@ -0,0 +1,59 @@ +export const NOTES_PER_PAGE = 3; + +export interface NotesPaginationElements { + container: HTMLElement; + previous: HTMLButtonElement; + next: HTMLButtonElement; + page: HTMLElement; +} + +export function setupNotesPagination( + elements: NotesPaginationElements, + onPageChange: (page: number) => void, +): { + reset: () => void; + update: (total: number) => number; + goTo: (page: number) => void; +} { + let currentPage = 0; + let totalPages = 0; + + function sync(): void { + const lastPage = Math.max(totalPages - 1, 0); + currentPage = Math.min(currentPage, lastPage); + elements.container.hidden = totalPages <= 1; + elements.page.textContent = `${totalPages === 0 ? 0 : currentPage + 1} из ${Math.max(totalPages, 1)}`; + elements.previous.disabled = currentPage === 0; + elements.next.disabled = totalPages === 0 || currentPage === lastPage; + } + + 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 = nextPage; + sync(); + onPageChange(currentPage); + } + + elements.previous.addEventListener("click", () => { + goTo(currentPage - 1); + }); + elements.next.addEventListener("click", () => { + goTo(currentPage + 1); + }); + + return { + reset: () => { + currentPage = 0; + }, + update: (total) => { + totalPages = Math.ceil(total / NOTES_PER_PAGE); + sync(); + return currentPage; + }, + goTo, + }; +} diff --git a/app/frontend/src/notes.css b/app/frontend/src/notes.css new file mode 100644 index 0000000..396e970 --- /dev/null +++ b/app/frontend/src/notes.css @@ -0,0 +1,293 @@ +#note-list { + display: grid; + align-content: start; + gap: 0.75rem; + padding: 0; + list-style: none; + overflow: hidden; + transition: + height 0.22s ease, + opacity 0.16s ease; +} + +#note-list.is-paging { + opacity: 0.65; +} + +#notes-empty { + margin: 0; + color: var(--pico-muted-color); + text-align: center; +} + +#tag-filter-form { + align-content: start; + grid-template-columns: minmax(0, 67fr) minmax(0, 33fr); + max-height: 0; + margin-bottom: 0; + overflow: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + margin-bottom 0.22s ease, + opacity 0.18s ease; +} + +#tag-filter-form input { + grid-column: 1 / -1; +} + +#tag-filter-form button { + border: 0; + white-space: nowrap; +} + +#tag-filter-form #clear-tag-filter { + background: var(--app-danger-background); + color: var(--app-danger-color); + font-weight: 700; + transition: filter 0.15s ease; +} + +#tag-filter-form #clear-tag-filter:hover { + background: var(--app-danger-background); + color: var(--app-danger-color); + filter: brightness(0.9); +} + +#tag-filter-form #clear-tag-filter:active { + filter: brightness(0.86); +} + +#tag-filter-form.is-open { + max-height: var(--disclosure-height); + margin-bottom: 1rem; + opacity: 1; +} + +#note-list li { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 0.5rem; + padding: 0.75rem 1rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.75rem; + background: var(--pico-background-color); +} + +.note-edit { + gap: 0.5rem; + grid-column: 1; + grid-row: 1; + max-height: 0; + margin: 0; + padding-bottom: 0.25rem; + overflow: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + opacity 0.18s ease; +} + +.note-edit.is-open { + max-height: var(--disclosure-height); + opacity: 1; +} + +.note-edit.is-closing { + max-height: var(--note-edit-close-height); +} + +.note-edit.is-resizing { + transition: none; +} + +.note-edit textarea { + min-height: calc(1.6rem + 1.5rem + 2px); + max-height: calc(6.4rem + 1.5rem + 2px); + padding: 0.75rem 1rem; + resize: vertical; +} + +.note-view { + display: grid; + grid-column: 1; + grid-row: 1; + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 0.5rem; + min-width: 0; + transition: opacity 0.18s ease; +} + +.note-view.is-editing { + opacity: 0; + pointer-events: none; +} + +.note-title { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.note-tags { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pico-muted-color); + font-size: 0.85rem; +} + +.note-actions { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + gap: 0.5rem; + margin-top: 0.25rem; +} + +#note-list li .note-body-toggle { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 2.4rem; + height: 2.4rem; + min-height: 2.4rem; + margin: 0 auto 0 0; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--app-highlight-background); + color: var(--app-highlight-color); +} + +.note-body-toggle svg { + position: relative; + left: 0; + width: 1.4rem; + height: 1.4rem; + transition: transform 0.15s ease; +} + +.note-body-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +#note-list li .note-body-toggle:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +.note-body { + max-height: 0; + margin: 0; + overflow-wrap: anywhere; + overflow: hidden; + white-space: normal; + color: var(--pico-muted-color); + visibility: hidden; + opacity: 0; + transition: + max-height 0.22s ease, + margin-top 0.22s ease, + opacity 0.18s ease; +} + +.note-body.is-expanded { + max-height: var(--disclosure-height); + margin-top: 0.5rem; + visibility: visible; + 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 { + 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; + align-items: center; + margin-top: 0.75rem; +} + +.notes-pagination button { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + min-height: 2rem; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--app-highlight-muted-color); + transition: color 0.15s ease; +} + +.notes-pagination button svg { + width: 1.25rem; + height: 1.25rem; +} + +.notes-pagination button:hover { + border: 0; + background: transparent; + color: var(--app-highlight-color); +} + +.notes-pagination button:disabled { + background: transparent; + color: var(--app-highlight-background); +} + +#notes-page { + color: var(--pico-muted-color); + font-size: 0.9rem; + text-align: center; +} diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts new file mode 100644 index 0000000..0966e75 --- /dev/null +++ b/app/frontend/src/notes.ts @@ -0,0 +1,201 @@ +import { api, type Book, type Note } from "./api"; +import { createBookPicker, type BookPickerView } from "./book-picker"; +import { createAnimatedDisclosure } from "./disclosure"; +import { createNoteItem, type NoteEditorState } from "./note-item"; +import { animateListHeight, highlightNote } from "./notes-animation"; +import { populateLinkOptions, populateNoteOptions } from "./note-options"; +import { readNoteForm } from "./note-form"; +import { NOTES_PER_PAGE, setupNotesPagination } from "./notes-pagination"; +import type { UiActions } from "./ui"; + +export interface NotesElements { + noteForm: HTMLFormElement; + noteBookPicker: HTMLElement; + noteLinksContainer: HTMLElement; + tagFilterForm: HTMLFormElement; + toggleTagFilter: HTMLButtonElement; + clearTagFilter: HTMLButtonElement; + noteList: HTMLElement; + notesEmpty: HTMLElement; + notesPagination: HTMLElement; + notesPrevious: HTMLButtonElement; + notesNext: HTMLButtonElement; + notesPage: HTMLElement; + cardNoteSelect: HTMLSelectElement; + onCreated: () => void; + closeCreateForms: () => void; +} + +export function setupNotes(elements: NotesElements, actions: UiActions) { + let activeTag: string | undefined; + let notes: Note[] = []; + let linkableNotes: Note[] = []; + let books: Book[] = []; + const editorState: NoteEditorState = { active: undefined, request: 0 }; + const noteBookPicker: BookPickerView = createBookPicker( + elements.noteBookPicker, + actions, + ); + + function clearActiveTag(): void { + const input = elements.tagFilterForm.elements.namedItem("tag"); + if (input instanceof HTMLInputElement) { + input.value = ""; + } + activeTag = undefined; + pagination.reset(); + } + async function navigateToLinkedNote(id: string): Promise { + let index = notes.findIndex((note) => note.id === id); + if (index < 0 && activeTag !== undefined) { + clearActiveTag(); + await refreshNotes(); + 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 openLinkedNote(id: string): void { + void navigateToLinkedNote(id).catch(actions.showError); + } + function noteItem(note: Note): HTMLLIElement { + return createNoteItem({ + note, + linkableNotes, + books, + actions, + editorState, + openLinkedNote, + refreshNotes, + closeCreateForms: elements.closeCreateForms, + }); + } + function renderPage( + page: number, + animate = false, + forceAnimation = false, + ): void { + const start = page * NOTES_PER_PAGE; + const visibleNotes = notes.slice(start, start + NOTES_PER_PAGE); + const list = elements.noteList; + if ( + !animate || + (!forceAnimation && list.childElementCount === visibleNotes.length) + ) { + list.replaceChildren(...visibleNotes.map(noteItem)); + return; + } + + animateListHeight(list, () => { + list.replaceChildren(...visibleNotes.map(noteItem)); + }); + } + const pagination = setupNotesPagination( + { + container: elements.notesPagination, + previous: elements.notesPrevious, + next: elements.notesNext, + page: elements.notesPage, + }, + (page) => { + renderPage(page, true); + }, + ); + const setFilterOpen = createAnimatedDisclosure( + elements.tagFilterForm, + "is-open", + ); + async function refreshNotes(animate = false): Promise { + const [allNotes, savedBooks] = await Promise.all([ + api.listNotes(), + api.listBooks(), + ]); + books = savedBooks; + noteBookPicker.setBooks(books); + 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; + populateNoteOptions( + elements.cardNoteSelect, + linkableNotes, + "Выберите заметку", + ); + populateLinkOptions(elements.noteLinksContainer, linkableNotes); + } + + elements.noteForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = elements.noteForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const input = readNoteForm( + elements.noteForm, + elements.noteLinksContainer, + actions, + noteBookPicker.selectedId(), + ); + + submit.disabled = true; + actions.clearStatus(); + void api + .createNote(input) + .then(() => { + elements.noteForm.reset(); + noteBookPicker.clear(); + pagination.reset(); + return refreshNotes(); + }) + .then(elements.onCreated) + .catch(actions.showError) + .finally(() => (submit.disabled = false)); + }); + + elements.tagFilterForm.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(elements.tagFilterForm); + const tag = actions.field(data, "tag").trim(); + activeTag = tag === "" ? undefined : tag; + pagination.reset(); + actions.clearStatus(); + void refreshNotes().catch(actions.showError); + }); + + elements.toggleTagFilter.addEventListener("click", () => { + const isOpen = !elements.tagFilterForm.hidden; + setFilterOpen(!isOpen); + elements.toggleTagFilter.setAttribute("aria-expanded", String(!isOpen)); + }); + + actions.onClick(elements.clearTagFilter, async () => { + clearActiveTag(); + await refreshNotes(); + }); + function closeEditMenus(): Promise { + ++editorState.request; + const editView = editorState.active; + editorState.active = undefined; + return editView?.close() ?? Promise.resolve(); + } + + return { + refresh: refreshNotes, + open: openLinkedNote, + closeEditMenus, + }; +} diff --git a/app/frontend/src/profile.css b/app/frontend/src/profile.css new file mode 100644 index 0000000..f3aa10c --- /dev/null +++ b/app/frontend/src/profile.css @@ -0,0 +1,124 @@ +#profile { + max-width: 42rem; + margin: 2rem auto; + padding: 2.25rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 1rem; + background: var(--pico-card-background-color); + box-shadow: 0 1rem 2rem rgba(91, 48, 40, 0.08); +} + +#profile h2 { + margin-top: 0; + color: var(--pico-color); +} + +#profile-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 1.5rem 0 2rem; +} + +#profile-stats div { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.75rem; + background: var(--pico-background-color); +} + +#profile-stats div:first-child { + grid-column: 1 / -1; + border-color: var(--app-highlight-background); + background: var(--app-highlight-background); +} + +#profile-stats div:first-child dt { + color: var(--app-highlight-muted-color); +} + +#profile-stats dt { + color: var(--pico-muted-color); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +#profile-stats dd { + margin: 0; + color: var(--pico-color); + font-size: 1.2rem; + font-weight: 700; +} + +#profile-stats div:first-child dd { + color: var(--app-highlight-color); +} + +#profile-error { + margin: 1rem 0 1.5rem; + padding: 0.85rem 1rem; + border: 1px solid #f0d1c5; + border-radius: 0.75rem; + background: #fff1eb; + color: #8f392f; +} + +#profile-home { + color: var(--pico-primary); + font-weight: 700; + text-decoration: none; +} + +#profile-home:hover { + text-decoration: underline; +} + +@media (min-width: 768px) { + #app:has(> #profile:not([hidden])) { + display: grid; + min-height: 100vh; + align-content: center; + padding-top: 1.25rem; + padding-bottom: 1.25rem; + } + + #profile { + margin-top: 1rem; + margin-bottom: 1rem; + padding: 1.5rem; + } + + #profile-stats { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + margin: 1rem 0 1.25rem; + } + + #profile-stats div { + padding: 0.75rem; + } + + #profile-stats dd { + font-size: 1.1rem; + } +} + +@media (max-width: 576px) { + #profile { + margin-top: 1rem; + padding: 1.5rem; + } + + #profile-stats { + grid-template-columns: 1fr; + } + + #profile-stats div:first-child { + grid-column: auto; + } +} diff --git a/app/frontend/src/profile.ts b/app/frontend/src/profile.ts new file mode 100644 index 0000000..d74976c --- /dev/null +++ b/app/frontend/src/profile.ts @@ -0,0 +1,70 @@ +import { api, ApiError } from "./api"; +import type { UiActions } from "./ui"; + +export interface ProfileElements { + section: HTMLElement; + content: HTMLElement; + error: HTMLElement; + username: HTMLElement; + createdAt: HTMLElement; + cardsCount: HTMLElement; + reviewsCount: HTMLElement; + practiceDays: HTMLElement; + currentStreak: HTMLElement; + longestStreak: HTMLElement; +} + +export function profileUsername(pathname: string): string | null { + const match = /^\/users\/([^/]+)\/?$/.exec(pathname); + if (!match?.[1]) { + return null; + } + + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.valueOf()) + ? value + : new Intl.DateTimeFormat("ru-RU", { dateStyle: "long" }).format(date); +} + +export function setupProfile( + elements: ProfileElements, + actions: UiActions, + username: string, +): void { + elements.section.hidden = false; + elements.content.hidden = true; + elements.error.hidden = true; + actions.clearStatus(); + + void api + .profile(username) + .then((profile) => { + elements.username.textContent = profile.username; + elements.createdAt.textContent = formatDate(profile.created_at); + elements.cardsCount.textContent = String(profile.cards_count); + elements.reviewsCount.textContent = String(profile.reviews_count); + elements.practiceDays.textContent = String(profile.practice_days); + elements.currentStreak.textContent = String(profile.current_streak); + elements.longestStreak.textContent = String(profile.longest_streak); + elements.content.hidden = false; + }) + .catch((error: unknown) => { + if (error instanceof ApiError && error.status === 404) { + elements.error.textContent = "Профиль не найден."; + elements.error.hidden = false; + return; + } + + elements.error.textContent = "Не удалось загрузить профиль."; + elements.error.hidden = false; + actions.showError(error); + }); +} diff --git a/app/frontend/src/reviews.ts b/app/frontend/src/reviews.ts new file mode 100644 index 0000000..7931afa --- /dev/null +++ b/app/frontend/src/reviews.ts @@ -0,0 +1,237 @@ +import { api, type Card, type Grade } from "./api"; +import { createCardEdit, type CardEditView } from "./card-edit"; +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; + dueWeek: HTMLElement; + streak: HTMLElement; + queueCount: HTMLElement; + queue: HTMLElement; + toggleCardEdit: HTMLButtonElement; + cardForm: HTMLFormElement; + onCreated: () => void; + onOpenNote: (id: string) => void; +} + +export function setupReviews( + elements: ReviewsElements, + actions: UiActions, +): { + refreshStats: () => Promise; + refreshQueue: () => Promise; + 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"; + review.setAttribute("aria-hidden", String(cardEditMode)); + 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; + 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.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 ? "Скрыть ответ" : "Показать ответ"; + if (visible) { + window.requestAnimationFrame(syncAnswerHeight); + } + }); + + const buttons = document.createElement("div"); + buttons.className = "grade-buttons"; + for (const grade of GRADES) { + const button = document.createElement("button"); + button.type = "button"; + 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_LABELS[grade]}`); + actions.onClick(button, async () => { + await api.grade(card.id, grade); + await refreshAll(); + }); + buttons.append(button); + } + reviewContent.append(sourceNote, reveal, back, buttons); + review.append(reviewContent); + + 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"; + 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(); + }); + + window.requestAnimationFrame(syncAnswerHeight); + 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); + card + .querySelector(".card-review") + ?.setAttribute("aria-hidden", String(enabled)); + card + .querySelector(".card-management") + ?.setAttribute("aria-hidden", String(!enabled)); + if (!enabled) { + return; + } + const back = card.querySelector(".back"); + const reveal = card.querySelector(".reveal-answer"); + back?.classList.remove("is-visible"); + back?.setAttribute("aria-hidden", "true"); + if (reveal) { + reveal.textContent = "Показать ответ"; + } + }); + } + + async function refreshStats(): Promise { + const stats = await api.stats(); + elements.dueToday.textContent = `сегодня: ${stats.due_today}`; + elements.dueWeek.textContent = `за неделю: ${stats.due_week}`; + elements.streak.textContent = `серия: ${stats.streak}`; + } + + async function refreshQueue(): Promise { + const cards = await api.queue(); + elements.queueCount.textContent = String(cards.length); + if (cards.length === 0) { + const done = document.createElement("p"); + done.dataset.testid = "queue-empty"; + done.textContent = "На сегодня всё повторено."; + elements.queue.replaceChildren(done); + return; + } + elements.queue.replaceChildren(cardItem(cards[0])); + } + + function setupCardForm(refresh: () => Promise): void { + refreshAll = refresh; + elements.cardForm.addEventListener("submit", (event) => { + event.preventDefault(); + const submit = elements.cardForm.querySelector( + "button[type='submit']", + ); + if (!submit) { + return; + } + const data = new FormData(elements.cardForm); + const input = { + note_id: actions.field(data, "note_id"), + front: actions.field(data, "front"), + back: actions.field(data, "back"), + }; + + submit.disabled = true; + actions.clearStatus(); + void api + .createCard(input) + .then(() => { + elements.cardForm.reset(); + return Promise.all([refreshStats(), refreshQueue()]); + }) + .then(elements.onCreated) + .catch(actions.showError) + .finally(() => (submit.disabled = false)); + }); + } + + elements.toggleCardEdit.addEventListener("click", () => { + setCardEditMode(!cardEditMode); + }); + + return { refreshStats, refreshQueue, setupCardForm }; +} diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css index 08d178d..ee6aa4b 100644 --- a/app/frontend/src/style.css +++ b/app/frontend/src/style.css @@ -1,26 +1,96 @@ +@import "@picocss/pico/css/pico.min.css"; + :root { - font-family: system-ui, sans-serif; - line-height: 1.5; - color: #1a1a1a; - background: #fafafa; + --pico-font-family: Inter, ui-sans-serif, system-ui, sans-serif; + --pico-line-height: 1.6; + --pico-background-color: #fffaf5; + --pico-color: #252323; + --pico-muted-color: #625f5c; + --pico-muted-border-color: #e7ddd5; + --pico-primary: #a94438; + --pico-primary-background: #a94438; + --pico-primary-border: #a94438; + --pico-primary-hover: #8f392f; + --pico-primary-hover-background: #8f392f; + --pico-primary-hover-border: #8f392f; + --pico-primary-focus: rgba(169, 68, 56, 0.35); + --pico-primary-inverse: #fff; + --pico-form-element-background-color: #fff; + --pico-form-element-border-color: #d8cbc2; + --pico-form-element-color: #252323; + --pico-form-element-placeholder-color: #625f5c; + --pico-form-element-active-border-color: #a94438; + --pico-form-element-focus-color: rgba(169, 68, 56, 0.35); + --pico-card-background-color: #fff; + --pico-border-radius: 0.75rem; + --pico-spacing: 1.25rem; + --app-highlight-background: #334155; + --app-highlight-hover-background: #273449; + --app-highlight-color: #fff; + --app-highlight-muted-color: #e2e8f0; + --app-danger-background: #f1c4bb; + --app-danger-color: #7f3028; } #app { - max-width: 42rem; - margin: 2rem auto; - padding: 0 1rem; + max-width: 56rem; + margin: 0 auto; + padding: 3rem 1.25rem 4rem; } -h1 { - margin-bottom: 0.25rem; +#app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--pico-muted-border-color); } -#stats { +#app-header h1 { + margin: 0; +} + +#app-userbar { display: flex; - gap: 1rem; - font-size: 0.9rem; - color: #555; - margin-bottom: 1.5rem; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + min-width: 0; +} + +#current-user { + min-width: 0; + overflow-wrap: anywhere; + color: var(--pico-color); + font-weight: 700; + text-decoration: none; +} + +#current-user:hover { + text-decoration: underline; +} + +#app-userbar #logout { + margin: 0; + border: 0; + background: var(--app-highlight-background); + box-shadow: none; + color: var(--app-highlight-color); + font-weight: 700; +} + +#app-userbar #logout:hover { + border: 0; + background: var(--app-danger-background); + color: var(--app-danger-color); +} + +h1 { + margin-bottom: 0.5rem; + color: var(--pico-color); + letter-spacing: -0.03em; } section { @@ -34,73 +104,122 @@ form { } input, -textarea { +textarea, +select { padding: 0.5rem; - border: 1px solid #ccc; - border-radius: 4px; + border: 1px solid var(--pico-form-element-border-color); + border-radius: 0.65rem; + background: var(--pico-form-element-background-color); + color: var(--pico-form-element-color); font: inherit; } +input::placeholder, +textarea::placeholder { + color: var(--pico-form-element-placeholder-color); + opacity: 1; +} + button { padding: 0.4rem 0.8rem; - border: 1px solid #ccc; - border-radius: 4px; - background: #fff; + min-height: 2.75rem; + border: 0; + border-radius: 0.65rem; + background: var(--pico-card-background-color); + color: var(--pico-color); cursor: pointer; font: inherit; } button:hover { - background: #f0f0f0; + border: 0; + background: #fff4ed; } -#note-list { - list-style: none; - padding: 0; - display: grid; - gap: 0.5rem; +button[type="submit"] { + border: 0; + background: var(--pico-primary-background); + color: var(--pico-primary-inverse); + font-weight: 700; } -#note-list li { - display: flex; - align-items: center; - gap: 0.5rem; +button[type="submit"]:hover { + border: 0; + background: var(--pico-primary-hover-background); } -.note-tags { - color: #595959; - font-size: 0.85rem; +button:disabled { + cursor: not-allowed; + opacity: 0.6; } -.card { - border: 1px solid #ddd; - border-radius: 6px; - padding: 1rem; - background: #fff; - margin-bottom: 1rem; +a:focus-visible { + outline: 3px solid var(--pico-primary-focus); + outline-offset: 2px; } -.card .front { - font-weight: 600; +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--pico-form-element-active-border-color); + box-shadow: none; } -.card .back { - color: #555; +button:focus, +button:focus-visible { + outline: none; + box-shadow: none; } -.card .grade-buttons { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; +a { + color: var(--pico-primary); +} + +a:hover { + color: var(--pico-primary-hover); } #status { min-height: 1.25rem; margin: 0 0 1rem; font-size: 0.9rem; - color: #555; + color: var(--pico-muted-color); +} + +#status:empty { + min-height: 0; + margin-bottom: 0; } #status[data-state="error"] { - color: #b00020; + color: #8f3028; +} + +#clear-tag-filter:hover { + border: 0; + background: var(--app-danger-background); + color: var(--app-danger-color); +} + +@media (max-width: 576px) { + #app-header { + align-items: flex-start; + flex-wrap: wrap; + } + + #app-userbar { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/app/frontend/src/ui.ts b/app/frontend/src/ui.ts new file mode 100644 index 0000000..136f30f --- /dev/null +++ b/app/frontend/src/ui.ts @@ -0,0 +1,46 @@ +import { ApiError } from "./api"; + +export interface UiActions { + field: (form: FormData, name: string) => string; + showError: (error: unknown) => void; + clearStatus: () => void; + 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); + return typeof value === "string" ? value : ""; + } + + function showError(error: unknown): void { + statusBar.textContent = errorMessage(error); + statusBar.dataset.state = "error"; + } + + function clearStatus(): void { + statusBar.textContent = ""; + delete statusBar.dataset.state; + } + + function onClick( + button: HTMLButtonElement, + action: () => Promise, + ): void { + button.addEventListener("click", () => { + button.disabled = true; + clearStatus(); + void action() + .catch(showError) + .finally(() => (button.disabled = false)); + }); + } + + return { field, showError, clearStatus, onClick }; +} diff --git a/app/frontend/src/workspace.css b/app/frontend/src/workspace.css new file mode 100644 index 0000000..f91f812 --- /dev/null +++ b/app/frontend/src/workspace.css @@ -0,0 +1,173 @@ +#workspace { + display: grid; + gap: 1.5rem; +} + +#stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin: 0; +} + +#stats span { + display: flex; + align-items: center; + min-height: 4.5rem; + padding: 1rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.75rem; + background: var(--pico-card-background-color); + color: var(--pico-color); + font-size: 0.95rem; + font-weight: 600; + box-shadow: 0 0.5rem 1.25rem rgba(91, 48, 40, 0.06); +} + +#stats [data-stat="streak"] { + border-color: var(--app-highlight-background); + background: var(--app-highlight-background); + color: var(--app-highlight-color); +} + +#workspace > section:not(#stats) { + margin: 0; + padding: 1.5rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 1rem; + background: var(--pico-card-background-color); + box-shadow: 0 0.5rem 1.25rem rgba(91, 48, 40, 0.05); +} + +#workspace > section:not(#stats) > h2, +#workspace > section:not(#stats) .section-heading h2 { + margin-top: 0; + color: var(--pico-color); +} + +#workspace > section[aria-label="Заметки"] .section-heading { + margin-bottom: 0.5rem; +} + +#note-list li button { + margin: 0; + min-height: 2.4rem; + padding: 0.35rem 0.65rem; + font-size: 0.9rem; +} + +#note-list li button[data-testid="edit-note"] { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 2.4rem; + height: 2.4rem; + padding: 0; + border-radius: 50%; + border: 0; + background: var(--app-highlight-background); + color: var(--app-highlight-color); +} + +#note-list li button[data-testid="edit-note"] svg { + width: 1.15rem; + height: 1.15rem; +} + +#note-list li button[data-testid="delete-note"] { + display: grid; + place-items: center; + flex: 0 0 auto; + width: 2.4rem; + height: 2.4rem; + padding: 0; + border-radius: 50%; + border: 0; + background: var(--app-danger-background); + color: var(--app-danger-color); + transition: filter 0.15s ease; +} + +#note-list li button[data-testid="edit-note"]:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +#note-list li button[data-testid="delete-note"] svg { + width: 1.15rem; + height: 1.15rem; +} + +#note-list li button[data-testid="delete-note"]:hover { + filter: brightness(0.9); +} + +#note-list li button[data-testid="delete-note"]:active { + filter: brightness(0.86); +} + +.note-edit { + width: 100%; + margin: 0; +} + +.note-edit-actions, +.create-actions { + display: grid; + grid-template-columns: minmax(0, 60fr) minmax(0, 40fr); + gap: 0.35rem; +} + +.note-edit-actions button, +.create-actions button { + width: 100%; + margin: 0; + border: 0; + font-weight: 700; +} + +.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, +.create-actions button[type="button"]:hover { + border: 0; + background: var(--app-highlight-hover-background); + color: var(--app-highlight-color); +} + +[data-testid="queue-empty"] { + margin: 0; + color: var(--pico-muted-color); + text-align: center; +} + +@media (min-width: 900px) and (orientation: landscape) { + #workspace { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + } + + #workspace > #stats { + grid-column: 1 / -1; + } + + #workspace > section[aria-label="Заметки"], + #workspace > section[aria-label="Повторение"] { + min-width: 0; + } +} + +@media (max-width: 576px) { + #stats { + grid-template-columns: 1fr; + } + + #workspace > section:not(#stats) { + padding: 1.25rem; + } +} diff --git a/compose.yaml b/compose.yaml index 4b6b7a6..8a3f440 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,11 +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} @@ -49,5 +52,6 @@ services: - backend volumes: + backend_data: backend_vendor: frontend_node_modules: diff --git a/e2e/tests/auth.ts b/e2e/tests/auth.ts new file mode 100644 index 0000000..9d7ab0a --- /dev/null +++ b/e2e/tests/auth.ts @@ -0,0 +1,47 @@ +import { expect, type Page } from "@playwright/test"; + +export const TEST_PASSWORD = "correct-horse-battery-staple"; + +export interface Credentials { + username: string; + password: string; +} + +export function newCredentials(): Credentials { + return { + username: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + password: TEST_PASSWORD, + }; +} + +export async function register( + page: Page, + credentials = newCredentials(), +): Promise { + await page.click("#show-registration"); + await page.fill("#register-form input[name='username']", credentials.username); + await page.fill( + "#register-form input[name='password']", + credentials.password, + ); + await page.click("#register-form button[type='submit']"); + + await expect(page.locator("#workspace")).toBeVisible(); + return credentials; +} + +export async function login(page: Page, credentials: Credentials): Promise { + await page.fill("#login-form input[name='username']", credentials.username); + await page.fill("#login-form input[name='password']", credentials.password); + await page.click("#login-form button[type='submit']"); + + await expect(page.locator("#workspace")).toBeVisible(); +} + +export async function openCreateForm( + page: Page, + type: "note" | "card", +): Promise { + await page.click(`#create-${type}`); + await expect(page.locator(`#${type}-form`)).toBeVisible(); +} diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..a15303c --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; +import { login, newCredentials, openCreateForm, register } from "./auth"; + +test("зарегистрированный пользователь может войти", async ({ page }) => { + await page.goto("/"); + const credentials = await register(page); + + await page.context().clearCookies(); + await page.reload(); + await login(page, credentials); +}); + +test("показывает ошибку при неверном пароле", async ({ page }) => { + await page.goto("/"); + const credentials = await register(page); + + await page.context().clearCookies(); + await page.reload(); + await page.fill("#login-form input[name='username']", credentials.username); + await page.fill("#login-form input[name='password']", "wrong-password"); + await page.click("#login-form button[type='submit']"); + + await expect(page.locator("#login-error")).toContainText( + "Неверный логин или пароль", + ); + await expect(page.locator("#login")).toBeVisible(); +}); + +test("показывает ошибку регистрации внутри формы", async ({ page }) => { + await page.goto("/"); + await page.click("#show-registration"); + await page.fill("#register-form input[name='username']", "bad username"); + await page.fill("#register-form input[name='password']", "password"); + await page.click("#register-form button[type='submit']"); + + const error = page.locator("#registration-error"); + await expect(error).toContainText("Проверьте поля регистрации"); + await expect(error).not.toContainText("Ошибка:"); + await expect(error).toBeVisible(); + await expect(page.locator("#registration")).toBeVisible(); +}); + +test("переключает видимость пароля", async ({ page }) => { + await page.goto("/"); + + const loginPassword = page.locator("#login-form input[name='password']"); + const loginToggle = page.locator("#login-form .password-toggle"); + await loginToggle.click(); + await expect(loginPassword).toHaveAttribute("type", "text"); + await expect(loginToggle).toHaveAttribute("aria-label", "Скрыть пароль"); + await loginToggle.click(); + await expect(loginPassword).toHaveAttribute("type", "password"); + + await page.click("#show-registration"); + const registrationPassword = page.locator( + "#register-form input[name='password']", + ); + const registrationToggle = page.locator("#register-form .password-toggle"); + await registrationToggle.click(); + await expect(registrationPassword).toHaveAttribute("type", "text"); +}); + +test("не показывает workspace прошлого пользователя до обновления данных", async ({ + page, +}) => { + await page.goto("/"); + await register(page); + + const privateTitle = `Private note ${Date.now()}`; + await openCreateForm(page, "note"); + await page.fill("#note-form input[name='title']", privateTitle); + await page.click("#note-form button[type='submit']"); + await expect( + page.locator("[data-testid='note']", { hasText: privateTitle }), + ).toBeVisible(); + + await page.click("#logout"); + await expect(page.locator("#login")).toBeVisible(); + + let releaseNotes!: () => void; + const notesBlocked = new Promise((resolve) => { + releaseNotes = resolve; + }); + await page.route("**/notes", async (route) => { + if (route.request().method() === "GET") { + await notesBlocked; + } + await route.continue(); + }); + + const credentials = newCredentials(); + await page.click("#show-registration"); + await page.fill( + "#register-form input[name='username']", + credentials.username, + ); + await page.fill( + "#register-form input[name='password']", + credentials.password, + ); + const notesRequest = page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === "/notes", + ); + await page.click("#register-form button[type='submit']"); + await notesRequest; + + await expect(page.locator("#workspace")).toBeHidden(); + releaseNotes(); + await expect(page.locator("#workspace")).toBeVisible(); + await expect( + page.locator("[data-testid='note']", { hasText: privateTitle }), + ).toHaveCount(0); + await page.unroute("**/notes"); +}); diff --git a/e2e/tests/logout.spec.ts b/e2e/tests/logout.spec.ts new file mode 100644 index 0000000..e63004a --- /dev/null +++ b/e2e/tests/logout.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "@playwright/test"; +import { register } from "./auth"; + +test("выход завершает сессию пользователя", async ({ page }) => { + await page.goto("/"); + await register(page); + await expect(page.locator("#status")).toHaveText(""); + + await page.click("#logout"); + + await expect(page.locator("#login")).toBeVisible(); + await expect(page.locator("#workspace")).toBeHidden(); + + await page.reload(); + await expect(page.locator("#login")).toBeVisible(); + await expect(page.locator("#workspace")).toBeHidden(); +}); diff --git a/e2e/tests/notes.spec.ts b/e2e/tests/notes.spec.ts index 07c2dee..94ceabe 100644 --- a/e2e/tests/notes.spec.ts +++ b/e2e/tests/notes.spec.ts @@ -1,14 +1,198 @@ import { test, expect } from "@playwright/test"; +import { openCreateForm, register } from "./auth"; test("a created note appears in the list", async ({ page }) => { await page.goto("/"); + await register(page); + + const emptyState = page.locator("#notes-empty"); + await expect(emptyState).toBeVisible(); + await expect(emptyState).toHaveText("Заметок нет. Нажмите +, чтобы создать."); const title = `E2E note ${Date.now()}`; + const body = "Содержимое заметки для раскрывающегося блока."; + await openCreateForm(page, "note"); await page.fill("#note-form input[name='title']", title); await page.fill("#note-form input[name='tags']", "e2e, demo"); + await page.fill("#note-form textarea[name='body']", body); await page.click("#note-form button[type='submit']"); - await expect( - page.locator("[data-testid='note']", { hasText: title }), - ).toBeVisible(); + const note = page.locator("[data-testid='note']", { hasText: title }); + await expect(note).toBeVisible(); + await expect(note.locator("[data-testid='edit-note'] svg")).toBeVisible(); + const bodyToggle = note.locator("[data-testid='toggle-note-body']"); + await expect(bodyToggle).toHaveAttribute("aria-label", "Показать содержание"); + await expect(bodyToggle).toHaveAttribute("aria-expanded", "false"); + await expect(note.locator("[data-testid='note-body']")).toBeHidden(); + await bodyToggle.click(); + await expect(note.locator("[data-testid='note-body']")).toHaveText(body); + await expect(bodyToggle).toHaveAttribute("aria-label", "Скрыть содержание"); + await expect(bodyToggle).toHaveAttribute("aria-expanded", "true"); + await expect(emptyState).toBeHidden(); +}); + +test("keeps note editing and creation menus mutually exclusive", async ({ + page, +}) => { + await page.goto("/"); + await register(page); + + for (const title of ["Edit menu one", "Edit menu two"]) { + 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("#note-form")).toBeHidden(); + } + + const notes = page.locator("[data-testid='note']"); + const first = notes.nth(0); + const second = notes.nth(1); + await first.locator("[data-testid='edit-note']").click(); + await expect(first.locator("[data-testid='edit-note-form']")).toBeVisible(); + + await second.locator("[data-testid='edit-note']").click(); + await expect(second.locator("[data-testid='edit-note-form']")).toBeVisible(); + await expect(first.locator("[data-testid='edit-note-form']")).toBeHidden(); + + await openCreateForm(page, "note"); + await expect(page.locator("#note-form")).toBeVisible(); + await expect(second.locator("[data-testid='edit-note-form']")).toBeHidden(); + + await first.locator("[data-testid='edit-note']").click(); + await expect(first.locator("[data-testid='edit-note-form']")).toBeVisible(); + 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); + + const longTitle = "A note title that is longer than thirty five characters"; + await openCreateForm(page, "note"); + await page.fill("#note-form input[name='title']", longTitle); + await page.fill( + "#note-form input[name='tags']", + "alpha, beta, gamma, delta, epsilon", + ); + await page.click("#note-form button[type='submit']"); + + const firstNote = page.locator("[data-testid='note']").first(); + await expect(firstNote.locator(".note-title")).toHaveText( + `${longTitle.slice(0, 32)}...`, + ); + await expect(firstNote.locator(".note-tags")).toHaveText( + "[alpha, beta, gamma, delta]...", + ); + const toggleOffset = await firstNote.evaluate((item) => { + const actions = item.querySelector(".note-actions"); + const toggle = item.querySelector(".note-body-toggle"); + return actions && toggle + ? toggle.getBoundingClientRect().left - + actions.getBoundingClientRect().left + : null; + }); + expect(toggleOffset).toBe(0); + + for (const title of ["Page note 2", "Page note 3", "Page note 4"]) { + 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']"); + } + + await expect(page.locator("#notes-pagination")).toBeVisible(); + await expect(page.locator("#notes-page")).toHaveText("1 из 2"); + await expect(page.locator("[data-testid='note']")).toHaveCount(3); + + const noteList = page.locator("#note-list"); + await page.click("#notes-next"); + await expect(page.locator("#notes-page")).toHaveText("2 из 2"); + await expect(page.locator("[data-testid='note']")).toHaveCount(1); + const heights = await noteList.evaluate(async (element) => { + const samples: number[] = []; + for (let index = 0; index < 8; index += 1) { + samples.push(element.getBoundingClientRect().height); + await new Promise((resolve) => window.setTimeout(resolve, 30)); + } + return samples; + }); + expect(heights[0]).toBeGreaterThan(heights[heights.length - 1]); + expect( + new Set(heights.map((height) => Math.round(height))).size, + ).toBeGreaterThan(2); + + await page.click("#notes-previous"); + await expect(page.locator("#notes-page")).toHaveText("1 из 2"); +}); + +test("the tag filter is hidden behind its toolbar button", async ({ page }) => { + await page.goto("/"); + await register(page); + + const filter = page.locator("#tag-filter-form"); + const toggle = page.locator("#toggle-tag-filter"); + await expect(filter).toBeHidden(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await toggle.click(); + await expect(filter).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await toggle.click(); + await expect(filter).toBeHidden(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); +}); + +test("переходит к связанной заметке за пределами фильтра", async ({ page }) => { + await page.goto("/"); + await register(page); + + const noteByTitle = (title: string) => + page.locator("[data-testid='note']").filter({ + has: page.locator(".note-title", { hasText: title }), + }); + + const targetTitle = `Filtered target ${Date.now()}`; + await openCreateForm(page, "note"); + await page.fill("#note-form input[name='title']", targetTitle); + await page.fill("#note-form input[name='tags']", "target"); + await page.click("#note-form button[type='submit']"); + const target = noteByTitle(targetTitle); + await expect(target).toBeVisible(); + const targetId = await target.getAttribute("data-id"); + expect(targetId).not.toBeNull(); + + const sourceTitle = `Filtered source ${Date.now()}`; + await openCreateForm(page, "note"); + await page.fill("#note-form input[name='title']", sourceTitle); + await page.fill("#note-form input[name='tags']", "source"); + await page.locator(`#note-links button[data-note-link='${targetId}']`).click(); + await page.click("#note-form button[type='submit']"); + await expect(page.locator("#note-form")).toBeHidden(); + + await page.click("#toggle-tag-filter"); + await page.fill("#tag-filter-form input[name='tag']", "source"); + await page.click("#tag-filter-form button[type='submit']"); + await expect(noteByTitle(sourceTitle)).toBeVisible(); + await expect(page.locator("[data-testid='note']")).toHaveCount(1); + + const source = noteByTitle(sourceTitle); + await source.locator("[data-testid='toggle-note-body']").click(); + await source.locator(".note-related-item").click(); + + await expect(page.locator("#tag-filter-form input[name='tag']")).toHaveValue(""); + await expect(noteByTitle(targetTitle)).toBeVisible(); }); diff --git a/e2e/tests/profile.spec.ts b/e2e/tests/profile.spec.ts new file mode 100644 index 0000000..510690c --- /dev/null +++ b/e2e/tests/profile.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { register } from "./auth"; + +test("показывает публичный профиль пользователя", async ({ page }) => { + await page.goto("/"); + const credentials = await register(page); + + const profileLink = page.locator("#current-user"); + await expect(profileLink).toHaveAttribute( + "href", + `/users/${credentials.username}`, + ); + await profileLink.click(); + + await expect(page.locator("#profile")).toBeVisible(); + await expect(page.locator("#profile-content")).toBeVisible(); + await expect(page.locator("[data-profile='username']")).toHaveText( + credentials.username, + ); + await expect(page.locator("[data-profile='cards_count']")).toHaveText("0"); + await expect(page.locator("[data-profile='reviews_count']")).toHaveText("0"); + await expect(page.locator("#profile-error")).toBeHidden(); +}); + +test("сообщает об отсутствующем публичном профиле", async ({ page }) => { + await page.goto("/users/profile-that-does-not-exist"); + + await expect(page.locator("#profile")).toBeVisible(); + await expect(page.locator("#profile-content")).toBeHidden(); + await expect(page.locator("#profile-error")).toHaveText("Профиль не найден."); +}); diff --git a/e2e/tests/review.spec.ts b/e2e/tests/review.spec.ts index 62dbd1c..1093db1 100644 --- a/e2e/tests/review.spec.ts +++ b/e2e/tests/review.spec.ts @@ -1,15 +1,36 @@ import { test, expect } from "@playwright/test"; +import { openCreateForm, 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 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']", "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(); + await expect(page.locator("#queue-count")).toHaveText("1"); 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(); @@ -17,7 +38,57 @@ test("grading a due card removes it from the queue", async ({ page }) => { await expect( page.locator(`[data-testid='queue-card'][data-id='${firstId}']`), ).toHaveCount(0); + await expect(page.locator("#queue-count")).toHaveText("0"); 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"); +}); diff --git a/e2e/tests/session.spec.ts b/e2e/tests/session.spec.ts new file mode 100644 index 0000000..e3fd951 --- /dev/null +++ b/e2e/tests/session.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test"; +import { register } from "./auth"; + +test("восстанавливает пользователя после перезагрузки", async ({ page }) => { + await page.goto("/"); + const credentials = await register(page); + + await page.reload(); + + await expect(page.locator("#workspace")).toBeVisible(); + await expect(page.locator("#current-user")).toHaveText(credentials.username); +}); diff --git a/outdatty.lock b/outdatty.lock index a6d8419..c071cee 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -3,28 +3,28 @@ algorithm: blake3 groups: api-surface: source: - spec/api/openapi.yaml: ab9c8ba2dbd1cd1a692a5e4bd62e5e4c71a076c08be9ef5a8316f7e068fe637e + spec/api/openapi.yaml: 6431078f502ea9bed3313463012c4d9b662fc91d259955b8e455aadc81502858 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 backend-stack: source: app/backend/composer.json: affae8dc08e919c50426844bbdd6b4ab76797d4e7957e210c445ff4a670ccdc0 - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + compose.yaml: 9be8443048e2b2278dbf39f366417c5a11646cddaeb9da37e06185221f84cb6b dependents: www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d commands: source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a + Justfile: ac294b3e02534859cc1e3532e5624b52df332b36dfd4c638197dfb82ff53e136 dependents: www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 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 @@ -41,8 +41,8 @@ groups: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d repo-layout: source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + Justfile: ac294b3e02534859cc1e3532e5624b52df332b36dfd4c638197dfb82ff53e136 + compose.yaml: 9be8443048e2b2278dbf39f366417c5a11646cddaeb9da37e06185221f84cb6b devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 dependents: www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d diff --git a/spec/acceptance/auth-isolation.hurl b/spec/acceptance/auth-isolation.hurl new file mode 100644 index 0000000..8aba765 --- /dev/null +++ b/spec/acceptance/auth-isolation.hurl @@ -0,0 +1,102 @@ +# 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 + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Bob private note", + "body": "Bob's own note." +} +HTTP 201 +[Captures] +bob_note_id: jsonpath "$.id" + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Bob cannot link Alice's note", + "body": "", + "links": ["{{note_id}}"] +} +HTTP 403 + +PUT {{base}}/notes/{{bob_note_id}} +Content-Type: application/json +{ + "title": "Bob cannot add Alice's link", + "body": "", + "links": ["{{note_id}}"] +} +HTTP 403 + +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/acceptance/auth-me.hurl b/spec/acceptance/auth-me.hurl new file mode 100644 index 0000000..5bf8e78 --- /dev/null +++ b/spec/acceptance/auth-me.hurl @@ -0,0 +1,14 @@ +# A current user requires a valid session cookie. + +GET {{base}}/auth/me +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-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/auth.hurl b/spec/acceptance/auth.hurl new file mode 100644 index 0000000..454e5b8 --- /dev/null +++ b/spec/acceptance/auth.hurl @@ -0,0 +1,82 @@ +# Registration creates a user without exposing a password hash. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}", + "password": "correct-horse-battery-staple" +} +HTTP 201 +[Captures] +user_id: jsonpath "$.id" +[Asserts] +jsonpath "$.id" exists +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" + +# 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 +{ + "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" + +# 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 +{ + "username": "{{username}}", + "password": "wrong-password" +} +HTTP 401 +[Asserts] +header "Set-Cookie" not exists + +# Logins are unique. +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}", + "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/acceptance/books.hurl b/spec/acceptance/books.hurl new file mode 100644 index 0000000..6c0f99d --- /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}}-k", + "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}} diff --git a/spec/acceptance/cards.hurl b/spec/acceptance/cards.hurl index 0cc55e8..7a0048d 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 @@ -34,6 +42,19 @@ HTTP 200 [Asserts] jsonpath "$.back" == "Paris" +# Update the card text. +PUT {{base}}/cards/{{card_id}} +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "Updated capital question", + "back": "Updated Paris" +} +HTTP 200 +[Asserts] +jsonpath "$.front" == "Updated capital question" +jsonpath "$.back" == "Updated Paris" + # It shows up in the list. GET {{base}}/cards HTTP 200 @@ -41,6 +62,13 @@ HTTP 200 jsonpath "$" isCollection jsonpath "$[*].id" contains {{card_id}} +# Delete the card. +DELETE {{base}}/cards/{{card_id}} +HTTP 204 + +GET {{base}}/cards/{{card_id}} +HTTP 404 + # A card needs an existing note. POST {{base}}/cards Content-Type: application/json 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 diff --git a/spec/acceptance/csrf.hurl b/spec/acceptance/csrf.hurl new file mode 100644 index 0000000..a4ef8f7 --- /dev/null +++ b/spec/acceptance/csrf.hurl @@ -0,0 +1,33 @@ +# A cross-origin form cannot mutate a cookie-authenticated workspace. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-x", + "password": "correct-horse-battery-staple" +} +HTTP 201 + +POST {{base}}/notes +Origin: https://untrusted.example +Content-Type: application/x-www-form-urlencoded +[FormParams] +title: Cross-site note +body: forbidden +tags: +links: +HTTP 403 +[Asserts] +jsonpath "$.error" == "недопустимый источник запроса" + +# The configured frontend origin remains allowed for mutations. +POST {{base}}/notes +Origin: {{frontend}} +Content-Type: application/json +{ + "title": "Trusted note", + "body": "allowed", + "tags": [], + "links": [] +} +HTTP 201 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..1d878d7 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 @@ -51,6 +59,27 @@ Content-Type: application/json } HTTP 422 +# Invalid note links are not silently discarded. +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Empty link", + "links": [""] +} +HTTP 422 +[Asserts] +jsonpath "$.details.links" contains "ссылка на заметку должна быть UUID" + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Non-string link", + "links": [null] +} +HTTP 422 +[Asserts] +jsonpath "$.details.links" contains "ссылка на заметку должна быть UUID" + # Delete it. DELETE {{base}}/notes/{{note_id}} HTTP 204 diff --git a/spec/acceptance/profile.hurl b/spec/acceptance/profile.hurl new file mode 100644 index 0000000..bb77d3f --- /dev/null +++ b/spec/acceptance/profile.hurl @@ -0,0 +1,64 @@ +# A public profile contains achievements but never private learning content. + +POST {{base}}/auth/register +Content-Type: application/json +{ + "username": "{{username}}-p", + "password": "correct-horse-battery-staple" +} +HTTP 201 + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Private note", + "body": "This must not appear in the profile." +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" + +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "Question", + "back": "Answer" +} +HTTP 201 +[Captures] +card_id: jsonpath "$.id" + +POST {{base}}/reviews/{{card_id}} +Content-Type: application/json +{ + "grade": "good" +} +HTTP 201 + +POST {{base}}/reviews/{{card_id}} +Content-Type: application/json +{ + "grade": "again" +} +HTTP 201 + +POST {{base}}/auth/logout +HTTP 204 + +GET {{base}}/users/{{username}}-p +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}-p" +jsonpath "$.created_at" exists +jsonpath "$.cards_count" == 1 +jsonpath "$.reviews_count" == 1 +jsonpath "$.practice_days" == 1 +jsonpath "$.current_streak" >= 1 +jsonpath "$.longest_streak" >= 1 +jsonpath "$.password_hash" not exists +jsonpath "$.notes" not exists +jsonpath "$.cards" not exists + +GET {{base}}/users/unknown-profile +HTTP 404 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 87e48a4..908b849 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -16,12 +16,111 @@ servers: description: Local backend (php -S / compose) tags: + - name: auth + - name: health - name: notes - name: cards - name: reviews - name: stats + - name: users 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] + summary: Register a user + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RegistrationInput" } + responses: + "201": + description: Registered user + headers: + Set-Cookie: + description: HttpOnly session cookie. + schema: { type: string } + 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" } + + /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" } + + /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" } + + /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] @@ -68,6 +167,7 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Note" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } put: tags: [notes] @@ -83,6 +183,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: @@ -90,6 +191,7 @@ paths: summary: Delete a note responses: "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /cards: @@ -118,6 +220,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" } @@ -133,12 +236,31 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Card" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [cards] + summary: Update a card + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CardInput" } + responses: + "200": + description: Updated card + content: + application/json: + schema: { $ref: "#/components/schemas/Card" } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/ValidationError" } delete: tags: [cards] summary: Delete a card responses: "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /reviews/queue: @@ -177,6 +299,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" } @@ -191,6 +314,25 @@ paths: application/json: schema: { $ref: "#/components/schemas/Stats" } + /users/{username}: + get: + tags: [users] + summary: Return a user's public learning profile + parameters: + - name: username + in: path + required: true + schema: + type: string + pattern: "^[a-z0-9][a-z0-9_-]{2,31}$" + responses: + "200": + description: Public learning profile + content: + application/json: + schema: { $ref: "#/components/schemas/PublicProfile" } + "404": { $ref: "#/components/responses/NotFound" } + components: parameters: Id: @@ -200,6 +342,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: @@ -212,6 +359,39 @@ 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] + 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 } + + 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] @@ -283,6 +463,19 @@ components: due_week: { type: integer } streak: { type: integer } + PublicProfile: + type: object + required: + [username, created_at, cards_count, reviews_count, practice_days, current_streak, longest_streak] + properties: + username: { type: string } + created_at: { type: string, format: date-time } + cards_count: { type: integer, minimum: 0 } + reviews_count: { type: integer, minimum: 0 } + practice_days: { type: integer, minimum: 0 } + current_streak: { type: integer, minimum: 0 } + longest_streak: { type: integer, minimum: 0 } + Error: type: object required: [error]