From 6b9278a5d57091fce636dbb640b380cfb990db6c Mon Sep 17 00:00:00 2001
From: Jacek Kowalski <Jacek@jacekk.info>
Date: Sat, 14 Nov 2026 20:40:27 +0000
Subject: [PATCH] Books version 2.0

---
 src/LegacyImport/CoverImporter.php                   |  221 +
 src/Controller/InventoryController.php               |  100 
 tests/Functional/ImportLegacyCommandTest.php         |  209 
 src/LegacyImport/ImportReport.php                    |   21 
 src/Service/CoverProcessingException.php             |    7 
 templates/ousted/bulk.html.twig                      |   57 
 bin/phpunit                                          |    5 
 config/packages/knp_paginator.yaml                   |    7 
 .env.test                                            |    3 
 tests/DatabaseTestCase.php                           |   42 
 src/Service/CoverThumbnailer.php                     |   82 
 templates/book/list.html.twig                        |   27 
 src/Controller/OustedBookController.php              |  120 
 src/Service/CoverStorage.php                         |   76 
 public/css/spectre-0.5.9/spectre.css                 | 3760 +++++++++++++++++
 src/Repository/LabelRepository.php                   |   33 
 src/Entity/Label.php                                 |   26 
 src/Lookup/LookupFailedException.php                 |    7 
 public/css/spectre-0.5.9/spectre-icons.min.css       |    1 
 src/Controller/CoverController.php                   |   64 
 src/Repository/OustedLabelRepository.php             |   17 
 templates/book/_details.html.twig                    |   42 
 templates/inventory/sw.js.twig                       |   46 
 src/Entity/AbstractLabel.php                         |   50 
 tests/Functional/CoverTest.php                       |  174 
 translations/messages.pl.yaml                        |  176 
 src/Validator/AvailableBookId.php                    |   20 
 phpunit.dist.xml                                     |   48 
 templates/book/show.html.twig                        |  106 
 .env.dev                                             |    4 
 src/Controller/SearchController.php                  |   34 
 config/routes.yaml                                   |   11 
 config/bundles.php                                   |   10 
 templates/book/_results.html.twig                    |    7 
 public/css/spectre-0.5.9/spectre-exp.css             | 1231 +++++
 src/Repository/OustedBookRepository.php              |   22 
 tests/Functional/BookTest.php                        |  144 
 templates/book/add.html.twig                         |  100 
 src/Repository/BorrowHistoryRepository.php           |   17 
 src/Command/ImportLegacyCommand.php                  |  109 
 templates/book/lookup.html.twig                      |   49 
 tests/Functional/SearchTest.php                      |   65 
 src/Controller/BorrowController.php                  |   54 
 config/packages/cache.yaml                           |   19 
 templates/search/index.html.twig                     |   39 
 src/Entity/BorrowHistory.php                         |   24 
 src/Entity/OustedBorrowHistory.php                   |   24 
 src/Entity/Borrow.php                                |   39 
 src/Entity/OustedLabel.php                           |   25 
 translations/messages.en.yaml                        |  176 
 templates/spectre/knp_paginator_pagination.html.twig |   31 
 config/packages/validator.yaml                       |   11 
 templates/inventory/index.html.twig                  |   96 
 tests/LegacyDatabase.php                             |   24 
 templates/book/_tile.html.twig                       |   35 
 config/routes/framework.yaml                         |    4 
 src/Repository/OustedBorrowHistoryRepository.php     |   17 
 src/Enum/LabelType.php                               |   35 
 config/packages/routing.yaml                         |   10 
 bin/console                                          |   22 
 config/services.yaml                                 |   36 
 templates/base.html.twig                             |   57 
 public/js/inventory.js                               |  596 ++
 config/packages/doctrine.yaml                        |   47 
 src/Repository/BorrowRepository.php                  |   25 
 src/Form/AddLabelType.php                            |   52 
 src/Form/BookSearchType.php                          |   35 
 src/Form/BorrowType.php                              |   25 
 config/packages/framework.yaml                       |   24 
 src/Lookup/BookLookupInterface.php                   |   18 
 src/Form/OustType.php                                |   25 
 src/Form/BulkOustType.php                            |   48 
 config/packages/translation.yaml                     |    8 
 public/css/spectre-0.5.9/spectre-icons.css           |  597 ++
 src/Repository/BookRepository.php                    |  170 
 config/packages/csrf.yaml                            |    7 
 src/Validator/AvailableBookIdValidator.php           |   32 
 LICENSE                                              |   28 
 public/css/inventory.css                             |   39 
 tests/Functional/InventoryTest.php                   |  139 
 src/Controller/BookController.php                    |  110 
 src/Entity/Book.php                                  |   85 
 src/Entity/OustedBook.php                            |   71 
 public/css/spectre-0.5.9/spectre.min.css             |    1 
 migrations/Version20261106120000.php                 |  110 
 src/LegacyImport/CoverImportReport.php               |   31 
 src/Entity/AbstractBorrowHistory.php                 |   49 
 config/packages/doctrine_migrations.yaml             |    6 
 tests/bootstrap.php                                  |   14 
 tests/Functional/ImportLegacyCoversTest.php          |  169 
 README.md                                            |   41 
 public/css/spectre                                   |    1 
 src/Kernel.php                                       |   11 
 migrations/Version20261113120000.php                 |   31 
 templates/ousted/show.html.twig                      |   55 
 src/Controller/LookupController.php                  |   42 
 src/Form/CoverType.php                               |   38 
 .gitignore                                           |   21 
 tests/Functional/LookupTest.php                      |   60 
 src/Entity/AbstractBook.php                          |  306 +
 tests/Fixtures/rotated-orientation-6.jpg             |    0 
 config/packages/property_info.yaml                   |    3 
 config/packages/twig.yaml                            |    7 
 public/css/spectre-0.5.9/spectre-exp.min.css         |    1 
 templates/book/new.html.twig                         |   13 
 src/Model/BookSearch.php                             |   15 
 templates/book/_cover.html.twig                      |    3 
 templates/book/_history.html.twig                    |   29 
 src/Controller/LabelController.php                   |   49 
 templates/ousted/list.html.twig                      |   31 
 src/Service/LibraryThingCovers.php                   |   70 
 src/Model/Isbn.php                                   |   48 
 .env                                                 |   45 
 composer.json                                        |   78 
 tests/Functional/LibraryThingCoverTest.php           |  116 
 public/index.php                                     |    9 
 config/preload.php                                   |    6 
 public/css/custom.css                                |   82 
 templates/borrow/list.html.twig                      |   27 
 templates/spectre/form.html.twig                     |   73 
 src/LegacyImport/LegacyImporter.php                  |  278 +
 /dev/null                                            |  146 
 tests/Functional/LoanTest.php                        |  116 
 src/Lookup/NationalLibraryLookup.php                 |   86 
 src/Service/Library.php                              |  132 
 src/Form/BookType.php                                |   41 
 src/Lookup/BookCandidate.php                         |   18 
 templates/book/edit.html.twig                        |   13 
 tests/Functional/BulkOustTest.php                    |   95 
 tests/Unit/IsbnTest.php                              |   39 
 130 files changed, 12,818 insertions(+), 146 deletions(-)

diff --git a/.env b/.env
new file mode 100644
index 0000000..d63c7cc
--- /dev/null
+++ b/.env
@@ -0,0 +1,45 @@
+# In all environments, the following files are loaded if they exist,
+# the latter taking precedence over the former:
+#
+#  * .env                contains default values for the environment variables needed by the app
+#  * .env.local          uncommitted file with local overrides
+#  * .env.$APP_ENV       committed environment-specific defaults
+#  * .env.$APP_ENV.local uncommitted environment-specific overrides
+#
+# Real environment variables win over .env files.
+#
+# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
+# https://symfony.com/doc/current/configuration/secrets.html
+#
+# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
+# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
+
+###> symfony/framework-bundle ###
+APP_ENV=dev
+APP_SECRET=
+APP_SHARE_DIR=var/share
+###< symfony/framework-bundle ###
+
+###> symfony/routing ###
+# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
+# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
+DEFAULT_URI=http://localhost
+###< symfony/routing ###
+
+###> doctrine/doctrine-bundle ###
+# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
+# SQLite is the development default. MySQL, MariaDB and PostgreSQL are supported too;
+# for those, state the server version in the URL:
+# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
+# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=mariadb-11.8.6&charset=utf8mb4"
+# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
+DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
+###< doctrine/doctrine-bundle ###
+
+# Database holding the legacy tables (ksiazki, pozycz, pozycz_historia), read only by
+# "bin/console app:import-legacy".
+LEGACY_DATABASE_URL="mysql://books:books123@127.0.0.1:3306/books?serverVersion=mariadb-11.8.6&charset=utf8mb4"
+
+# Key for the LibraryThing covers API (https://www.librarything.com/services/keys.php). When set,
+# a book added with an ISBN gets its cover from LibraryThing. Put the key in .env.local.
+LIBRARYTHING_API_KEY=
diff --git a/.env.dev b/.env.dev
new file mode 100644
index 0000000..2db97f0
--- /dev/null
+++ b/.env.dev
@@ -0,0 +1,4 @@
+
+###> symfony/framework-bundle ###
+APP_SECRET=dfe52648fee5c8f57d208278d991605a
+###< symfony/framework-bundle ###
diff --git a/.env.test b/.env.test
new file mode 100644
index 0000000..d5b3840
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,3 @@
+KERNEL_CLASS='App\Kernel'
+APP_SECRET='$ecretf0rt3st'
+LEGACY_DATABASE_URL="sqlite:///%kernel.project_dir%/var/legacy_test.db"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..41117bd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,21 @@
+composer.phar
+.composer
+.idea
+nbproject
+/config/reference.php
+
+###> symfony/framework-bundle ###
+/.env.local
+/.env.local.php
+/.env.*.local
+/config/secrets/prod/prod.decrypt.private.php
+/public/bundles/
+/public/covers/
+/var/
+/vendor/
+###< symfony/framework-bundle ###
+
+###> phpunit/phpunit ###
+/phpunit.xml
+/.phpunit.cache/
+###< phpunit/phpunit ###
diff --git a/BUILD b/BUILD
deleted file mode 100644
index a77948d..0000000
--- a/BUILD
+++ /dev/null
@@ -1,19 +0,0 @@
-SETUSER httpd
-SETGROUP daemon
-
-REMOVEGLOB /.git/*
-REMOVEDIR /.git
-REMOVE /covers/own/.emptydir
-REMOVE /covers_big/own/.emptydir
-
-REMOVE /includes/MARC21_opisy.php
-
-REMOVEGLOB /help/*
-REMOVEDIR /help
-
-VERSION
-
-CHOWN
-RCHMOD /covers* 755
-
-HEADER *.php "<?php" HEADER
diff --git a/CHANGELOG b/CHANGELOG
deleted file mode 100644
index 92301af..0000000
--- a/CHANGELOG
+++ /dev/null
@@ -1,90 +0,0 @@
-System Biblioteczny - CHANGELOG
-
-1.4 Stable
-- Przeniesienie ustawień skryptu do osobnego pliku (includes/config.php)
-- Zmiana modułu do połączeń z MySQL z mysql na pdo_mysql
-- Rozdzielenie tabeli pozycz na dwie: pozycz i pozycz_historia
-- Wydzielenie pokazywania historii wypożyczeń do klasy gotowe
-- Zmiany w wielu miejscach w kodzie związane z tabelą pozycz
-- Poprawki w pliku test.php w związku z w/w zmianami
-- Dodanie pliku UPGRADE z instrukcją aktualizacji
-
-1.3 Stable
-- Poprawienie okładek - getimagesize() zwraca IMAGETYPE_XXX, a nie IMG_XXX
-- Zmiana add_search.php w związku z nowym sposobem wywoływania
-  metod klasy ibd (przeładowanie __call)
-- Naprawienie pokazywania trafności przy wyszukiwaniu pełnotekstowym
-- Dodanie wyświetlania ISBN-10 oraz ISSN-8 dla pojedynczych woluminów
-- Zalążek interfejsu do synchronizacji danych (sync.php)
-- Zalążek system inwentaryzacji (zobacz katalog inwentaryzacja)
-- Dodanie wywołania metody db2::escape_data() przy niestandardowym
-  zapytaniu w funkcji ksiazki::cache_update()
-
-1.2 Stable
-- Dodanie wywołania metody db2::escape_data() przy niestandardowym
-  zapytaniu w funkcji ksiazki::szukaj_info()
-- Przebudowa systemu okładek (Bug #2)
-
-1.1 Stable
-- Modułowy system pobierania informacji ze źródeł zewnętrznych
-- Poprawka wyszukiwania po miejscu wraz z podaną półką/rzędem
-- Możliwość usuwania własnych okładek
-
-1.03 Stable
-- Poprawienie wyszukiwania po ISSN
-- Dodanie wyglądu wydruku w CSS
-- Poprawienie drobnych błędów w kodzie HTML
-- Skrypt sprawdzający spełnienie wymagań systemu - test.php
-- Poprawienie skryptów JavaScript, ktore wymagały, by system znajdował się
-  w głównej scieżce serwera/vHosta (/)
-
-1.02 Stable
-- Możliwość dodawania własnych okładek (także do książek bez ISBN) oraz
-  podmieniania nimi obrazków pobranych z LibraryThing.
-
-1.01 Stable
-- Zmniejszono częstotliwość odświeżania informacji o nieistniejących
-  miniaturach.
-- Poprawienie nazwy pola (`od2` na `od`) w odpowiedzi z bazy danych już przy
-  cache'owaniu danych.
-
-1.0 Stable
-- Poprawiono informowanie o stanie książki po więcej niż jednym wypożyczeniu
-- Dodano pobieranie okładek książek
-
-1.0 RC
-- Dodano możliwość zmiany nazwy półki/regału.
-- Prosta historia wypożyczeń.
-- Poprawiono błąd w formularzu wyszukiwania książek
-  (po pożyczeniu/oddaniu woluminów).
-- Możliwość wyszukiwania książek bez podania półki/rzędu.
-
-0.94 Beta
-- Poprawiono informowanie o stanie książki tuż po wypożyczeniu/zwrocie.
-- Poprawienie cache'owania danych o książkach.
-
-0.93 Beta
-- Drobne zmiany wizualne (<fieldset> dla wypożyczania).
-
-0.92 Beta
-- Poprawiono konwersję ISBN-13 do ISBN-10. Funkcja sprawdzająca
-  poczatek ISBN-13 (978) wkradła się do konwersji ISSNu.
-- Usunięto możliwość podania ISBNu w miejsce ISSNu i na odwrót przy
-  dodawaniu i edycji książek.
-- Przy wyświetlaniu danych książki ISSN jest już podawany poprawnie.
-
-0.91 Beta
-- Poprawa błędnej jednostki w stopce (ms => s).
-- Wykrywanie brakujących rozszerzeń PHP.
-- Jeżeli wyszukiwanie nie zwraca rekordów (SELECT COUNT(*)=='0'),
-  zapytanie o dane nie jest już wykonywane
-- Przy wyszukiwaniu w bazach Biblioteki Narodowej ISBN-13 nie jest
-  konwertowany na ISBN-10, jeśli nie zaczyna się od 978.
-- Poprawiono błąd przy wyszukiwaniu wg. podanych informacji
-  (wynik funkcji ksiazki::szukaj_info() nie był tablicą rekordów).
-- Przygotowania do utworzenia systemu automatycznego budowania
-  wersji skryptów (dodawanie nagłówków do plików,
-  usuwanie zbędnych plików przez system).
-
-0.9 Beta
-- Pierwsza wersja publiczna.
diff --git a/HEADER b/HEADER
deleted file mode 100644
index 37aa5de..0000000
--- a/HEADER
+++ /dev/null
@@ -1,11 +0,0 @@
-
-/***************************************************** 
- *                System biblioteczny                * 
- ***************************************************** 
- * Wersja: $BUILD__VERSION                           * 
- * Data: $BUILDDATE ($TIME)                          * 
- * Autor: Jacek Kowalski (http://jacekk.info)        * 
- *                                                   * 
- * Utwor rozprowadzany na licencji                   * 
- * http://creativecommons.org/licenses/by-nc-sa/2.5/ * 
- *****************************************************/
diff --git a/INSTALL b/INSTALL
deleted file mode 100644
index 0b27c21..0000000
--- a/INSTALL
+++ /dev/null
@@ -1,8 +0,0 @@
-1. Rozpakuj archiwum do folderu serwera WWW (najlepiej dostępnego tylko z sieci lokalnej)
-2. Utwórz bazę danych biblioteka i odpowiedniego użytkownika
-3. Wykonaj plik biblioteka.sql w bazie danych
-4. Ustaw dane logowania do bazy w pliku /includes/config.php
-5. Folderom /covers i /covers_big nadaj uprawnienia do odczytu i zapisu
-6. W przypadku instalacji modułu OPAC foldery /covers i /covers_big powinny linkować do odpowiedników w katalogu głównego modułu
-7. Ustaw klucz API do Library Thing w pliku /includes/config.php
-8. Sprawdź wymagania systemu (otwórz poprzez serwer WWW plik test.php)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6061718
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2026, Jacek Kowalski
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..997c073
--- /dev/null
+++ b/README.md
@@ -0,0 +1,41 @@
+# Book Shelf
+
+Home book library tracker.
+Rebuilt using Symfony 8.1, it runs on SQLite, MySQL and PostgreSQL.
+
+## Setup
+
+Requires PHP 8.4 or newer with the GD, Exif and PDO extensions.
+
+```bash
+composer install
+php bin/console doctrine:migrations:migrate
+php -S 127.0.0.1:8080 -t public
+```
+
+Defaults to using SQLite in var directory.
+To use another database, set `DATABASE_URL` in `.env.local`.
+See `.env` for database URL fomats.
+
+## LibraryThing covers
+
+With a [LibraryThing developer key](https://www.librarything.com/services/keys.php) in `.env.local`,
+a book added with an ISBN gets its cover from LibraryThing:
+
+```bash
+LIBRARYTHING_API_KEY=your-key
+```
+
+## Upgrading the old system
+
+`app:import-legacy` copies the data from legacy tables into the current schema.
+Additionally covers can be imported from the `covers_big` directory.
+Data is read from MySQL at `LEGACY_DATABASE_URL` set in `.env.local`.
+
+```bash
+php bin/console doctrine:migrations:migrate
+php bin/console app:import-legacy --dry-run
+php bin/console app:import-legacy --covers=/path/to/covers_big --dry-run
+php bin/console app:import-legacy
+php bin/console app:import-legacy --covers=/path/to/covers_big
+```
diff --git a/UPGRADE b/UPGRADE
deleted file mode 100644
index 298d2f4..0000000
--- a/UPGRADE
+++ /dev/null
@@ -1,26 +0,0 @@
-=================================================
- Aktualizacja wersji 1.3 Stable i wcześniejszych
-=================================================
-
-- zapisz następujące informacje: dane połączenia z bazą danych,
-  klucz API LibraryThing
-- wgraj wszystkie zmienione pliki,
-- w pliku /includes/config.php podaj zapisane wcześniej dane,
-- wykonaj w bazie danych poniższe zapytania:
-
-CREATE TABLE `pozycz_historia` (
-  `id` mediumint(8) unsigned NOT NULL,
-  `kto` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `od` int(11) NOT NULL,
-  `do` int(11) NOT NULL,
-  PRIMARY KEY (`id`,`od`)
-);
-
-INSERT INTO `pozycz_historia` (`id`, `kto`, `od`, `do`)
-  SELECT `id`, `kto`, `od`, `do` FROM `pozycz` WHERE `do` IS NOT NULL;
-DELETE FROM `pozycz` WHERE `do` IS NOT NULL;
-
-ALTER TABLE `pozycz` DROP INDEX `do`;
-ALTER TABLE `pozycz` DROP COLUMN `do`;
-
-ALTER TABLE `pozycz` DROP PRIMARY KEY, ADD PRIMARY KEY (`id`);
diff --git a/add.php b/add.php
deleted file mode 100644
index fe493d6..0000000
--- a/add.php
+++ /dev/null
@@ -1,84 +0,0 @@
-<?php
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Krok 1 - podaj podstawowe informacje o egzemplarzu </h3>
-
-<table class="main">
-<tr>
-<th> Wyszukiwanie </th>
-<th> Dodawanie wg. podanych informacji </th>
-</tr>
-
-<tr>
-<td>
-
-<form action="add_isbn.php" method="post" onsubmit="return ffalse('step1')">
-<p>KOD: <input type="text" name="kod" id="step11" required="required" accesskey="1" /><br />
-IS*N: <input type="text" name="isn" id="step12" required="required" /></p>
-<p><input type="submit" value="Znajdź i zapisz" /></p>
-</form>
-
-<script type="text/javascript">
-document.getElementById('step11').focus();
-</script>
-
-</td>
-<td>
-
-<form action="add_file.php" method="post" enctype="multipart/form-data" onsubmit="return ffalse('step3')">
-<p>KOD: <input type="text" name="kod" id="step31" required="required" accesskey="3" /><br />
-Plik MARC21: <input type="file" name="marc" id="step32" required="required" /></p>
-<p><input type="submit" value="Wyślij i zapisz" /></p>
-</form>
-
-</td>
-</tr>
-<tr>
-<td>
-
-<form action="add_search.php" method="post" onsubmit="return ffalse('step2')">
-<p>KOD: <input type="text" name="kod" id="step21" required="required" accesskey="2" /><br />
-Tytuł: <input type="text" name="tytul" id="step22" required="required" /><br />
-Autor: <input type="text" name="autor" /><br />
-Wydawnictwo: <input type="text" name="wydawnictwo" /></p>
-<p><input type="submit" value="Znajdź i zapisz" /></p>
-</form>
-
-</td>
-<td rowspan="2">
-
-<form action="add_book.php" method="post" onsubmit="return ffalse('step4')" enctype="multipart/form-data">
-<p>KOD: <input type="text" name="id" id="step41" required="required" accesskey="4" /><br />
-Tytuł: <input type="text" name="tytul" id="step42" required="required" /><br />
-Autor: <input type="text" name="autor" id="step43" required="required" /><br />
-Język: <input type="text" name="jezyk" id="step44" required="required" /><br />
-Wydanie: <input type="text" name="wydanie" /></p>
-<p>Miejsce: <input type="text" name="miejsce" /><br />
-Rok: <input type="text" name="rok" /><br />
-Wydawnictwo: <input type="text" name="wydawnictwo" /></p>
-<p>ISBN: <input type="text" name="ISBN" /><br />
-ISSN: <input type="text" name="ISSN" /></p>
-<p>Okładka: <input type="file" name="okladka" /></p>
-<p><input type="submit" value="Zapisz" /></p>
-</form>
-
-</td>
-</tr>
-<tr>
-<td>
-
-<form action="add_similar.php" method="post" onsubmit="return ffalse('step5')">
-<p>KOD: <input type="text" name="kod" id="step51" required="required" accesskey="5" /><br />
-KOD podobnej książki: <input type="text" name="kod2" id="step52" required="required" /></p>
-<p><input type="submit" name="Znajdź i zapisz" value="Zapisz" /></p>
-</form>
-
-</td>
-</tr>
-</table>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/add_book.php b/add_book.php
deleted file mode 100644
index 248b119..0000000
--- a/add_book.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-include('./includes/std.php');
-
-if($_POST['id']=='' OR empty($_POST['autor']) OR empty($_POST['tytul']) OR empty($_POST['jezyk'])) {
-	errorclass::add('Brak wymaganych danych o książce (kod, autor, tytuł, język)');
-}
-
-ksiazki::dodaj($_POST);
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Książka została dodana! </h3>
-
-<p>Informacje o dodanym egzemplarzu:</p>
-
-<?php
-gotowe::informacje($_POST['id']);
-
-include('./design/bottom.php');
-?>
diff --git a/add_file.php b/add_file.php
deleted file mode 100644
index 515dc55..0000000
--- a/add_file.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_POST['kod'], FALSE);
-
-if(!is_uploaded_file($_FILES['marc']['tmp_name'])) {
-	errorclass::add('Nie wysłano pliku!');
-}
-
-$szukaj = array( MARC21::to_array( MARC21::from_string( file_get_contents( $_FILES['marc']['tmp_name'] ) ) ) );
-$i = 0;
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Krok 2 - wybierz żądaną książkę </h3>
-
-<?php
-gotowe::dodaj_lista($_POST['kod'], $szukaj);
-?>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/add_isbn.php b/add_isbn.php
deleted file mode 100644
index 0b93bfa..0000000
--- a/add_isbn.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_POST['kod'], FALSE);
-
-$ibd = new ibd;
-
-switch( validate::type($_POST['isn']) ) {
-	case 'ISBN':
-		$szukaj1 = ksiazki::szukaj_ISBN($_POST['isn']);
-		$szukaj2 = $ibd->szukaj_ISBN($_POST['isn']);
-	break;
-	case 'ISSN':
-		$szukaj1 = ksiazki::szukaj_ISSN($_POST['isn']);
-		$szukaj2 = $ibd->szukaj_ISSN($_POST['isn']);
-	break;
-}
-
-$i = 0;
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Krok 2 - wybierz żądaną książkę </h3>
-
-<?php
-gotowe::dodaj_lista($_POST['kod'], $szukaj1, $szukaj2);
-
-include('./design/bottom.php');
-?>
diff --git a/add_search.php b/add_search.php
deleted file mode 100644
index c9b80d0..0000000
--- a/add_search.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_POST['kod']);
-
-list(,$szukaj1) = ksiazki::szukaj_info($_POST);
-$ibd = new ibd;
-$szukaj2 = $ibd->szukaj_info($_POST['tytul'], $_POST['autor'], $_POST['wydawnictwo']);
-
-$i = 0;
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Krok 2 - wybierz żądaną książkę </h3>
-
-<?php
-gotowe::dodaj_lista($_POST['kod'], $szukaj1, $szukaj2);
-?>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/add_similar.php b/add_similar.php
deleted file mode 100644
index d0b1854..0000000
--- a/add_similar.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_POST['kod'], FALSE);
-
-$szukaj = ksiazki::szukaj_KOD($_POST['kod2']);
-
-$i = 0;
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Krok 2 - wybierz żądaną książkę </h3>
-
-<?php
-gotowe::dodaj_lista($_POST['kod'], array($szukaj) );
-?>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/biblioteka.sql b/biblioteka.sql
deleted file mode 100644
index c36324e..0000000
--- a/biblioteka.sql
+++ /dev/null
@@ -1,40 +0,0 @@
-CREATE TABLE `ksiazki` (
-  `id` mediumint(8) unsigned NOT NULL,
-  `tytul` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `autor` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `jezyk` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `rok` year(4) DEFAULT NULL,
-  `miejsce` varchar(255) COLLATE utf8_polish_ci DEFAULT NULL,
-  `wydawnictwo` varchar(255) COLLATE utf8_polish_ci DEFAULT NULL,
-  `wydanie` varchar(255) COLLATE utf8_polish_ci DEFAULT NULL,
-  `ISBN` decimal(13,0) unsigned DEFAULT NULL,
-  `ISSN` decimal(13,0) unsigned DEFAULT NULL,
-  `regal` char(5) COLLATE utf8_polish_ci DEFAULT NULL,
-  `polka` decimal(2,0) unsigned DEFAULT NULL,
-  `rzad` decimal(2,0) unsigned DEFAULT NULL,
-  `wycofana` enum('0','1') CHARACTER SET ascii NOT NULL DEFAULT '0',
-  `powod` varchar(255) COLLATE utf8_polish_ci DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `ISBN` (`ISBN`),
-  KEY `wycofana` (`wycofana`),
-  KEY `ISSN` (`ISSN`),
-  FULLTEXT KEY `tytul` (`tytul`),
-  FULLTEXT KEY `autor` (`autor`),
-  FULLTEXT KEY `wydawnictwo` (`wydawnictwo`)
-);
-
-CREATE TABLE `pozycz` (
-  `id` mediumint(8) unsigned NOT NULL,
-  `kto` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `od` int(11) NOT NULL,
-  PRIMARY KEY (`id`,`od`)
-);
-
-CREATE TABLE `pozycz_historia` (
-  `id` mediumint(8) unsigned NOT NULL,
-  `kto` varchar(255) COLLATE utf8_polish_ci NOT NULL,
-  `od` int(11) NOT NULL,
-  `do` int(11) DEFAULT NULL,
-  PRIMARY KEY (`id`,`od`),
-  KEY `do` (`do`)
-);
diff --git a/bin/console b/bin/console
new file mode 100755
index 0000000..9aaf1ca
--- /dev/null
+++ b/bin/console
@@ -0,0 +1,22 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+use App\Kernel;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+
+if (!is_dir(dirname(__DIR__) . '/vendor')) {
+	throw new LogicException('Dependencies are missing. Try running "composer install".');
+}
+
+if (!is_file(dirname(__DIR__) . '/vendor/autoload_runtime.php')) {
+	throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
+}
+
+require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
+
+return function (array $context) {
+	$kernel = new Kernel($context['APP_ENV'], (bool)$context['APP_DEBUG']);
+
+	return new Application($kernel);
+};
diff --git a/bin/phpunit b/bin/phpunit
new file mode 100755
index 0000000..c95ca15
--- /dev/null
+++ b/bin/phpunit
@@ -0,0 +1,5 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+
+require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
diff --git a/borrow_back.php b/borrow_back.php
deleted file mode 100644
index 51c362e..0000000
--- a/borrow_back.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-include('./includes/std.php');
-
-pozycz::zwrot($_POST['kod']);
-
-$title = 'Wypożyczanie książki';
-include('./design/top.php');
-
-gotowe::informacje($_POST['kod']);
-
-echo '<form action="search.php" method="get" onsubmit="return ffalse(\'step2\')">
-<p>KOD/IS*N: <input type="text" name="id" id="step21" required="required" /></p>
-<p><input type="submit" value="Znajdź" /></p>
-
-<script type="text/javascript">
-document.getElementById(\'step21\').focus();
-</script>
-</form>';
-
-include('./design/bottom.php');
-?>
diff --git a/borrow_book.php b/borrow_book.php
deleted file mode 100644
index e48e520..0000000
--- a/borrow_book.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-include('./includes/std.php');
-
-pozycz::wypozyczenie($_POST['kod'], $_POST['kto']);
-
-$title = 'Wypożyczanie książki';
-include('./design/top.php');
-
-gotowe::informacje($_POST['kod']);
-
-echo '<form action="search.php" method="get" onsubmit="return ffalse(\'step2\')">
-<p>KOD/IS*N: <input type="text" name="id" id="step21" required="required" /></p>
-<p><input type="submit" value="Znajdź" /></p>
-
-<script type="text/javascript">
-document.getElementById(\'step21\').focus();
-</script>
-</form>';
-
-include('./design/bottom.php');
-?>
diff --git a/borrow_history.php b/borrow_history.php
deleted file mode 100644
index caa8f34..0000000
--- a/borrow_history.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_GET['kod']);
-
-$title = 'Historia wypożyczeń książki';
-include('./design/top.php');
-
-gotowe::informacje($_GET['kod']);
-gotowe::historia($_GET['kod']);
-
-include('./design/bottom.php');
-?>
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..498b95c
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,78 @@
+{
+    "type": "project",
+    "license": "proprietary",
+    "minimum-stability": "stable",
+    "prefer-stable": true,
+    "require": {
+        "php": ">=8.4",
+        "ext-ctype": "*",
+        "ext-exif": "*",
+        "ext-gd": "*",
+        "ext-iconv": "*",
+        "doctrine/doctrine-bundle": "^3.4.1",
+        "doctrine/doctrine-migrations-bundle": "^4.0.1",
+        "doctrine/orm": "^3.7.6",
+        "knplabs/knp-paginator-bundle": "^6.11",
+        "symfony/asset": "8.1.*",
+        "symfony/console": "8.1.*",
+        "symfony/dotenv": "8.1.*",
+        "symfony/flex": "^2.12",
+        "symfony/form": "8.1.*",
+        "symfony/framework-bundle": "8.1.*",
+        "symfony/http-client": "8.1.*",
+        "symfony/mime": "8.1.*",
+        "symfony/runtime": "8.1.*",
+        "symfony/security-csrf": "8.1.*",
+        "symfony/translation": "8.1.*",
+        "symfony/twig-bundle": "8.1.*",
+        "symfony/validator": "8.1.*",
+        "symfony/yaml": "8.1.*"
+    },
+    "config": {
+        "allow-plugins": {
+            "php-http/discovery": true,
+            "symfony/flex": true,
+            "symfony/runtime": true
+        },
+        "bump-after-update": true,
+        "sort-packages": true
+    },
+    "autoload": {
+        "psr-4": {
+            "App\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "App\\Tests\\": "tests/"
+        }
+    },
+    "scripts": {
+        "auto-scripts": {
+            "cache:clear": "symfony-cmd",
+            "assets:install %PUBLIC_DIR%": "symfony-cmd"
+        },
+        "post-install-cmd": [
+            "@auto-scripts"
+        ],
+        "post-update-cmd": [
+            "@auto-scripts"
+        ]
+    },
+    "conflict": {
+        "symfony/symfony": "*"
+    },
+    "extra": {
+        "symfony": {
+            "allow-contrib": false,
+            "require": "8.1.*"
+        }
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^13.4.7",
+        "symfony/browser-kit": "8.1.*",
+        "symfony/css-selector": "8.1.*",
+        "symfony/maker-bundle": "^1.68",
+        "symfony/phpunit-bridge": "^8.1.10"
+    }
+}
diff --git a/config/bundles.php b/config/bundles.php
new file mode 100644
index 0000000..e7a1d31
--- /dev/null
+++ b/config/bundles.php
@@ -0,0 +1,10 @@
+<?php
+
+return [
+    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
+    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
+    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
+    Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
+    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
+    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
+];
diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml
new file mode 100644
index 0000000..6899b72
--- /dev/null
+++ b/config/packages/cache.yaml
@@ -0,0 +1,19 @@
+framework:
+    cache:
+        # Unique name of your app: used to compute stable namespaces for cache keys.
+        #prefix_seed: your_vendor_name/app_name
+
+        # The "app" cache stores to the filesystem by default.
+        # The data in this cache should persist between deploys.
+        # Other options include:
+
+        # Redis
+        #app: cache.adapter.redis
+        #default_redis_provider: redis://localhost
+
+        # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
+        #app: cache.adapter.apcu
+
+        # Namespaced pools use the above "app" backend by default
+        #pools:
+            #my.dedicated.cache: null
diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml
new file mode 100644
index 0000000..060785e
--- /dev/null
+++ b/config/packages/csrf.yaml
@@ -0,0 +1,7 @@
+# Session-backed tokens: the app has no JavaScript for the stateless double-submit flow.
+framework:
+    form:
+        csrf_protection:
+            token_id: submit
+
+    csrf_protection: true
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
new file mode 100644
index 0000000..3c7e19f
--- /dev/null
+++ b/config/packages/doctrine.yaml
@@ -0,0 +1,47 @@
+doctrine:
+    dbal:
+        default_connection: default
+        connections:
+            default:
+                url: '%env(resolve:DATABASE_URL)%'
+                # The legacy tables may share this database; keep them out of schema tooling.
+                schema_filter: ~^(?!(ksiazki|pozycz|pozycz_historia)$)~
+                profiling_collect_backtrace: '%kernel.debug%'
+            legacy:
+                url: '%env(resolve:LEGACY_DATABASE_URL)%'
+    orm:
+        default_entity_manager: default
+        entity_managers:
+            default:
+                connection: default
+                validate_xml_mapping: true
+                naming_strategy: doctrine.orm.naming_strategy.underscore
+                identity_generation_preferences:
+                    Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
+                auto_mapping: true
+                mappings:
+                    App:
+                        type: attribute
+                        is_bundle: false
+                        dir: '%kernel.project_dir%/src/Entity'
+                        prefix: 'App\Entity'
+
+when@prod:
+    doctrine:
+        orm:
+            entity_managers:
+                default:
+                    query_cache_driver:
+                        type: pool
+                        pool: doctrine.system_cache_pool
+                    result_cache_driver:
+                        type: pool
+                        pool: doctrine.result_cache_pool
+
+    framework:
+        cache:
+            pools:
+                doctrine.result_cache_pool:
+                    adapter: cache.app
+                doctrine.system_cache_pool:
+                    adapter: cache.system
diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml
new file mode 100644
index 0000000..29231d9
--- /dev/null
+++ b/config/packages/doctrine_migrations.yaml
@@ -0,0 +1,6 @@
+doctrine_migrations:
+    migrations_paths:
+        # namespace is arbitrary but should be different from App\Migrations
+        # as migrations classes should NOT be autoloaded
+        'DoctrineMigrations': '%kernel.project_dir%/migrations'
+    enable_profiler: false
diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml
new file mode 100644
index 0000000..070501d
--- /dev/null
+++ b/config/packages/framework.yaml
@@ -0,0 +1,24 @@
+# see https://symfony.com/doc/current/reference/configuration/framework.html
+framework:
+    secret: '%env(APP_SECRET)%'
+
+    # Note that the session will be started ONLY if you read or write from it.
+    session: true
+
+    #esi: true
+    #fragments: true
+
+    http_client:
+        scoped_clients:
+            bn.client:
+                base_uri: 'https://data.bn.org.pl'
+                timeout: 10
+            librarything.client:
+                base_uri: 'https://covers.librarything.com'
+                timeout: 5
+
+when@test:
+    framework:
+        test: true
+        session:
+            storage_factory_id: session.storage.factory.mock_file
diff --git a/config/packages/knp_paginator.yaml b/config/packages/knp_paginator.yaml
new file mode 100644
index 0000000..8a63cd0
--- /dev/null
+++ b/config/packages/knp_paginator.yaml
@@ -0,0 +1,7 @@
+knp_paginator:
+    page_range: 10
+    default_options:
+        page_name: page
+        distinct: false
+    template:
+        pagination: 'spectre/knp_paginator_pagination.html.twig'
diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml
new file mode 100644
index 0000000..dd31b9d
--- /dev/null
+++ b/config/packages/property_info.yaml
@@ -0,0 +1,3 @@
+framework:
+    property_info:
+        with_constructor_extractor: true
diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml
new file mode 100644
index 0000000..0f34f87
--- /dev/null
+++ b/config/packages/routing.yaml
@@ -0,0 +1,10 @@
+framework:
+    router:
+        # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
+        # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
+        default_uri: '%env(DEFAULT_URI)%'
+
+when@prod:
+    framework:
+        router:
+            strict_requirements: null
diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml
new file mode 100644
index 0000000..bc61c3b
--- /dev/null
+++ b/config/packages/translation.yaml
@@ -0,0 +1,8 @@
+framework:
+    default_locale: en
+    enabled_locales: [en, pl]
+    set_locale_from_accept_language: true
+    translator:
+        default_path: '%kernel.project_dir%/translations'
+        fallbacks:
+            - en
diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml
new file mode 100644
index 0000000..2e3318d
--- /dev/null
+++ b/config/packages/twig.yaml
@@ -0,0 +1,7 @@
+twig:
+    file_name_pattern: '*.twig'
+    form_themes: ['spectre/form.html.twig']
+
+when@test:
+    twig:
+        strict_variables: true
diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml
new file mode 100644
index 0000000..dd47a6a
--- /dev/null
+++ b/config/packages/validator.yaml
@@ -0,0 +1,11 @@
+framework:
+    validation:
+        # Enables validator auto-mapping support.
+        # For instance, basic validation constraints will be inferred from Doctrine's metadata.
+        #auto_mapping:
+        #    App\Entity\: []
+
+when@test:
+    framework:
+        validation:
+            not_compromised_password: false
diff --git a/config/preload.php b/config/preload.php
new file mode 100644
index 0000000..3a66fd1
--- /dev/null
+++ b/config/preload.php
@@ -0,0 +1,6 @@
+<?php
+declare(strict_types=1);
+
+foreach (glob(dirname(__DIR__) . '/var/cache/prod/*.preload.php') ?: [] as $file) {
+	require $file;
+}
diff --git a/config/routes.yaml b/config/routes.yaml
new file mode 100644
index 0000000..cef258c
--- /dev/null
+++ b/config/routes.yaml
@@ -0,0 +1,11 @@
+# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
+
+# This file is the entry point to configure the routes of your app.
+# Methods with the #[Route] attribute are automatically imported.
+# See also https://symfony.com/doc/current/routing.html
+
+# To list all registered routes, run the following command:
+#   bin/console debug:router
+
+controllers:
+    resource: routing.controllers
diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml
new file mode 100644
index 0000000..bc1feac
--- /dev/null
+++ b/config/routes/framework.yaml
@@ -0,0 +1,4 @@
+when@dev:
+    _errors:
+        resource: '@FrameworkBundle/Resources/config/routing/errors.php'
+        prefix: /_error
diff --git a/config/services.yaml b/config/services.yaml
new file mode 100644
index 0000000..2f47ec3
--- /dev/null
+++ b/config/services.yaml
@@ -0,0 +1,36 @@
+# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
+
+# This file is the entry point to configure your own services.
+# Files in the packages/ subdirectory configure your dependencies.
+# See also https://symfony.com/doc/current/service_container/import.html
+
+# Put parameters here that don't need to change on each machine where the app is deployed
+# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
+parameters:
+    app.cover_originals_dir: '%kernel.project_dir%/var/covers'
+    app.cover_thumbnails_dir: '%kernel.project_dir%/public/covers'
+    app.librarything_api_key: '%env(default::LIBRARYTHING_API_KEY)%'
+
+when@test:
+    parameters:
+        app.cover_originals_dir: '%kernel.project_dir%/var/test/covers/originals'
+        app.cover_thumbnails_dir: '%kernel.project_dir%/var/test/covers/thumbnails'
+
+services:
+    # default configuration for services in *this* file
+    _defaults:
+        autowire: true      # Automatically injects dependencies in your services.
+        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+
+    # makes classes in src/ available to be used as services
+    # this creates a service per class whose id is the fully-qualified class name
+    App\:
+        resource: '../src/'
+        exclude:
+            - '../src/Entity/'
+            - '../src/Enum/'
+            - '../src/Kernel.php'
+            - '../src/Model/'
+
+    # add more service definitions when explicit configuration is needed
+    # please note that last definitions always *replace* previous ones
diff --git a/cover.php b/cover.php
deleted file mode 100644
index 42229ca..0000000
--- a/cover.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-include('./includes/std.php');
-
-header('Content-type: text/html; charset=utf-8');
-
-$cover = ksiazki::okladka_big($_GET['KOD'], $_GET['ISBN']);
-
-if($cover) {
-	echo '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
-<html>
-<head>
-<title>Okładka</title>
-<script type="text/javascript">
-function fit() {
-	window_width = (window.innerWidth) ? window.innerWidth : document.body.clientWidth;
-	window_height = (window.innerHeight) ? window.innerHeight : document.body.clientHeight;
-	resize_width = document.getElementsByTagName(\'img\').item(0).width - window_width;
-	resize_height = document.getElementsByTagName(\'img\').item(0).height - window_height;
-	window.resizeBy(resize_width + 100, resize_height + 100);
-}
-</script>
-</head>
-<body '.(isset($_GET['pop']) ? 'onclick="window.close();" ' : '').'onload="fit();" style="text-align:center;">
-<p><img src="'.$cover.'" alt="Okładka"></p>'.(isset($_GET['pop']) ? '
-<p>Kliknij, aby zamknąć.</p>' : '').'
-</body>
-</html>';
-}
-else
-{
-	echo 'Brak okładki!';
-}
-?>
\ No newline at end of file
diff --git a/covers/own/.emptydir b/covers/own/.emptydir
deleted file mode 100644
index e69de29..0000000
--- a/covers/own/.emptydir
+++ /dev/null
diff --git a/covers_big/own/.emptydir b/covers_big/own/.emptydir
deleted file mode 100644
index e69de29..0000000
--- a/covers_big/own/.emptydir
+++ /dev/null
diff --git a/del.php b/del.php
deleted file mode 100644
index eadfec4..0000000
--- a/del.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::KOD($_GET['kod']);
-
-$ksiazka = ksiazki::szukaj_KOD($_GET['kod']);
-
-if(empty($ksiazka)) {
-	errorclass::add('Wybrana książka nie istnieje');
-}
-
-$title = 'Usuwanie książki';
-include('./design/top.php');
-?>
-
-<h3>Czy na pewno chcesz usunąć poniższą książkę?</h3>
-
-<form action="del_book.php" method="post" onsubmit="return ffalse('step4')">
-<p>KOD: <input type="text" name="kod" id="step41" value="<?php echo $_GET['kod']; ?>" readonly="readonly" required="required" /><br />
-Tytuł: <?php echo $ksiazka['tytul']; ?><br />
-Autor: <?php echo $ksiazka['autor']; ?><br />
-Wydanie: <?php echo $ksiazka['wydanie']; ?></p>
-
-<p><?php echo $ksiazka['wydawnictwo']; ?></p>
-<p><input type="submit" value="Usuń" /></p>
-</form>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/del_book.php b/del_book.php
deleted file mode 100644
index 1445b91..0000000
--- a/del_book.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-include('./includes/std.php');
-
-ksiazki::usun($_POST['kod']);
-
-$title = 'Usuwanie książki';
-include('./design/top.php');
-?>
-
-<h3> Książka została usunięta </h3>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/design/bottom.php b/design/bottom.php
deleted file mode 100644
index 347fd5d..0000000
--- a/design/bottom.php
+++ /dev/null
@@ -1,7 +0,0 @@
-
-<p class="footer"> Copyrights © 2008, 2009 by Jacek Kowalski | Covers by <a href="http://www.librarything.com">LibraryThing</a> <?php
-if(defined('STANDARD')) { echo '<br /> Zapytań SQL: '.sql::$queries.' | Czas wykonywania: '.(microtime(TRUE)-errorclass::$time).' s '; } ?></p>
-
-<script type="text/javascript" src="design/script.js"></script>
-</body>
-</html>
diff --git a/design/script.js b/design/script.js
deleted file mode 100644
index 8fbbf47..0000000
--- a/design/script.js
+++ /dev/null
@@ -1,85 +0,0 @@
-function keypress(e) {
-	if(!e) {
-		e = window.event;
-	}
-	
-	switch(e.keyCode) {
-		case 112:
-			setTimeout('window.location.replace("index.php");', 10);
-			return false;
-		break;
-		case 113:
-			setTimeout('window.location.replace("add.php");', 10);
-			return false;
-		break;
-		case 114:
-			setTimeout('window.location.replace("place.php");', 10);
-			return false;
-		break;
-		case 115:
-			setTimeout('window.location.replace("generate.php");', 10);
-			return false;
-		break;
-	}
-}
-
-function ffalse(ids) {
-	i=1;
-	while(true) {
-		obj = document.getElementById(ids+i);
-		if(!obj) break;
-		
-		if((obj.hasAttribute('required') || obj.className=='focus') && obj.value=='') {
-			obj.focus();
-			return false;
-		}
-		i++;
-	}
-	
-	return true;
-}
-
-function ffalse_focus() {
-	el = document.getElementsByClassName('focus');
-	
-	for(i=0; i<el.length; i++) {
-		el.item(i).onfocus = "this.className=''";
-	}
-}
-
-function uc_all(ids, check) {
-	list = document.getElementById(ids).getElementsByTagName('input');
-	
-	for(i=0; i<list.length; i++) {
-		box = list.item(i);
-		if(box.type == 'checkbox') {
-			box.checked = check;
-		}
-	}
-}
-
-function pups_init() {
-	an = document.getElementsByTagName('a');
-	
-	for(i=0; i<an.length; i++) {
-		if((an.item(i).href+'').match('cover.php\?')) {
-			an[i].onclick = pups;
-		}
-	}
-}
-
-function pups(an) {
-	an = an.target.parentNode;
-	if(an) {
-		win = window.open(an.href+'&pop', '_blank', 'dependent=yes,toolbar=no,resizable=yes');
-		return false;
-	}
-}
-
-function on_loaded() {
-	ffalse_focus();
-	pups_init();
-}
-
-document.onkeydown = keypress;
-window.onload = on_loaded();
diff --git a/design/style.css b/design/style.css
deleted file mode 100644
index 4c75aea..0000000
--- a/design/style.css
+++ /dev/null
@@ -1,105 +0,0 @@
-html, body {
-	font: 14px Verdana, Helvetica, sans-serif;
-	background: #ffd;
-}
-
-table {border: 2px solid black;}
-a img {border: none;}
-
-/* TABELE AKCJI */
-.width {width: 100%;}
-.width span, #book span {
-	font-size: 80%;
-	padding-left: 10px;
-}
-table.main {
-	border: 0;
-	border-spacing: 10px;
-}
-.main td {
-	vertical-align: top;
-	border: 2px solid black;
-	padding: 5px 10px;
-	margin: 10px;
-}
-
-/* STYLE MENU */
-#menu {	padding: 0px;
-	text-align: center;
-	background: #dda;
-}
-#menu li {
-	margin: 0px 2px;
-	padding: 0px;
-	display: inline;
-	list-style: inside none;
-}
-#menu a {
-	background: #fd6;
-	padding: 3px 15px;
-	margin: 0px;
-	text-decoration: none;
-	color: #000;
-}
-#menu a:hover {
-	background: #fea;
-	text-decoration: underline;
-}
-
-/* Brakujące etykiety */
-table.no td {
-	border: 1px solid black;
-	padding: 0px 10px;
-}
-.or {
-	display: block;
-	padding: 5px 0px;
-}
-.or * {
-	vertical-align: middle;
-	display: inline-block;
-	width: 30%;
-}
-.width td {	vertical-align: top;
-	padding: 5px;}
-td p:first-child, td h3:first-child {margin-top: 0;}
-td p:last-child {margin-bottom: 3px;}
-.width tr td:first-child {padding:0;}
-/* BEZ ZAWIJANIA WIERSZY */
-.n {	white-space: nowrap;}
-/* KOLORY PRZY WYSZUKIWANIU (książki wycofane, wypożyczone itp.) */
-.wyc {	background: #ddd;}
-.poz {	background: #fdd;}
-.norm {	background: #dfd;}
-
-/* TEKSTY POCHYLONE */
-i {	font-size: 75%;}
-
-/* POJEDYNCZA KSIĄŻKA */
-#book {	border: 2px solid black;
-	padding: 5px;
-	display: inline-block;
-}
-#book h4, #book h5, #book p {margin: 2px;}
-#book p {margin-top: 0.5em;}
-#bhist {margin: 0.5em; float:right;}
-#book a {
-	float: left;
-	margin: 10px;
-	margin-left: 0px;
-}
-fieldset {margin: 0.5em auto;}
-
-@media print {
-	* {color: black; background: white;}
-	h1, ul, .footer, img {display: none;}
-	a {text-decoration: none;}
-	form {margin:0; padding:0;}
-	input[type=submit] {display: none;}
-	#bhist {float:none; margin-left:0;}
-	/* OKŁADKI */
-	.width td {padding: 1mm;}
-	.width tr td:first-child, .width tr th:first-child {display: none;}
-	/* OPCJE */
-	.width tr th:last-child, .width tr td:last-child {display: none;}
-}
diff --git a/design/top.php b/design/top.php
deleted file mode 100644
index 4fc12bc..0000000
--- a/design/top.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-if(!defined('TOP_SEND')) {
-	header('Content-type: text/html; charset=utf-8');
-	header('Pragma: no-cache');
-	header('Cache-control: private, no-cache, must-revalidate');
-?>
-<!DOCTYPE html>
-<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="pl" lang="pl">
-<head>
-<title>System biblioteczny - <?php echo $title; ?></title>
-<link rel="stylesheet" type="text/css" href="design/style.css" />
-</head>
-<body>
-
-<h1> System biblioteczny </h1>
-
-<ul id="menu">
-<li><a href="index.php">Wypożyczanie/wyszukiwanie</a></li>
-
-<li> </li>
-
-<li><a href="add.php">Dodaj książkę</a></li>
-<li><a href="place.php">Ustaw położenie</a></li>
-
-<li> </li>
-
-<li><a href="generate.php">Etykiety</a></li>
-</ul>
-
-<h2> <?php echo $title; ?> </h2>
-
-<?php
-	define('TOP_SEND', TRUE);
-}
-?>
\ No newline at end of file
diff --git a/edit.php b/edit.php
deleted file mode 100644
index f55fd05..0000000
--- a/edit.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$ksiazka = ksiazki::szukaj_KOD($_GET['kod']);
-
-$cover = ksiazki::okladka($ksiazka['id'], $ksiazka['ISBN']);
-
-$title = 'Edytowanie książki';
-include('./design/top.php');
-?>
-
-<table cellspacing="10" class="main">
-<tr>
-<td>
-
-<form action="edit_book.php" method="post" onsubmit="return ffalse('step4')" enctype="multipart/form-data">
-<p>KOD: <input type="text" name="id" id="step41" value="<?php echo $ksiazka['id']; ?>" readonly="readonly" required="required" /><br />
-Nowy KOD: <input type="text" name="nid" /><br />
-Tytuł: <input type="text" name="tytul" id="step42" value="<?php echo $ksiazka['tytul']; ?>" required="required" /><br />
-Autor: <input type="text" name="autor" id="step43" value="<?php echo $ksiazka['autor']; ?>" required="required" /><br />
-Język: <input type="text" name="jezyk" id="step44" value="<?php echo $ksiazka['jezyk']; ?>" required="required" /><br />
-Wydanie: <input type="text" name="wydanie" value="<?php echo $ksiazka['wydanie']; ?>" /></p>
-<p>Miejsce: <input type="text" name="miejsce" value="<?php echo $ksiazka['miejsce']; ?>" /><br />
-Rok: <input type="text" name="rok" value="<?php echo $ksiazka['rok']; ?>" /><br />
-Wydawnictwo: <input type="text" name="wydawnictwo" value="<?php echo $ksiazka['wydawnictwo']; ?>" /></p>
-<p>ISBN: <input type="text" name="ISBN" value="<?php echo $ksiazka['ISBN']; ?>" /><br />
-ISSN: <input type="text" name="ISSN" value="<?php echo $ksiazka['ISSN']; ?>" /></p>
-<p>Miejsce (regał/półka/rząd): <br />
-   <input type="text" name="regal" value="<?php echo $ksiazka['regal']; ?>" size="5" maxsize="5" />
- / <input type="text" name="polka" value="<?php echo $ksiazka['polka']; ?>" size="3" maxsize="3" />
- / <input type="text" name="rzad" value="<?php echo $ksiazka['rzad']; ?>" size="3" maxsize="3" /></p>
-<p>Wycofana? <input type="checkbox" name="wycofana" value="1" <?php if($ksiazka['wycofana']) {echo 'checked="checked" ';} ?>/><br />
-Powód: <input type="text" name="powod" value="<?php echo $ksiazka['powod']; ?>" /></p>
-
-<p>Okładka: <br />
-<?php echo ($cover ? '<a href="cover.php?KOD='.$ksiazka['id'].'&amp;ISBN='.$ksiazka['ISBN'].'"><img src="'.$cover.'" alt="Okładka" /></a> <br /> <label><input type="checkbox" name="okladka_del" value="1" /> Usuń</label>' : 'brak'); ?> <br />
-<input type="file" name="okladka" /></p>
-<p><input type="submit" value="Zapisz" /></p>
-</form>
-
-</td>
-</tr>
-</table>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/edit_book.php b/edit_book.php
deleted file mode 100644
index 0f75fef..0000000
--- a/edit_book.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-include('./includes/std.php');
-
-ksiazki::edytuj($_POST);
-
-$title = 'Dodawanie książki';
-include('./design/top.php');
-?>
-
-<h3> Książka została zmieniona! </h3>
-
-<p>Informacje o zmienionym egzemplarzu:</p>
-
-<?php
-gotowe::informacje($_POST['id']);
-
-include('./design/bottom.php');
-?>
diff --git a/generate.php b/generate.php
deleted file mode 100644
index 318944b..0000000
--- a/generate.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php
-$title = 'Etykiety';
-include('./design/top.php');
-?>
-
-<table class="main">
-<tr>
-<td>
-
-<form action="generate_page.php" method="post" onsubmit="return ffalse('step1')">
-<p>Kody od numeru: <input type="text" name="from" id="step11" required="required" /></p>
-
-<p>Zaznacz brakujące etykiety (<a href="javascript:uc_all('no_a', true)">wszystkie</a> <a href="javascript:uc_all('no_a', false)">żadna</a>):</p>
-<table class="no" id="no_a">
-<tr>
-<?php
-for($i=0; $i<11; $i++) {
-	for($j=0; $j<4; $j++) {
-		echo '	<td><input type="checkbox" name="no_'.$i.'_'.$j.'" /></td>'."\n";
-	}
-	if($i<10) {
-		echo '
-</tr>
-<tr>
-';
-	}
-}
-?>
-</tr>
-</table>
-<p><input type="submit" value="Utwórz" /></p>
-</form>
-
-</td>
-<td>
-
-<form action="generate_list.php" method="post" onsubmit="return ffalse('step2')">
-<p>Lista potrzebnych kodów:<br />
-<textarea name="kody" id="step21" cols="18" rows="4" required="required"></textarea></p>
-
-<p>Zaznacz brakujące etykiety (<a href="javascript:uc_all('no_b', true)">wszystkie</a> <a href="javascript:uc_all('no_b', false)">żadna</a>):</p>
-<table class="no" id="no_b">
-<tr>
-<?php
-for($i=0; $i<11; $i++) {
-	for($j=0; $j<4; $j++) {
-		echo '	<td><input type="checkbox" name="no_'.$i.'_'.$j.'" /></td>'."\n";
-	}
-	if($i<10) {
-		echo '
-</tr>
-<tr>
-';
-	}
-}
-?>
-</tr>
-</table>
-
-<p><input type="submit" value="Utwórz" /></p>
-</form>
-
-</td>
-<td>
-
-<form action="generate39_list.php" method="post" onsubmit="return ffalse('step3')">
-<p>Lista potrzebnych identyfikatorów: <br />
-Format: REGAŁ/<i>PÓŁKA</i>/<i>RZĄD</i> <br />
-<textarea name="kody" id="step31" cols="14" rows="3" required="required"></textarea></p>
-
-<p>Zaznacz brakujące etykiety (<a href="javascript:uc_all('no_c', true)">wszystkie</a> <a href="javascript:uc_all('no_c', false)">żadna</a>):</p>
-<table class="no" id="no_c">
-<tr>
-<?php
-for($i=0; $i<11; $i++) {
-	for($j=0; $j<4; $j++) {
-		echo '	<td><input type="checkbox" name="no_'.$i.'_'.$j.'" /></td>'."\n";
-	}
-	if($i<10) {
-		echo '
-</tr>
-<tr>
-';
-	}
-}
-?>
-</tr>
-</table>
-
-
-<p><input type="submit" value="Utwórz" /></p>
-</form>
-
-</td>
-</tr>
-</table>
-
-<?php
-include('./design/bottom.php');
-?>
diff --git a/generate39_list.php b/generate39_list.php
deleted file mode 100644
index 36f05eb..0000000
--- a/generate39_list.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-include('./includes/std.php');
-
-require_once('includes/generate_html.php');
-require_once('includes/generate_code39.php');
-
-echo GENERATE_STYLE;
-
-echo '<table cellspacing="0">
-<tr>';
-
-$column = 0;
-$row = 0;
-foreach(explode("\n", $_POST['kody']) as $i) {
-	$i = trim($i);
-	
-	if(empty($i)) {
-		continue;
-	}
-	
-	$i = explode('/', $i);
-	validate::MSC($i[0], $i[1], $i[2]);
-	$i = $i[0].'/'.$i[1].'/'.$i[2];
-	
-	while(true) {
-		if(!$_POST['no_'.$row.'_'.$column]) {
-			break;
-		}
-		else
-		{
-			echo '<td></td>';
-			column($column, $row);
-			continue;
-		}
-	};
-	
-	echo '<td style="padding-left: '.margin($column%4).'mm;">
-<img src="data:image/gif;base64,'.base64_encode(kod(str_replace('/', '$I', $i))).'" alt=""><br>'.$i.'
-</td>
-';
-	
-	column($column, $row);
-}
-
-while($row != 10 OR $column != 3) {
-	echo '<td></td>';
-	column($column, $row);
-}
-
-column($column, $row);
-
-echo GENERATE_END;
-?>
\ No newline at end of file
diff --git a/generate_list.php b/generate_list.php
deleted file mode 100644
index af68bf3..0000000
--- a/generate_list.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-include('./includes/std.php');
-
-require_once('includes/generate_html.php');
-require_once('includes/generate_codabar.php');
-
-foreach(explode("\n", $_POST['kody']) as $i) {
-	$i = trim($i);
-	
-	if($i=='') {
-		continue;
-	}
-	
-	if(($pos=strpos($i, '-'))!==FALSE) {
-		$from = trim(substr($i, 0, $pos));
-		$to = trim(substr($i, $pos+1));
-		
-		validate::KOD($from);
-		validate::KOD($to);
-		
-		for($from=(int)$from; $from<=$to; $from++) {
-			$kody[] = $from;
-		}
-	}
-	else
-	{
-		validate::KOD($i);
-		
-		$kody[] = $i;
-	}
-}
-
-echo GENERATE_STYLE;
-
-echo '<table cellspacing="0">
-<tr>';
-
-$column = 0;
-$row = 0;
-foreach($kody as $i) {
-	while(true) {
-		if(!$_POST['no_'.$row.'_'.$column]) {
-			break;
-		}
-		else
-		{
-			echo '<td></td>';
-			column($column, $row);
-			continue;
-		}
-	}
-	
-	$kod = str_pad($i, 8, '0', STR_PAD_LEFT);
-	
-	echo '<td style="padding-left: '.margin($column%4).'mm;">
-<img src="data:image/gif;base64,'.base64_encode(kod($kod)).'" alt=""><br>'.$kod.'
-</td>
-';
-	
-	column($column, $row);
-}
-
-while($row != 10 OR $column != 3) {
-	echo '<td></td>';
-	column($column, $row);
-}
-
-column($column, $row);
-
-echo GENERATE_END;
-?>
diff --git a/generate_page.php b/generate_page.php
deleted file mode 100644
index 8eec749..0000000
--- a/generate_page.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<?php
-include('./includes/std.php');
-
-require_once('includes/generate_html.php');
-require_once('includes/generate_codabar.php');
-
-echo GENERATE_STYLE;
-
-echo '<table cellspacing="0">
-<tr>';
-
-$column = 0;
-$row = 0;
-for($i=$_POST['from']; $i<$_POST['from']+44; $i++) {
-	$i = trim($i);
-	
-	if(!ctype_digit($i) OR empty($i) OR strlen($i)>8) {
-		continue;
-	}
-	
-	validate::KOD($i);
-	
-	while(true) {
-		if(!$_POST['no_'.$row.'_'.$column]) {
-			break;
-		}
-		else
-		{
-			echo '<td></td>';
-			column($column, $row);
-			continue;
-		}
-	}
-	
-	$kod = str_pad($i, 8, '0', STR_PAD_LEFT);
-	
-	echo '<td style="padding-left: '.margin($column%4).'mm;">
-<img src="data:image/gif;base64,'.base64_encode(kod($kod)).'" alt=""><br>'.$kod.'
-</td>
-';
-	
-	column($column, $row);
-}
-
-while(true) {
-	column($column, $row);
-}
-
-echo GENERATE_END;
-?>
diff --git a/help/db2_php.png b/help/db2_php.png
deleted file mode 100644
index 799c565..0000000
--- a/help/db2_php.png
+++ /dev/null
Binary files differ
diff --git a/help/db2_php.xcf b/help/db2_php.xcf
deleted file mode 100644
index 59e160a..0000000
--- a/help/db2_php.xcf
+++ /dev/null
Binary files differ
diff --git a/help/db2_php2.png b/help/db2_php2.png
deleted file mode 100644
index ccfabe4..0000000
--- a/help/db2_php2.png
+++ /dev/null
Binary files differ
diff --git a/help/db2_php3.png b/help/db2_php3.png
deleted file mode 100644
index 2dca7c0..0000000
--- a/help/db2_php3.png
+++ /dev/null
Binary files differ
diff --git a/help/pma1.png b/help/pma1.png
deleted file mode 100644
index 481c72f..0000000
--- a/help/pma1.png
+++ /dev/null
Binary files differ
diff --git a/help/pma1.xcf b/help/pma1.xcf
deleted file mode 100644
index a126171..0000000
--- a/help/pma1.xcf
+++ /dev/null
Binary files differ
diff --git a/help/pma2.png b/help/pma2.png
deleted file mode 100644
index 220a6d0..0000000
--- a/help/pma2.png
+++ /dev/null
Binary files differ
diff --git a/help/pma2.xcf b/help/pma2.xcf
deleted file mode 100644
index f8c3a51..0000000
--- a/help/pma2.xcf
+++ /dev/null
Binary files differ
diff --git a/help/podrecznik.odt b/help/podrecznik.odt
deleted file mode 100644
index cb6dec8..0000000
--- a/help/podrecznik.odt
+++ /dev/null
Binary files differ
diff --git a/help/podrecznik_orig.pdf b/help/podrecznik_orig.pdf
deleted file mode 100644
index b47965d..0000000
--- a/help/podrecznik_orig.pdf
+++ /dev/null
Binary files differ
diff --git a/includes/MARC21.php b/includes/MARC21.php
deleted file mode 100644
index fc2163f..0000000
--- a/includes/MARC21.php
+++ /dev/null
@@ -1,186 +0,0 @@
-<?php
-define('RECORD_SEPERATOR', chr(0x1e));
-define('UNIT_SEPERATOR', chr(0x1f));
-
-class MARC21 {
-	static function from_string($data) {
-		$lead_len = 24;
-		$lead = substr($data, 0, $lead_len);
-		
-		$file_length = substr($lead, 0, 5);
-		$head_len = substr($lead, 12, 5);
-		
-		$cat_record_lof = substr($lead, 20, 1);
-		$cat_record_scp = substr($lead, 21, 1);
-		$cat_record_imp = substr($lead, 22, 1);
-		$cat_record_len = 3 + $cat_record_lof + $cat_record_scp + $cat_record_imp;
-		$cat_len = $head_len-$lead_len-1;
-		
-		$cat = substr($data, $lead_len, $cat_len);
-		$info = substr($data, $head_len);
-		
-		$unit = FALSE;
-		for($i=0; $i<$cat_len; $i += $cat_record_len) {
-			$rec_num = substr($cat, $i, 3);
-			
-			if($rec_num>899) {
-				continue;
-			}
-			
-			$rec_len = substr($cat, $i+3, $cat_record_lof);
-			$rec_start = substr($cat, $i+3+$cat_record_lof, $cat_record_scp);
-			$rec = substr($info, $rec_start, $rec_len-1);
-			
-			$temp = array();
-			$unit = FALSE;
-			$unit_letter = 'a';
-			
-			for($j=0; $j<$rec_len; $j++) {
-				$char = substr($rec, $j, 1);
-				
-				if(($j==0 || $j==1) && $rec_num>9 && $char!=' ') {
-					if($j==0) {
-						$temp['f0'] = $char;
-					}
-					elseif($j==1) {
-						$temp['f1'] = $char;
-					}
-				}
-				elseif($char == UNIT_SEPERATOR) {
-					$unit = TRUE;
-				}
-				elseif($unit === TRUE) {
-					$temp[$unit_letter] = trim($collect, '	 :;,/');
-					$unit = FALSE;
-					$unit_letter = $char;
-					$collect = '';
-				}
-				else
-				{
-					$collect .= $char;
-				}
-			}
-			
-			$temp[$unit_letter] = trim($collect, '	 :;,/');
-			$collect = '';
-			
-			$return[$rec_num][] = $temp;
-		}
-		
-		return $return;
-	}
-	
-	static function to_array($MARC) {
-		if(!$MARC['020']) {
-			$MARC['020'] = array();
-		}
-		foreach($MARC['020'] as $value) {
-			$value = (string)$value['a'];
-			if(strlen($value)==9 AND strlen($value)!=13) {
-				$value .= checksum::ISBN($value);
-			}
-			if( strlen($value) > strlen($ISBN) ) {
-				$ISBN = $value;
-			}
-		}
-		
-		if(!$MARC['022']) {
-			$MARC['022'] = array();
-		}
-		foreach($MARC['022'] as $value) {
-			$value = (int)$value['a'];
-			if( strlen($value) > strlen($ISSN) ) {
-				$ISSN = $value;
-			}
-		}
-		
-		if($MARC['100'][0]['f0']==1) {
-			$autor = explode(', ', $MARC['100'][0]['a'], 2);
-			$MARC['100'][0]['a'] = str_replace('.', '', $autor[1]).' '.$autor[0];
-		}
-		
-		if(empty($MARC['100'][0]['a'])) {
-			$MARC['100'][0]['a'] = 'Praca zbiorowa';
-		}
-		
-		$MARC['260'][0]['b'] = str_replace(
-			array(
-				'Wydaw.',
-				'Państ.',
-				'Państw.',
-				'PK',
-				'Min.',
-			),
-			array(
-				'Wydawnictwo',
-				'Państwowy',
-				'Państwowe',
-				'Politechnika Krakowska',
-				'Ministerstwa',
-			),
-		$MARC['260'][0]['b']);
-		
-		if($MARC['041'][0]['a'] == 'pol') {
-			$MARC['041'][0]['a'] = 'polski';
-		}
-		
-		
-		if(empty($MARC['041'][0]['a'])) {
-			$MARC['041'][0]['a'] = 'polski';
-		}
-		
-		if(!empty($MARC['245'][0]['b'])) {
-			$MARC['245'][0]['a'] = trim($MARC['245'][0]['a'], '().,\\/"\' ').'. '.ucfirst(trim($MARC['245'][0]['b'], '().,\\/"\' '));
-		}
-		
-		return array(
-			'tytul' => trim($MARC['245'][0]['a'], '().,\\/"\' '),
-			'autor' => trim($MARC['100'][0]['a'], '().,\\/"\' '),
-			'rok' => trim($MARC['260'][0]['c'], '[]().,:\\/"\' '),
-			'miejsce' => trim($MARC['260'][0]['a'], '[]().,:\\/"\' '),
-			'wydawnictwo' => trim($MARC['260'][0]['b'], '[]().,:\\/"\' '),
-			'wydanie' => trim($MARC['250'][0]['a'], '[]().,:\\/"\' '),
-			'jezyk' => $MARC['041'][0]['a'],
-			'ISBN' => $ISBN,
-			'ISSN' => $ISSN,
-			/* stan */
-		);
-	}
-	
-	static function to_database($kod, $MARC) {
-		if(!$MARC['020']) {
-			$MARC['020'] = array();
-		}
-		foreach($MARC['020'] as $value) {
-			$value = (int)$value['a'];
-			if( strlen($value) > strlen($ISBN) ) {
-				$ISBN = $value;
-			}
-		}
-		
-		if(!$MARC['022']) {
-			$MARC['022'] = array();
-		}
-		foreach($MARC['022'] as $value) {
-			$value = (int)$value['a'];
-			if( strlen($value) > strlen($ISSN) ) {
-				$ISSN = $value;
-			}
-		}
-		
-		db2::add('ksiazki', array(
-			'id' => $kod,
-			'tytul' => $MARC['245'][0]['a'],
-			'autor' => $MARC['100'][0]['a'],
-			'rok' => $MARC['260'][0]['c'],
-			'miejsce' => $MARC['260'][0]['a'],
-			'wydawnictwo' => $MARC['260'][0]['b'],
-			'wydanie' => $MARC['250'][0]['a'],
-			'jezyk' => $MARC['041'][0]['a'],
-			'ISBN' => $ISBN,
-			'ISSN' => $ISSN,
-			/* stan */
-		));
-	}
-}
-?>
diff --git a/includes/MARC21_opisy.php b/includes/MARC21_opisy.php
deleted file mode 100644
index 6b8843e..0000000
--- a/includes/MARC21_opisy.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-$MARC21_opis = array(
-	'001' => array(
-		'Numer kontrolny',
-		'a' => '^'
-	),
-	'003' => array(
-		'Instytucja nadająca num. kontrolny',
-		'a' => '^',
-	),
-	'005' => array(
-		// rrmmddggmmss.0
-		'Data ostatniej modyfikacji',
-		'a' => '^',
-	),
-	'015' => array(
-		'Numer bibliografii narodowej',
-		'a' => '^'
-	),
-	'020' => array(
-		'ISBN',
-		'a' => 'ISBN',
-		'z' => 'Błędny/unieważniony'
-	),
-	'100' => array(
-		'Główny autor',
-		
-	)
-);
-?>
\ No newline at end of file
diff --git a/includes/PDOO.php b/includes/PDOO.php
deleted file mode 100644
index ecd70fa..0000000
--- a/includes/PDOO.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-if(!extension_loaded('pdo')) {
-	throw new Exception('Brak rozszerzenia PDO. Skrypt nie będzie działał.');
-}
-
-if(!extension_loaded('pdo_mysql')) {
-	throw new Exception('Brak rozszerzenia PDO MySQL. Skrypt nie będzie działał.');
-}
-
-class PDOO {
-	private static $PDO;
-	
-	static function Singleton() {
-		if(self::$PDO === NULL) {
-			self::$PDO = new PDO('mysql:dbname='.config::$db_base.';host='.config::$db_host,
-				config::$db_user, config::$db_pass);
-			self::$PDO->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
-			self::$PDO->query('SET NAMES utf8');
-		}
-		
-		return self::$PDO;
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/YAZ.php b/includes/YAZ.php
deleted file mode 100644
index 3b4493c..0000000
--- a/includes/YAZ.php
+++ /dev/null
@@ -1,83 +0,0 @@
-<?php
-if(!extension_loaded('yaz')) {
-	errorclass::add('Brak rozszerzenia YAZ. Wyszukiwanie w bazach Biblioteki Narodowej niemożliwe.');
-}
-
-class YAZ {
-	private static $connection;
-	static $timeout = 10;
-	
-	static function connect($host) {
-		self::$connection = yaz_connect($host, array('charset' => 'UTF-8'));
-		yaz_syntax(self::$connection, 'marc21');
-	}
-	
-	static function search($query, $start=1, $num=10) {
-		yaz_search(self::$connection, 'rpn', $query);
-		yaz_range(self::$connection, $start, $num);
-		yaz_wait();
-		self::is_error();
-	}
-	
-	static function scan($query) {
-		yaz_scan(self::$connection, 'rpn', $query);
-		yaz_wait();
-		self::is_error();
-	}
-	
-	static function scan_result() {
-		return yaz_scan_result(self::$connection);
-	}
-	
-	static function scan_get($start=1, $num=10) {
-		yaz_range(self::$connection, $start, $num);
-		yaz_present(self::$connection);
-		yaz_wait();
-		self::is_error();
-	}
-	
-	static function hits() {
-		return yaz_hits(self::$connection);
-	}
-	
-	static function return_MARCs() {
-		$hits = self::hits();
-		
-		$records = array();
-		
-		$time = time();
-		
-		for($i=1; $i<=$hits; $i++) {
-			if($time+self::$timeout <= time()) {
-				break;
-			}
-			$record = yaz_record(self::$connection, $i, 'raw');
-			$records[] = MARC21::from_string($record);
-		}
-		
-		return $records;
-	}
-	
-	static function return_arrays() {
-		$return = array();
-		
-		$MARCs = self::return_MARCs();
-
-		foreach($MARCs as $MARC) {
-			$return[] = MARC21::to_array( $MARC );
-		}
-		
-		return $return;
-	}
-	
-	static function is_error() {
-		if($e = yaz_error(self::$connection)) {
-			errorclass::add('Błąd YAZ: '.$e);
-		}
-		else
-		{
-			return FALSE;
-		}
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/YAZ_ibd.php b/includes/YAZ_ibd.php
deleted file mode 100644
index a080dc8..0000000
--- a/includes/YAZ_ibd.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-__autoload('ibd');
-
-class YAZ_ibd implements ibd_module {
-	var $name, $yaz_server;
-	
-	function __construct($name, $server) {
-		$this->name = $name;
-		$this->yaz_server = $server;
-	}
-	
-	function zapytanie_info($ISBN=NULL, $ISSN=NULL, $tytul=NULL, $autor=NULL, $wydawnictwo=NULL) {
-		if(!empty($ISBN)) {
-			$attrs[] = '@attr 1=7 "'.$ISBN.'"';
-		}
-		if(!empty($ISSN)) {
-			$attrs[] = '@attr 1=8 "'.$ISSN.'"';
-		}
-		if(!empty($tytul)) {
-			$attrs[] = '@attr 1=4 "'.$tytul.'"';
-		}
-		if(!empty($autor)) {
-			$attrs[] = '@attr 1=1003 "'.$autor.'"';
-		}
-		if(!empty($wydawnictwo)) {
-			$attrs[] = '@attr 1=1018 "'.$wydawnictwo.'"';
-		}
-		
-		if(count($attrs)==1) {
-			return $attrs[0];
-		}
-		elseif(count($attrs)>1) {
-			$return = '@and '.array_pop($attrs).' '.array_pop($attrs);
-		}
-		
-		if(count($attrs)>0) {
-			foreach($attrs as $value) {
-				$return = '@and '.$value.' '.$return;
-			}
-		}
-		
-		return $return;
-	}
-	
-	function szukaj_info($tytul=NULL, $autor=NULL, $wydawnictwo=NULL) {
-		YAZ::connect( $this->yaz_server );
-		YAZ::search( self::zapytanie_info( NULL, NULL, $tytul, $autor, $wydawnictwo ) );
-		
-		return YAZ::return_arrays();
-	}
-	
-	function szukaj_ISBN($kod) {
-		YAZ::connect( $this->yaz_server );
-		YAZ::search( self::zapytanie_info( $kod ) );
-		if(substr($kod, 0, 3)=='978') {
-			YAZ::search( self::zapytanie_info( convert::ISBN13_to_ISBN10( $kod ) ) );
-		}
-		
-		return YAZ::return_arrays();
-	}
-	
-	function szukaj_ISSN($kod) {
-		YAZ::connect( $this->yaz_server );
-		YAZ::search( self::zapytanie_info( convert::ISSN13_to_ISSN8( $kod ) ) );
-		
-		return YAZ::return_arrays();
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/checksum.php b/includes/checksum.php
deleted file mode 100644
index 56d944a..0000000
--- a/includes/checksum.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-class checksum {
-	static function EAN($kod) {
-		$kod = str_split($kod);
-		
-		$now = 1;
-		foreach($kod as $v) {
-			if($now==1) {
-				$sum += $v;
-				$now = 3;
-			}
-			else
-			{
-				$sum += $v*3;
-				$now = 1;
-			}
-		}
-		
-		return (10 - ($sum%10)) % 10;
-	}
-	
-	static function ISBN($kod) {
-		$kod = str_split($kod);
-		
-		foreach($kod as $k => $v) {
-			$sum += (10-$k)*$v;
-		}
-		
-		$sum = (11 - ($sum % 11)) % 11;
-		if($sum == 10) {
-			$sum = 'X';
-		}
-		
-		return $sum;
-	}
-	
-	static function ISSN($kod) {
-		$kod = str_split($kod);
-		
-		foreach($kod as $k => $v) {
-			$sum += (8-$k)*$v;
-		}
-		
-		$sum = (11 - ($sum % 11)) % 11;
-		if($sum == 10) {
-			$sum = 'X';
-		}
-		
-		return $sum;
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/config.php b/includes/config.php
deleted file mode 100644
index 9a1727c..0000000
--- a/includes/config.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-class config {
-	// Nazwa hosta bazy danych
-	static $db_host = '';
-	
-	// Nazwa użytkownika bazy
-	static $db_user = '';
-	
-	// Hasło użytkownika
-	static $db_pass = '';
-	
-	// Nazwa bazy danych
-	static $db_base = '';
-	
-	
-	// Klucz API LibraryThing
-	static $lt_api = '';
-}
-?>
\ No newline at end of file
diff --git a/includes/convert.php b/includes/convert.php
deleted file mode 100644
index 9a3ada3..0000000
--- a/includes/convert.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-class convert {
-	static function ISBN13_to_ISBN10($kod) {
-		validate::EAN($kod);
-		
-		if(substr($kod, 0, 3)=='978') {
-			$kod = substr($kod, 3, 9);
-			
-			return strtoupper( $kod . checksum::ISBN($kod) );
-		}
-		else
-		{
-			errorclass::add('Kodu ISBN-13 '.$kod.' nie można zamienić na ISBN-10!');
-		}
-	}
-	
-	static function ISBN10_to_ISBN13($kod) {
-		validate::ISBN($kod);
-		
-		$kod = '978' . substr($kod, 0, -1);
-		
-		return $kod . checksum::EAN($kod);
-	}
-	
-	static function ISSN13_to_ISSN8($kod) {
-		validate::EAN($kod);
-		
-		$kod = substr($kod, 3, 7);
-		
-		return strtoupper( $kod . checksum::ISSN($kod) );
-	}
-	
-	static function ISSN8_to_ISSN13($kod) {
-		validate::ISSN($kod);
-		
-		$kod = '977' . substr($kod, 0, -1).'00';
-		
-		return $kod . checksum::EAN($kod);
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/db2.php b/includes/db2.php
deleted file mode 100644
index 890879d..0000000
--- a/includes/db2.php
+++ /dev/null
@@ -1,411 +0,0 @@
-<?php
-class sql {
-	static $db;
-	static $queries = 0;
-	
-	static $PDO;
-	
-	static function connect() {
-	}
-	
-	static function query($q) {
-		if(!self::$PDO) {
-			self::$PDO = PDOO::Singleton();
-		}
-		
-		self::$queries++;
-		
-		return self::$PDO->query($q);
-	}
-	
-	static function fetchonea($q) {
-		return $q->fetch(PDO::FETCH_ASSOC);
-	}
-	
-	static function fetchone($q) {
-		return $q->fetch(PDO::FETCH_ASSOC);
-	}
-	
-	static function fetch($q) {
-		return $q->fetchAll();
-	}
-	
-	static function increment_id() {
-		if(!self::$PDO) {
-			self::$PDO = PDOO::Singleton();
-		}
-		
-		return self::$PDO->lastInsertId();
-	}
-	
-	static function affected($q = NULL) {
-		if($q === NULL) {
-			return 'nieznana';
-		}
-		
-		return $q->rowCount();
-	}
-	
-	static function escape($q) {
-		if(!self::$PDO) {
-			self::$PDO = PDOO::Singleton();
-		}
-		
-		return self::$PDO->quote($q);
-	}
-	
-	static function close() {
-		mysql_close(self::$db);
-	}
-}
-
-class db2 {
-	// FALSE - Allows WHERE queries other than "is equal"
-	//         LIKE, MATCH ... AGAINST, !=, >, <, IS NULL ...
-	static $SAFE_MODE_WHERE = FALSE;
-	
-	// FALSE - Allows SELECT *
-	static $SAFE_MODE_SELECT = FALSE;
-	
-	// FALSE - Skipping empty fields in INSERTs
-	static $SAFE_MODE_INSERT = FALSE;
-	
-	// FALSE - Empty field is stated as NULL
-	static $SAFE_MODE_UPDATE = FALSE;
-	
-	// FALSE - Allows JOINs
-	static $SAFE_MODE_TABLE = FALSE;
-	
-	// FALSE - Allow `table`.`key` using table.key
-	static $SAFE_MODE_KEY = FALSE;
-	
-	// Do htmlspecialchars() on db2::get results?
-	static $ESCAPE_DATA = TRUE;
-	
-	static $revelance = FALSE;
-	
-	static function revelance() {
-		return (bool)self::$revelance;
-	}
-	
-	static function __combine_insert($keys) {
-		foreach($keys as $key => $value) {
-			if(empty($key)) {
-				continue;
-			}
-			
-			if(!self::$SAFE_MODE_INSERT AND $value==='') {
-				continue;
-			}
-			
-			$a[] = '`'.$key.'`';
-			$b[] = sql::escape($value);
-		}
-		
-		return '('.implode(', ', $a).') VALUES ('.implode(', ', $b).')';
-	}
-	
-	static function __combine_update($keys) {
-		foreach($keys as $key => $value) {
-			if(empty($key)) {
-				continue;
-			}
-			
-			if(!self::$SAFE_MODE_UPDATE AND $value==='') {
-				$value = NULL;
-			}
-			if(is_null($value)) {
-				$a[] = '`'.$key.'`=NULL';
-			}
-			else
-			{
-				$a[] = '`'.$key.'`='.sql::escape($value);
-			}
-		}
-		
-		return implode(', ', $a);
-	}
-	
-	static function __combine_select($keys, $revelance=FALSE) {
-		if(!self::$SAFE_MODE_SELECT && $keys=='*') {
-			$a[] =  '*';
-		}
-		else
-		{
-			if(!is_array($keys)) {
-				$keys = array($keys);
-			}
-			
-			foreach($keys as $v) {
-				$a[] = self::__combine_keyn($v);
-				if($one) break;
-			}
-			
-		}
-		
-		if($revelance && self::$revelance) {
-			$a[] = self::$revelance;
-		}
-		
-		return implode(', ', $a);
-	}
-	
-	static function __combine_where($keys, $revelance=FALSE) {
-		self::$revelance = FALSE;
-		
-		$implode = ' AND ';
-		
-		if(!is_array($keys) OR empty($keys)) {
-			return '';
-		}
-		
-		if(self::$SAFE_MODE_WHERE) {
-			foreach($keys as $key => $value) {
-				if(is_null($value)) {
-					$a[] = self::__combine_keyn($key).' IS NULL';
-				}
-				else
-				{
-					$a[] = self::__combine_keyn($key).'='.sql::escape($value);
-				}
-			}
-		}
-		else
-		{
-			$a = array();
-			foreach($keys as $key => $v) {
-				if(!is_array($v)) {
-					$v = array($v);
-				}
-				
-				foreach($v as $value) {
-					if($key === 'OR') {
-						$implode = ' OR ';
-					}
-					elseif(substr($key, -1)=='!' AND is_null($value) OR $value==='') {
-						$a[] = self::__combine_keyn(substr($key, 0, -1)).' IS NOT NULL';
-					}
-					elseif(is_null($value) OR $value==='') {
-						$a[] = self::__combine_keyn($key).' IS NULL';
-					}
-					elseif(substr($key, -1)=='!') {
-						$a[] = self::__combine_keyn(substr($key, 0, -1)).'!='.sql::escape($value);
-					}
-					elseif($key=='^') {
-						$a[] = 'MAX('.self::__combine_keyn($value).')';
-					}
-					elseif(substr($key, -2)=='~~') {
-						$temp = 'MATCH ('.self::__combine_keyn(substr($key, 0, -2)).') AGAINST ('.sql::escape($value).')';
-						if($revelance) {
-							self::$revelance = $temp.' AS `revelance`';
-						}
-						
-						$a[] = $temp;
-					}
-					elseif(substr($key, -1)=='~') {
-						$a[] = self::__combine_keyn(substr($key, 0, -1)).' LIKE '.sql::escape($value);
-					}
-					elseif(substr($key, -2)=='>=') {
-						$a[] = self::__combine_keyn(substr($key, 0, -2)).'>='.sql::escape($value);
-					}
-					elseif(substr($key, -2)=='<=') {
-						$a[] = self::__combine_keyn(substr($key, 0, -2)).'<='.sql::escape($value);
-					}
-					elseif(substr($key, -1)=='>') {
-						$a[] = self::__combine_keyn(substr($key, 0, -1)).'>'.sql::escape($value);
-					}
-					elseif(substr($key, -1)=='<') {
-						$a[] = self::__combine_keyn(substr($key, 0, -1)).'<'.sql::escape($value);
-					}
-					else
-					{
-						$a[] = self::__combine_keyn($key).'='.sql::escape($value);
-					}
-				}
-			}
-		}
-		
-		return ' WHERE '.implode($implode, $a).$addon;
-	}
-	
-	static function __combine_limit($limit, $stop) {
-		if(!ctype_digit($limit) AND !is_int($limit)) {
-			return '';
-		}
-		elseif(!ctype_digit($stop) AND !is_int($stop)) {
-			return ' LIMIT '.$limit;
-		}
-		else
-		{
-			return ' LIMIT '.$limit.', '.$stop;
-		}
-	}
-	
-	static function __combine_order($keys, $revelance=FALSE) {
-		if(empty($keys)) {
-			return '';
-		}
-		
-		if(!is_array($keys)) {
-			$keys = array($keys => 'ASC');
-		}
-		
-		foreach($keys as $key => $value) {
-			$bvalue = strtoupper($value);
-			if($bvalue != 'ASC' AND $bvalue != 'DESC') {
-				$key = $value;
-				$value = 'ASC';
-			}
-			else
-			{
-				$value = $bvalue;
-			}
-			
-			if((!$revelance OR !self::$revelance) AND $key=='revelance') {
-				continue;
-			}
-			
-			$a[] = self::__combine_keyn($key).' '.$value;
-		}
-		
-		return ' ORDER BY '.implode(', ', $a);
-	}
-	
-	private static function __combine_tablelist($key, $value) {
-		$as = FALSE;
-		if(!is_int($key)) {
-			$as = $value;
-			$value = $key;
-		}
-		return '`'.$value.'`'.($as ? ' AS `'.$as.'`' : '');
-	}
-	
-	static function __combine_keyn($key) {
-		if(!self::$SAFE_MODE_KEY AND strpos($key, '.')!==FALSE) {
-			$key = explode('.', $key, 2);
-			return '`'.$key[0].'`.`'.$key[1].'`';
-		}
-		
-		return '`'.$key.'`';
-	}
-	
-	static function __combine_table($table) {
-		if(!is_array($table) OR self::$SAFE_MODE_TABLE) {
-			return '`'.$table.'` ';
-		}
-		else
-		{
-			$ret = array();
-			foreach($table as $key => $value) {
-				if(empty($value)) {
-					continue;
-				}
-				
-				if(!is_array($value)) {
-					$ret[] = self::__combine_tablelist($key, $value);
-				}
-				else
-				{
-					switch(strtoupper($value[0])) {
-						case 'L':
-							$sub = 'LEFT JOIN ';
-						break;
-						case 'R':
-							$sub = 'RIGHT JOIN ';
-						break;
-						case 'J':
-							$sub = 'JOIN ';
-						break;
-						case 'S':
-							$sub = 'STRAIGHT_JOIN ';
-						break;
-						default:
-							continue 2;
-						break;
-					}
-					
-					if(!is_array($value[1])) {
-						$value[1] = array($value[1]);
-					}
-					foreach($value[1] as $k => $v) {
-						$sub1[] = self::__combine_tablelist($k, $v);
-					}
-					$sub .= implode(', ', $sub1).' ';
-					
-					if(strtoupper($value[2])=='USING') {
-						$sub .= 'USING ('.self::__combine_select($value[3]).')';
-					}
-					elseif(strtoupper($value[2])=='ON') {
-						$sub .= 'ON '.substr(self::__combine_where($value[3]), 7);
-					}
-					
-					if($value[4]) {
-						$sub .= ' HAVING '.self::__combine_where($value[4]);
-						
-					}
-					
-					$ret[] = $sub;
-				}
-			}
-			
-			return implode(' ', $ret).' ';
-		}
-	}
-	
-	static function escape_data($data) {
-		if(self::$ESCAPE_DATA) {
-			if(is_array($data)) {
-				foreach($data as &$value) {
-					$value = self::escape_data($value);
-				}
-			}
-			elseif(is_string($data)) {
-				$data = htmlspecialchars($data);
-			}
-		}
-		
-		return $data;
-	}
-	
-	static function get($table, $keys, $where=NULL, $order=NULL, $limit=NULL, $stop=NULL, $revelance=FALSE) {
-		$where = self::__combine_where($where, $revelance);
-		$keys = self::__combine_select($keys, $revelance);
-		
-		$q = sql::query('SELECT '.$keys.' FROM '.self::__combine_table($table).$where.self::__combine_order($order, $revelance).self::__combine_limit($limit, $stop));
-		return self::escape_data(sql::fetch($q));
-	}
-	
-	static function getone($table, $keys, $where=NULL, $order=NULL, $limit=NULL, $stop=NULL, $revelance=FALSE) {
-		$where = self::__combine_where($where, $revelance);
-		$keys = self::__combine_select($keys, $revelance);
-		
-		$q = sql::query('SELECT '.$keys.' FROM '.self::__combine_table($table).$where.self::__combine_order($order, $revelance).self::__combine_limit($limit, $stop));
-		return self::escape_data(sql::fetchone($q));
-	}
-	
-	static function num($table, $key, $where=NULL) {
-		$q = sql::query('SELECT COUNT('.self::__combine_select($key, $one).') AS `num` FROM '.self::__combine_table($table).self::__combine_where($where));
-		$r = sql::fetchone($q);
-		return $r['num'];
-	}
-	
-	static function add($table, $keys) {
-		sql::query('INSERT INTO `'.$table.'` '.self::__combine_insert($keys));
-		return sql::affected();
-	}
-	
-	static function edit($table, $keys, $where=NULL, $order=NULL, $limit=NULL, $stop=NULL) {
-		sql::query('UPDATE `'.$table.'` SET '.self::__combine_update($keys).self::__combine_where($where).self::__combine_order($order).self::__combine_limit($limit, $stop));
-		return sql::affected();
-	}
-	
-	static function del($table, $where=NULL, $order=NULL, $limit=NULL, $stop=NULL) {
-		sql::query('DELETE FROM `'.$table.'`'.self::__combine_where($where).self::__combine_order($order).self::__combine_limit($limit, $stop));
-		return sql::affected();
-	}
-	
-	static function last_id() {
-		return sql::increment_id();
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/generate_codabar.php b/includes/generate_codabar.php
deleted file mode 100644
index 617048a..0000000
--- a/includes/generate_codabar.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-if(!extension_loaded('gd')) {
-	errorclass::add('Brak rozszerzenia GD/GD2. Generowanie kodów kreskowych jest niemożliwe.');
-}
-
-$code = array(
-	'SS' => '1011110000100001',
-	'BT' => '0',
-	'0' => '1010100001111',
-	'1' => '1010111100001',
-	'2' => '1010000101111',
-	'3' => '1111000010101',
-	'4' => '1011110100001',
-	'5' => '1111010100001',
-	'6' => '1000010101111',
-	'7' => '1000010111101',
-	'8' => '1000011110101',
-	'9' => '1111010000101',
-	'-' => '1010000111101',
-	'$' => '1011110000101',
-	':' => '1111010111101111',
-	'/' => '1111011110101111',
-	'.' => '1111011110111101',
-	'+' => '1011110111101111'
-);
-
-function gen_binary($kod) {
-	global $code;
-
-	$kod = str_split($kod);
-	$ret = '';
-	foreach($kod as $key => $val) {
-		$ret .= $code[$val].$code['BT'];
-	}
-
-	return $ret;
-}
-function print_code($kod, $img, $b, $w) {
-	$kod = str_split($kod);
-	foreach($kod as $val) {
-		if($val==1) {
-			imageline($img, $now, 0, $now, 40, $b);
-			$now++;
-		}
-		elseif($val==0) {
-			$now++;
-		}
-	}
-}
-
-function kod($kod) {
-	global $code;
-	$kod = $code['SS'].$code['BT'].gen_binary($kod).$code['SS'];
-	
-	$i = imagecreate(strlen($kod), 40);
-	$w = imagecolorallocate($i, 255, 255, 255);
-	$b = imagecolorallocate($i, 0, 0, 0);
-	
-	print_code($kod, $i, $b, $w);
-	
-	ob_start();
-	imagegif($i);
-	$img = ob_get_contents();
-	ob_end_clean();
-	
-	return $img;
-}
-?>
\ No newline at end of file
diff --git a/includes/generate_code39.php b/includes/generate_code39.php
deleted file mode 100644
index fcf221e..0000000
--- a/includes/generate_code39.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php
-if(!extension_loaded('gd')) {
-	errorclass::add('Brak rozszerzenia GD/GD2. Generowanie kodów kreskowych jest niemożliwe.');
-}
-
-$code = array(
-	'SS' => '100101101101',
-	'BT' => '0',
-	'0' => '101001101101',
-	'1' => '110100101011',
-	'2' => '101100101011',
-	'3' => '110110010101',
-	'4' => '101001101011',
-	'5' => '110100110101',
-	'6' => '101100110101',
-	'7' => '101001011011',
-	'8' => '110100101101',
-	'9' => '101100101101',
-	'A' => '110101001011',
-	'B' => '101101001011',
-	'C' => '110110100101',
-	'D' => '101011001011',
-	'E' => '110101100101',
-	'F' => '101101100101',
-	'G' => '101010011011',
-	'H' => '110101001101',
-	'I' => '101101001101',
-	'J' => '101011001101',
-	'K' => '110101010011',
-	'L' => '101101010011',
-	'M' => '110110101001',
-	'N' => '101011010011',
-	'O' => '110101101001',
-	'P' => '101101101001',
-	'Q' => '101010110011',
-	'R' => '110101011001',
-	'S' => '101101011001',
-	'T' => '101011011001',
-	'U' => '110010101011',
-	'V' => '100110101011',
-	'W' => '110011010101',
-	'X' => '100101101011',
-	'Y' => '110010110101',
-	'Z' => '100110110101',
-	'-' => '100101011011',
-	'.' => '110010101101',
-	' ' => '100110101101',
-	'$' => '100100100101',
-	'/' => '100100101001',
-	'+' => '100101001001',
-	'%' => '101001001001',
-);
-
-function gen_binary($kod) {
-	global $code;
-
-	$kod = str_split($kod);
-	$ret = '';
-	foreach($kod as $key => $val) {
-		$ret .= $code[$val].$code['BT'];
-	}
-
-	return $ret;
-}
-function print_code($kod, $img, $b, $w) {
-	$kod = str_split($kod);
-	foreach($kod as $val) {
-		if($val==1) {
-			imageline($img, $now, 0, $now, 40, $b);
-			$now++;
-		}
-		elseif($val==0) {
-			$now++;
-		}
-	}
-}
-
-function kod($kod) {
-	global $code;
-	
-	if(trim($kod, '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. $')!='') {
-		errorclass::add('Znaki inne niż cyfry, litery, pauza, kropka, spacja, ukośnik');
-	}
-	
-	$kod = $code['SS'].$code['BT'].gen_binary($kod).$code['SS'];
-	
-	$i = imagecreate(strlen($kod), 40);
-	$w = imagecolorallocate($i, 255, 255, 255);
-	$b = imagecolorallocate($i, 0, 0, 0);
-	
-	print_code($kod, $i, $b, $w);
-	
-	ob_start();
-	imagegif($i);
-	$img = ob_get_contents();
-	ob_end_clean();
-	
-	return $img;
-}
-?>
\ No newline at end of file
diff --git a/includes/generate_html.php b/includes/generate_html.php
deleted file mode 100644
index c7cb4b9..0000000
--- a/includes/generate_html.php
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-define('GENERATE_STYLE', '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
-<html>
-<head>
-<title>System biblioteczny - Wygenerowane etykiety</title>
-<style type="text/css">
-html, body, table, * {
-	margin:0;
-	padding:0;
-	font-size: 4mm;
-}
-table {
-	margin-top:7mm; border:0;
-	width: 100%;
-}
-td {
-	border: 0;
-	text-align: center;
-	width: 25%;
-	height: 25mm;
-}
-</style>
-</head>
-<body>
-');
-define('GENERATE_END', '
-</body>
-</html>
-');
-
-function column(&$column, &$row) {
-	if($column == 3) {
-		if($row == 10) {
-			$row = 0;
-			echo '</tr> </table> </body> </html>';
-			die();
-		}
-		else
-		{
-			$row++;
-			echo '</tr> <tr>';
-		}
-	}
-	
-	$column++;
-	if($column == 4) {
-		$column = 0;
-	}
-}
-
-function margin($i) {
-	switch($i) {
-		default:
-			return 0;
-		break;
-		case 1:
-			return 0;
-		break;
-		case 2:
-			return 0;
-		break;
-		case 3:
-			return 0;
-		break;
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/gotowe.php b/includes/gotowe.php
deleted file mode 100644
index 2a29215..0000000
--- a/includes/gotowe.php
+++ /dev/null
@@ -1,340 +0,0 @@
-<?php
-class gotowe {
-	static $pola = array('autor', 'tytul', 'wydanie', 'miejsce', 'rok', 'wydawnictwo', 'jezyk', 'ISBN', 'ISSN');
-	static $nastrone = 25;
-	static $add = '';
-	static $default = FALSE;
-	
-	static function dodaj_lista($kod, $wlasne, $znalezione=array()) {
-		validate::KOD($kod, FALSE);
-		
-		echo '<p>KOD: <input type="text" name="kod" value="'.$kod.'" readonly="readonly" /></p>
-
-';
-		
-		$i = 0;
-?>
-
-<table>
-<tr> <th>Autor</th> <th>Tytuł</th> <th>Wydanie</th> <th>Miejsce</th> <th>Rok</th> <th>Wydawnictwo</th> <th>Język</th> <th>ISBN</th> <th>ISSN</th> <th>Zapisz</th> </tr>
-<?php
-	if(!empty($wlasne)) {
-?>
-<tr> <th colspan="10">Zasoby własne</th> </tr>
-
-<?php
-foreach($wlasne as $value) {
-	echo '<tr> <form action="add_book.php" method="post">
-';
-	foreach(self::$pola as $v) {
-		echo '<td> <input type="text" name="'.$v.'" value="'.htmlspecialchars($value[$v]).'" /> </td>
-';
-	}
-	echo '<td> <input type="hidden" name="id" value="'.$kod.'" readonly="readonly" /> <input type="submit" value="Zapisz" /> </td>
-</form> </tr>
-';
-}
-	}
-
-foreach($znalezione as $nazwa => $dane) {
-	echo '<tr> <th colspan="10">'.$nazwa.'</th> </tr>';
-	foreach($dane as $value) {
-		echo '<tr> <form action="add_book.php" method="post">
-';
-		foreach(self::$pola as $v) {
-			echo '<td> <input type="text" name="'.$v.'" value="'.htmlspecialchars($value[$v]).'" /> </td>
-';
-		}
-		echo '<td> <input type="hidden" name="id" value="'.$kod.'" readonly="readonly" /> <input type="submit" value="Zapisz" /> </td>
-</form> </tr>
-';
-	}
-}
-?>
-
-<tr> <th colspan="10">Dodaj własną</th> </tr>
-
-<?php
-echo '<tr> <form action="add_book.php" method="post">
-';
-foreach(self::$pola as $v) {
-	echo '<td> <input type="text" name="'.$v.'" /> </td>
-';
-}
-echo '<td> <input type="hidden" name="id" value="'.$kod.'" readonly="readonly" /> <input type="submit" value="Zapisz" /> </td>
-</form> </tr>
-
-</table>
-';
-	}
-	
-	static function sort($by=NULL, $strona=NULL) {
-		if($strona === NULL) {
-			$strona = 0;
-		}
-		
-		if($by === NULL) {
-			if(!self::$default) {
-				$by = $_GET['sort'];
-			}
-			
-			$ord = $_GET['ord'];
-		}
-		elseif($by == 'default' && self::$default) {
-			$ord = self::invert_sort($_GET['ord']);
-		}
-		elseif($_GET['sort'] == $by && $_GET['ord'] == 'asc') {
-			$ord = 'desc';
-		}
-		else
-		{
-			$ord = 'asc';
-		}
-		
-		return $_SERVER['PHP_SELF'].'?'.self::$add.'strona='.$strona.'&amp;sort='.$by.'&amp;ord='.$ord;
-	}
-	
-	static function add($what, $size=50) {
-		if($_GET[$what]!='') {
-			self::$add .= $what.'='.urlencode(substr($_GET[$what], 0, $size)).'&';
-		}
-	}
-	
-	static function invert_sort($ord) {
-		$ord = strtolower($ord);
-		if($ord != 'asc') {
-			$ord = 'asc';
-		}
-		else
-		{
-			$ord = 'desc';
-		}
-		return $ord;
-	}
-	
-	static function historia($kod) {
-		$st = PDOO::Singleton()->prepare(
-			 'SELECT * FROM pozycz_historia WHERE id=?'."\n"
-			.'UNION'."\n"
-			.'SELECT *, \'\' AS do FROM pozycz WHERE id=? ORDER BY od ASC'
-		);
-		$st->execute(array($kod, $kod));
-		$dane = $st->fetchAll();
-		
-		$info = '<table id="bhist">
-<tr> <th>Pożyczający</th> <th>Od</th> <th>Do</th> </tr>
-';
-		
-		foreach($dane as $o) {
-			$info .= '<tr> <td>'.htmlspecialchars($o['kto']).'</td> <td>'.date('Y-m-d H:i:s', $o['od']).'</td> <td>'.($o['do'] ? date('Y-m-d H:i:s', $o['do']) : '').'</td> </tr>'."\n";
-		}
-		
-		echo $info.'</table>';
-	}
-	
-	static function informacje($kod, $dane=NULL) {
-		if(is_null($dane)) {
-			$dane = ksiazki::szukaj_KOD($kod);
-		}
-		
-		if($dane['wycofana']) {
-			$class = 'wyc';
-			$info = '<p>Książka wycofana'.($dane['powod'] ? ' <br /> '.$dane['powod'] : '').'</p>';
-		}
-		else
-		{
-			if(!pozycz::pozyczona($dane['id'])) {
-				$class = 'norm';
-				$info = '
-
-<fieldset>
-<legend>Wypożyczanie</legend>
-<p>Książka w bibliotece</p>
-<form action="borrow_book.php" method="post" onsubmit="return ffalse(\'step1\')">
-<p>Pożycz <input type="text" name="kod" value="'.$dane['id'].'" readonly="readonly" /><br />
-Komu? <input type="text" name="kto" id="step11" required="required" /></p>
-<p><input type="submit" value="Pożycz" /></p>
-
-<script type="text/javascript">
-document.getElementById(\'step11\').focus();
-</script>
-</form>
-</fieldset>
-
-';
-			}
-			else
-			{
-				$class = 'poz';
-				$info = '
-
-<fieldset>
-<legend>Wypożyczanie</legend>
-<p>Wypożyczył(a) '.$dane['kto'].' w dniu '.date('d.m.Y', $dane['od']).'</p>
-
-<form action="borrow_back.php" method="post">
-<p><input type="hidden" name="kod" value="'.$dane['id'].'" /> <input type="submit" value="Zwrot"  id="step11" /></p>
-
-<script type="text/javascript">
-document.getElementById(\'step11\').focus();
-</script>
-</form>
-</fieldset>
-
-';
-			}
-		}
-		
-		// Okładka
-		$cover = ksiazki::okladka($dane['id'], $dane['ISBN']);
-		
-		echo '<div class="'.$class.'" id="book">
-'.($cover ? '<a href="cover.php?KOD='.$dane['id'].'&amp;ISBN='.$dane['ISBN'].'"><img src="'.$cover.'" alt="Okładka" /></a>
-' : '').'<h4>'.$dane['tytul'].'</h4>
-<h5>'.$dane['autor'].($dane['regal'] ? ' <span>('.$dane['regal'].($dane['polka'] ? '/'.$dane['polka'] : '').($dane['rzad'] ? '/'.$dane['rzad'] : '').')</span>' : '').'</h5>
-'.($dane['wydanie'] ? '<p>Wydanie '.$dane['wydanie'].'</p>' : '').'
-<p>'.($dane['wydawnictwo'] ? $dane['wydawnictwo'].'<br />
-' : '').$dane['miejsce'].' '.$dane['rok'].($dane['ISBN'] ? '<br />
-ISBN-13: '.$dane['ISBN'] : '').(substr($dane['ISBN'], 0, 3) == '978' ? '<br />
-ISBN-10: '.convert::ISBN13_to_ISBN10($dane['ISBN']) : '').($dane['ISSN'] ? '<br />
-ISSN-13: '.$dane['ISSN'].'<br />
-ISSN-10: '.convert::ISSN13_to_ISSN8($dane['ISSN']) : '').'</p>
-'.$info.'
-<form action="edit.php" method="get" style="float:left;">
-<p><input type="hidden" name="kod" value="'.$dane['id'].'" /> <input type="submit" value="Edytuj" /></p>
-</form>
-
-<form action="del.php" method="get" style="float:left;">
-<p><input type="hidden" name="kod" value="'.$dane['id'].'" /> <input type="submit" value="Usuń" /></p>
-</form>
-'.($dane['od'] ? '
-<form action="borrow_history.php" method="get" style="float:right;">
-<p><input type="hidden" name="kod" value="'.$dane['id'].'" /> <input type="submit" value="Historia wypożyczeń" /></p>
-</form>' : '').'
-</div>';
-	}
-	
-	static function lista() {
-		if(!in_array($_GET['sort'], array('id', 'autor', 'tytul', 'miejsce', 'rok', 'wydawnictwo'))) {
-			$_GET['sort'] = 'tytul';
-			self::$default = TRUE;
-		}
-		if($_GET['ord'] != 'desc') {
-			$_GET['ord'] = 'asc';
-		}
-		if(!ctype_digit($_GET['strona'])) {
-			$_GET['strona'] = 0;
-		}
-		
-		if($_GET['id']) {
-			self::add('id', 13);
-			self::add('polka', 3);
-			self::add('rzad', 3);
-		}
-		else
-		{
-			self::add('tytul');
-			self::add('autor');
-			self::add('wydanie', 25);
-			self::add('miejsce');
-			self::add('rok', 4);
-			self::add('wydawnictwo');
-		}
-		
-		if(self::$default) {
-			$sort = array('revelance' => self::invert_sort($_GET['ord']), $_GET['sort'] => $_GET['ord']);
-		}
-		else
-		{
-			$sort = array($_GET['sort'] => $_GET['ord']);
-		}
-		
-		list($num, $ksiazki, $revelance) = ksiazki::szukaj_info($_GET, $sort, $_GET['strona']*self::$nastrone, self::$nastrone);
-		
-		if($num==0) {
-			errorclass::add('Brak książek spełniających podane kryteria');
-		}
-		elseif($num==1 AND !$revelance) {
-			self::informacje(NULL, $ksiazki[0]);
-			return TRUE;
-		}
-		
-		echo '<table class="width">
-<tr> <th>Okł.</th> <th> <b><a href="'.self::sort('id').'">Kod</a></b> <br /> Wyd. </th> <th> <a href="'.self::sort('autor').'">Autor</a> <br /> <b><a href="'.self::sort('tytul').'">Tytuł</a></b> </th> <th> <a href="'.self::sort('miejsce').'">Miejsce</a>, <a href="'.self::sort('rok').'">rok</a> <br /> <a href="'.self::sort('wydawnictwo').'">Wydawnictwo</a> </th>'.($revelance ? ' <th><a href="'.self::sort('default').'">Trafność</a></th>' : '').' <th> Wypożyczenie <br /> Opcje </th> </tr>
-';
-		
-		foreach($ksiazki as $ksiazka) {
-			if($ksiazka['wycofana']) {
-				$info = 'Książka wycofana';
-				$class = 'wyc';
-			}
-			else
-			{
-				if($ksiazka['do']!==NULL OR $ksiazka['od']===NULL) {
-					$class = 'norm';
-					$info = 'Książka w bibliotece';
-				}
-				else
-				{
-					$class = 'poz';
-					$info = $ksiazka['kto'];
-					$pozycz = TRUE;
-				}
-			}
-			
-			$cover = ksiazki::okladka($ksiazka['id'], $ksiazka['ISBN']);
-			
-			echo '<tr'.($class ? ' class="'.$class.'"' : '').'>
-	<td>
- 		'.($cover ? '<a href="cover.php?KOD='.$ksiazka['id'].'&amp;ISBN='.$ksiazka['ISBN'].'"><img src="'.$cover.'" alt="Okładka" /></a>' : '').'
-	</td>
-	<td>
-		<b>'.$ksiazka['id'].'</b> <br />
-		'.($ksiazka['wydanie'] ? 'W. '.$ksiazka['wydanie'] : '').'
-	</td>
-	<td>
-		'.$ksiazka['autor'].
-			($ksiazka['regal'] ? ' <span>('.$ksiazka['regal'].
-			($ksiazka['polka'] ? '/'.$ksiazka['polka'] : '').
-			($ksiazka['rzad'] ? '/'.$ksiazka['rzad'] : '').
-			')</span>' : '').' <br />
-		<b>'.$ksiazka['tytul'].'</b> </td>
-	<td>
-		'.$ksiazka['miejsce'].' '.$ksiazka['rok'].' <br />
-		'.$ksiazka['wydawnictwo'].' 
-	</td>'.($revelance ? '
-	<td>'.min(100, (int)($ksiazka['revelance']*10)).'% </td>' : '').'
-	<td class="n">
-		'.$info.' <br />
-		<a href="info.php?kod='.$ksiazka['id'].'">Więcej</a>
-		<a href="edit.php?kod='.$ksiazka['id'].'">Edycja</a>
-		<a href="del.php?kod='.$ksiazka['id'].'">Usuń</a>
-	</td>
-</tr>
-';
-		}
-		
-		echo '</table>
-
-';
-		self::strony($num);
-	}
-	
-	static function strony($elementow) {
-		$stron = ceil($elementow / self::$nastrone) - 1;
-		
-		echo '<p class="paginator"> ';
-		for($strona=0; $strona<=$stron; $strona++) {
-			if($strona == $_GET['strona']) {
-				echo '<b>[ '.($strona+1).' ]</b> ';
-			}
-			else
-			{
-				echo '<a href="'.self::sort(NULL, $strona).'">[ '.($strona+1).' ]</a> ';
-			}
-		}
-		
-		echo '</p>';
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/ibd.php b/includes/ibd.php
deleted file mode 100644
index 42cb20c..0000000
--- a/includes/ibd.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?php
-interface ibd_module {
-	//static $name;
-	function szukaj_info($tytul=NULL, $autor=NULL, $wydawnictwo=NULL);
-	function szukaj_ISBN($ISBN);
-	function szukaj_ISSN($ISSN);
-}
-
-class ibd implements Countable {
-	static $providers = array(
-		'ibd_BN',
-	);
-	
-	static $timelimit = 25;
-	
-	function __call($function, $args) {
-		$stop = time() + self::$timelimit;
-		$return = array();
-		
-		foreach(self::$providers as $provider) {
-			if(time() >= $stop) break;
-			
-			$name = new $provider;
-			if(!method_exists($name, $function)) {
-				continue;
-			}
-			
-			$results = call_user_func_array(array($name, $function), $args);
-			
-			if(!empty($results)) {
-				$return[$name->name] = $results;
-			}
-		}
-		
-		return $return;
-	}
-	
-	function count() {
-		return count(self::$providers);
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/ibd_BN.php b/includes/ibd_BN.php
deleted file mode 100644
index 62e5d4f..0000000
--- a/includes/ibd_BN.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<?php
-class ibd_BN implements ibd_module {
-	public $url = 'http://data.bn.org.pl/api/bibs.json';
-	public $limit = 20;
-	
-	function szukaj_info($tytul=NULL, $autor=NULL, $wydawnictwo=NULL) {
-		$params = [];
-		if(!empty($tytul)) {
-			$params['title'] = $tytul;
-		}
-		if(!empty($autor)) {
-			$params['author'] = $author;
-		}
-		if(!empty($wydawnictwo)) {
-			$params['publisher'] = $wydawnictwo;
-		}
-		return $this->query($params);
-	}
-	
-	function szukaj_ISBN($ISBN) {
-		$result = $this->query(array(
-			'isbnIssn' => $ISBN,
-		));
-		if(substr($ISBN, 0, 3) == '978') {
-			$result = array_merge(
-				$result,
-				$this->query(array(
-					'isbnIssn' => convert::ISBN13_to_ISBN10( $ISBN ),
-				))
-			);
-		}
-		return $result;
-	}
-	
-	function szukaj_ISSN($ISSN) {
-		$result = $this->query(array(
-			'isbnIssn' => $ISSN,
-		));
-		if(substr($ISSN, 0, 3) == '977') {
-			$result = array_merge(
-				$result,
-				$this->query(array(
-					'isbnIssn' => convert::ISSN13_to_ISSN8( $ISSN )
-				))
-			);
-		}
-		return $result;
-	}
-	
-	protected function query($params) {
-		$params['limit'] = $limit;
-		$result = file_get_contents($this->url . '?' . http_build_query($params));
-		$result = json_decode($result, TRUE);
-		return $this->extractArrays($result);
-		
-	}
-	
-	protected function convertSubfields($values) {
-		$result = array();
-		if(!isset($values['subfields'])) {
-			return array();
-		}
-		if(isset($values['ind1'])) {
-			$result['f0'] = $values['ind1'];
-		}
-		if(isset($values['ind2'])) {
-			$result['f1'] = $values['ind2'];
-		}
-		foreach($values['subfields'] as $subfield) {
-			foreach($subfield as $name => $value) {
-				$result[$name] = $value;
-			}
-		}
-		return $result;
-	}
-	
-	protected function convert($entry) {
-		$marc = array();
-		if(!isset($entry['marc'])) return NULL;
-		if(!isset($entry['marc']['fields'])) return NULL;
-		foreach($entry['marc']['fields'] as $fields) {
-			foreach($fields as $field => $values) {
-				if(!isset($marc[$field])) {
-					$marc[$field] = array();
-				}
-				$marc[$field][] = $this->convertSubfields($values);
-			}
-		}
-		return $marc;
-	}
-	
-	protected function extractArrays($result) {
-		if(!$result) return array();
-		if(!$result['bibs']) return array();
-		
-		$return = array();
-		foreach($result['bibs'] as $bib) {
-			$marc = $this->convert($bib);
-			$return[] = MARC21::to_array($marc);
-		}
-		return $return;
-	}
-}
diff --git a/includes/ksiazki.php b/includes/ksiazki.php
deleted file mode 100644
index 2ca24e7..0000000
--- a/includes/ksiazki.php
+++ /dev/null
@@ -1,260 +0,0 @@
-<?php
-class ksiazki_cache {
-	static $cache = array();
-	
-	static function cache_get($kod) {
-		$kod = (int)$kod;
-		
-		if(!isset(self::$cache[(int)$kod])) {
-			self::cache_update($kod);
-		}
-		
-		return self::$cache[(int)$kod];
-	}
-	
-	static function cache_add($kod, &$dane) {
-		self::$cache[(int)$kod] = $dane;
-	}
-	
-	static function cache_addarray(&$dane) {
-		foreach($dane as &$r) {
-			self::cache_add($r['id'], $r);
-		}
-	}
-	
-	static function cache_clear($id=NULL) {
-		if(is_null($id)) {
-			self::$cache = array();
-		}
-		else
-		{
-			unset(self::$cache[(int)$id]);
-		}
-	}
-	
-	static function cache_update($kod) {
-		$dane = db2::escape_data(sql::fetchone(sql::query('SELECT `ksiazki`.*, `pozycz`.`od`, `pozycz`.`kto` FROM `ksiazki` LEFT OUTER JOIN `pozycz` ON `pozycz`.`id`=`ksiazki`.`id` WHERE `ksiazki`.`id`='.sql::escape($kod))));
-		self::cache_add($kod, $dane);
-	}
-}
-
-class ksiazki extends ksiazki_cache {
-	static function okladka($KOD, $ISBN) {
-		return okladki::znajdz($KOD, $ISBN, 'covers');
-	}
-	
-	static function okladka_big($KOD, $ISBN) {
-		return okladki::znajdz($KOD, $ISBN, 'covers_big');
-	}
-	
-	static function dodaj(&$dane) {
-		validate::KOD($dane['id'], FALSE);
-		
-		if($dane['ISBN']) {
-			$t = validate::type($dane['ISBN']);
-			if($t!='ISBN') {
-				errorclass::add('W polu ISBN znajduje się '.$t);
-			}
-		}
-		if($dane['ISSN']) {
-			$t = validate::type($dane['ISSN']);
-			if($t!='ISSN') {
-				errorclass::add('W polu ISSN znajduje się '.$t);
-			}
-		}
-		
-		if($dane['jezyk']=='pol') {
-			$dane['jezyk'] = 'polski';
-		}
-		
-		unset($_POST['okladka']);
-		
-		okladki::upload($_FILES['okladka'], $dane['id'], $dane['ISBN']);
-		
-		db2::add('ksiazki', $dane);
-		self::cache_update($dane['id']);
-	}
-	
-	static function exists($kod) {
-		$info = self::cache_get($kod);
-		if(isset($info['id'])) {
-			return TRUE;
-		}
-		else
-		{
-			return FALSE;
-		}
-	}
-	
-	static function edytuj(&$dane) {
-		validate::KOD($dane['id']);
-		$kod = $dane['id'];
-		
-		$old = self::szukaj_KOD($kod);
-		
-		if($dane['id']=='' OR empty($dane['autor']) OR empty($dane['tytul']) OR empty($dane['jezyk'])) {
-			errorclass::add('Brak wymaganych danych o książce (kod, autor, tytuł, język)');
-		}
-		
-		if($dane['nid']!='') {
-			validate::KOD($dane['nid']);
-			$dane['id'] = $dane['nid'];
-		}
-		
-		unset($dane['nid']);
-		
-		if($dane['ISBN']) {
-			$t = validate::type($dane['ISBN']);
-			if($t!='ISBN') {
-				errorclass::add('W polu ISBN znajduje się '.$t);
-			}
-		}
-		if($dane['ISSN']) {
-			$t = validate::type($dane['ISSN']);
-			if($t!='ISSN') {
-				errorclass::add('W polu ISSN znajduje się '.$t);
-			}
-		}
-		
-		if(!$dane['wycofana']) {
-			$dane['wycofana'] = 0;
-			$dane['powod'] = NULL;
-		}
-		
-		okladki::przenies($old['id'], $old['ISBN'], $dane['id'], $dane['ISBN']);
-		
-		unset($_POST['okladka']);
-		
-		// Nowa okładka
-		if(isset($_POST['okladka_del']) || (isset($_FILES['okladka']) && is_uploaded_file($_FILES['okladka']['tmp_name']))) {
-			okladki::usun($dane['id'], $dane['ISBN']);
-			unset($_POST['okladka_del']);
-		}
-		
-		okladki::upload($_FILES['okladka'], $dane['id'], $dane['ISBN']);
-		
-		db2::edit('ksiazki', $dane, array('id' => $kod));
-		self::cache_update($kod);
-		if($dane['id']!=$kod) {
-			self::cache_update($dane['id']);
-		}
-	}
-	
-	static function miejsce($regal, $polka, $rzad, $where) {
-		db2::edit('ksiazki', array('regal' => strtoupper($regal), 'polka' => $polka, 'rzad' => $rzad), $where);
-		self::cache_clear();
-	}
-	
-	static function usun(&$kod) {
-		validate::KOD($kod);
-		
-		$dane = self::szukaj_KOD($kod);
-		
-		okladki::usun($dane['id'], $dane['ISBN']);
-		
-		db2::del('ksiazki', array('id' => $kod));
-		self::cache_clear($kod);
-	}
-	
-	static function szukaj_KOD($kod) {
-		validate::KOD($kod, TRUE);
-		
-		return self::cache_get($kod);
-	}
-	
-	static function szukaj_ISBN($ISBN) {
-		validate::EAN($ISBN);
-		
-		return db2::get('ksiazki', '*', array('ISBN' => $ISBN), NULL, 10);
-	}
-	
-	static function szukaj_ISSN($ISSN) {
-		validate::EAN($ISSN);
-		
-		return db2::get('ksiazki', '*', array('ISSN' => $ISSN), NULL, 10);
-	}
-	
-	static function szukaj_info($dane, $order=NULL, $start=NULL, $limit=30) {
-		$allow = array('id', 'tytul', 'autor', 'wydawnictwo', 'miejsce', 'rok', 'wydanie', 'wycofana');
-		$replace = array('tytul' => 'tytul~~', 'autor' => 'autor~~', 'wydawnictwo' => 'wydawnictwo~~');
-		
-		$where = array();
-		
-		foreach($dane as $key => $value) {
-			if(!in_array($key, $allow) OR $value==='') {
-				continue;
-			}
-			
-			if($replace[$key]) {
-				$key = $replace[$key];
-			}
-			
-			$where[$key] = $value;
-		}
-		
-		if($where['id']) {
-			validate::$kod = TRUE;
-			switch(validate::type($where['id'])) {
-				case 'ISBN':
-					$where['ISBN'] = $where['id'];
-					unset($where['id']);
-				break;
-				case 'ISSN':
-					$where['ISSN'] = $where['id'];
-					unset($where['id']);
-				break;
-				case 'MSC':
-					$where['regal'] = $where['id'];
-					if($dane['polka']) {
-						$where['polka'] = $dane['polka'];
-					}
-					if($dane['rzad']) {
-						$where['rzad'] = $dane['rzad'];
-					}
-					unset($where['id']);
-				break;
-			}
-			validate::$kod = FALSE;
-		}
-		
-		if(!$where['regal']) {
-			unset($where['polka']);
-			unset($where['rzad']);
-		}
-		
-		if($where['id']) {
-			$ret[] = self::szukaj_KOD($where['id']);
-			$num = count($ret);
-		}
-		else
-		{
-			if($dane['do']) {
-				$num = db2::num('pozycz', 'id');
-				if($num == 0) {
-					$ret = array();
-				}
-				else
-				{
-					$ret = db2::get(array('pozycz', array('J', 'ksiazki', 'USING', 'id')), '*', NULL, $order, $start, $limit);
-				}
-			}
-			else
-			{
-				$num = db2::num('ksiazki', 'id', $where);
-				if($num == 0) {
-					$ret = array();
-				}
-				else
-				{
-					$where = db2::__combine_where($where, TRUE);
-					$ret = db2::escape_data(sql::fetch(sql::query('SELECT `ksiazki`.*, `pozycz`.`od`, `pozycz`.`kto`'.(db2::revelance() ? ', '.db2::$revelance : '').' FROM `ksiazki` LEFT OUTER JOIN `pozycz` ON `pozycz`.`id`=`ksiazki`.`id` '.$where.db2::__combine_order($order, TRUE).db2::__combine_limit($start, $limit))));
-				}
-			}
-			
-			self::cache_addarray($ret);
-		}
-		
-		return array($num, $ret, db2::revelance());
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/okladki.php b/includes/okladki.php
deleted file mode 100644
index e9f0626..0000000
--- a/includes/okladki.php
+++ /dev/null
@@ -1,181 +0,0 @@
-<?php
-class okladki {
-	static function znajdz($KOD, $ISBN, $dir = 'covers') {
-		if(strlen($KOD)<=8 && ctype_digit($KOD)) {
-			validate::KOD($KOD);
-			
-			if(file_exists('./'.$dir.'/own/'.$KOD)) {
-				return './'.$dir.'/own/'.$KOD;
-			}
-		}
-		
-		if(strlen($ISBN)==13) {
-			validate::EAN($ISBN);
-			
-			if(substr($ISBN, 0, 3)=='978') {
-				$ISBN10 = convert::ISBN13_to_ISBN10($ISBN);
-			}
-			else
-			{
-				$ISBN10 = $ISBN;
-			}
-			
-			if(file_exists('./'.$dir.'/own/'.$ISBN)) {
-				return './'.$dir.'/own/'.$ISBN;
-			}
-			
-			if(self::librarything($ISBN, $dir)!==FALSE) {
-				return './'.$dir.'/'.$ISBN;
-			}
-		}
-		
-		return FALSE;
-	}
-	
-	static function librarything($ISBN, $dir = 'covers') {
-		if(!function_exists('curl_init') || !config::$lt_api) {
-			return FALSE;
-		}
-		
-		// Okładkę już mamy
-		if(file_exists('./'.$dir.'/'.$ISBN)) {
-			if(filesize('./'.$dir.'/'.$ISBN)>0) {
-				return NULL;
-			}
-			
-			// Negatywne cache'owanie
-			if(filesize('./'.$dir.'/'.$ISBN)==0 AND filemtime('./'.$dir.'/'.$ISBN)+(30*24*60*60) > time()) {
-				return FALSE;
-			}
-		}
-		
-		$get = 'http://www.librarything.com/devkey/'.config::$lt_api.'/'.($dir=='covers_big' ? 'large' : 'small').'/isbn/'.$ISBN10;
-		$curl = curl_init($get);
-		curl_setopt($curl, CURLOPT_FOLLOWLOCATION, TRUE);
-		curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
-		
-		$img = @curl_exec($curl);
-		$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
-		$type = curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
-		
-		if($code != 200 || substr($type, 0, 6) != 'image/' || $img == FALSE || strlen($img) < 100) {
-			// Negatywne cache'owanie
-			touch('./'.$dir.'/'.$ISBN);
-			return FALSE;
-		}
-		else
-		{
-			file_put_contents('./'.$dir.'/'.$ISBN, $img);
-			return TRUE;
-		}
-	}
-	
-	static function przenies($SKOD, $SISBN, $KOD, $ISBN) {
-		foreach(array('covers', 'covers_big') as $where) {
-			$nowaokl = self::znajdz($KOD, $ISBN, $where);
-			if(!$nowaokl) {
-				$staraokl = self::znajdz($SKOD, $SISBN, $where);
-				if(!$staraokl) {
-					continue;
-				}
-				if($nowaokl == $staraokl) {
-					continue;
-				}
-					
-				if(!empty($ISBN)) {
-					if(strpos($staraokl, '/own/')) {
-						rename($staraokl, './'.$where.'/own/'.$ISBN);
-					}
-					else
-					{
-						rename($staraokl, './'.$where.'/'.$ISBN);
-					}
-				}
-				else
-				{
-					rename($staraokl, './'.$where.'/own/'.$KOD);
-				}
-			}
-		}
-		
-		if($KOD != $SKOD) {
-			self::usun($SKOD, '');
-		}
-	}
-	
-	static function usun($KOD, $ISBN) {
-		if($ISBN) {
-			@unlink('./covers/'.$ISBN);
-			@unlink('./covers_big/'.$ISBN);
-			@unlink('./covers/own/'.$ISBN);
-			@unlink('./covers_big/own/'.$ISBN);
-		}
-		else
-		{
-			@unlink('./covers/own/'.$KOD);
-			@unlink('./covers_big/own/'.$KOD);
-		}
-	}
-	
-	static function upload($files, $KOD, $ISBN) {
-		if($ISBN) {
-			$DANE = $ISBN;
-		}
-		else
-		{
-			$DANE = $KOD;
-		}
-		
-		if(is_uploaded_file($files['tmp_name'])) {
-			okladki::skaluj($files['tmp_name'], 500, 500, './covers_big/own/'.$DANE);
-			okladki::skaluj($files['tmp_name'], 53, 80, './covers/own/'.$DANE);
-			if(file_exists('./covers/'.$DANE)) {
-				unlink('./covers/'.$DANE);
-			}
-		}
-	}
-	
-	static function skaluj($file, $max_width, $max_height, $outfile) {
-		list($width, $height, $type) = getimagesize($file);
-		
-		switch($type) {
-			case IMAGETYPE_JPEG:
-				$image = imagecreatefromjpeg($file);
-			break;
-			case IMAGETYPE_PNG:
-				$image = imagecreatefrompng($file);
-			break;
-			case IMAGETYPE_GIF:
-				$image = imagecreatefromgif($file);
-			break;
-			case IMAGETYPE_XBM:
-				$image = imagecreatefromxpm($file);
-			break;
-			default:
-				errorclass::add('Nieznany format obrazka: '.$type.'!');
-				return FALSE;
-			break;
-		}
-		
-		if ($width > $max_width OR $height > $max_height) {
-			if($width*$max_height > $height*$max_width) {
-				$new_width = $max_width;
-				$new_height = round( ($new_width / $width) * $height );
-			}
-			else
-			{
-				$new_height = $max_height;
-				$new_width = round( ($new_height / $height) * $width );
-			}
-			
-			$new_image = imagecreatetruecolor($new_width, $new_height);
-			imagecopyresampled($new_image, $image, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
-			return imagejpeg($new_image, $outfile, 100);
-		}
-		else
-		{
-			return imagejpeg($image, $outfile, 100);
-		}
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/pozycz.php b/includes/pozycz.php
deleted file mode 100644
index e48734f..0000000
--- a/includes/pozycz.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-class pozycz {
-	static function wypozyczenie($kod, $kto) {
-		if(self::pozyczona($kod)!==FALSE) {
-			errorclass::add('Książka jest już wypożyczona!');
-		}
-		
-		db2::add('pozycz', array('id' => $kod, 'kto' => $kto, 'od' => time()));
-		ksiazki::cache_clear($kod);
-	}
-	
-	static function zwrot($kod) {
-		if(self::pozyczona($kod)===FALSE) {
-			errorclass::add('Książka nie jest wypożyczona!');
-		}
-		
-		$st = PDOO::Singleton()->prepare('INSERT INTO pozycz_historia (id, kto, od, do)
-			SELECT id, kto, od, ? FROM pozycz WHERE id=?');
-		$st->execute(array(time(), $kod));
-		
-		$st = PDOO::Singleton()->prepare('DELETE FROM pozycz WHERE id=?');
-		$st->execute(array($kod));
-		
-		ksiazki::cache_update($kod);
-	}
-	
-	static function pozyczona($kod) {
-		$ksiazka = ksiazki::szukaj_KOD($kod);
-		return $ksiazka['od'] != NULL;
-	}
-}
-?>
\ No newline at end of file
diff --git a/includes/std.php b/includes/std.php
deleted file mode 100644
index bf71a22..0000000
--- a/includes/std.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-define('STANDARD', TRUE);
-
-class errorclass {
-	static $time;
-	static function add($text) {
-		$title = 'Błąd';
-		include('design/top.php');
-		
-		echo '<p>'.$text.'</p>';
-		
-		include('design/bottom.php');
-		die();
-	}
-}
-
-errorclass::$time = microtime(TRUE);
-
-function __autoload($class) {
-	if($class == 'sql') {
-		$class = 'db2';
-	}
-	require_once('./includes/'.$class.'.php');
-}
-?>
diff --git a/includes/validate.php b/includes/validate.php
deleted file mode 100644
index b19e1de..0000000
--- a/includes/validate.php
+++ /dev/null
@@ -1,127 +0,0 @@
-<?php
-class validate {
-	static $kod = FALSE;
-	
-	static function KOD(&$kod, $exists=NULL) {
-		if(ltrim($kod, '0123456789')!='' OR strlen($kod)>8 OR strlen($kod)<1) {
-			errorclass::add('Błędny KOD - dozwolone tylko cyfry');
-		}
-		
-		$kod = str_pad($kod, 8, '0', STR_PAD_LEFT);
-		
-		if(!is_null($exists)) {
-			if($exists!==ksiazki::exists($kod)) {
-				errorclass::add('Wybrana książka '.($exists ? 'nie' : 'już').' istnieje!');
-			}
-		}
-	}
-	
-	static function EAN($kod) {
-		if( ltrim($kod, '0123456789') != '' OR strlen($kod) != 13 ) {
-			errorclass::add('Błędny ISN - dozwolone tylko cyfry');
-		}
-		
-		if( substr($kod, -1) != checksum::EAN(substr($kod, 0, -1)) ) {
-			errorclass::add('Podany kod ISN jest błędny');
-		}
-	}
-	
-	static function ISBN(&$kod) {
-		$kod = str_replace('-', '', strtoupper($kod));
-		if( ltrim($kod, '0123456789X') != '' OR strlen($kod) != 10 ) {
-			errorclass::add('Błędny ISBN - dozwolone tylko cyfry i znak X');
-		}
-		
-		if( substr($kod, -1) != checksum::ISBN(substr($kod, 0, -1)) ) {
-			errorclass::add('Podany ISBN jest błędny');
-		}
-	}
-	
-	static function ISSN(&$kod) {
-		$kod = str_replace('-', '', strtoupper($kod));
-		if( ltrim($kod, '0123456789X') != '' OR strlen($kod) != 8 ) {
-			errorclass::add('Błędny ISSN - dozwolone tylko cyfry');
-		}
-		
-		if( substr($kod, -1) != checksum::ISSN(substr($kod, 0, -1)) ) {
-			errorclass::add('Podany ISSN jest błędny');
-		}
-	}
-	
-	static function MSC(&$kod, $polka=NULL, $rzad=NULL) {
-		$kod = strtoupper($kod);
-		
-		if($polka===NULL) {
-			$polka = $_GET['polka'];
-		}
-		if($rzad===NULL) {
-			$rzad = $_GET['rzad'];
-		}
-		
-		if(!ctype_alnum($kod) OR ctype_digit($kod) OR $kod=='' OR strlen($kod)>5) {
-			errorclass::add('Podany kod miejsca (regał) jest błędny');
-		}
-		if($polka!='' AND (!ctype_digit($polka) OR $polka>255)) {
-			errorclass::add('Podany kod miejsca (półka) jest błędny');
-		}
-		if($rzad!='' AND (!ctype_digit($rzad) OR $rzad>255)) {
-			errorclass::add('Podany kod miejsca (rząd) jest błędny');
-		}
-	}
-	
-	static function type(&$kod) {
-		$kod = str_replace('-', '', $kod);
-		
-		switch(strlen($kod)) {
-			case 13:
-				self::EAN($kod);
-				
-				if(substr($kod, 0, 3)=='978' OR substr($kod, 0, 3)=='979') {
-					return 'ISBN';
-				}
-				elseif(substr($kod, 0, 3)=='977') {
-					return 'ISSN';
-				}
-			break;
-			case 9:
-				if(self::$kod) {
-					$kod = substr($kod, 1);
-					self::ISSN($kod);
-					$kod = convert::ISSN8_to_ISSN13($kod);
-					return 'ISSN';
-				}
-			break;
-			case 8:
-				if(self::$kod) {
-					self::KOD($kod);
-					return 'KOD';
-				}
-				else
-				{
-					self::ISSN($kod);
-					$kod = convert::ISSN8_to_ISSN13($kod);
-					return 'ISSN';
-				}
-			break;
-			case 10:
-				self::ISBN($kod);
-				$kod = convert::ISBN10_to_ISBN13($kod);
-				return 'ISBN';
-			break;
-			default:
-				if(self::$kod AND ctype_digit($kod)) {
-					self::KOD($kod);
-					return 'KOD';
-				}
-			break;
-		}
-		
-		if(self::$kod) {
-			self::MSC($kod);
-			return 'MSC';
-		}
-		
-		errorclass::add('Nieznany typ kodu');
-	}
-}
-?>
\ No newline at end of file
diff --git a/index.php b/index.php
deleted file mode 100644
index 27a4753..0000000
--- a/index.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-$title = 'Wyszukiwanie i wypożyczanie';
-include('design/top.php');
-?>
-
-<table class="main">
-<tr>
-<td>
-
-<form action="search.php" method="get" onsubmit="return ffalse('step1')">
-<p>KOD/IS*N: <input type="text" name="id" id="step11" required="required" /><br />
-Półka, rząd: <input type="text" name="polka" size="3" maxlength="3" />
-/ <input type="text" name="rzad" size="3" maxlength="3" /></p>
-<p><input type="submit" value="Znajdź" /></p>
-
-<p><i>Przy ręcznym wpisywaniu ośmiocyfrowego <br /> numeru ISSN, poprzedź go zerem</i></p>
-</form>
-
-<script type="text/javascript">
-document.getElementById('step11').focus();
-</script>
-
-<hr />
-
-<a href="list_all.php">Pełna lista książek</a> <br />
-<a href="list_borrowed.php">Wypożyczone</a> <br />
-<a href="list_repulsed.php">Wycofane</a>
-
-</td>
-<td>
-
-<form action="search.php" method="get">
-<p>Tytuł: <input type="text" name="tytul" /><br />
-Autor: <input type="text" name="autor" /><br />
-Wydanie: <input type="text" name="wydanie" /></p>
-<p>Miejsce: <input type="text" name="miejsce" /><br />
-Rok: <input type="text" name="rok" /><br />
-Wydawnictwo: <input type="text" name="wydawnictwo" /></p>
-<p><input type="submit" value="Szukaj" /></p>
-</form>
-
-</td>
-</tr>
-</table>
-
-<?php
-include('design/bottom.php');
-?>
diff --git a/info.php b/info.php
deleted file mode 100644
index 9d539a2..0000000
--- a/info.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Wypożyczanie książki';
-include('./design/top.php');
-
-gotowe::informacje($_GET['kod']);
-
-include('./design/bottom.php');
-?>
diff --git a/inwentaryzacja/begin.php b/inwentaryzacja/begin.php
deleted file mode 100644
index 3370176..0000000
--- a/inwentaryzacja/begin.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-$title = 'Inwentaryzacja - rozpoczęcie';
-include('design/top.php');
-?>
-
-<h3>Krok 1 z 3: Przygotowanie listy książek</h3>
-
-<p>Ten krok służy do przygotowania listy książek, które zostaną objęte inwentaryzacją. Na liście nie znajdą się książki wycofane. W przypadku książki wypożyczonej, informacja o zwrocie (w przypadku jej zainwentaryzowania) nie zostanie zapisana w bazie!</p>
-
-<p>W trakcie inwentaryzacji zaleca się nie zmieniać położenia książek za pomocą interfejsu, gdyż dane te mogą zostać nadpisane po zakończeniu procesu (krok 3).</p>
-
-<p>Z interfejsu inwentaryzacji w danej chwili powinna korzystać tylko jedna osoba, w przeciwnym wypadku osoby inwentaryzujące mogą wzajemnie nadpisywać zgromadzone dane.</p>
-
-<?php
-if(file_exists('list.xml')) {
-?>
-<p><strong>Dane z poprzedniej (niedokończonej) inwentaryzacji zostaną usunięte!</strong></p>
-
-<?php
-}
-?>
-<form action="make.php">
-<p><input type="submit" value="Utwórz listę" /></p>
-</form>
-
-<?php
-include('design/bottom.php');
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/design/bottom.php b/inwentaryzacja/design/bottom.php
deleted file mode 100644
index e9b8131..0000000
--- a/inwentaryzacja/design/bottom.php
+++ /dev/null
@@ -1,7 +0,0 @@
-
-<p class="footer"> Copyrights © 2008, 2009 by Jacek Kowalski | Covers by <a href="http://www.librarything.com">LibraryThing</a> <?php
-if(defined('STANDARD')) { echo '<br /> Zapytań SQL: '.sql::$queries.' | Czas wykonywania: '.(microtime(TRUE)-error::$time).' s '; } ?></p>
-
-<script type="text/javascript" src="design/script.js"></script>
-</body>
-</html>
diff --git a/inwentaryzacja/design/script.js b/inwentaryzacja/design/script.js
deleted file mode 100644
index 8fbbf47..0000000
--- a/inwentaryzacja/design/script.js
+++ /dev/null
@@ -1,85 +0,0 @@
-function keypress(e) {
-	if(!e) {
-		e = window.event;
-	}
-	
-	switch(e.keyCode) {
-		case 112:
-			setTimeout('window.location.replace("index.php");', 10);
-			return false;
-		break;
-		case 113:
-			setTimeout('window.location.replace("add.php");', 10);
-			return false;
-		break;
-		case 114:
-			setTimeout('window.location.replace("place.php");', 10);
-			return false;
-		break;
-		case 115:
-			setTimeout('window.location.replace("generate.php");', 10);
-			return false;
-		break;
-	}
-}
-
-function ffalse(ids) {
-	i=1;
-	while(true) {
-		obj = document.getElementById(ids+i);
-		if(!obj) break;
-		
-		if((obj.hasAttribute('required') || obj.className=='focus') && obj.value=='') {
-			obj.focus();
-			return false;
-		}
-		i++;
-	}
-	
-	return true;
-}
-
-function ffalse_focus() {
-	el = document.getElementsByClassName('focus');
-	
-	for(i=0; i<el.length; i++) {
-		el.item(i).onfocus = "this.className=''";
-	}
-}
-
-function uc_all(ids, check) {
-	list = document.getElementById(ids).getElementsByTagName('input');
-	
-	for(i=0; i<list.length; i++) {
-		box = list.item(i);
-		if(box.type == 'checkbox') {
-			box.checked = check;
-		}
-	}
-}
-
-function pups_init() {
-	an = document.getElementsByTagName('a');
-	
-	for(i=0; i<an.length; i++) {
-		if((an.item(i).href+'').match('cover.php\?')) {
-			an[i].onclick = pups;
-		}
-	}
-}
-
-function pups(an) {
-	an = an.target.parentNode;
-	if(an) {
-		win = window.open(an.href+'&pop', '_blank', 'dependent=yes,toolbar=no,resizable=yes');
-		return false;
-	}
-}
-
-function on_loaded() {
-	ffalse_focus();
-	pups_init();
-}
-
-document.onkeydown = keypress;
-window.onload = on_loaded();
diff --git a/inwentaryzacja/design/style.css b/inwentaryzacja/design/style.css
deleted file mode 100644
index 4c75aea..0000000
--- a/inwentaryzacja/design/style.css
+++ /dev/null
@@ -1,105 +0,0 @@
-html, body {
-	font: 14px Verdana, Helvetica, sans-serif;
-	background: #ffd;
-}
-
-table {border: 2px solid black;}
-a img {border: none;}
-
-/* TABELE AKCJI */
-.width {width: 100%;}
-.width span, #book span {
-	font-size: 80%;
-	padding-left: 10px;
-}
-table.main {
-	border: 0;
-	border-spacing: 10px;
-}
-.main td {
-	vertical-align: top;
-	border: 2px solid black;
-	padding: 5px 10px;
-	margin: 10px;
-}
-
-/* STYLE MENU */
-#menu {	padding: 0px;
-	text-align: center;
-	background: #dda;
-}
-#menu li {
-	margin: 0px 2px;
-	padding: 0px;
-	display: inline;
-	list-style: inside none;
-}
-#menu a {
-	background: #fd6;
-	padding: 3px 15px;
-	margin: 0px;
-	text-decoration: none;
-	color: #000;
-}
-#menu a:hover {
-	background: #fea;
-	text-decoration: underline;
-}
-
-/* Brakujące etykiety */
-table.no td {
-	border: 1px solid black;
-	padding: 0px 10px;
-}
-.or {
-	display: block;
-	padding: 5px 0px;
-}
-.or * {
-	vertical-align: middle;
-	display: inline-block;
-	width: 30%;
-}
-.width td {	vertical-align: top;
-	padding: 5px;}
-td p:first-child, td h3:first-child {margin-top: 0;}
-td p:last-child {margin-bottom: 3px;}
-.width tr td:first-child {padding:0;}
-/* BEZ ZAWIJANIA WIERSZY */
-.n {	white-space: nowrap;}
-/* KOLORY PRZY WYSZUKIWANIU (książki wycofane, wypożyczone itp.) */
-.wyc {	background: #ddd;}
-.poz {	background: #fdd;}
-.norm {	background: #dfd;}
-
-/* TEKSTY POCHYLONE */
-i {	font-size: 75%;}
-
-/* POJEDYNCZA KSIĄŻKA */
-#book {	border: 2px solid black;
-	padding: 5px;
-	display: inline-block;
-}
-#book h4, #book h5, #book p {margin: 2px;}
-#book p {margin-top: 0.5em;}
-#bhist {margin: 0.5em; float:right;}
-#book a {
-	float: left;
-	margin: 10px;
-	margin-left: 0px;
-}
-fieldset {margin: 0.5em auto;}
-
-@media print {
-	* {color: black; background: white;}
-	h1, ul, .footer, img {display: none;}
-	a {text-decoration: none;}
-	form {margin:0; padding:0;}
-	input[type=submit] {display: none;}
-	#bhist {float:none; margin-left:0;}
-	/* OKŁADKI */
-	.width td {padding: 1mm;}
-	.width tr td:first-child, .width tr th:first-child {display: none;}
-	/* OPCJE */
-	.width tr th:last-child, .width tr td:last-child {display: none;}
-}
diff --git a/inwentaryzacja/design/top.php b/inwentaryzacja/design/top.php
deleted file mode 100644
index 3b4f6ba..0000000
--- a/inwentaryzacja/design/top.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-if(!defined('TOP_SEND')) {
-	header('Content-type: text/html; charset=utf-8');
-	header('Pragma: no-cache');
-	header('Cache-control: private, no-cache, must-revalidate');
-?>
-<!DOCTYPE html>
-<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="pl" lang="pl">
-<head>
-<title>System biblioteczny - Inwentaryzacja</title>
-<link rel="stylesheet" type="text/css" href="design/style.css" />
-</head>
-<body>
-
-<h1> System biblioteczny </h1>
-
-<ul id="menu">
-<li><a href="../index.php">Wypożyczanie/wyszukiwanie</a></li>
-
-<li> </li>
-
-<li><a href="../add.php">Dodaj książkę</a></li>
-<li><a href="../place.php">Ustaw położenie</a></li>
-
-<li> </li>
-
-<li><a href="../generate.php">Etykiety</a></li>
-</ul>
-
-<h2> Inwentaryzacja </h2>
-
-<?php
-	define('TOP_SEND', TRUE);
-}
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/end.php b/inwentaryzacja/end.php
deleted file mode 100644
index 4eaeda0..0000000
--- a/inwentaryzacja/end.php
+++ /dev/null
@@ -1,76 +0,0 @@
-<?php
-$title = 'Inwentaryzacja - zakończenie';
-include('design/top.php');
-?>
-
-<h3>Krok 3 z 3: Zakończenie inwentaryzacji</h3>
-
-<?php
-if(!is_file('list.xml')) {
-	echo '<p>Wystąpił błąd: plik z danymi nie istnieje!</p>';
-	
-	include('design/bottom.php');
-	die();
-}
-?>
-
-<p>Poniżej znajduje się lista zmian do wykonania. Aby nie dokonywać danej zmiany należy odznaczyć odpowiednie pole wyboru.</p>
-
-<form action="finish.php" method="post">
-<table class="width">
-<tr> <th> <b>Kod</b> <br /> Wyd. </th> <th> Autor <br /> <b>Tytuł</b> </th> <th> Miejsce, rok <br /> Wydawnictwo </th> <th> Akcje </th> </tr>
-<?php
-$doc = new DOMDocument;
-if(!$doc->load('list.xml')) {
-	echo '<p>Wystąpił błąd: plik z danymi nie jest poprawnym arkuszem XML!</p>';
-	
-	include('design/bottom.php');
-	die();
-}
-
-foreach($doc->documentElement->childNodes as $loc) {
-	if(!($loc instanceof DOMElement) || $loc->tagName != 'lokalizacja') {
-		continue;
-	}
-	
-	$location = $loc->getAttribute('regal').'/'.$loc->getAttribute('polka').'/'.$loc->getAttribute('rzad');
-	
-	foreach($loc->childNodes as $node) {
-		if(!($node instanceof DOMElement) || $node->tagName != 'ksiazka') {
-			continue;
-		}
-		if($node->hasAttribute('status') && $node->getAttribute('status') == 'ok') {
-			continue;
-		}
-		
-		$dane = array(
-			'id' => substr($node->getAttribute('id'), 1),
-			'status' => $node->getAttribute('status'),
-		);
-		
-		foreach($node->childNodes as $attr) {
-			if($node instanceof DOMElement) {
-				$dane[$attr->tagName] = htmlspecialchars($attr->textContent);
-			}
-		}
-		
-		echo '<tr class="'.($dane['status'] == 'moved' ? 'poz' : 'wyc').'"> <td> <b>'.$dane['id'].'</b> <br /> </td> <td>'.$dane['autor'].' <br /> <b>'.$dane['tytul'].'</b></td> <td>'.$dane['miejsce'].' '.$dane['rok'].' <br /> '.$dane['wydawnictwo'].'</td> <td>';
-		if($dane['status'] == 'moved') {
-			echo '<label><input type="checkbox" name="move['.$dane['id'].']" value="'.htmlspecialchars($location).'" checked="checked" />Przenieś do '.htmlspecialchars($location).'</label>';
-		}
-		else
-		{
-			echo '<label><input type="checkbox" name="repulse['.$dane['id'].']" value="1" checked="checked" />Wycofaj</label>';
-		}
-		echo '</td> </tr>'."\n";
-	}
-}
-?>
-</table>
-
-<p><input type="submit" value="Wykonaj wybrane operacje" /> <a href="locate.htm">Kontynuuj inwentaryzację</a> <a href="begin.php">Zacznij inwentaryzację od nowa</a></p>
-</form>
-
-<?php
-include('design/bottom.php');
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/error.wav b/inwentaryzacja/error.wav
deleted file mode 100644
index b2dc9e9..0000000
--- a/inwentaryzacja/error.wav
+++ /dev/null
Binary files differ
diff --git a/inwentaryzacja/finish.php b/inwentaryzacja/finish.php
deleted file mode 100644
index 368dbfb..0000000
--- a/inwentaryzacja/finish.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-$title = 'Inwentaryzacja - zakończenie';
-include('design/top.php');
-
-require('../includes/config.php');
-require('../includes/PDOO.php');
-$PDO = PDOO::Singleton();
-?>
-
-<h3>Krok 3 z 3: Zakończenie inwentaryzacji</h3> 
-
-<p>Wykonano następujące operacje:</p>
-
-<ul>
-<?php
-$st = $PDO->prepare('UPDATE ksiazki SET regal=?, polka=?, rzad=? WHERE id=?');
-foreach($_POST['move'] as $id => $placed) {
-	if(strlen($placed) == 0) continue;
-	
-	$place = explode('/', $placed, 3);
-	if($place[2] == '') {
-		$place[2] = NULL;
-		if($place[1] == '') {
-			$place[1] = NULL;
-			if($place[0] == '') {
-				$place[0] = NULL;
-			}
-		}
-	}
-	
-	echo '<li>Zmiana miejsca '.$id.' na '.htmlspecialchars($placed).'</li>'."\n";
-	$st->execute(array($place[0], $place[1], $place[2], $id));
-}
-
-$date = date('d.m.Y H:i');
-$st = $PDO->prepare('UPDATE ksiazki SET wycofana=\'1\', powod=\'Inwentaryzacja '.$date.'\' WHERE id=?');
-
-foreach($_POST['repulse'] as $id => $placed) {
-	if(!$placed) continue;
-	
-	echo '<li>Wycofanie '.$id.'</li>'."\n";
-	$st->execute(array($id));
-}
-
-echo '<li>Usunięcie listy książek do inwentaryzacji</li>'."\n";
-unlink('list.xml');
-?>
-</ul>
-
-<?php
-include('design/bottom.php');
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/index.php b/inwentaryzacja/index.php
deleted file mode 100644
index b576f00..0000000
--- a/inwentaryzacja/index.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-if(is_file('list.xml')) {
-	header('Location: locate.htm');
-}
-else
-{
-	header('Location: begin.php');
-}
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/locate.css b/inwentaryzacja/locate.css
deleted file mode 100644
index 525c876..0000000
--- a/inwentaryzacja/locate.css
+++ /dev/null
@@ -1,65 +0,0 @@
-body {	font: 12px Verdana;}
-.error {color: red; font-weight: bold;}
-
-table, table caption {
-	border: 1px solid black;
-	border-radius: 12px;
-	padding: 8px 10px;}
-table caption {
-	margin: 0 15px;
-	font-size: 250%;
-	font-weight: bold;}
-table.current, table.current caption {
-	border: 10px solid orange;
-	border-radius: 12px;}
-table.current caption {
-	margin-left: 30px;}
-
-table caption, table.current caption {
-	border-bottom: none;
-	-webkit-border-bottom-left-radius: 0;
-	-moz-border-radius-bottomleft: 0;
-	border-bottom-left-radius: 0;
-	-webkit-border-bottom-right-radius: 0;
-	-moz-border-radius-bottomright: 0;
-	border-bottom-right-radius: 0;}
-
-table {		margin: 15px 10px; border-spacing: 0 3px;}
-tr td {		border-width: 7px; border-style: solid;}
-tr td:first-child {
-	-webkit-border-top-left-radius: 8px;
-	-webkit-border-bottom-left-radius: 8px;
-	-moz-border-radius-topleft: 8px;
-	-moz-border-radius-bottomleft: 8px;
-	border-top-left-radius: 8px;
-	border-bottom-left-radius: 8px;
-}
-tr td:last-child {
-	-webkit-border-top-right-radius: 8px;
-	-webkit-border-bottom-right-radius: 8px;
-	-moz-border-radius-topright: 8px;
-	-moz-border-radius-bottomright: 8px;
-	border-top-right-radius: 8px;
-	border-bottom-right-radius: 8px;
-}
-tr.ok td {	background: #afa; border-color: #afa;}
-tr.err td {	background: #faa; border-color: #faa;}
-tr td {		background: #ffa; border-color: #ffa;}
-
-#data {		margin-bottom: 120px;}
-#status {
-	background: #ddd;
-	position: fixed;
-	bottom: 0;
-	left: 0;
-	right: 0;
-	height: 100px;
-	padding: 5px 10px;
-}
-#status p {
-	margin: 5px;
-	padding: 0;
-}
-#result1 {	opacity: .4;}
-#result2 {	opacity: .6;}
-#input {	display: none;} 
diff --git a/inwentaryzacja/locate.htm b/inwentaryzacja/locate.htm
deleted file mode 100644
index d8b404c..0000000
--- a/inwentaryzacja/locate.htm
+++ /dev/null
@@ -1,28 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-<title>Inwentaryzacja</title>
-<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
-<link rel="stylesheet" type="text/css" href="locate.css" />
-<script type="text/javascript" src="locate.js"></script>
-</head>
-<body>
-
-<noscript>
-Narzędzie wymaga JavaScriptu.
-</noscript>
-
-<div id="data">
-</div>
-
-<div id="status">
-<p id="result1">(puste)</p>
-<p id="result2">(puste)</p>
-<p id="result3">Proszę czekać...</p>
-<p id="input"><input type="text" name="i1" id="i1" size="8" tabindex="1" /> <input type="text" name="i2" id="i2" size="3" tabindex="2" /> <input type="text" name="i3" id="i3" size="3" tabindex="3" /> <input type="button" id="i4" value="Wprowadź" tabindex="4" /> <input type="button" id="i5" value="Zapisz dane na serwerze" tabindex="5" /> <input type="button" id="i6" value="Zakończ inwentaryzację" tabindex="6" /> </p>
-</div>
-
-<audio src="error.wav" id="audio"></audio>
-
-</body>
-</html>
\ No newline at end of file
diff --git a/inwentaryzacja/locate.js b/inwentaryzacja/locate.js
deleted file mode 100644
index 64a84de..0000000
--- a/inwentaryzacja/locate.js
+++ /dev/null
@@ -1,359 +0,0 @@
-var xml;
-var ajax;
-var loc = false;
-
-function ajax() {
-	try {
-		ajax = new XMLHttpRequest(); 
-		return;
-	}
-	catch(e) {
-		var activex = ['Msxml2.XMLHTTP.6.0', 'Msxml2.XMLHTTP.3.0', 'Msxml2.XMLHTTP', 'Microsoft.XMLHTTP'];
-		for(var i=0; i<activex.length; i++) {
-			try {
-				ajax = new ActiveXObject(activex[i]);
-				return;
-			}
-			catch(e) {
-			}
-		}
-	}
-	
-	fatalError('Przeglądarka nie obsługuje XMLHttpRequest');
-}
-
-function status(msg) {
-	document.getElementById('result1').innerHTML = document.getElementById('result2').innerHTML
-	document.getElementById('result2').innerHTML = document.getElementById('result3').innerHTML;
-	document.getElementById('result3').innerHTML = msg;
-}
-
-function error(msg) {
-	status('<span class="error">'+msg+'</span>');
-	document.getElementById('audio').play();
-}
-
-function fatalError(msg) {
-	status('<span class="error">'+msg+'</span>');
-	throw new Exception('Błąd krytyczny: '+msg);
-}
-
-function changeLocation(element) {
-	while(element && element.parentNode!=document && element.tagName != 'TABLE') {
-		element = element.parentNode
-	}
-	
-	if(!element || element.tagName != 'TABLE') {
-		error('Podana lokalizacja nie istnieje lub jest nieznana.');
-		return;
-	}
-	
-	if(loc) {
-		loc.className = '';
-	}
-	
-	loc = element;
-	loc.className = 'current';
-	loc.scrollIntoView();
-}
-
-function makeCaption(regal, polka, rzad) {
-	var caption = document.createElement('caption');
-	caption.appendChild(document.createTextNode('Półka: '+regal+'/'+polka+'/'+rzad));
-	caption.onclick = 'changeLocation(this)';
-	return caption;
-}
-
-function textValue(element) {
-	var text = '';
-	
-	for(var i=0; i<element.childNodes.length; i++) {
-		if(element.childNodes.item(i) instanceof String) {
-			text += element.childNodes.item(i);
-		}
-		else if(element.childNodes.item(i) instanceof Text) {
-			text += element.childNodes.item(i).nodeValue;
-		}
-		else
-		{
-			text += textValue(element.childNodes.item(i));
-		}
-	}
-	
-	return text;
-}
-
-function makeHeader(caption) {
-	var tr = document.createElement('tr');
-	var th = document.createElement('th');
-	th.appendChild(document.createTextNode('ID'));
-	th.appendChild(document.createElement('br'));
-	th.appendChild(document.createTextNode('status'));
-	tr.appendChild(th);
-	var th = document.createElement('th');
-	th.appendChild(document.createTextNode('Autor'));
-	th.appendChild(document.createElement('br'));
-	th.appendChild(document.createTextNode('Tytuł'));
-	tr.appendChild(th);
-	var th = document.createElement('th');
-	th.appendChild(document.createTextNode('Miejsce, rok'));
-	th.appendChild(document.createElement('br'));
-	th.appendChild(document.createTextNode('Wydawnictwo'));
-	tr.appendChild(th);
-	
-	return tr;
-}
-
-function processBook(book) {
-	var tr = document.createElement('tr');
-	tr.id = book.attributes['id'].nodeValue;
-	tr.onclick = 'changeBook(this)';
-	
-	var th = document.createElement('td');
-	th.appendChild(document.createTextNode(book.attributes['id'].nodeValue.substr(1)));
-	th.appendChild(document.createElement('br'));
-	if(!book.hasAttribute('status')) {
-		th.appendChild(document.createTextNode('Nieznany'));
-	}
-	else if(book.getAttribute('status') == 'ok') {
-		tr.className = 'ok';
-		th.appendChild(document.createTextNode('Na miejscu'));
-	}
-	else
-	{
-		tr.className = 'ok';
-		th.appendChild(document.createTextNode('Przeniesiona'));
-	}
-	tr.appendChild(th);
-	var th = document.createElement('td');
-	th.appendChild(document.createTextNode(textValue(book.getElementsByTagName('autor').item(0))));
-	th.appendChild(document.createElement('br'));
-	th.appendChild(document.createTextNode(textValue(book.getElementsByTagName('tytul').item(0))));
-	tr.appendChild(th);
-	var th = document.createElement('td');
-	th.appendChild(document.createTextNode(textValue(book.getElementsByTagName('miejsce').item(0))+' '+textValue(book.getElementsByTagName('rok').item(0))));
-	th.appendChild(document.createElement('br'));
-	th.appendChild(document.createTextNode(textValue(book.getElementsByTagName('wydawnictwo').item(0))));
-	tr.appendChild(th);
-	
-	return tr;
-}
-
-function changeBook(book) {
-	if(document.getElementById('input').style.display != 'block') {
-		return false;
-	}
-	
-	if(book.parentNode == loc) {
-		xml.getElementById(book.id).setAttribute('status', 'ok');
-		
-		book.className = 'ok';
-		book.childNodes[0].childNodes[2].data = 'Na miejscu';
-		book.scrollIntoView();
-	}
-	else
-	{
-		xml.getElementById(loc.id).appendChild(xml.getElementById(book.id));
-		xml.getElementById(book.id).setAttribute('status', 'moved');
-		
-		var book2 = book.cloneNode(true);
-		book2.className = 'ok';
-		book2.childNodes[0].childNodes[2].data = 'Przeniesiona';
-		
-		if(document.getElementById('e'+book.id)) {
-			document.getElementById('e'+book.id).parentNode.removeChild(document.getElementById('e'+book.id));
-		}
-		
-		book.className = 'err';
-		book.id = 'e'+book.id;
-		book.childNodes[0].childNodes[2].data = 'Przeniesiona';
-		
-		loc.appendChild(book2);
-		book2.scrollIntoView();
-	}
-}
-
-function clearInput() {
-	document.getElementById('i1').value = document.getElementById('i2').value = document.getElementById('i3').value = '';
-	document.getElementById('i1').focus();
-}
-
-function processInput() {
-	if(document.getElementById('input').style.display != 'block') {
-		return false;
-	}
-	
-	var reg = /^([0-9]{1,8})$/;
-	var i1 = document.getElementById('i1').value;
-	var i2 = document.getElementById('i2').value;
-	var i3 = document.getElementById('i3').value;
-	
-	if(i2 != '' || i3 != '') {
-		if(!document.getElementById('m_'+i1+'_'+i2+'_'+i3)) {
-			error('Podane regał/półka/rząd nie istnieją!');
-			clearInput();
-			return false;
-		}
-		
-		changeLocation(document.getElementById('m_'+i1+'_'+i2+'_'+i3));
-		status('Wybrano '+i1+'/'+i2+'/'+i3);
-		clearInput();
-		return true;
-	}
-	
-	if(reg.test(i1)) {
-		i1 = parseInt(i1, 10);
-		if(!document.getElementById('k'+i1)) {
-			error('Wybrana książka nie istnieje!');
-			clearInput();
-			return false;
-		}
-		
-		changeBook(document.getElementById('k'+i1));
-		status('OK - książka '+i1);
-		clearInput();
-		return true;
-	}
-	
-	error('Nieznany typ (książka - 8 cyfr; regał - tekst; półka/rząd - liczby)!');
-	clearInput();
-	return false;
-}
-
-function keyEvent(e) {
-	if(!e) e = window.event;
-	
-	if(e.keyCode == 13) {
-		processInput();
-		clearInput();
-		return false;
-	}
-}
-
-function saveWithCallback(callback) {
-	ajax.open('POST', 'save.php', true);
-	ajax.onreadystatechange = callback;
-	ajax.send(xml);
-}
-
-function save() {
-	document.getElementById('input').style.display = 'none';
-	status('Zapisywanie. Proszę czekać...');
-	
-	saveWithCallback(function() {
-		if(ajax.readyState == 3) {
-			status('Wysyłanie danych...');
-		}
-		else if(ajax.readyState == 4) {
-			if(ajax.status == 200) {
-				status('Dane zostały zapisane. '+ajax.status);
-			}
-			else
-			{
-				error('Zapis nie powiódł się. Błąd HTTP '+ajax.status);
-			}
-			
-			document.getElementById('input').style.display = 'block';
-		}
-	});
-}
-
-function end() {
-	document.getElementById('input').style.display = 'none';
-	status('Zapisywanie. Proszę czekać...');
-	
-	saveWithCallback(function() {
-		if(ajax.readyState == 3) {
-			status('Wysyłanie danych...');
-		}
-		else if(ajax.readyState == 4) {
-			if(ajax.status == 200) {
-				status('Dane zostały zapisane. '+ajax.status);
-				status('Przekierowywanie... Proszę czekać...');
-				window.location.replace('end.php');
-			}
-			else
-			{
-				error('Zapis nie powiódł się. Błąd HTTP '+ajax.status);
-				document.getElementById('input').style.display = 'block';
-			}
-		}
-	});
-}
-
-function process() {
-	var number = 0;
-	var header = makeHeader();
-	
-	xml = ajax.responseXML;
-	if(!(xml instanceof XMLDocument)) {
-		fatalError('Pobrany dokument nie jest poprawnym arkuszem XML');
-	}
-	
-	var total = xml.getElementsByTagName('ksiazka').length;
-	
-	if(!xml.getElementById) {
-		fatalError('Przeglądarka nie wspiera XMLDocument.getElementById');
-	}
-	
-	status('Książek do przetworzenia: '+total);
-	var miejsca = xml.getElementsByTagName('lokalizacja');
-	for(var i=0; i<miejsca.length; i++) {
-		var table = document.createElement('table');
-		if(loc == false) {
-			loc = table;
-		}
-		table.id = 'm_'+miejsca[i].attributes['regal'].nodeValue+'_'+miejsca[i].attributes['polka'].nodeValue+'_'+miejsca[i].attributes['rzad'].nodeValue;
-		table.appendChild(makeCaption(miejsca[i].attributes['regal'].nodeValue, miejsca[i].attributes['polka'].nodeValue, miejsca[i].attributes['rzad'].nodeValue));
-		table.appendChild(header.cloneNode(true));
-		
-		for(var j=0; j<miejsca[i].childNodes.length; j++) {
-			if(!miejsca[i].childNodes[j].tagName) continue;
-			table.appendChild(processBook(miejsca[i].childNodes[j]));
-			
-			if((++number % 100) == 0) {
-				status('Przetworzono: '+Math.floor(number*100/total)+'% ('+number+' z '+total+')');
-			}
-		}
-		
-		document.getElementById('data').appendChild(table);
-	}
-	
-	status('Przetworzono: 100% ('+total+' z '+total+')');
-	
-	changeLocation(loc);
-	
-	document.getElementById('input').style.display = 'block';
-	
-	document.getElementById('i1').onkeydown = document.getElementById('i2').onkeydown = document.getElementById('i3').onkeydown = keyEvent;
-	document.getElementById('i4').onclick = processInput;
-	document.getElementById('i5').onclick = save;
-	document.getElementById('i6').onclick = end;
-	document.getElementById('i1').focus();
-	
-	status('Gotowy do pracy.');
-}
-
-function getData() {
-	status('Inicjowanie transferu...');
-	ajax();
-	ajax.open('GET', 'list.xml?time='+((new Date()).getTime())+'&rand='+Math.random(), true);
-	ajax.onreadystatechange = function() {
-		if(ajax.readyState == 3) {
-			status('Pobieranie danych...');
-		}
-		else if(ajax.readyState == 4) {
-			if(ajax.status == 200 || ajax.status == 304) {
-				status('Przetwarzanie danych...');
-				process();
-			}
-			else
-			{
-				fatalError('Kod HTTP '+ajax.status+'. Nie udało się pobrać danych. Spróbuj przeładować stronę.');
-			}
-		}
-	};
-	ajax.send();
-}
-
-window.onload = getData; 
diff --git a/inwentaryzacja/make.php b/inwentaryzacja/make.php
deleted file mode 100644
index 3d54497..0000000
--- a/inwentaryzacja/make.php
+++ /dev/null
@@ -1,87 +0,0 @@
-<?php
-$title = 'Inwentaryzacja - rozpoczęcie';
-include('design/top.php');
-
-require('../includes/config.php');
-require('../includes/PDOO.php');
-
-try {
-	$fields = array('tytul', 'autor', 'miejsce', 'rok', 'wydawnictwo');
-
-	$PDO = PDOO::Singleton();
-
-	$st = $PDO->query('SELECT * FROM `ksiazki` WHERE `wycofana`=\'0\' ORDER BY `regal` ASC, `polka` ASC, `rzad` ASC');
-
-	$fp = fopen('list.xml', 'w');
-	if($fp === FALSE) {
-		throw new Exception('Otwarcie pliku inwentaryzacja/list.xml nie powiodło się.');
-	}
-
-	fwrite($fp, '<?xml version="1.0" encoding="utf-8" ?>
-	<!DOCTYPE inwentaryzacja [
-	<!ENTITY % quot "&#34;">
-	<!ENTITY % amp "&#38;">
-	<!ENTITY % lt "&#60;">
-	<!ENTITY % gt "&#62;">
-	<!ELEMENT inwentaryzacja (lokalizacja)*>
-	<!ELEMENT lokalizacja (ksiazka)*>
-	<!ATTLIST lokalizacja
-		id	ID	#IMPLIED
-		regal	CDATA	#IMPLIED
-		polka	CDATA	#IMPLIED
-		rzad	CDATA	#IMPLIED>
-	<!ELEMENT ksiazka (tytul | autor | miejsce | rok | wydawnictwo)*>
-	<!ATTLIST ksiazka
-		id	ID	#IMPLIED
-		status	CDATA	#IMPLIED>
-	<!ELEMENT tytul (#PCDATA)>
-	<!ELEMENT autor (#PCDATA)>
-	<!ELEMENT miejsce (#PCDATA)>
-	<!ELEMENT rok (#PCDATA)>
-	<!ELEMENT wydawnictwo (#PCDATA)>
-	]>
-	<inwentaryzacja>
-	');
-
-	$lastplace = NULL;
-
-	while($entry = $st->fetch()) {
-		if($lastplace != $entry['regal'].'/'.$entry['polka'].'/'.$entry['rzad']) {
-			if($lastplace !== NULL) {
-				fwrite($fp, '</lokalizacja>'."\n");
-			}
-			fwrite($fp, '<lokalizacja id="m_'.$entry['regal'].'_'.$entry['polka'].'_'.$entry['rzad'].'" regal="'.$entry['regal'].'" polka="'.$entry['polka'].'" rzad="'.$entry['rzad'].'">'."\n");
-			$lastplace = $entry['regal'].'/'.$entry['polka'].'/'.$entry['rzad'];
-		}
-		
-		fwrite($fp, '<ksiazka id="k'.$entry['id'].'">'."\n");
-		foreach($fields as $key) {
-			fwrite($fp, '<'.$key.'>'.htmlspecialchars($entry[$key]).'</'.$key.'>'."\n");
-		}
-		fwrite($fp, '</ksiazka>'."\n");
-	}
-
-	if($lastplace !== NULL) {
-		fwrite($fp, '</lokalizacja>'."\n");
-	}
-
-	fwrite($fp, '</inwentaryzacja>'."\n");
-
-?>
-
-<h3>Krok 1 z 3: Przygotowanie listy książek</h3>
-
-<p>Jeżeli nie widzisz błędów powyżej to krok pierwszy został pomyślnie zakończony.</p>
-
-<form action="locate.htm">
-<p>Możesz: <input type="submit" value="Rozpocząć inwentaryzację" /></p>
-</form>
-
-<?php
-}
-catch(Exception $e) {
-	echo '<p style="color:red">Wystąpił błąd: '.$e->getMessage().'</p>';
-}
-
-include('design/bottom.php');
-?>
\ No newline at end of file
diff --git a/inwentaryzacja/save.php b/inwentaryzacja/save.php
deleted file mode 100644
index 020b9f2..0000000
--- a/inwentaryzacja/save.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-function errorHandler($errno, $errstr, $errfile, $errline) {
-	header('HTTP/1.1 500 Internal Server Error');
-	echo $errstr;
-	die();
-}
-
-set_error_handler('errorHandler');
-
-file_put_contents('list.xml', strtr(
-	file_get_contents('php://input'),
-	array( '<!DOCTYPE inwentaryzacja>' => '<!DOCTYPE inwentaryzacja [
-<!ENTITY % quot "&#34;">
-<!ENTITY % amp "&#38;">
-<!ENTITY % lt "&#60;">
-<!ENTITY % gt "&#62;">
-<!ELEMENT inwentaryzacja (lokalizacja)*>
-<!ELEMENT lokalizacja (ksiazka)*>
-<!ATTLIST lokalizacja
-	id	ID	#IMPLIED
-	regal	CDATA	#IMPLIED
-	polka	CDATA	#IMPLIED
-	rzad	CDATA	#IMPLIED>
-<!ELEMENT ksiazka (tytul | autor | miejsce | rok | wydawnictwo)*>
-<!ATTLIST ksiazka
-	id	ID	#IMPLIED
-	status	CDATA	#IMPLIED>
-<!ELEMENT tytul (#PCDATA)>
-<!ELEMENT autor (#PCDATA)>
-<!ELEMENT miejsce (#PCDATA)>
-<!ELEMENT rok (#PCDATA)>
-<!ELEMENT wydawnictwo (#PCDATA)>
-]>')
-));
-?>
\ No newline at end of file
diff --git a/list_all.php b/list_all.php
deleted file mode 100644
index d8ce07d..0000000
--- a/list_all.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Wszystkie książki';
-include('./design/top.php');
-
-gotowe::lista();
-
-include('./design/bottom.php');
-?>
diff --git a/list_borrowed.php b/list_borrowed.php
deleted file mode 100644
index 5689ae2..0000000
--- a/list_borrowed.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Książki wypożyczone';
-include('./design/top.php');
-
-$_GET['do'] = TRUE;
-gotowe::lista();
-
-include('./design/bottom.php');
-?>
diff --git a/list_repulsed.php b/list_repulsed.php
deleted file mode 100644
index 4d05b44..0000000
--- a/list_repulsed.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Książki wycofane';
-include('./design/top.php');
-
-$_GET['wycofana'] = 1;
-gotowe::lista();
-
-include('./design/bottom.php');
-?>
diff --git a/migrations/Version20261106120000.php b/migrations/Version20261106120000.php
new file mode 100644
index 0000000..06555d0
--- /dev/null
+++ b/migrations/Version20261106120000.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Migrations\AbstractMigration;
+
+/**
+ * Baseline schema. It uses the Schema API rather than SQL so that the same migration runs on
+ * SQLite, MySQL, MariaDB and PostgreSQL.
+ */
+final class Version20261106120000 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return 'Create the book, loan and label tables';
+    }
+
+    public function up(Schema $schema): void
+    {
+        $book = $schema->createTable('book');
+        $this->addBookColumns($book);
+        $book->setPrimaryKey(['id']);
+
+        $oustedBook = $schema->createTable('ousted_book');
+        $this->addBookColumns($oustedBook);
+        $oustedBook->addColumn('reason', Types::STRING, ['length' => 255]);
+        $oustedBook->setPrimaryKey(['id']);
+
+        $borrow = $schema->createTable('borrow');
+        $borrow->addColumn('book_id', Types::INTEGER);
+        $borrow->addColumn('who', Types::STRING, ['length' => 255]);
+        $borrow->addColumn('at', Types::DATETIME_IMMUTABLE);
+        $borrow->setPrimaryKey(['book_id']);
+        $borrow->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_55DBA8B016A2B381');
+
+        $history = $schema->createTable('borrow_history');
+        $this->addHistoryColumns($history);
+        $history->addUniqueIndex(['book_id', 'at'], 'borrow_history_book_at');
+        $history->addIndex(['book_id'], 'IDX_794A76CF16A2B381');
+        $history->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_794A76CF16A2B381');
+
+        $oustedHistory = $schema->createTable('ousted_borrow_history');
+        $this->addHistoryColumns($oustedHistory);
+        $oustedHistory->addUniqueIndex(['book_id', 'at'], 'ousted_borrow_history_book_at');
+        $oustedHistory->addIndex(['book_id'], 'IDX_8BF39F5F16A2B381');
+        $oustedHistory->addForeignKeyConstraint('ousted_book', ['book_id'], ['id'], ['deferrable' => false], 'FK_8BF39F5F16A2B381');
+
+        $label = $schema->createTable('label');
+        $this->addLabelColumns($label);
+        $label->addUniqueIndex(['book_id', 'type', 'label'], 'label_book_type_label');
+        $label->addIndex(['label'], 'label_label');
+        $label->addIndex(['book_id'], 'IDX_EA750E816A2B381');
+        $label->addForeignKeyConstraint('book', ['book_id'], ['id'], ['deferrable' => false], 'FK_EA750E816A2B381');
+
+        $oustedLabel = $schema->createTable('ousted_label');
+        $this->addLabelColumns($oustedLabel);
+        $oustedLabel->addUniqueIndex(['book_id', 'type', 'label'], 'ousted_label_book_type_label');
+        $oustedLabel->addIndex(['book_id'], 'IDX_CCF8BC1B16A2B381');
+        $oustedLabel->addForeignKeyConstraint('ousted_book', ['book_id'], ['id'], ['deferrable' => false], 'FK_CCF8BC1B16A2B381');
+    }
+
+    public function down(Schema $schema): void
+    {
+        $schema->dropTable('ousted_label');
+        $schema->dropTable('label');
+        $schema->dropTable('ousted_borrow_history');
+        $schema->dropTable('borrow_history');
+        $schema->dropTable('borrow');
+        $schema->dropTable('ousted_book');
+        $schema->dropTable('book');
+    }
+
+    private function addBookColumns(Table $table): void
+    {
+        $table->addColumn('id', Types::INTEGER);
+        $table->addColumn('title', Types::STRING, ['length' => 255]);
+        $table->addColumn('author', Types::STRING, ['length' => 255]);
+        $table->addColumn('language', Types::STRING, ['length' => 255, 'notnull' => false]);
+        $table->addColumn('edition', Types::STRING, ['length' => 255, 'notnull' => false]);
+        $table->addColumn('publisher', Types::STRING, ['length' => 255, 'notnull' => false]);
+        $table->addColumn('publication_loc', Types::STRING, ['length' => 255, 'notnull' => false]);
+        $table->addColumn('publication_year', Types::INTEGER, ['notnull' => false]);
+        $table->addColumn('placement', Types::STRING, ['length' => 255, 'notnull' => false]);
+        $table->addColumn('cover', Types::STRING, ['length' => 64, 'notnull' => false]);
+    }
+
+    private function addLabelColumns(Table $table): void
+    {
+        $table->addColumn('id', Types::INTEGER, ['autoincrement' => true]);
+        $table->addColumn('book_id', Types::INTEGER);
+        $table->addColumn('type', Types::STRING, ['length' => 16]);
+        $table->addColumn('label', Types::STRING, ['length' => 255]);
+        $table->setPrimaryKey(['id']);
+    }
+
+    private function addHistoryColumns(Table $table): void
+    {
+        $table->addColumn('id', Types::INTEGER, ['autoincrement' => true]);
+        $table->addColumn('book_id', Types::INTEGER);
+        $table->addColumn('who', Types::STRING, ['length' => 255]);
+        $table->addColumn('at', Types::DATETIME_IMMUTABLE);
+        $table->addColumn('till', Types::DATETIME_IMMUTABLE);
+        $table->setPrimaryKey(['id']);
+    }
+}
diff --git a/migrations/Version20261113120000.php b/migrations/Version20261113120000.php
new file mode 100644
index 0000000..b38ac40
--- /dev/null
+++ b/migrations/Version20261113120000.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Migrations\AbstractMigration;
+
+final class Version20261113120000 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return 'Add the date a book was last seen during an inventory';
+    }
+
+    public function up(Schema $schema): void
+    {
+        foreach (['book', 'ousted_book'] as $table) {
+            $schema->getTable($table)->addColumn('last_seen', Types::DATETIME_IMMUTABLE, ['notnull' => false]);
+        }
+    }
+
+    public function down(Schema $schema): void
+    {
+        foreach (['book', 'ousted_book'] as $table) {
+            $schema->getTable($table)->dropColumn('last_seen');
+        }
+    }
+}
diff --git a/phpunit.dist.xml b/phpunit.dist.xml
new file mode 100644
index 0000000..e244000
--- /dev/null
+++ b/phpunit.dist.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+         colors="true"
+         failOnDeprecation="true"
+         failOnNotice="true"
+         failOnWarning="true"
+         bootstrap="tests/bootstrap.php"
+         cacheDirectory=".phpunit.cache"
+>
+    <php>
+        <ini name="display_errors" value="1" />
+        <ini name="error_reporting" value="-1" />
+        <server name="APP_ENV" value="test" force="true" />
+        <server name="SHELL_VERBOSITY" value="-1" />
+    </php>
+
+    <testsuites>
+        <testsuite name="Project Test Suite">
+            <directory>tests</directory>
+        </testsuite>
+    </testsuites>
+
+    <source ignoreSuppressionOfDeprecations="true"
+            ignoreIndirectDeprecations="true"
+            restrictNotices="true"
+            restrictWarnings="true"
+    >
+        <include>
+            <directory>src</directory>
+        </include>
+
+        <deprecationTrigger>
+            <method>Doctrine\Deprecations\Deprecation::trigger</method>
+            <method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
+            <function>trigger_deprecation</function>
+        </deprecationTrigger>
+    </source>
+
+    <extensions>
+        <bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension">
+            <parameter name="clock-mock-namespaces" value="App" />
+            <parameter name="dns-mock-namespaces" value="App" />
+        </bootstrap>
+    </extensions>
+</phpunit>
diff --git a/place.php b/place.php
deleted file mode 100644
index 2fdb68a..0000000
--- a/place.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<?php
-$title = 'Położenie książki';
-include('design/top.php');
-?>
-
-<table class="main">
-<tr>
-<td>
-
-<h3>Ustawianie</h3>
-
-<form action="place_set.php" method="post" onsubmit="return ffalse('step1')">
-<p>Miejsce (regał/półka/rząd): <br />
-<input type="text" name="regal" id="step11" size="5" maxlength="5" required="required" />
-/ <input type="text" name="polka" id="step12" class="focus" size="3" maxlength="3" />
-/ <input type="text" name="rzad" id="step13" class="focus" size="3" maxlength="3" /></p>
-<p>KODY książek: <br /> <textarea name="kody" id="step14" cols="18" rows="8" required="required"></textarea></p>
-<p><input type="submit" value="Zastosuj" /></p>
-</form>
-
-<script type="text/javascript">
-document.getElementById('step11').focus();
-</script>
-
-</td>
-<td>
-
-<h3>Zmiana</h3>
-
-<form action="place_change.php" method="post" onsubmit="return ffalse('step2')">
-<p>Miejsce (regał/półka/rząd): <br />
-<input type="text" name="regal" id="step21" size="5" maxlength="5" required="required" />
-/ <input type="text" name="polka" id="step22" class="focus" size="3" maxlength="3" />
-/ <input type="text" name="rzad" id="step23" class="focus" size="3" maxlength="3" /></p>
-<p>Nowe miejsce (zostaw puste, aby usunąć): <br />
-<input type="text" name="regal2" id="step24" class="focus" size="5" maxlength="5"  />
-/ <input type="text" name="polka2" id="step25" class="focus" size="3" maxlength="3" />
-/ <input type="text" name="rzad2" id="step26" class="focus" size="3" maxlength="3" /></p>
-<p><input type="submit" value="Zastosuj" /></p>
-</form>
-
-</td>
-</tr>
-
-<tr><td colspan="2"><p><i>Można pomijać argumenty półka i rząd, jeśli jest taka potrzeba.</i></p></td></tr>
-</table>
-
-<?php
-include('design/bottom.php');
-?>
diff --git a/place_change.php b/place_change.php
deleted file mode 100644
index 0d54718..0000000
--- a/place_change.php
+++ /dev/null
@@ -1,29 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::MSC($_POST['regal'], $_POST['polka'], $_POST['rzad']);
-if(!empty($_POST['regal2']) || !empty($_POST['polka2']) || !empty($_POST['rzad2'])) {
-	validate::MSC($_POST['regal2'], $_POST['polka2'], $_POST['rzad2']);
-}
-
-$arr = array();
-
-if(!empty($_POST['regal'])) {
-	$arr['regal'] = $_POST['regal'];
-}
-if(!empty($_POST['polka'])) {
-	$arr['polka'] = $_POST['polka'];
-}
-if(!empty($_POST['rzad'])) {
-	$arr['rzad'] = $_POST['rzad'];
-}
-
-$aff = ksiazki::miejsce($_POST['regal2'], $_POST['polka2'], $_POST['rzad2'], $arr);
-
-$title = 'Położenie książki';
-include('design/top.php');
-
-echo '<p>Ustalono położenie '.$aff.' książek.</p>';
-
-include('design/bottom.php');
-?>
diff --git a/place_set.php b/place_set.php
deleted file mode 100644
index edebd04..0000000
--- a/place_set.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-include('./includes/std.php');
-
-validate::MSC($_POST['regal'], $_POST['polka'], $_POST['rzad']);
-
-$kody = explode("\n", $_POST['kody']);
-$and = array('OR' => NULL);
-foreach($kody as $kod) {
-	$kod = trim($kod);
-	if(($pos=strpos($kod, '-'))!==FALSE) {
-		$from = trim(substr($kod, 0, $pos));
-		$to = trim(substr($kod, $pos+1));
-		
-		validate::KOD($from);
-		validate::KOD($to);
-		
-		for($from=(int)$from; $from<=$to; $from++) {
-			$and['id'][] = $from;
-		}
-	}
-	elseif($kod != '') {
-		validate::KOD($kod);
-		$and['id'][] = (int)$kod;
-	}
-}
-
-ksiazki::miejsce($_POST['regal'], $_POST['polka'], $_POST['rzad'], $and);
-
-$title = 'Położenie książki';
-include('design/top.php');
-
-echo '<p>Ustalono położenie następujących książek:</p>
-
-<ul>
-';
-
-foreach($and['id'] as $kod) {
-	echo '<li>'.$kod.'</li>'."\n";
-}
-
-echo '</ul>';
-
-include('design/bottom.php');
-?>
diff --git a/public/css/custom.css b/public/css/custom.css
new file mode 100644
index 0000000..bf50d0c
--- /dev/null
+++ b/public/css/custom.css
@@ -0,0 +1,82 @@
+body {
+	padding: 5px;
+}
+header.navbar {
+	padding-left: 5px;
+}
+.tile-icon {
+	width: 75px;
+	min-height: 75px;
+}
+a.tile-icon {
+	display: block;
+	color: inherit;
+}
+.tile-icon .icon {
+	font-size: 70px;
+}
+.results > .tile:nth-child(even) {
+	background-color: #eee;
+}
+.results .tile {
+	align-items: center;
+}
+.results .tile-title .label {
+	margin-left: .5rem;
+}
+.page_content {
+	padding: .6rem;
+}
+.page_content + .results {
+	margin-top: -.6rem;
+}
+.flash {
+	margin: .6rem 0;
+}
+.book-labels {
+	list-style: none;
+	margin-left: 0;
+}
+form.inline {
+	display: inline;
+}
+.tile-cover {
+	display: block;
+	max-width: 75px;
+	max-height: 113px;
+}
+.book-cover {
+	display: block;
+	max-width: 100%;
+	width: 300px;
+	height: auto;
+}
+.book-panel {
+	margin-bottom: .8rem;
+}
+.book-panel .panel-body:last-child {
+	padding-bottom: .8rem;
+}
+.book-panel .panel-body .table:last-child,
+.book-panel .panel-body p:last-child,
+.book-panel .panel-body .book-labels {
+	margin-bottom: 0;
+}
+.book-panel .panel-footer form:last-child,
+.book-panel .panel-footer .form-group:last-child {
+	margin-bottom: 0;
+}
+.results .tile-borrowed {
+	background-color: #ffd;
+}
+.results > .tile-borrowed:nth-child(even) {
+	background-color: #feb;
+}
+.tile-borrowed .tile-title a {
+	color: #a60;
+}
+.tile-borrowed .tile-subtitle .label-primary,
+.tile-borrowed .tile-subtitle .label-secondary {
+	background: #fb0;
+	color: #fff;
+}
diff --git a/public/css/inventory.css b/public/css/inventory.css
new file mode 100644
index 0000000..080500d
--- /dev/null
+++ b/public/css/inventory.css
@@ -0,0 +1,39 @@
+[hidden] {
+	display: none !important;
+}
+body {
+	padding: 5px;
+}
+#inventory {
+	max-width: 960px;
+	margin: 0 auto;
+	padding: .6rem;
+}
+.scan-form .form-input {
+	font-size: 1.2rem;
+}
+.current-location {
+	font-size: 1.4rem;
+	margin: .4rem 0;
+}
+.current-location strong {
+	margin-left: .4rem;
+}
+.message {
+	margin: .6rem 0;
+}
+.status {
+	margin: .6rem 0 1rem;
+}
+.status .column {
+	padding: .2rem .4rem;
+}
+.log td {
+	vertical-align: middle;
+}
+.log .log-date {
+	white-space: nowrap;
+}
+.log .label + .btn {
+	margin-left: .2rem;
+}
diff --git a/public/css/spectre b/public/css/spectre
new file mode 120000
index 0000000..80c4c8c
--- /dev/null
+++ b/public/css/spectre
@@ -0,0 +1 @@
+spectre-0.5.9
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre-exp.css b/public/css/spectre-0.5.9/spectre-exp.css
new file mode 100644
index 0000000..446b277
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre-exp.css
@@ -0,0 +1,1231 @@
+/*! Spectre.css Experimentals v0.5.9 | MIT License | github.com/picturepan2/spectre */
+.form-autocomplete {
+  position: relative;
+}
+
+.form-autocomplete .form-autocomplete-input {
+  align-content: flex-start;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-line-pack: start;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+  height: auto;
+  min-height: 1.6rem;
+  padding: .1rem;
+}
+
+.form-autocomplete .form-autocomplete-input.is-focused {
+  border-color: #5755d9; 
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.form-autocomplete .form-autocomplete-input .form-input {
+  border-color: transparent;
+  box-shadow: none;
+  display: inline-block;
+  -ms-flex: 1 0 auto;
+  flex: 1 0 auto;
+  height: 1.2rem;
+  line-height: .8rem;
+  margin: .1rem;
+  width: auto;
+}
+
+.form-autocomplete .menu {
+  left: 0;
+  position: absolute;
+  top: 100%;
+  width: 100%;
+}
+
+.form-autocomplete.autocomplete-oneline .form-autocomplete-input {
+  -ms-flex-wrap: nowrap;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+}
+
+.form-autocomplete.autocomplete-oneline .chip {
+  -ms-flex: 1 0 auto;
+  flex: 1 0 auto;
+}
+
+.calendar {
+  border: .05rem solid #dadee4;
+  border-radius: .1rem;
+  display: block;
+  min-width: 280px;
+}
+
+.calendar .calendar-nav {
+  align-items: center;
+  background: #f7f8f9;
+  border-top-left-radius: .1rem;
+  border-top-right-radius: .1rem;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  font-size: .9rem;
+  padding: .4rem;
+}
+
+.calendar .calendar-header,
+.calendar .calendar-body {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-pack: center;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+  justify-content: center;
+  padding: .4rem 0;
+}
+
+.calendar .calendar-header .calendar-date,
+.calendar .calendar-body .calendar-date {
+  -ms-flex: 0 0 14.28%;
+  flex: 0 0 14.28%;
+  max-width: 14.28%;
+}
+
+.calendar .calendar-header {
+  background: #f7f8f9;
+  border-bottom: .05rem solid #dadee4;
+  color: #bcc3ce;
+  font-size: .7rem;
+  text-align: center;
+}
+
+.calendar .calendar-body {
+  color: #66758c;
+}
+
+.calendar .calendar-date {
+  border: 0;
+  padding: .2rem;
+}
+
+.calendar .calendar-date .date-item {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: transparent;
+  border: .05rem solid transparent;
+  border-radius: 50%;
+  color: #66758c;
+  cursor: pointer;
+  font-size: .7rem;
+  height: 1.4rem;
+  line-height: 1rem;
+  outline: none;
+  padding: .1rem;
+  position: relative;
+  text-align: center;
+  text-decoration: none;
+  transition: background .2s, border .2s, box-shadow .2s, color .2s;
+  vertical-align: middle;
+  white-space: nowrap;
+  width: 1.4rem;
+}
+
+.calendar .calendar-date .date-item.date-today {
+  border-color: #e5e5f9;
+  color: #5755d9;
+}
+
+.calendar .calendar-date .date-item:focus {
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.calendar .calendar-date .date-item:focus,
+.calendar .calendar-date .date-item:hover {
+  background: #fefeff;
+  border-color: #e5e5f9;
+  color: #5755d9;
+  text-decoration: none;
+}
+
+.calendar .calendar-date .date-item:active,
+.calendar .calendar-date .date-item.active {
+  background: #4b48d6;
+  border-color: #3634d2;
+  color: #fff;
+}
+
+.calendar .calendar-date .date-item.badge::after {
+  position: absolute;
+  right: 3px;
+  top: 3px;
+  transform: translate(50%, -50%);
+}
+
+.calendar .calendar-date .date-item:disabled,
+.calendar .calendar-date .date-item.disabled,
+.calendar .calendar-date .calendar-event:disabled,
+.calendar .calendar-date .calendar-event.disabled {
+  cursor: default;
+  opacity: .25;
+  pointer-events: none;
+}
+
+.calendar .calendar-date.prev-month .date-item,
+.calendar .calendar-date.prev-month .calendar-event,
+.calendar .calendar-date.next-month .date-item,
+.calendar .calendar-date.next-month .calendar-event {
+  opacity: .25;
+}
+
+.calendar .calendar-range {
+  position: relative;
+}
+
+.calendar .calendar-range::before {
+  background: #f1f1fc;
+  content: "";
+  height: 1.4rem;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+.calendar .calendar-range.range-start::before {
+  left: 50%;
+}
+
+.calendar .calendar-range.range-end::before {
+  right: 50%;
+}
+
+.calendar .calendar-range.range-start .date-item,
+.calendar .calendar-range.range-end .date-item {
+  background: #4b48d6;
+  border-color: #3634d2;
+  color: #fff;
+}
+
+.calendar .calendar-range .date-item {
+  color: #5755d9;
+}
+
+.calendar.calendar-lg .calendar-body {
+  padding: 0;
+}
+
+.calendar.calendar-lg .calendar-body .calendar-date {
+  border-bottom: .05rem solid #dadee4;
+  border-right: .05rem solid #dadee4;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+  height: 5.5rem;
+  padding: 0;
+}
+
+.calendar.calendar-lg .calendar-body .calendar-date:nth-child(7n) {
+  border-right: 0;
+}
+
+.calendar.calendar-lg .calendar-body .calendar-date:nth-last-child(-n+7) {
+  border-bottom: 0;
+}
+
+.calendar.calendar-lg .date-item {
+  align-self: flex-end;
+  -ms-flex-item-align: end;
+  height: 1.4rem;
+  margin-right: .2rem;
+  margin-top: .2rem;
+}
+
+.calendar.calendar-lg .calendar-range::before {
+  top: 19px;
+}
+
+.calendar.calendar-lg .calendar-range.range-start::before {
+  left: auto;
+  width: 19px;
+}
+
+.calendar.calendar-lg .calendar-range.range-end::before {
+  right: 19px;
+}
+
+.calendar.calendar-lg .calendar-events {
+  flex-grow: 1;
+  -ms-flex-positive: 1;
+  line-height: 1;
+  overflow-y: auto;
+  padding: .2rem;
+}
+
+.calendar.calendar-lg .calendar-event {
+  border-radius: .1rem;
+  display: block;
+  font-size: .7rem;
+  margin: .1rem auto;
+  overflow: hidden;
+  padding: 3px 4px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.carousel .carousel-locator:nth-of-type(1):checked ~ .carousel-container .carousel-item:nth-of-type(1),
+.carousel .carousel-locator:nth-of-type(2):checked ~ .carousel-container .carousel-item:nth-of-type(2),
+.carousel .carousel-locator:nth-of-type(3):checked ~ .carousel-container .carousel-item:nth-of-type(3),
+.carousel .carousel-locator:nth-of-type(4):checked ~ .carousel-container .carousel-item:nth-of-type(4),
+.carousel .carousel-locator:nth-of-type(5):checked ~ .carousel-container .carousel-item:nth-of-type(5),
+.carousel .carousel-locator:nth-of-type(6):checked ~ .carousel-container .carousel-item:nth-of-type(6),
+.carousel .carousel-locator:nth-of-type(7):checked ~ .carousel-container .carousel-item:nth-of-type(7),
+.carousel .carousel-locator:nth-of-type(8):checked ~ .carousel-container .carousel-item:nth-of-type(8) {
+  animation: carousel-slidein .75s ease-in-out 1;
+  opacity: 1;
+  z-index: 100;
+}
+
+.carousel .carousel-locator:nth-of-type(1):checked ~ .carousel-nav .nav-item:nth-of-type(1),
+.carousel .carousel-locator:nth-of-type(2):checked ~ .carousel-nav .nav-item:nth-of-type(2),
+.carousel .carousel-locator:nth-of-type(3):checked ~ .carousel-nav .nav-item:nth-of-type(3),
+.carousel .carousel-locator:nth-of-type(4):checked ~ .carousel-nav .nav-item:nth-of-type(4),
+.carousel .carousel-locator:nth-of-type(5):checked ~ .carousel-nav .nav-item:nth-of-type(5),
+.carousel .carousel-locator:nth-of-type(6):checked ~ .carousel-nav .nav-item:nth-of-type(6),
+.carousel .carousel-locator:nth-of-type(7):checked ~ .carousel-nav .nav-item:nth-of-type(7),
+.carousel .carousel-locator:nth-of-type(8):checked ~ .carousel-nav .nav-item:nth-of-type(8) {
+  color: #f7f8f9;
+}
+
+.carousel {
+  background: #f7f8f9;
+  display: block;
+  overflow: hidden;
+  -webkit-overflow-scrolling: touch;
+  position: relative;
+  width: 100%;
+  z-index: 1;
+}
+
+.carousel .carousel-container {
+  height: 100%;
+  left: 0;
+  position: relative;
+}
+
+.carousel .carousel-container::before {
+  content: "";
+  display: block;
+  padding-bottom: 56.25%;
+}
+
+.carousel .carousel-container .carousel-item {
+  animation: carousel-slideout 1s ease-in-out 1;
+  height: 100%;
+  left: 0;
+  margin: 0;
+  opacity: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+
+.carousel .carousel-container .carousel-item:hover .item-prev,
+.carousel .carousel-container .carousel-item:hover .item-next {
+  opacity: 1;
+}
+
+.carousel .carousel-container .item-prev,
+.carousel .carousel-container .item-next {
+  background: rgba(247, 248, 249, .25);
+  border-color: rgba(247, 248, 249, .5);
+  color: #f7f8f9;
+  opacity: 0;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  transition: all .4s;
+  z-index: 100;
+}
+
+.carousel .carousel-container .item-prev {
+  left: 1rem;
+}
+
+.carousel .carousel-container .item-next {
+  right: 1rem;
+}
+
+.carousel .carousel-nav {
+  bottom: .4rem;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-pack: center;
+  justify-content: center;
+  left: 50%;
+  position: absolute;
+  transform: translateX(-50%);
+  width: 10rem;
+  z-index: 100;
+}
+
+.carousel .carousel-nav .nav-item {
+  color: rgba(247, 248, 249, .5);
+  display: block;
+  -ms-flex: 1 0 auto;
+  flex: 1 0 auto;
+  height: 1.6rem;
+  margin: .2rem;
+  max-width: 2.5rem;
+  position: relative;
+}
+
+.carousel .carousel-nav .nav-item::before {
+  background: currentColor;
+  content: "";
+  display: block;
+  height: .1rem;
+  position: absolute;
+  top: .5rem;
+  width: 100%;
+}
+
+@keyframes carousel-slidein {
+  0% {
+    transform: translateX(100%);
+  }
+  100% {
+    transform: translateX(0);
+  }
+}
+
+@keyframes carousel-slideout {
+  0% {
+    opacity: 1;
+    transform: translateX(0);
+  }
+  100% {
+    opacity: 1;
+    transform: translateX(-50%);
+  }
+}
+
+.comparison-slider {
+  height: 50vh;
+  overflow: hidden;
+  -webkit-overflow-scrolling: touch; 
+  position: relative;
+  width: 100%;
+}
+
+.comparison-slider .comparison-before,
+.comparison-slider .comparison-after {
+  height: 100%;
+  left: 0;
+  margin: 0;
+  overflow: hidden;
+  position: absolute;
+  top: 0;
+}
+
+.comparison-slider .comparison-before img,
+.comparison-slider .comparison-after img {
+  height: 100%;
+  object-fit: cover;
+  object-position: left center;
+  position: absolute;
+  width: 100%;
+}
+
+.comparison-slider .comparison-before {
+  width: 100%;
+  z-index: 1;
+}
+
+.comparison-slider .comparison-before .comparison-label {
+  right: .8rem;
+}
+
+.comparison-slider .comparison-after {
+  max-width: 100%;
+  min-width: 0;
+  z-index: 2;
+}
+
+.comparison-slider .comparison-after::before {
+  background: transparent;
+  content: "";
+  cursor: default;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  right: .8rem;
+  top: 0;
+  z-index: 1;
+}
+
+.comparison-slider .comparison-after::after {
+  background: currentColor;
+  border-radius: 50%;
+  box-shadow: 0 -5px, 0 5px;
+  color: #fff;
+  content: "";
+  height: 3px;
+  pointer-events: none;
+  position: absolute;
+  right: .4rem;
+  top: 50%;
+  transform: translate(50%, -50%);
+  width: 3px;
+}
+
+.comparison-slider .comparison-after .comparison-label {
+  left: .8rem;
+}
+
+.comparison-slider .comparison-resizer {
+  animation: first-run 1.5s 1 ease-in-out;
+  cursor: ew-resize;
+  height: .8rem;
+  left: 0;
+  max-width: 100%;
+  min-width: .8rem;
+  opacity: 0;
+  outline: none;
+  position: relative;
+  resize: horizontal;
+  top: 50%;
+  transform: translateY(-50%) scaleY(30);
+  width: 0;
+}
+
+.comparison-slider .comparison-label {
+  background: rgba(48, 55, 66, .5);
+  bottom: .8rem;
+  color: #fff;
+  padding: .2rem .4rem;
+  position: absolute;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+@keyframes first-run {
+  0% {
+    width: 0;
+  }
+  25% {
+    width: 2.4rem;
+  }
+  50% {
+    width: .8rem;
+  }
+  75% {
+    width: 1.2rem;
+  }
+  100% {
+    width: 0;
+  }
+}
+
+.filter .filter-tag#tag-0:checked ~ .filter-nav .chip[for="tag-0"],
+.filter .filter-tag#tag-1:checked ~ .filter-nav .chip[for="tag-1"],
+.filter .filter-tag#tag-2:checked ~ .filter-nav .chip[for="tag-2"],
+.filter .filter-tag#tag-3:checked ~ .filter-nav .chip[for="tag-3"],
+.filter .filter-tag#tag-4:checked ~ .filter-nav .chip[for="tag-4"],
+.filter .filter-tag#tag-5:checked ~ .filter-nav .chip[for="tag-5"],
+.filter .filter-tag#tag-6:checked ~ .filter-nav .chip[for="tag-6"],
+.filter .filter-tag#tag-7:checked ~ .filter-nav .chip[for="tag-7"],
+.filter .filter-tag#tag-8:checked ~ .filter-nav .chip[for="tag-8"] {
+  background: #5755d9;
+  color: #fff;
+}
+
+.filter .filter-tag#tag-1:checked ~ .filter-body .filter-item:not([data-tag~="tag-1"]),
+.filter .filter-tag#tag-2:checked ~ .filter-body .filter-item:not([data-tag~="tag-2"]),
+.filter .filter-tag#tag-3:checked ~ .filter-body .filter-item:not([data-tag~="tag-3"]),
+.filter .filter-tag#tag-4:checked ~ .filter-body .filter-item:not([data-tag~="tag-4"]),
+.filter .filter-tag#tag-5:checked ~ .filter-body .filter-item:not([data-tag~="tag-5"]),
+.filter .filter-tag#tag-6:checked ~ .filter-body .filter-item:not([data-tag~="tag-6"]),
+.filter .filter-tag#tag-7:checked ~ .filter-body .filter-item:not([data-tag~="tag-7"]),
+.filter .filter-tag#tag-8:checked ~ .filter-body .filter-item:not([data-tag~="tag-8"]) {
+  display: none;
+}
+
+.filter .filter-nav {
+  margin: .4rem 0;
+}
+
+.filter .filter-body {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+}
+
+.meter {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: #f7f8f9;
+  border: 0;
+  border-radius: .1rem;
+  display: block;
+  height: .8rem; 
+  width: 100%;
+}
+
+.meter::-webkit-meter-inner-element {
+  display: block;
+}
+
+.meter::-webkit-meter-bar,
+.meter::-webkit-meter-optimum-value,
+.meter::-webkit-meter-suboptimum-value,
+.meter::-webkit-meter-even-less-good-value {
+  border-radius: .1rem;
+}
+
+.meter::-webkit-meter-bar {
+  background: #f7f8f9;
+}
+
+.meter::-webkit-meter-optimum-value {
+  background: #32b643;
+}
+
+.meter::-webkit-meter-suboptimum-value {
+  background: #ffb700;
+}
+
+.meter::-webkit-meter-even-less-good-value {
+  background: #e85600;
+}
+
+.meter::-moz-meter-bar,
+.meter:-moz-meter-optimum,
+.meter:-moz-meter-sub-optimum,
+.meter:-moz-meter-sub-sub-optimum {
+  border-radius: .1rem;
+}
+
+.meter:-moz-meter-optimum::-moz-meter-bar {
+  background: #32b643;
+}
+
+.meter:-moz-meter-sub-optimum::-moz-meter-bar {
+  background: #ffb700;
+}
+
+.meter:-moz-meter-sub-sub-optimum::-moz-meter-bar {
+  background: #e85600;
+}
+
+.off-canvas {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-flow: nowrap;
+  flex-flow: nowrap;
+  height: 100%;
+  position: relative;
+  width: 100%;
+}
+
+.off-canvas .off-canvas-toggle {
+  display: block;
+  left: .4rem; 
+  position: absolute;
+  top: .4rem;
+  transition: none;
+  z-index: 1;
+}
+
+.off-canvas .off-canvas-sidebar {
+  background: #f7f8f9;
+  bottom: 0;
+  left: 0;
+  min-width: 10rem;
+  overflow-y: auto;
+  position: fixed;
+  top: 0;
+  transform: translateX(-100%); 
+  transition: transform .25s;
+  z-index: 200;
+}
+
+.off-canvas .off-canvas-content {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+  height: 100%;
+  padding: .4rem .4rem .4rem 4rem;
+}
+
+.off-canvas .off-canvas-overlay {
+  background: rgba(48, 55, 66, .1);
+  border-color: transparent;
+  border-radius: 0;
+  bottom: 0;
+  display: none;
+  height: 100%;
+  left: 0;
+  position: fixed;
+  right: 0;
+  top: 0;
+  width: 100%;
+}
+
+.off-canvas .off-canvas-sidebar:target,
+.off-canvas .off-canvas-sidebar.active {
+  transform: translateX(0);
+}
+
+.off-canvas .off-canvas-sidebar:target ~ .off-canvas-overlay,
+.off-canvas .off-canvas-sidebar.active ~ .off-canvas-overlay {
+  display: block;
+  z-index: 100;
+}
+
+@media (min-width: 960px) {
+  .off-canvas.off-canvas-sidebar-show .off-canvas-toggle {
+    display: none;
+  }
+  .off-canvas.off-canvas-sidebar-show .off-canvas-sidebar {
+    -ms-flex: 0 0 auto;
+    flex: 0 0 auto;
+    position: relative;
+    transform: none;
+  }
+  .off-canvas.off-canvas-sidebar-show .off-canvas-overlay {
+    display: none !important;
+  }
+}
+
+.parallax {
+  display: block;
+  height: auto;
+  position: relative;
+  width: auto;
+}
+
+.parallax .parallax-content {
+  box-shadow: 0 1rem 2.1rem rgba(48, 55, 66, .3);
+  height: auto;
+  transform: perspective(1000px);
+  transform-style: preserve-3d;
+  transition: all .4s ease;
+  width: 100%;
+}
+
+.parallax .parallax-content::before {
+  content: "";
+  display: block;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+}
+
+.parallax .parallax-front {
+  align-items: center;
+  color: #fff;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  -ms-flex-pack: center;
+  height: 100%;
+  justify-content: center;
+  left: 0;
+  position: absolute;
+  text-align: center;
+  text-shadow: 0 0 20px rgba(48, 55, 66, .75);
+  top: 0;
+  transform: translateZ(50px) scale(.95);
+  transition: transform .4s;
+  width: 100%;
+  z-index: 1;
+}
+
+.parallax .parallax-top-left {
+  height: 50%;
+  left: 0;
+  outline: none;
+  position: absolute;
+  top: 0; 
+  width: 50%;
+  z-index: 100;
+}
+
+.parallax .parallax-top-left:focus ~ .parallax-content,
+.parallax .parallax-top-left:hover ~ .parallax-content {
+  transform: perspective(1000px) rotateX(3deg) rotateY(-3deg);
+}
+
+.parallax .parallax-top-left:focus ~ .parallax-content::before,
+.parallax .parallax-top-left:hover ~ .parallax-content::before {
+  background: linear-gradient(135deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
+}
+
+.parallax .parallax-top-left:focus ~ .parallax-content .parallax-front,
+.parallax .parallax-top-left:hover ~ .parallax-content .parallax-front {
+  transform: translate3d(4.5px, 4.5px, 50px) scale(.95);
+}
+
+.parallax .parallax-top-right {
+  height: 50%;
+  outline: none;
+  position: absolute;
+  right: 0;
+  top: 0; 
+  width: 50%;
+  z-index: 100;
+}
+
+.parallax .parallax-top-right:focus ~ .parallax-content,
+.parallax .parallax-top-right:hover ~ .parallax-content {
+  transform: perspective(1000px) rotateX(3deg) rotateY(3deg);
+}
+
+.parallax .parallax-top-right:focus ~ .parallax-content::before,
+.parallax .parallax-top-right:hover ~ .parallax-content::before {
+  background: linear-gradient(-135deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
+}
+
+.parallax .parallax-top-right:focus ~ .parallax-content .parallax-front,
+.parallax .parallax-top-right:hover ~ .parallax-content .parallax-front {
+  transform: translate3d(-4.5px, 4.5px, 50px) scale(.95);
+}
+
+.parallax .parallax-bottom-left {
+  bottom: 0;
+  height: 50%;
+  left: 0; 
+  outline: none;
+  position: absolute;
+  width: 50%;
+  z-index: 100;
+}
+
+.parallax .parallax-bottom-left:focus ~ .parallax-content,
+.parallax .parallax-bottom-left:hover ~ .parallax-content {
+  transform: perspective(1000px) rotateX(-3deg) rotateY(-3deg);
+}
+
+.parallax .parallax-bottom-left:focus ~ .parallax-content::before,
+.parallax .parallax-bottom-left:hover ~ .parallax-content::before {
+  background: linear-gradient(45deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
+}
+
+.parallax .parallax-bottom-left:focus ~ .parallax-content .parallax-front,
+.parallax .parallax-bottom-left:hover ~ .parallax-content .parallax-front {
+  transform: translate3d(4.5px, -4.5px, 50px) scale(.95);
+}
+
+.parallax .parallax-bottom-right {
+  bottom: 0;
+  height: 50%;
+  outline: none;
+  position: absolute;
+  right: 0; 
+  width: 50%;
+  z-index: 100;
+}
+
+.parallax .parallax-bottom-right:focus ~ .parallax-content,
+.parallax .parallax-bottom-right:hover ~ .parallax-content {
+  transform: perspective(1000px) rotateX(-3deg) rotateY(3deg);
+}
+
+.parallax .parallax-bottom-right:focus ~ .parallax-content::before,
+.parallax .parallax-bottom-right:hover ~ .parallax-content::before {
+  background: linear-gradient(-45deg, rgba(255, 255, 255, .35) 0%, transparent 50%);
+}
+
+.parallax .parallax-bottom-right:focus ~ .parallax-content .parallax-front,
+.parallax .parallax-bottom-right:hover ~ .parallax-content .parallax-front {
+  transform: translate3d(-4.5px, -4.5px, 50px) scale(.95);
+}
+
+.progress {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: #eef0f3;
+  border: 0;
+  border-radius: .1rem;
+  color: #5755d9;
+  height: .2rem;
+  position: relative;
+  width: 100%;
+}
+
+.progress::-webkit-progress-bar {
+  background: transparent;
+  border-radius: .1rem;
+}
+
+.progress::-webkit-progress-value {
+  background: #5755d9;
+  border-radius: .1rem;
+}
+
+.progress::-moz-progress-bar {
+  background: #5755d9;
+  border-radius: .1rem;
+}
+
+.progress:indeterminate {
+  animation: progress-indeterminate 1.5s linear infinite;
+  background: #eef0f3 linear-gradient(to right, #5755d9 30%, #eef0f3 30%) top left/150% 150% no-repeat;
+}
+
+.progress:indeterminate::-moz-progress-bar {
+  background: transparent;
+}
+
+@keyframes progress-indeterminate {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+.slider {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: transparent;
+  display: block;
+  height: 1.2rem; 
+  width: 100%;
+}
+
+.slider:focus {
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+  outline: none;
+}
+
+.slider.tooltip:not([data-tooltip])::after {
+  content: attr(value);
+}
+
+.slider::-webkit-slider-thumb {
+  -webkit-appearance: none;
+  background: #5755d9;
+  border: 0;
+  border-radius: 50%;
+  height: .6rem;
+  margin-top: -.25rem;
+  -webkit-transition: transform .2s;
+  transition: transform .2s;
+  width: .6rem;
+}
+
+.slider::-moz-range-thumb {
+  background: #5755d9;
+  border: 0;
+  border-radius: 50%;
+  height: .6rem;
+  -moz-transition: transform .2s;
+  transition: transform .2s;
+  width: .6rem;
+}
+
+.slider::-ms-thumb {
+  background: #5755d9;
+  border: 0;
+  border-radius: 50%;
+  height: .6rem;
+  -ms-transition: transform .2s;
+  transition: transform .2s;
+  width: .6rem;
+}
+
+.slider:active::-webkit-slider-thumb {
+  transform: scale(1.25);
+}
+
+.slider:active::-moz-range-thumb {
+  transform: scale(1.25);
+}
+
+.slider:active::-ms-thumb {
+  transform: scale(1.25);
+}
+
+.slider:disabled::-webkit-slider-thumb,
+.slider.disabled::-webkit-slider-thumb {
+  background: #f7f8f9;
+  transform: scale(1);
+}
+
+.slider:disabled::-moz-range-thumb,
+.slider.disabled::-moz-range-thumb {
+  background: #f7f8f9;
+  transform: scale(1);
+}
+
+.slider:disabled::-ms-thumb,
+.slider.disabled::-ms-thumb {
+  background: #f7f8f9;
+  transform: scale(1);
+}
+
+.slider::-webkit-slider-runnable-track {
+  background: #eef0f3;
+  border-radius: .1rem;
+  height: .1rem;
+  width: 100%;
+}
+
+.slider::-moz-range-track {
+  background: #eef0f3;
+  border-radius: .1rem;
+  height: .1rem;
+  width: 100%;
+}
+
+.slider::-ms-track {
+  background: #eef0f3;
+  border-radius: .1rem;
+  height: .1rem;
+  width: 100%;
+}
+
+.slider::-ms-fill-lower {
+  background: #5755d9;
+}
+
+.timeline .timeline-item {
+  display: -ms-flexbox;
+  display: flex;
+  margin-bottom: 1.2rem;
+  position: relative;
+}
+
+.timeline .timeline-item::before {
+  background: #dadee4;
+  content: "";
+  height: 100%;
+  left: 11px;
+  position: absolute;
+  top: 1.2rem;
+  width: 2px;
+}
+
+.timeline .timeline-item .timeline-left {
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto;
+}
+
+.timeline .timeline-item .timeline-content {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+  padding: 2px 0 2px .8rem;
+}
+
+.timeline .timeline-item .timeline-icon {
+  align-items: center;
+  border-radius: 50%;
+  color: #fff;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  -ms-flex-pack: center;
+  height: 1.2rem;
+  justify-content: center;
+  text-align: center;
+  width: 1.2rem;
+}
+
+.timeline .timeline-item .timeline-icon::before {
+  border: .1rem solid #5755d9;
+  border-radius: 50%;
+  content: "";
+  display: block;
+  height: .4rem;
+  left: .4rem;
+  position: absolute;
+  top: .4rem;
+  width: .4rem;
+}
+
+.timeline .timeline-item .timeline-icon.icon-lg {
+  background: #5755d9;
+  line-height: 1.2rem;
+}
+
+.timeline .timeline-item .timeline-icon.icon-lg::before {
+  content: none;
+}
+
+.viewer-360 {
+  align-items: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  -ms-flex-direction: column;
+  flex-direction: column;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="1"] + .viewer-image {
+  background-position-y: 0;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="2"] + .viewer-image {
+  background-position-y: 2.8571428571%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="3"] + .viewer-image {
+  background-position-y: 5.7142857143%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="4"] + .viewer-image {
+  background-position-y: 8.5714285714%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="5"] + .viewer-image {
+  background-position-y: 11.4285714286%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="6"] + .viewer-image {
+  background-position-y: 14.2857142857%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="7"] + .viewer-image {
+  background-position-y: 17.1428571429%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="8"] + .viewer-image {
+  background-position-y: 20%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="9"] + .viewer-image {
+  background-position-y: 22.8571428571%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="10"] + .viewer-image {
+  background-position-y: 25.7142857143%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="11"] + .viewer-image {
+  background-position-y: 28.5714285714%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="12"] + .viewer-image {
+  background-position-y: 31.4285714286%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="13"] + .viewer-image {
+  background-position-y: 34.2857142857%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="14"] + .viewer-image {
+  background-position-y: 37.1428571429%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="15"] + .viewer-image {
+  background-position-y: 40%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="16"] + .viewer-image {
+  background-position-y: 42.8571428571%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="17"] + .viewer-image {
+  background-position-y: 45.7142857143%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="18"] + .viewer-image {
+  background-position-y: 48.5714285714%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="19"] + .viewer-image {
+  background-position-y: 51.4285714286%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="20"] + .viewer-image {
+  background-position-y: 54.2857142857%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="21"] + .viewer-image {
+  background-position-y: 57.1428571429%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="22"] + .viewer-image {
+  background-position-y: 60%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="23"] + .viewer-image {
+  background-position-y: 62.8571428571%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="24"] + .viewer-image {
+  background-position-y: 65.7142857143%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="25"] + .viewer-image {
+  background-position-y: 68.5714285714%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="26"] + .viewer-image {
+  background-position-y: 71.4285714286%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="27"] + .viewer-image {
+  background-position-y: 74.2857142857%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="28"] + .viewer-image {
+  background-position-y: 77.1428571429%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="29"] + .viewer-image {
+  background-position-y: 80%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="30"] + .viewer-image {
+  background-position-y: 82.8571428571%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="31"] + .viewer-image {
+  background-position-y: 85.7142857143%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="32"] + .viewer-image {
+  background-position-y: 88.5714285714%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="33"] + .viewer-image {
+  background-position-y: 91.4285714286%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="34"] + .viewer-image {
+  background-position-y: 94.2857142857%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="35"] + .viewer-image {
+  background-position-y: 97.1428571429%;
+}
+
+.viewer-360 .viewer-slider[max="36"][value="36"] + .viewer-image {
+  background-position-y: 100%;
+}
+
+.viewer-360 .viewer-slider {
+  cursor: ew-resize;
+  -ms-flex-order: 2;
+  margin: 1rem;
+  order: 2;
+  width: 60%;
+}
+
+.viewer-360 .viewer-image {
+  background-position-y: 0;
+  background-repeat: no-repeat;
+  background-size: 100%;
+  -ms-flex-order: 1;
+  max-width: 100%;
+  order: 1;
+}
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre-exp.min.css b/public/css/spectre-0.5.9/spectre-exp.min.css
new file mode 100644
index 0000000..d313774
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre-exp.min.css
@@ -0,0 +1 @@
+/*! Spectre.css Experimentals v0.5.9 | MIT License | github.com/picturepan2/spectre */.form-autocomplete{position:relative}.form-autocomplete .form-autocomplete-input{align-content:flex-start;display:-ms-flexbox;display:flex;-ms-flex-line-pack:start;-ms-flex-wrap:wrap;flex-wrap:wrap;height:auto;min-height:1.6rem;padding:.1rem}.form-autocomplete .form-autocomplete-input.is-focused{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-autocomplete .form-autocomplete-input .form-input{border-color:transparent;box-shadow:none;display:inline-block;-ms-flex:1 0 auto;flex:1 0 auto;height:1.2rem;line-height:.8rem;margin:.1rem;width:auto}.form-autocomplete .menu{left:0;position:absolute;top:100%;width:100%}.form-autocomplete.autocomplete-oneline .form-autocomplete-input{-ms-flex-wrap:nowrap;flex-wrap:nowrap;overflow-x:auto}.form-autocomplete.autocomplete-oneline .chip{-ms-flex:1 0 auto;flex:1 0 auto}.calendar{border:.05rem solid #dadee4;border-radius:.1rem;display:block;min-width:280px}.calendar .calendar-nav{align-items:center;background:#f7f8f9;border-top-left-radius:.1rem;border-top-right-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-align:center;font-size:.9rem;padding:.4rem}.calendar .calendar-body,.calendar .calendar-header{display:-ms-flexbox;display:flex;-ms-flex-pack:center;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:center;padding:.4rem 0}.calendar .calendar-body .calendar-date,.calendar .calendar-header .calendar-date{-ms-flex:0 0 14.28%;flex:0 0 14.28%;max-width:14.28%}.calendar .calendar-header{background:#f7f8f9;border-bottom:.05rem solid #dadee4;color:#bcc3ce;font-size:.7rem;text-align:center}.calendar .calendar-body{color:#66758c}.calendar .calendar-date{border:0;padding:.2rem}.calendar .calendar-date .date-item{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:0 0;border:.05rem solid transparent;border-radius:50%;color:#66758c;cursor:pointer;font-size:.7rem;height:1.4rem;line-height:1rem;outline:0;padding:.1rem;position:relative;text-align:center;text-decoration:none;transition:background .2s,border .2s,box-shadow .2s,color .2s;vertical-align:middle;white-space:nowrap;width:1.4rem}.calendar .calendar-date .date-item.date-today{border-color:#e5e5f9;color:#5755d9}.calendar .calendar-date .date-item:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.calendar .calendar-date .date-item:focus,.calendar .calendar-date .date-item:hover{background:#fefeff;border-color:#e5e5f9;color:#5755d9;text-decoration:none}.calendar .calendar-date .date-item.active,.calendar .calendar-date .date-item:active{background:#4b48d6;border-color:#3634d2;color:#fff}.calendar .calendar-date .date-item.badge::after{position:absolute;right:3px;top:3px;transform:translate(50%,-50%)}.calendar .calendar-date .calendar-event.disabled,.calendar .calendar-date .calendar-event:disabled,.calendar .calendar-date .date-item.disabled,.calendar .calendar-date .date-item:disabled{cursor:default;opacity:.25;pointer-events:none}.calendar .calendar-date.next-month .calendar-event,.calendar .calendar-date.next-month .date-item,.calendar .calendar-date.prev-month .calendar-event,.calendar .calendar-date.prev-month .date-item{opacity:.25}.calendar .calendar-range{position:relative}.calendar .calendar-range::before{background:#f1f1fc;content:"";height:1.4rem;left:0;position:absolute;right:0;top:50%;transform:translateY(-50%)}.calendar .calendar-range.range-start::before{left:50%}.calendar .calendar-range.range-end::before{right:50%}.calendar .calendar-range.range-end .date-item,.calendar .calendar-range.range-start .date-item{background:#4b48d6;border-color:#3634d2;color:#fff}.calendar .calendar-range .date-item{color:#5755d9}.calendar.calendar-lg .calendar-body{padding:0}.calendar.calendar-lg .calendar-body .calendar-date{border-bottom:.05rem solid #dadee4;border-right:.05rem solid #dadee4;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;height:5.5rem;padding:0}.calendar.calendar-lg .calendar-body .calendar-date:nth-child(7n){border-right:0}.calendar.calendar-lg .calendar-body .calendar-date:nth-last-child(-n+7){border-bottom:0}.calendar.calendar-lg .date-item{align-self:flex-end;-ms-flex-item-align:end;height:1.4rem;margin-right:.2rem;margin-top:.2rem}.calendar.calendar-lg .calendar-range::before{top:19px}.calendar.calendar-lg .calendar-range.range-start::before{left:auto;width:19px}.calendar.calendar-lg .calendar-range.range-end::before{right:19px}.calendar.calendar-lg .calendar-events{flex-grow:1;-ms-flex-positive:1;line-height:1;overflow-y:auto;padding:.2rem}.calendar.calendar-lg .calendar-event{border-radius:.1rem;display:block;font-size:.7rem;margin:.1rem auto;overflow:hidden;padding:3px 4px;text-overflow:ellipsis;white-space:nowrap}.carousel .carousel-locator:nth-of-type(1):checked~.carousel-container .carousel-item:nth-of-type(1),.carousel .carousel-locator:nth-of-type(2):checked~.carousel-container .carousel-item:nth-of-type(2),.carousel .carousel-locator:nth-of-type(3):checked~.carousel-container .carousel-item:nth-of-type(3),.carousel .carousel-locator:nth-of-type(4):checked~.carousel-container .carousel-item:nth-of-type(4),.carousel .carousel-locator:nth-of-type(5):checked~.carousel-container .carousel-item:nth-of-type(5),.carousel .carousel-locator:nth-of-type(6):checked~.carousel-container .carousel-item:nth-of-type(6),.carousel .carousel-locator:nth-of-type(7):checked~.carousel-container .carousel-item:nth-of-type(7),.carousel .carousel-locator:nth-of-type(8):checked~.carousel-container .carousel-item:nth-of-type(8){animation:carousel-slidein .75s ease-in-out 1;opacity:1;z-index:100}.carousel .carousel-locator:nth-of-type(1):checked~.carousel-nav .nav-item:nth-of-type(1),.carousel .carousel-locator:nth-of-type(2):checked~.carousel-nav .nav-item:nth-of-type(2),.carousel .carousel-locator:nth-of-type(3):checked~.carousel-nav .nav-item:nth-of-type(3),.carousel .carousel-locator:nth-of-type(4):checked~.carousel-nav .nav-item:nth-of-type(4),.carousel .carousel-locator:nth-of-type(5):checked~.carousel-nav .nav-item:nth-of-type(5),.carousel .carousel-locator:nth-of-type(6):checked~.carousel-nav .nav-item:nth-of-type(6),.carousel .carousel-locator:nth-of-type(7):checked~.carousel-nav .nav-item:nth-of-type(7),.carousel .carousel-locator:nth-of-type(8):checked~.carousel-nav .nav-item:nth-of-type(8){color:#f7f8f9}.carousel{background:#f7f8f9;display:block;overflow:hidden;-webkit-overflow-scrolling:touch;position:relative;width:100%;z-index:1}.carousel .carousel-container{height:100%;left:0;position:relative}.carousel .carousel-container::before{content:"";display:block;padding-bottom:56.25%}.carousel .carousel-container .carousel-item{animation:carousel-slideout 1s ease-in-out 1;height:100%;left:0;margin:0;opacity:0;position:absolute;top:0;width:100%}.carousel .carousel-container .carousel-item:hover .item-next,.carousel .carousel-container .carousel-item:hover .item-prev{opacity:1}.carousel .carousel-container .item-next,.carousel .carousel-container .item-prev{background:rgba(247,248,249,.25);border-color:rgba(247,248,249,.5);color:#f7f8f9;opacity:0;position:absolute;top:50%;transform:translateY(-50%);transition:all .4s;z-index:100}.carousel .carousel-container .item-prev{left:1rem}.carousel .carousel-container .item-next{right:1rem}.carousel .carousel-nav{bottom:.4rem;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;left:50%;position:absolute;transform:translateX(-50%);width:10rem;z-index:100}.carousel .carousel-nav .nav-item{color:rgba(247,248,249,.5);display:block;-ms-flex:1 0 auto;flex:1 0 auto;height:1.6rem;margin:.2rem;max-width:2.5rem;position:relative}.carousel .carousel-nav .nav-item::before{background:currentColor;content:"";display:block;height:.1rem;position:absolute;top:.5rem;width:100%}@keyframes carousel-slidein{0%{transform:translateX(100%)}100%{transform:translateX(0)}}@keyframes carousel-slideout{0%{opacity:1;transform:translateX(0)}100%{opacity:1;transform:translateX(-50%)}}.comparison-slider{height:50vh;overflow:hidden;-webkit-overflow-scrolling:touch;position:relative;width:100%}.comparison-slider .comparison-after,.comparison-slider .comparison-before{height:100%;left:0;margin:0;overflow:hidden;position:absolute;top:0}.comparison-slider .comparison-after img,.comparison-slider .comparison-before img{height:100%;object-fit:cover;object-position:left center;position:absolute;width:100%}.comparison-slider .comparison-before{width:100%;z-index:1}.comparison-slider .comparison-before .comparison-label{right:.8rem}.comparison-slider .comparison-after{max-width:100%;min-width:0;z-index:2}.comparison-slider .comparison-after::before{background:0 0;content:"";cursor:default;height:100%;left:0;position:absolute;right:.8rem;top:0;z-index:1}.comparison-slider .comparison-after::after{background:currentColor;border-radius:50%;box-shadow:0 -5px,0 5px;color:#fff;content:"";height:3px;pointer-events:none;position:absolute;right:.4rem;top:50%;transform:translate(50%,-50%);width:3px}.comparison-slider .comparison-after .comparison-label{left:.8rem}.comparison-slider .comparison-resizer{animation:first-run 1.5s 1 ease-in-out;cursor:ew-resize;height:.8rem;left:0;max-width:100%;min-width:.8rem;opacity:0;outline:0;position:relative;resize:horizontal;top:50%;transform:translateY(-50%) scaleY(30);width:0}.comparison-slider .comparison-label{background:rgba(48,55,66,.5);bottom:.8rem;color:#fff;padding:.2rem .4rem;position:absolute;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@keyframes first-run{0%{width:0}25%{width:2.4rem}50%{width:.8rem}75%{width:1.2rem}100%{width:0}}.filter .filter-tag#tag-0:checked~.filter-nav .chip[for=tag-0],.filter .filter-tag#tag-1:checked~.filter-nav .chip[for=tag-1],.filter .filter-tag#tag-2:checked~.filter-nav .chip[for=tag-2],.filter .filter-tag#tag-3:checked~.filter-nav .chip[for=tag-3],.filter .filter-tag#tag-4:checked~.filter-nav .chip[for=tag-4],.filter .filter-tag#tag-5:checked~.filter-nav .chip[for=tag-5],.filter .filter-tag#tag-6:checked~.filter-nav .chip[for=tag-6],.filter .filter-tag#tag-7:checked~.filter-nav .chip[for=tag-7],.filter .filter-tag#tag-8:checked~.filter-nav .chip[for=tag-8]{background:#5755d9;color:#fff}.filter .filter-tag#tag-1:checked~.filter-body .filter-item:not([data-tag~=tag-1]),.filter .filter-tag#tag-2:checked~.filter-body .filter-item:not([data-tag~=tag-2]),.filter .filter-tag#tag-3:checked~.filter-body .filter-item:not([data-tag~=tag-3]),.filter .filter-tag#tag-4:checked~.filter-body .filter-item:not([data-tag~=tag-4]),.filter .filter-tag#tag-5:checked~.filter-body .filter-item:not([data-tag~=tag-5]),.filter .filter-tag#tag-6:checked~.filter-body .filter-item:not([data-tag~=tag-6]),.filter .filter-tag#tag-7:checked~.filter-body .filter-item:not([data-tag~=tag-7]),.filter .filter-tag#tag-8:checked~.filter-body .filter-item:not([data-tag~=tag-8]){display:none}.filter .filter-nav{margin:.4rem 0}.filter .filter-body{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.meter{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#f7f8f9;border:0;border-radius:.1rem;display:block;height:.8rem;width:100%}.meter::-webkit-meter-inner-element{display:block}.meter::-webkit-meter-bar,.meter::-webkit-meter-even-less-good-value,.meter::-webkit-meter-optimum-value,.meter::-webkit-meter-suboptimum-value{border-radius:.1rem}.meter::-webkit-meter-bar{background:#f7f8f9}.meter::-webkit-meter-optimum-value{background:#32b643}.meter::-webkit-meter-suboptimum-value{background:#ffb700}.meter::-webkit-meter-even-less-good-value{background:#e85600}.meter:-moz-meter-optimum,.meter:-moz-meter-sub-optimum,.meter:-moz-meter-sub-sub-optimum,.meter::-moz-meter-bar{border-radius:.1rem}.meter:-moz-meter-optimum::-moz-meter-bar{background:#32b643}.meter:-moz-meter-sub-optimum::-moz-meter-bar{background:#ffb700}.meter:-moz-meter-sub-sub-optimum::-moz-meter-bar{background:#e85600}.off-canvas{display:-ms-flexbox;display:flex;-ms-flex-flow:nowrap;flex-flow:nowrap;height:100%;position:relative;width:100%}.off-canvas .off-canvas-toggle{display:block;left:.4rem;position:absolute;top:.4rem;transition:none;z-index:1}.off-canvas .off-canvas-sidebar{background:#f7f8f9;bottom:0;left:0;min-width:10rem;overflow-y:auto;position:fixed;top:0;transform:translateX(-100%);transition:transform .25s;z-index:200}.off-canvas .off-canvas-content{-ms-flex:1 1 auto;flex:1 1 auto;height:100%;padding:.4rem .4rem .4rem 4rem}.off-canvas .off-canvas-overlay{background:rgba(48,55,66,.1);border-color:transparent;border-radius:0;bottom:0;display:none;height:100%;left:0;position:fixed;right:0;top:0;width:100%}.off-canvas .off-canvas-sidebar.active,.off-canvas .off-canvas-sidebar:target{transform:translateX(0)}.off-canvas .off-canvas-sidebar.active~.off-canvas-overlay,.off-canvas .off-canvas-sidebar:target~.off-canvas-overlay{display:block;z-index:100}@media (min-width:960px){.off-canvas.off-canvas-sidebar-show .off-canvas-toggle{display:none}.off-canvas.off-canvas-sidebar-show .off-canvas-sidebar{-ms-flex:0 0 auto;flex:0 0 auto;position:relative;transform:none}.off-canvas.off-canvas-sidebar-show .off-canvas-overlay{display:none!important}}.parallax{display:block;height:auto;position:relative;width:auto}.parallax .parallax-content{box-shadow:0 1rem 2.1rem rgba(48,55,66,.3);height:auto;transform:perspective(1000px);transform-style:preserve-3d;transition:all .4s ease;width:100%}.parallax .parallax-content::before{content:"";display:block;height:100%;left:0;position:absolute;top:0;width:100%}.parallax .parallax-front{align-items:center;color:#fff;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;height:100%;justify-content:center;left:0;position:absolute;text-align:center;text-shadow:0 0 20px rgba(48,55,66,.75);top:0;transform:translateZ(50px) scale(.95);transition:transform .4s;width:100%;z-index:1}.parallax .parallax-top-left{height:50%;left:0;outline:0;position:absolute;top:0;width:50%;z-index:100}.parallax .parallax-top-left:focus~.parallax-content,.parallax .parallax-top-left:hover~.parallax-content{transform:perspective(1000px) rotateX(3deg) rotateY(-3deg)}.parallax .parallax-top-left:focus~.parallax-content::before,.parallax .parallax-top-left:hover~.parallax-content::before{background:linear-gradient(135deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-top-left:focus~.parallax-content .parallax-front,.parallax .parallax-top-left:hover~.parallax-content .parallax-front{transform:translate3d(4.5px,4.5px,50px) scale(.95)}.parallax .parallax-top-right{height:50%;outline:0;position:absolute;right:0;top:0;width:50%;z-index:100}.parallax .parallax-top-right:focus~.parallax-content,.parallax .parallax-top-right:hover~.parallax-content{transform:perspective(1000px) rotateX(3deg) rotateY(3deg)}.parallax .parallax-top-right:focus~.parallax-content::before,.parallax .parallax-top-right:hover~.parallax-content::before{background:linear-gradient(-135deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-top-right:focus~.parallax-content .parallax-front,.parallax .parallax-top-right:hover~.parallax-content .parallax-front{transform:translate3d(-4.5px,4.5px,50px) scale(.95)}.parallax .parallax-bottom-left{bottom:0;height:50%;left:0;outline:0;position:absolute;width:50%;z-index:100}.parallax .parallax-bottom-left:focus~.parallax-content,.parallax .parallax-bottom-left:hover~.parallax-content{transform:perspective(1000px) rotateX(-3deg) rotateY(-3deg)}.parallax .parallax-bottom-left:focus~.parallax-content::before,.parallax .parallax-bottom-left:hover~.parallax-content::before{background:linear-gradient(45deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-bottom-left:focus~.parallax-content .parallax-front,.parallax .parallax-bottom-left:hover~.parallax-content .parallax-front{transform:translate3d(4.5px,-4.5px,50px) scale(.95)}.parallax .parallax-bottom-right{bottom:0;height:50%;outline:0;position:absolute;right:0;width:50%;z-index:100}.parallax .parallax-bottom-right:focus~.parallax-content,.parallax .parallax-bottom-right:hover~.parallax-content{transform:perspective(1000px) rotateX(-3deg) rotateY(3deg)}.parallax .parallax-bottom-right:focus~.parallax-content::before,.parallax .parallax-bottom-right:hover~.parallax-content::before{background:linear-gradient(-45deg,rgba(255,255,255,.35) 0,transparent 50%)}.parallax .parallax-bottom-right:focus~.parallax-content .parallax-front,.parallax .parallax-bottom-right:hover~.parallax-content .parallax-front{transform:translate3d(-4.5px,-4.5px,50px) scale(.95)}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#eef0f3;border:0;border-radius:.1rem;color:#5755d9;height:.2rem;position:relative;width:100%}.progress::-webkit-progress-bar{background:0 0;border-radius:.1rem}.progress::-webkit-progress-value{background:#5755d9;border-radius:.1rem}.progress::-moz-progress-bar{background:#5755d9;border-radius:.1rem}.progress:indeterminate{animation:progress-indeterminate 1.5s linear infinite;background:#eef0f3 linear-gradient(to right,#5755d9 30%,#eef0f3 30%) top left/150% 150% no-repeat}.progress:indeterminate::-moz-progress-bar{background:0 0}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}.slider{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:0 0;display:block;height:1.2rem;width:100%}.slider:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2);outline:0}.slider.tooltip:not([data-tooltip])::after{content:attr(value)}.slider::-webkit-slider-thumb{-webkit-appearance:none;background:#5755d9;border:0;border-radius:50%;height:.6rem;margin-top:-.25rem;-webkit-transition:transform .2s;transition:transform .2s;width:.6rem}.slider::-moz-range-thumb{background:#5755d9;border:0;border-radius:50%;height:.6rem;-moz-transition:transform .2s;transition:transform .2s;width:.6rem}.slider::-ms-thumb{background:#5755d9;border:0;border-radius:50%;height:.6rem;-ms-transition:transform .2s;transition:transform .2s;width:.6rem}.slider:active::-webkit-slider-thumb{transform:scale(1.25)}.slider:active::-moz-range-thumb{transform:scale(1.25)}.slider:active::-ms-thumb{transform:scale(1.25)}.slider.disabled::-webkit-slider-thumb,.slider:disabled::-webkit-slider-thumb{background:#f7f8f9;transform:scale(1)}.slider.disabled::-moz-range-thumb,.slider:disabled::-moz-range-thumb{background:#f7f8f9;transform:scale(1)}.slider.disabled::-ms-thumb,.slider:disabled::-ms-thumb{background:#f7f8f9;transform:scale(1)}.slider::-webkit-slider-runnable-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-moz-range-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-ms-track{background:#eef0f3;border-radius:.1rem;height:.1rem;width:100%}.slider::-ms-fill-lower{background:#5755d9}.timeline .timeline-item{display:-ms-flexbox;display:flex;margin-bottom:1.2rem;position:relative}.timeline .timeline-item::before{background:#dadee4;content:"";height:100%;left:11px;position:absolute;top:1.2rem;width:2px}.timeline .timeline-item .timeline-left{-ms-flex:0 0 auto;flex:0 0 auto}.timeline .timeline-item .timeline-content{-ms-flex:1 1 auto;flex:1 1 auto;padding:2px 0 2px .8rem}.timeline .timeline-item .timeline-icon{align-items:center;border-radius:50%;color:#fff;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;height:1.2rem;justify-content:center;text-align:center;width:1.2rem}.timeline .timeline-item .timeline-icon::before{border:.1rem solid #5755d9;border-radius:50%;content:"";display:block;height:.4rem;left:.4rem;position:absolute;top:.4rem;width:.4rem}.timeline .timeline-item .timeline-icon.icon-lg{background:#5755d9;line-height:1.2rem}.timeline .timeline-item .timeline-icon.icon-lg::before{content:none}.viewer-360{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-direction:column;flex-direction:column}.viewer-360 .viewer-slider[max="36"][value="1"]+.viewer-image{background-position-y:0}.viewer-360 .viewer-slider[max="36"][value="2"]+.viewer-image{background-position-y:2.8571428571%}.viewer-360 .viewer-slider[max="36"][value="3"]+.viewer-image{background-position-y:5.7142857143%}.viewer-360 .viewer-slider[max="36"][value="4"]+.viewer-image{background-position-y:8.5714285714%}.viewer-360 .viewer-slider[max="36"][value="5"]+.viewer-image{background-position-y:11.4285714286%}.viewer-360 .viewer-slider[max="36"][value="6"]+.viewer-image{background-position-y:14.2857142857%}.viewer-360 .viewer-slider[max="36"][value="7"]+.viewer-image{background-position-y:17.1428571429%}.viewer-360 .viewer-slider[max="36"][value="8"]+.viewer-image{background-position-y:20%}.viewer-360 .viewer-slider[max="36"][value="9"]+.viewer-image{background-position-y:22.8571428571%}.viewer-360 .viewer-slider[max="36"][value="10"]+.viewer-image{background-position-y:25.7142857143%}.viewer-360 .viewer-slider[max="36"][value="11"]+.viewer-image{background-position-y:28.5714285714%}.viewer-360 .viewer-slider[max="36"][value="12"]+.viewer-image{background-position-y:31.4285714286%}.viewer-360 .viewer-slider[max="36"][value="13"]+.viewer-image{background-position-y:34.2857142857%}.viewer-360 .viewer-slider[max="36"][value="14"]+.viewer-image{background-position-y:37.1428571429%}.viewer-360 .viewer-slider[max="36"][value="15"]+.viewer-image{background-position-y:40%}.viewer-360 .viewer-slider[max="36"][value="16"]+.viewer-image{background-position-y:42.8571428571%}.viewer-360 .viewer-slider[max="36"][value="17"]+.viewer-image{background-position-y:45.7142857143%}.viewer-360 .viewer-slider[max="36"][value="18"]+.viewer-image{background-position-y:48.5714285714%}.viewer-360 .viewer-slider[max="36"][value="19"]+.viewer-image{background-position-y:51.4285714286%}.viewer-360 .viewer-slider[max="36"][value="20"]+.viewer-image{background-position-y:54.2857142857%}.viewer-360 .viewer-slider[max="36"][value="21"]+.viewer-image{background-position-y:57.1428571429%}.viewer-360 .viewer-slider[max="36"][value="22"]+.viewer-image{background-position-y:60%}.viewer-360 .viewer-slider[max="36"][value="23"]+.viewer-image{background-position-y:62.8571428571%}.viewer-360 .viewer-slider[max="36"][value="24"]+.viewer-image{background-position-y:65.7142857143%}.viewer-360 .viewer-slider[max="36"][value="25"]+.viewer-image{background-position-y:68.5714285714%}.viewer-360 .viewer-slider[max="36"][value="26"]+.viewer-image{background-position-y:71.4285714286%}.viewer-360 .viewer-slider[max="36"][value="27"]+.viewer-image{background-position-y:74.2857142857%}.viewer-360 .viewer-slider[max="36"][value="28"]+.viewer-image{background-position-y:77.1428571429%}.viewer-360 .viewer-slider[max="36"][value="29"]+.viewer-image{background-position-y:80%}.viewer-360 .viewer-slider[max="36"][value="30"]+.viewer-image{background-position-y:82.8571428571%}.viewer-360 .viewer-slider[max="36"][value="31"]+.viewer-image{background-position-y:85.7142857143%}.viewer-360 .viewer-slider[max="36"][value="32"]+.viewer-image{background-position-y:88.5714285714%}.viewer-360 .viewer-slider[max="36"][value="33"]+.viewer-image{background-position-y:91.4285714286%}.viewer-360 .viewer-slider[max="36"][value="34"]+.viewer-image{background-position-y:94.2857142857%}.viewer-360 .viewer-slider[max="36"][value="35"]+.viewer-image{background-position-y:97.1428571429%}.viewer-360 .viewer-slider[max="36"][value="36"]+.viewer-image{background-position-y:100%}.viewer-360 .viewer-slider{cursor:ew-resize;-ms-flex-order:2;margin:1rem;order:2;width:60%}.viewer-360 .viewer-image{background-position-y:0;background-repeat:no-repeat;background-size:100%;-ms-flex-order:1;max-width:100%;order:1}
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre-icons.css b/public/css/spectre-0.5.9/spectre-icons.css
new file mode 100644
index 0000000..fd881f1
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre-icons.css
@@ -0,0 +1,597 @@
+/*! Spectre.css Icons v0.5.9 | MIT License | github.com/picturepan2/spectre */
+.icon {
+  box-sizing: border-box;
+  display: inline-block;
+  font-size: inherit;
+  font-style: normal;
+  height: 1em;
+  position: relative;
+  text-indent: -9999px;
+  vertical-align: middle;
+  width: 1em;
+}
+
+.icon::before,
+.icon::after {
+  content: "";
+  display: block;
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translate(-50%, -50%);
+}
+
+.icon.icon-2x {
+  font-size: 1.6rem;
+}
+
+.icon.icon-3x {
+  font-size: 2.4rem;
+}
+
+.icon.icon-4x {
+  font-size: 3.2rem;
+}
+
+.accordion .icon,
+.btn .icon,
+.toast .icon,
+.menu .icon {
+  vertical-align: -10%;
+}
+
+.btn-lg .icon {
+  vertical-align: -15%;
+}
+
+.icon-arrow-down::before,
+.icon-arrow-left::before,
+.icon-arrow-right::before,
+.icon-arrow-up::before,
+.icon-downward::before,
+.icon-back::before,
+.icon-forward::before,
+.icon-upward::before {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-right: 0;
+  height: .65em;
+  width: .65em;
+}
+
+.icon-arrow-down::before {
+  transform: translate(-50%, -75%) rotate(225deg);
+}
+
+.icon-arrow-left::before {
+  transform: translate(-25%, -50%) rotate(-45deg);
+}
+
+.icon-arrow-right::before {
+  transform: translate(-75%, -50%) rotate(135deg);
+}
+
+.icon-arrow-up::before {
+  transform: translate(-50%, -25%) rotate(45deg);
+}
+
+.icon-back::after,
+.icon-forward::after {
+  background: currentColor;
+  height: .1rem;
+  width: .8em;
+}
+
+.icon-downward::after,
+.icon-upward::after {
+  background: currentColor;
+  height: .8em;
+  width: .1rem;
+}
+
+.icon-back::after {
+  left: 55%;
+}
+
+.icon-back::before {
+  transform: translate(-50%, -50%) rotate(-45deg);
+}
+
+.icon-downward::after {
+  top: 45%;
+}
+
+.icon-downward::before {
+  transform: translate(-50%, -50%) rotate(-135deg);
+}
+
+.icon-forward::after {
+  left: 45%;
+}
+
+.icon-forward::before {
+  transform: translate(-50%, -50%) rotate(135deg);
+}
+
+.icon-upward::after {
+  top: 55%;
+}
+
+.icon-upward::before {
+  transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.icon-caret::before {
+  border-left: .3em solid transparent;
+  border-right: .3em solid transparent;
+  border-top: .3em solid currentColor;
+  height: 0;
+  transform: translate(-50%, -25%);
+  width: 0;
+}
+
+.icon-menu::before {
+  background: currentColor;
+  box-shadow: 0 -.35em, 0 .35em;
+  height: .1rem;
+  width: 100%;
+}
+
+.icon-apps::before {
+  background: currentColor;
+  box-shadow: -.35em -.35em, -.35em 0, -.35em .35em, 0 -.35em, 0 .35em, .35em -.35em, .35em 0, .35em .35em;
+  height: 3px;
+  width: 3px;
+}
+
+.icon-resize-horiz::before,
+.icon-resize-horiz::after,
+.icon-resize-vert::before,
+.icon-resize-vert::after {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-right: 0;
+  height: .45em;
+  width: .45em;
+}
+
+.icon-resize-horiz::before,
+.icon-resize-vert::before {
+  transform: translate(-50%, -90%) rotate(45deg);
+}
+
+.icon-resize-horiz::after,
+.icon-resize-vert::after {
+  transform: translate(-50%, -10%) rotate(225deg);
+}
+
+.icon-resize-horiz::before {
+  transform: translate(-90%, -50%) rotate(-45deg);
+}
+
+.icon-resize-horiz::after {
+  transform: translate(-10%, -50%) rotate(135deg);
+}
+
+.icon-more-horiz::before,
+.icon-more-vert::before {
+  background: currentColor;
+  border-radius: 50%;
+  box-shadow: -.4em 0, .4em 0;
+  height: 3px;
+  width: 3px;
+}
+
+.icon-more-vert::before {
+  box-shadow: 0 -.4em, 0 .4em;
+}
+
+.icon-plus::before,
+.icon-minus::before,
+.icon-cross::before {
+  background: currentColor;
+  height: .1rem;
+  width: 100%;
+}
+
+.icon-plus::after,
+.icon-cross::after {
+  background: currentColor;
+  height: 100%;
+  width: .1rem;
+}
+
+.icon-cross::before {
+  width: 100%;
+}
+
+.icon-cross::after {
+  height: 100%;
+}
+
+.icon-cross::before,
+.icon-cross::after {
+  transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.icon-check::before {
+  border: .1rem solid currentColor;
+  border-right: 0;
+  border-top: 0;
+  height: .5em;
+  transform: translate(-50%, -75%) rotate(-45deg); 
+  width: .9em;
+}
+
+.icon-stop {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+}
+
+.icon-stop::before {
+  background: currentColor;
+  height: .1rem;
+  transform: translate(-50%, -50%) rotate(45deg);
+  width: 1em;
+}
+
+.icon-shutdown {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  border-top-color: transparent;
+}
+
+.icon-shutdown::before {
+  background: currentColor;
+  content: "";
+  height: .5em;
+  top: .1em;
+  width: .1rem;
+}
+
+.icon-refresh::before {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  border-right-color: transparent;
+  height: 1em;
+  width: 1em;
+}
+
+.icon-refresh::after {
+  border: .2em solid currentColor;
+  border-left-color: transparent;
+  border-top-color: transparent;
+  height: 0;
+  left: 80%;
+  top: 20%;
+  width: 0;
+}
+
+.icon-search::before {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  height: .75em;
+  left: 5%;
+  top: 5%;
+  transform: translate(0, 0) rotate(45deg);
+  width: .75em;
+}
+
+.icon-search::after {
+  background: currentColor;
+  height: .1rem;
+  left: 80%;
+  top: 80%;
+  transform: translate(-50%, -50%) rotate(45deg);
+  width: .4em;
+}
+
+.icon-edit::before {
+  border: .1rem solid currentColor;
+  height: .4em;
+  transform: translate(-40%, -60%) rotate(-45deg);
+  width: .85em;
+}
+
+.icon-edit::after {
+  border: .15em solid currentColor;
+  border-right-color: transparent;
+  border-top-color: transparent;
+  height: 0;
+  left: 5%;
+  top: 95%;
+  transform: translate(0, -100%);
+  width: 0;
+}
+
+.icon-delete::before {
+  border: .1rem solid currentColor;
+  border-bottom-left-radius: .1rem;
+  border-bottom-right-radius: .1rem;
+  border-top: 0;
+  height: .75em;
+  top: 60%;
+  width: .75em;
+}
+
+.icon-delete::after {
+  background: currentColor;
+  box-shadow: -.25em .2em, .25em .2em;
+  height: .1rem;
+  top: .05rem;
+  width: .5em;
+}
+
+.icon-share {
+  border: .1rem solid currentColor;
+  border-radius: .1rem;
+  border-right: 0;
+  border-top: 0;
+}
+
+.icon-share::before {
+  border: .1rem solid currentColor;
+  border-left: 0;
+  border-top: 0;
+  height: .4em;
+  left: 100%;
+  top: .25em;
+  transform: translate(-125%, -50%) rotate(-45deg);
+  width: .4em;
+}
+
+.icon-share::after {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-radius: 75% 0;
+  border-right: 0;
+  height: .5em;
+  width: .6em;
+}
+
+.icon-flag::before {
+  background: currentColor;
+  height: 1em;
+  left: 15%;
+  width: .1rem;
+}
+
+.icon-flag::after {
+  border: .1rem solid currentColor;
+  border-bottom-right-radius: .1rem;
+  border-left: 0;
+  border-top-right-radius: .1rem;
+  height: .65em;
+  left: 60%;
+  top: 35%;
+  width: .8em;
+}
+
+.icon-bookmark::before {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-top-left-radius: .1rem;
+  border-top-right-radius: .1rem;
+  height: .9em;
+  width: .8em;
+}
+
+.icon-bookmark::after {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-left: 0;
+  border-radius: .1rem;
+  height: .5em;
+  transform: translate(-50%, 35%) rotate(-45deg) skew(15deg, 15deg);
+  width: .5em;
+}
+
+.icon-download,
+.icon-upload {
+  border-bottom: .1rem solid currentColor;
+}
+
+.icon-download::before,
+.icon-upload::before {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-right: 0;
+  height: .5em;
+  transform: translate(-50%, -60%) rotate(-135deg); 
+  width: .5em;
+}
+
+.icon-download::after,
+.icon-upload::after {
+  background: currentColor;
+  height: .6em;
+  top: 40%;
+  width: .1rem;
+}
+
+.icon-upload::before {
+  transform: translate(-50%, -60%) rotate(45deg);
+}
+
+.icon-upload::after {
+  top: 50%;
+}
+
+.icon-copy::before {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-radius: .1rem;
+  border-right: 0;
+  height: .8em;
+  left: 40%;
+  top: 35%;
+  width: .8em;
+}
+
+.icon-copy::after {
+  border: .1rem solid currentColor;
+  border-radius: .1rem;
+  height: .8em;
+  left: 60%;
+  top: 60%;
+  width: .8em;
+}
+
+.icon-time {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+}
+
+.icon-time::before {
+  background: currentColor;
+  height: .4em;
+  transform: translate(-50%, -75%);
+  width: .1rem;
+}
+
+.icon-time::after {
+  background: currentColor;
+  height: .3em;
+  transform: translate(-50%, -75%) rotate(90deg);
+  transform-origin: 50% 90%;
+  width: .1rem;
+}
+
+.icon-mail::before {
+  border: .1rem solid currentColor;
+  border-radius: .1rem;
+  height: .8em;
+  width: 1em;
+}
+
+.icon-mail::after {
+  border: .1rem solid currentColor;
+  border-right: 0;
+  border-top: 0;
+  height: .5em;
+  transform: translate(-50%, -90%) rotate(-45deg) skew(10deg, 10deg);
+  width: .5em;
+}
+
+.icon-people::before {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  height: .45em;
+  top: 25%;
+  width: .45em;
+}
+
+.icon-people::after {
+  border: .1rem solid currentColor;
+  border-radius: 50% 50% 0 0;
+  height: .4em;
+  top: 75%;
+  width: .9em;
+}
+
+.icon-message {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-radius: .1rem;
+  border-right: 0;
+}
+
+.icon-message::before {
+  border: .1rem solid currentColor;
+  border-bottom-right-radius: .1rem;
+  border-left: 0;
+  border-top: 0;
+  height: .8em;
+  left: 65%;
+  top: 40%;
+  width: .7em;
+}
+
+.icon-message::after {
+  background: currentColor;
+  border-radius: .1rem;
+  height: .3em;
+  left: 10%;
+  top: 100%;
+  transform: translate(0, -90%) rotate(45deg);
+  width: .1rem;
+}
+
+.icon-photo {
+  border: .1rem solid currentColor;
+  border-radius: .1rem;
+}
+
+.icon-photo::before {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  height: .25em;
+  left: 35%;
+  top: 35%;
+  width: .25em;
+}
+
+.icon-photo::after {
+  border: .1rem solid currentColor;
+  border-bottom: 0;
+  border-left: 0;
+  height: .5em;
+  left: 60%;
+  transform: translate(-50%, 25%) rotate(-45deg);
+  width: .5em;
+}
+
+.icon-link::before,
+.icon-link::after {
+  border: .1rem solid currentColor;
+  border-radius: 5em 0 0 5em;
+  border-right: 0;
+  height: .5em;
+  width: .75em;
+}
+
+.icon-link::before {
+  transform: translate(-70%, -45%) rotate(-45deg);
+}
+
+.icon-link::after {
+  transform: translate(-30%, -55%) rotate(135deg);
+}
+
+.icon-location::before {
+  border: .1rem solid currentColor;
+  border-radius: 50% 50% 50% 0;
+  height: .8em;
+  transform: translate(-50%, -60%) rotate(-45deg);
+  width: .8em;
+}
+
+.icon-location::after {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+  height: .2em;
+  transform: translate(-50%, -80%);
+  width: .2em;
+}
+
+.icon-emoji {
+  border: .1rem solid currentColor;
+  border-radius: 50%;
+}
+
+.icon-emoji::before {
+  border-radius: 50%;
+  box-shadow: -.17em -.1em, .17em -.1em;
+  height: .15em;
+  width: .15em;
+}
+
+.icon-emoji::after {
+  border: .1rem solid currentColor;
+  border-bottom-color: transparent;
+  border-radius: 50%;
+  border-right-color: transparent;
+  height: .5em;
+  transform: translate(-50%, -40%) rotate(-135deg);
+  width: .5em;
+}
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre-icons.min.css b/public/css/spectre-0.5.9/spectre-icons.min.css
new file mode 100644
index 0000000..0276f7b
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre-icons.min.css
@@ -0,0 +1 @@
+/*! Spectre.css Icons v0.5.9 | MIT License | github.com/picturepan2/spectre */.icon{box-sizing:border-box;display:inline-block;font-size:inherit;font-style:normal;height:1em;position:relative;text-indent:-9999px;vertical-align:middle;width:1em}.icon::after,.icon::before{content:"";display:block;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%)}.icon.icon-2x{font-size:1.6rem}.icon.icon-3x{font-size:2.4rem}.icon.icon-4x{font-size:3.2rem}.accordion .icon,.btn .icon,.menu .icon,.toast .icon{vertical-align:-10%}.btn-lg .icon{vertical-align:-15%}.icon-arrow-down::before,.icon-arrow-left::before,.icon-arrow-right::before,.icon-arrow-up::before,.icon-back::before,.icon-downward::before,.icon-forward::before,.icon-upward::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.65em;width:.65em}.icon-arrow-down::before{transform:translate(-50%,-75%) rotate(225deg)}.icon-arrow-left::before{transform:translate(-25%,-50%) rotate(-45deg)}.icon-arrow-right::before{transform:translate(-75%,-50%) rotate(135deg)}.icon-arrow-up::before{transform:translate(-50%,-25%) rotate(45deg)}.icon-back::after,.icon-forward::after{background:currentColor;height:.1rem;width:.8em}.icon-downward::after,.icon-upward::after{background:currentColor;height:.8em;width:.1rem}.icon-back::after{left:55%}.icon-back::before{transform:translate(-50%,-50%) rotate(-45deg)}.icon-downward::after{top:45%}.icon-downward::before{transform:translate(-50%,-50%) rotate(-135deg)}.icon-forward::after{left:45%}.icon-forward::before{transform:translate(-50%,-50%) rotate(135deg)}.icon-upward::after{top:55%}.icon-upward::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-caret::before{border-left:.3em solid transparent;border-right:.3em solid transparent;border-top:.3em solid currentColor;height:0;transform:translate(-50%,-25%);width:0}.icon-menu::before{background:currentColor;box-shadow:0 -.35em,0 .35em;height:.1rem;width:100%}.icon-apps::before{background:currentColor;box-shadow:-.35em -.35em,-.35em 0,-.35em .35em,0 -.35em,0 .35em,.35em -.35em,.35em 0,.35em .35em;height:3px;width:3px}.icon-resize-horiz::after,.icon-resize-horiz::before,.icon-resize-vert::after,.icon-resize-vert::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.45em;width:.45em}.icon-resize-horiz::before,.icon-resize-vert::before{transform:translate(-50%,-90%) rotate(45deg)}.icon-resize-horiz::after,.icon-resize-vert::after{transform:translate(-50%,-10%) rotate(225deg)}.icon-resize-horiz::before{transform:translate(-90%,-50%) rotate(-45deg)}.icon-resize-horiz::after{transform:translate(-10%,-50%) rotate(135deg)}.icon-more-horiz::before,.icon-more-vert::before{background:currentColor;border-radius:50%;box-shadow:-.4em 0,.4em 0;height:3px;width:3px}.icon-more-vert::before{box-shadow:0 -.4em,0 .4em}.icon-cross::before,.icon-minus::before,.icon-plus::before{background:currentColor;height:.1rem;width:100%}.icon-cross::after,.icon-plus::after{background:currentColor;height:100%;width:.1rem}.icon-cross::before{width:100%}.icon-cross::after{height:100%}.icon-cross::after,.icon-cross::before{transform:translate(-50%,-50%) rotate(45deg)}.icon-check::before{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-75%) rotate(-45deg);width:.9em}.icon-stop{border:.1rem solid currentColor;border-radius:50%}.icon-stop::before{background:currentColor;height:.1rem;transform:translate(-50%,-50%) rotate(45deg);width:1em}.icon-shutdown{border:.1rem solid currentColor;border-radius:50%;border-top-color:transparent}.icon-shutdown::before{background:currentColor;content:"";height:.5em;top:.1em;width:.1rem}.icon-refresh::before{border:.1rem solid currentColor;border-radius:50%;border-right-color:transparent;height:1em;width:1em}.icon-refresh::after{border:.2em solid currentColor;border-left-color:transparent;border-top-color:transparent;height:0;left:80%;top:20%;width:0}.icon-search::before{border:.1rem solid currentColor;border-radius:50%;height:.75em;left:5%;top:5%;transform:translate(0,0) rotate(45deg);width:.75em}.icon-search::after{background:currentColor;height:.1rem;left:80%;top:80%;transform:translate(-50%,-50%) rotate(45deg);width:.4em}.icon-edit::before{border:.1rem solid currentColor;height:.4em;transform:translate(-40%,-60%) rotate(-45deg);width:.85em}.icon-edit::after{border:.15em solid currentColor;border-right-color:transparent;border-top-color:transparent;height:0;left:5%;top:95%;transform:translate(0,-100%);width:0}.icon-delete::before{border:.1rem solid currentColor;border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem;border-top:0;height:.75em;top:60%;width:.75em}.icon-delete::after{background:currentColor;box-shadow:-.25em .2em,.25em .2em;height:.1rem;top:.05rem;width:.5em}.icon-share{border:.1rem solid currentColor;border-radius:.1rem;border-right:0;border-top:0}.icon-share::before{border:.1rem solid currentColor;border-left:0;border-top:0;height:.4em;left:100%;top:.25em;transform:translate(-125%,-50%) rotate(-45deg);width:.4em}.icon-share::after{border:.1rem solid currentColor;border-bottom:0;border-radius:75% 0;border-right:0;height:.5em;width:.6em}.icon-flag::before{background:currentColor;height:1em;left:15%;width:.1rem}.icon-flag::after{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top-right-radius:.1rem;height:.65em;left:60%;top:35%;width:.8em}.icon-bookmark::before{border:.1rem solid currentColor;border-bottom:0;border-top-left-radius:.1rem;border-top-right-radius:.1rem;height:.9em;width:.8em}.icon-bookmark::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;border-radius:.1rem;height:.5em;transform:translate(-50%,35%) rotate(-45deg) skew(15deg,15deg);width:.5em}.icon-download,.icon-upload{border-bottom:.1rem solid currentColor}.icon-download::before,.icon-upload::before{border:.1rem solid currentColor;border-bottom:0;border-right:0;height:.5em;transform:translate(-50%,-60%) rotate(-135deg);width:.5em}.icon-download::after,.icon-upload::after{background:currentColor;height:.6em;top:40%;width:.1rem}.icon-upload::before{transform:translate(-50%,-60%) rotate(45deg)}.icon-upload::after{top:50%}.icon-copy::before{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0;height:.8em;left:40%;top:35%;width:.8em}.icon-copy::after{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;left:60%;top:60%;width:.8em}.icon-time{border:.1rem solid currentColor;border-radius:50%}.icon-time::before{background:currentColor;height:.4em;transform:translate(-50%,-75%);width:.1rem}.icon-time::after{background:currentColor;height:.3em;transform:translate(-50%,-75%) rotate(90deg);transform-origin:50% 90%;width:.1rem}.icon-mail::before{border:.1rem solid currentColor;border-radius:.1rem;height:.8em;width:1em}.icon-mail::after{border:.1rem solid currentColor;border-right:0;border-top:0;height:.5em;transform:translate(-50%,-90%) rotate(-45deg) skew(10deg,10deg);width:.5em}.icon-people::before{border:.1rem solid currentColor;border-radius:50%;height:.45em;top:25%;width:.45em}.icon-people::after{border:.1rem solid currentColor;border-radius:50% 50% 0 0;height:.4em;top:75%;width:.9em}.icon-message{border:.1rem solid currentColor;border-bottom:0;border-radius:.1rem;border-right:0}.icon-message::before{border:.1rem solid currentColor;border-bottom-right-radius:.1rem;border-left:0;border-top:0;height:.8em;left:65%;top:40%;width:.7em}.icon-message::after{background:currentColor;border-radius:.1rem;height:.3em;left:10%;top:100%;transform:translate(0,-90%) rotate(45deg);width:.1rem}.icon-photo{border:.1rem solid currentColor;border-radius:.1rem}.icon-photo::before{border:.1rem solid currentColor;border-radius:50%;height:.25em;left:35%;top:35%;width:.25em}.icon-photo::after{border:.1rem solid currentColor;border-bottom:0;border-left:0;height:.5em;left:60%;transform:translate(-50%,25%) rotate(-45deg);width:.5em}.icon-link::after,.icon-link::before{border:.1rem solid currentColor;border-radius:5em 0 0 5em;border-right:0;height:.5em;width:.75em}.icon-link::before{transform:translate(-70%,-45%) rotate(-45deg)}.icon-link::after{transform:translate(-30%,-55%) rotate(135deg)}.icon-location::before{border:.1rem solid currentColor;border-radius:50% 50% 50% 0;height:.8em;transform:translate(-50%,-60%) rotate(-45deg);width:.8em}.icon-location::after{border:.1rem solid currentColor;border-radius:50%;height:.2em;transform:translate(-50%,-80%);width:.2em}.icon-emoji{border:.1rem solid currentColor;border-radius:50%}.icon-emoji::before{border-radius:50%;box-shadow:-.17em -.1em,.17em -.1em;height:.15em;width:.15em}.icon-emoji::after{border:.1rem solid currentColor;border-bottom-color:transparent;border-radius:50%;border-right-color:transparent;height:.5em;transform:translate(-50%,-40%) rotate(-135deg);width:.5em}
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre.css b/public/css/spectre-0.5.9/spectre.css
new file mode 100644
index 0000000..1499dcc
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre.css
@@ -0,0 +1,3760 @@
+/*! Spectre.css v0.5.9 | MIT License | github.com/picturepan2/spectre */
+/* Manually forked from Normalize.css */
+/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
+/** 1. Change the default font family in all browsers (opinionated). 2. Correct the line height in all browsers. 3. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS. */
+/* Document ========================================================================== */
+html {
+  font-family: sans-serif; /* 1 */
+  -webkit-text-size-adjust: 100%; /* 3 */ 
+  -ms-text-size-adjust: 100%; /* 3 */
+}
+
+/* Sections ========================================================================== */
+/** Remove the margin in all browsers (opinionated). */
+body {
+  margin: 0;
+}
+
+/** Add the correct display in IE 9-. */
+article,
+aside,
+footer,
+header,
+nav,
+section {
+  display: block;
+}
+
+/** Correct the font size and margin on `h1` elements within `section` and `article` contexts in Chrome, Firefox, and Safari. */
+h1 {
+  font-size: 2em;
+  margin: .67em 0;
+}
+
+/* Grouping content ========================================================================== */
+/** Add the correct display in IE 9-. 1. Add the correct display in IE. */
+figcaption,
+figure,
+main {
+  /* 1 */ display: block;
+}
+
+/** Add the correct margin in IE 8 (removed). */
+/** 1. Add the correct box sizing in Firefox. 2. Show the overflow in Edge and IE. */
+hr {
+  box-sizing: content-box; /* 1 */
+  height: 0; /* 1 */
+  overflow: visible; /* 2 */
+}
+
+/** 1. Correct the inheritance and scaling of font size in all browsers. (removed) 2. Correct the odd `em` font sizing in all browsers. */
+/* Text-level semantics ========================================================================== */
+/** 1. Remove the gray background on active links in IE 10. 2. Remove gaps in links underline in iOS 8+ and Safari 8+. */
+a {
+  background-color: transparent; /* 1 */
+  -webkit-text-decoration-skip: objects; /* 2 */
+}
+
+/** Remove the outline on focused links when they are also active or hovered in all browsers (opinionated). */
+a:active,
+a:hover {
+  outline-width: 0;
+}
+
+/** Modify default styling of address. */
+address {
+  font-style: normal;
+}
+
+/** 1. Remove the bottom border in Firefox 39-. 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed) */
+/** Prevent the duplicate application of `bolder` by the next rule in Safari 6. */
+b,
+strong {
+  font-weight: inherit;
+}
+
+/** Add the correct font weight in Chrome, Edge, and Safari. */
+b,
+strong {
+  font-weight: bolder;
+}
+
+/** 1. Correct the inheritance and scaling of font size in all browsers. 2. Correct the odd `em` font sizing in all browsers. */
+code,
+kbd,
+pre,
+samp {
+  font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace; /* 1 (changed) */
+  font-size: 1em; /* 2 */
+}
+
+/** Add the correct font style in Android 4.3-. */
+dfn {
+  font-style: italic;
+}
+
+/** Add the correct background and color in IE 9-. (Removed) */
+/** Add the correct font size in all browsers. */
+small {
+  font-size: 80%;
+  font-weight: 400; /* (added) */
+}
+
+/** Prevent `sub` and `sup` elements from affecting the line height in all browsers. */
+sub,
+sup {
+  font-size: 75%;
+  line-height: 0;
+  position: relative;
+  vertical-align: baseline;
+}
+
+sub {
+  bottom: -.25em;
+}
+
+sup {
+  top: -.5em;
+}
+
+/* Embedded content ========================================================================== */
+/** Add the correct display in IE 9-. */
+audio,
+video {
+  display: inline-block;
+}
+
+/** Add the correct display in iOS 4-7. */
+audio:not([controls]) {
+  display: none;
+  height: 0;
+}
+
+/** Remove the border on images inside links in IE 10-. */
+img {
+  border-style: none;
+}
+
+/** Hide the overflow in IE. */
+svg:not(:root) {
+  overflow: hidden;
+}
+
+/* Forms ========================================================================== */
+/** 1. Change the font styles in all browsers (opinionated). 2. Remove the margin in Firefox and Safari. */
+button,
+input,
+optgroup,
+select,
+textarea {
+  font-family: inherit; /* 1 (changed) */
+  font-size: inherit; /* 1 (changed) */
+  line-height: inherit; /* 1 (changed) */
+  margin: 0; /* 2 */
+}
+
+/** Show the overflow in IE. 1. Show the overflow in Edge. */
+button,
+input {
+  /* 1 */ overflow: visible;
+}
+
+/** Remove the inheritance of text transform in Edge, Firefox, and IE. 1. Remove the inheritance of text transform in Firefox. */
+button,
+select {
+  /* 1 */ text-transform: none;
+}
+
+/** 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` controls in Android 4. 2. Correct the inability to style clickable types in iOS and Safari. */
+button,
+html [type="button"],
+[type="reset"],
+[type="submit"] {
+  -webkit-appearance: button; /* 2 */
+}
+
+/** Remove the inner border and padding in Firefox. */
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+  border-style: none;
+  padding: 0;
+}
+
+/** Restore the focus styles unset by the previous rule (removed). */
+/** Change the border, margin, and padding in all browsers (opinionated) (changed). */
+fieldset {
+  border: 0;
+  margin: 0;
+  padding: 0;
+}
+
+/** 1. Correct the text wrapping in Edge and IE. 2. Correct the color inheritance from `fieldset` elements in IE. 3. Remove the padding so developers are not caught out when they zero out `fieldset` elements in all browsers. */
+legend {
+  box-sizing: border-box; /* 1 */
+  color: inherit; /* 2 */
+  display: table; /* 1 */
+  max-width: 100%; /* 1 */
+  padding: 0; /* 3 */
+  white-space: normal; /* 1 */
+}
+
+/** 1. Add the correct display in IE 9-. 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. */
+progress {
+  display: inline-block; /* 1 */
+  vertical-align: baseline; /* 2 */
+}
+
+/** Remove the default vertical scrollbar in IE. */
+textarea {
+  overflow: auto;
+}
+
+/** 1. Add the correct box sizing in IE 10-. 2. Remove the padding in IE 10-. */
+[type="checkbox"],
+[type="radio"] {
+  box-sizing: border-box; /* 1 */
+  padding: 0; /* 2 */
+}
+
+/** Correct the cursor style of increment and decrement buttons in Chrome. */
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+  height: auto;
+}
+
+/** 1. Correct the odd appearance in Chrome and Safari. 2. Correct the outline style in Safari. */
+[type="search"] {
+  -webkit-appearance: textfield; /* 1 */
+  outline-offset: -2px; /* 2 */
+}
+
+/** Remove the inner padding and cancel buttons in Chrome and Safari on macOS. */
+[type="search"]::-webkit-search-cancel-button,
+[type="search"]::-webkit-search-decoration {
+  -webkit-appearance: none;
+}
+
+/** 1. Correct the inability to style clickable types in iOS and Safari. 2. Change font properties to `inherit` in Safari. */
+::-webkit-file-upload-button {
+  -webkit-appearance: button; /* 1 */
+  font: inherit; /* 2 */
+}
+
+/* Interactive ========================================================================== */
+/* Add the correct display in IE 9-. 1. Add the correct display in Edge, IE, and Firefox. */
+details,
+menu {
+  display: block;
+}
+
+/* Add the correct display in all browsers. */
+summary {
+  display: list-item;
+  outline: none;
+}
+
+/* Scripting ========================================================================== */
+/** Add the correct display in IE 9-. */
+canvas {
+  display: inline-block;
+}
+
+/** Add the correct display in IE. */
+template {
+  display: none;
+}
+
+/* Hidden ========================================================================== */
+/** Add the correct display in IE 10-. */
+[hidden] {
+  display: none;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: inherit;
+}
+
+html {
+  box-sizing: border-box;
+  font-size: 20px;
+  line-height: 1.5;
+  -webkit-tap-highlight-color: transparent;
+}
+
+body {
+  background: #fff;
+  color: #3b4351;
+  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+  font-size: .8rem;
+  overflow-x: hidden;
+  text-rendering: optimizeLegibility;
+}
+
+a {
+  color: #5755d9;
+  outline: none;
+  text-decoration: none;
+}
+
+a:focus {
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+a:focus,
+a:hover,
+a:active,
+a.active {
+  color: #302ecd;
+  text-decoration: underline;
+}
+
+a:visited {
+  color: #807fe2;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  color: inherit;
+  font-weight: 500;
+  line-height: 1.2;
+  margin-bottom: .5em;
+  margin-top: 0;
+}
+
+.h1,
+.h2,
+.h3,
+.h4,
+.h5,
+.h6 {
+  font-weight: 500;
+}
+
+h1,
+.h1 {
+  font-size: 2rem;
+}
+
+h2,
+.h2 {
+  font-size: 1.6rem;
+}
+
+h3,
+.h3 {
+  font-size: 1.4rem;
+}
+
+h4,
+.h4 {
+  font-size: 1.2rem;
+}
+
+h5,
+.h5 {
+  font-size: 1rem;
+}
+
+h6,
+.h6 {
+  font-size: .8rem;
+}
+
+p {
+  margin: 0 0 1.2rem;
+}
+
+a,
+ins,
+u {
+  -webkit-text-decoration-skip: ink edges;
+  text-decoration-skip: ink edges;
+}
+
+abbr[title] {
+  border-bottom: .05rem dotted;
+  cursor: help;
+  text-decoration: none;
+}
+
+kbd {
+  background: #303742;
+  border-radius: .1rem;
+  color: #fff;
+  font-size: .7rem; 
+  line-height: 1.25;
+  padding: .1rem .2rem;
+}
+
+mark {
+  background: #ffe9b3;
+  border-bottom: .05rem solid #ffd367;
+  border-radius: .1rem;
+  color: #3b4351;
+  padding: .05rem .1rem 0;
+}
+
+blockquote {
+  border-left: .1rem solid #dadee4;
+  margin-left: 0;
+  padding: .4rem .8rem;
+}
+
+blockquote p:last-child {
+  margin-bottom: 0;
+}
+
+ul,
+ol {
+  margin: .8rem 0 .8rem .8rem;
+  padding: 0;
+}
+
+ul ul,
+ul ol,
+ol ul,
+ol ol {
+  margin: .8rem 0 .8rem .8rem;
+}
+
+ul li,
+ol li {
+  margin-top: .4rem;
+}
+
+ul {
+  list-style: disc inside;
+}
+
+ul ul {
+  list-style-type: circle;
+}
+
+ol {
+  list-style: decimal inside;
+}
+
+ol ol {
+  list-style-type: lower-alpha;
+}
+
+dl dt {
+  font-weight: bold;
+}
+
+dl dd {
+  margin: .4rem 0 .8rem 0;
+}
+
+html:lang(zh),
+html:lang(zh-Hans),
+.lang-zh,
+.lang-zh-hans {
+  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", sans-serif;
+}
+
+html:lang(zh-Hant),
+.lang-zh-hant {
+  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang TC", "Hiragino Sans CNS", "Microsoft JhengHei", "Helvetica Neue", sans-serif;
+}
+
+html:lang(ja),
+.lang-ja {
+  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, "Helvetica Neue", sans-serif;
+}
+
+html:lang(ko),
+.lang-ko {
+  font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Malgun Gothic", "Helvetica Neue", sans-serif;
+}
+
+:lang(zh) ins,
+:lang(zh) u,
+:lang(ja) ins,
+:lang(ja) u,
+.lang-cjk ins,
+.lang-cjk u {
+  border-bottom: .05rem solid;
+  text-decoration: none;
+}
+
+:lang(zh) del + del,
+:lang(zh) del + s,
+:lang(zh) ins + ins,
+:lang(zh) ins + u,
+:lang(zh) s + del,
+:lang(zh) s + s,
+:lang(zh) u + ins,
+:lang(zh) u + u,
+:lang(ja) del + del,
+:lang(ja) del + s,
+:lang(ja) ins + ins,
+:lang(ja) ins + u,
+:lang(ja) s + del,
+:lang(ja) s + s,
+:lang(ja) u + ins,
+:lang(ja) u + u,
+.lang-cjk del + del,
+.lang-cjk del + s,
+.lang-cjk ins + ins,
+.lang-cjk ins + u,
+.lang-cjk s + del,
+.lang-cjk s + s,
+.lang-cjk u + ins,
+.lang-cjk u + u {
+  margin-left: .125em;
+}
+
+.table {
+  border-collapse: collapse;
+  border-spacing: 0;
+  text-align: left; 
+  width: 100%;
+}
+
+.table.table-striped tbody tr:nth-of-type(odd) {
+  background: #f7f8f9;
+}
+
+.table tbody tr.active,
+.table.table-striped tbody tr.active {
+  background: #eef0f3;
+}
+
+.table.table-hover tbody tr:hover {
+  background: #eef0f3;
+}
+
+.table.table-scroll {
+  display: block;
+  overflow-x: auto;
+  padding-bottom: .75rem;
+  white-space: nowrap;
+}
+
+.table td,
+.table th {
+  border-bottom: .05rem solid #dadee4;
+  padding: .6rem .4rem;
+}
+
+.table th {
+  border-bottom-width: .1rem;
+}
+
+.btn {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: #fff;
+  border: .05rem solid #5755d9;
+  border-radius: .1rem;
+  color: #5755d9;
+  cursor: pointer;
+  display: inline-block;
+  font-size: .8rem;
+  height: 1.8rem;
+  line-height: 1.2rem;
+  outline: none;
+  padding: .25rem .4rem;
+  text-align: center;
+  text-decoration: none;
+  transition: background .2s, border .2s, box-shadow .2s, color .2s;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  vertical-align: middle;
+  white-space: nowrap;
+}
+
+.btn:focus {
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.btn:focus,
+.btn:hover {
+  background: #f1f1fc;
+  border-color: #4b48d6;
+  text-decoration: none;
+}
+
+.btn:active,
+.btn.active {
+  background: #4b48d6;
+  border-color: #3634d2;
+  color: #fff;
+  text-decoration: none;
+}
+
+.btn:active.loading::after,
+.btn.active.loading::after {
+  border-bottom-color: #fff;
+  border-left-color: #fff;
+}
+
+.btn[disabled],
+.btn:disabled,
+.btn.disabled {
+  cursor: default;
+  opacity: .5;
+  pointer-events: none;
+}
+
+.btn.btn-primary {
+  background: #5755d9;
+  border-color: #4b48d6;
+  color: #fff;
+}
+
+.btn.btn-primary:focus,
+.btn.btn-primary:hover {
+  background: #4240d4;
+  border-color: #3634d2;
+  color: #fff;
+}
+
+.btn.btn-primary:active,
+.btn.btn-primary.active {
+  background: #3a38d2;
+  border-color: #302ecd;
+  color: #fff;
+}
+
+.btn.btn-primary.loading::after {
+  border-bottom-color: #fff;
+  border-left-color: #fff;
+}
+
+.btn.btn-success {
+  background: #32b643;
+  border-color: #2faa3f;
+  color: #fff;
+}
+
+.btn.btn-success:focus {
+  box-shadow: 0 0 0 .1rem rgba(50, 182, 67, .2);
+}
+
+.btn.btn-success:focus,
+.btn.btn-success:hover {
+  background: #30ae40;
+  border-color: #2da23c;
+  color: #fff;
+}
+
+.btn.btn-success:active,
+.btn.btn-success.active {
+  background: #2a9a39;
+  border-color: #278e34;
+  color: #fff;
+}
+
+.btn.btn-success.loading::after {
+  border-bottom-color: #fff;
+  border-left-color: #fff;
+}
+
+.btn.btn-error {
+  background: #e85600;
+  border-color: #d95000;
+  color: #fff;
+}
+
+.btn.btn-error:focus {
+  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
+}
+
+.btn.btn-error:focus,
+.btn.btn-error:hover {
+  background: #de5200;
+  border-color: #cf4d00;
+  color: #fff;
+}
+
+.btn.btn-error:active,
+.btn.btn-error.active {
+  background: #c44900;
+  border-color: #b54300;
+  color: #fff;
+}
+
+.btn.btn-error.loading::after {
+  border-bottom-color: #fff;
+  border-left-color: #fff;
+}
+
+.btn.btn-link {
+  background: transparent;
+  border-color: transparent;
+  color: #5755d9;
+}
+
+.btn.btn-link:focus,
+.btn.btn-link:hover,
+.btn.btn-link:active,
+.btn.btn-link.active {
+  color: #302ecd;
+}
+
+.btn.btn-sm {
+  font-size: .7rem;
+  height: 1.4rem;
+  padding: .05rem .3rem;
+}
+
+.btn.btn-lg {
+  font-size: .9rem;
+  height: 2rem;
+  padding: .35rem .6rem;
+}
+
+.btn.btn-block {
+  display: block;
+  width: 100%;
+}
+
+.btn.btn-action {
+  padding-left: 0;
+  padding-right: 0; 
+  width: 1.8rem;
+}
+
+.btn.btn-action.btn-sm {
+  width: 1.4rem;
+}
+
+.btn.btn-action.btn-lg {
+  width: 2rem;
+}
+
+.btn.btn-clear {
+  background: transparent;
+  border: 0;
+  color: currentColor;
+  height: 1rem;
+  line-height: .8rem;
+  margin-left: .2rem;
+  margin-right: -2px;
+  opacity: 1;
+  padding: .1rem;
+  text-decoration: none;
+  width: 1rem;
+}
+
+.btn.btn-clear:focus,
+.btn.btn-clear:hover {
+  background: rgba(247, 248, 249, .5);
+  opacity: .95;
+}
+
+.btn.btn-clear::before {
+  content: "\2715";
+}
+
+.btn-group {
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+}
+
+.btn-group .btn {
+  -ms-flex: 1 0 auto;
+  flex: 1 0 auto;
+}
+
+.btn-group .btn:first-child:not(:last-child) {
+  border-bottom-right-radius: 0;
+  border-top-right-radius: 0;
+}
+
+.btn-group .btn:not(:first-child):not(:last-child) {
+  border-radius: 0;
+  margin-left: -.05rem;
+}
+
+.btn-group .btn:last-child:not(:first-child) {
+  border-bottom-left-radius: 0;
+  border-top-left-radius: 0;
+  margin-left: -.05rem;
+}
+
+.btn-group .btn:focus,
+.btn-group .btn:hover,
+.btn-group .btn:active,
+.btn-group .btn.active {
+  z-index: 1;
+}
+
+.btn-group.btn-group-block {
+  display: -ms-flexbox;
+  display: flex;
+}
+
+.btn-group.btn-group-block .btn {
+  -ms-flex: 1 0 0;
+  flex: 1 0 0;
+}
+
+.form-group:not(:last-child) {
+  margin-bottom: .4rem;
+}
+
+fieldset {
+  margin-bottom: .8rem;
+}
+
+legend {
+  font-size: .9rem;
+  font-weight: 500;
+  margin-bottom: .8rem;
+}
+
+.form-label {
+  display: block;
+  line-height: 1.2rem;
+  padding: .3rem 0;
+}
+
+.form-label.label-sm {
+  font-size: .7rem;
+  padding: .1rem 0;
+}
+
+.form-label.label-lg {
+  font-size: .9rem;
+  padding: .4rem 0;
+}
+
+.form-input {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: #fff;
+  background-image: none;
+  border: .05rem solid #bcc3ce;
+  border-radius: .1rem;
+  color: #3b4351;
+  display: block;
+  font-size: .8rem;
+  height: 1.8rem;
+  line-height: 1.2rem;
+  max-width: 100%;
+  outline: none;
+  padding: .25rem .4rem;
+  position: relative;
+  transition: background .2s, border .2s, box-shadow .2s, color .2s;
+  width: 100%;
+}
+
+.form-input:focus {
+  border-color: #5755d9; 
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.form-input:-ms-input-placeholder {
+  color: #bcc3ce;
+}
+
+.form-input::-ms-input-placeholder {
+  color: #bcc3ce;
+}
+
+.form-input::placeholder {
+  color: #bcc3ce;
+}
+
+.form-input.input-sm {
+  font-size: .7rem;
+  height: 1.4rem;
+  padding: .05rem .3rem;
+}
+
+.form-input.input-lg {
+  font-size: .9rem;
+  height: 2rem;
+  padding: .35rem .6rem;
+}
+
+.form-input.input-inline {
+  display: inline-block;
+  vertical-align: middle;
+  width: auto;
+}
+
+.form-input[type="file"] {
+  height: auto;
+}
+
+textarea.form-input,
+textarea.form-input.input-lg,
+textarea.form-input.input-sm {
+  height: auto;
+}
+
+.form-input-hint {
+  color: #bcc3ce;
+  font-size: .7rem;
+  margin-top: .2rem;
+}
+
+.has-success .form-input-hint,
+.is-success + .form-input-hint {
+  color: #32b643;
+}
+
+.has-error .form-input-hint,
+.is-error + .form-input-hint {
+  color: #e85600;
+}
+
+.form-select {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  background: #fff; 
+  border: .05rem solid #bcc3ce;
+  border-radius: .1rem;
+  color: inherit;
+  font-size: .8rem;
+  height: 1.8rem;
+  line-height: 1.2rem;
+  outline: none;
+  padding: .25rem .4rem;
+  vertical-align: middle;
+  width: 100%;
+}
+
+.form-select:focus {
+  border-color: #5755d9; 
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.form-select::-ms-expand {
+  display: none;
+}
+
+.form-select.select-sm {
+  font-size: .7rem;
+  height: 1.4rem;
+  padding: .05rem 1.1rem .05rem .3rem;
+}
+
+.form-select.select-lg {
+  font-size: .9rem;
+  height: 2rem;
+  padding: .35rem 1.4rem .35rem .6rem;
+}
+
+.form-select[size],
+.form-select[multiple] {
+  height: auto;
+  padding: .25rem .4rem;
+}
+
+.form-select[size] option,
+.form-select[multiple] option {
+  padding: .1rem .2rem;
+}
+
+.form-select:not([multiple]):not([size]) {
+  background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;
+  padding-right: 1.2rem;
+}
+
+.has-icon-left,
+.has-icon-right {
+  position: relative;
+}
+
+.has-icon-left .form-icon,
+.has-icon-right .form-icon {
+  height: .8rem;
+  margin: 0 .25rem;
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  width: .8rem;
+  z-index: 2;
+}
+
+.has-icon-left .form-icon {
+  left: .05rem;
+}
+
+.has-icon-left .form-input {
+  padding-left: 1.3rem;
+}
+
+.has-icon-right .form-icon {
+  right: .05rem;
+}
+
+.has-icon-right .form-input {
+  padding-right: 1.3rem;
+}
+
+.form-checkbox,
+.form-radio,
+.form-switch {
+  display: block;
+  line-height: 1.2rem;
+  margin: .2rem 0;
+  min-height: 1.4rem;
+  padding: .1rem .4rem .1rem 1.2rem;
+  position: relative;
+}
+
+.form-checkbox input,
+.form-radio input,
+.form-switch input {
+  clip: rect(0, 0, 0, 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  position: absolute;
+  width: 1px;
+}
+
+.form-checkbox input:focus + .form-icon,
+.form-radio input:focus + .form-icon,
+.form-switch input:focus + .form-icon {
+  border-color: #5755d9; 
+  box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2);
+}
+
+.form-checkbox input:checked + .form-icon,
+.form-radio input:checked + .form-icon,
+.form-switch input:checked + .form-icon {
+  background: #5755d9;
+  border-color: #5755d9;
+}
+
+.form-checkbox .form-icon,
+.form-radio .form-icon,
+.form-switch .form-icon {
+  border: .05rem solid #bcc3ce;
+  cursor: pointer;
+  display: inline-block;
+  position: absolute;
+  transition: background .2s, border .2s, box-shadow .2s, color .2s;
+}
+
+.form-checkbox.input-sm,
+.form-radio.input-sm,
+.form-switch.input-sm {
+  font-size: .7rem;
+  margin: 0;
+}
+
+.form-checkbox.input-lg,
+.form-radio.input-lg,
+.form-switch.input-lg {
+  font-size: .9rem;
+  margin: .3rem 0;
+}
+
+.form-checkbox .form-icon,
+.form-radio .form-icon {
+  background: #fff;
+  height: .8rem;
+  left: 0;
+  top: .3rem;
+  width: .8rem;
+}
+
+.form-checkbox input:active + .form-icon,
+.form-radio input:active + .form-icon {
+  background: #eef0f3;
+}
+
+.form-checkbox .form-icon {
+  border-radius: .1rem;
+}
+
+.form-checkbox input:checked + .form-icon::before {
+  background-clip: padding-box;
+  border: .1rem solid #fff;
+  border-left-width: 0;
+  border-top-width: 0;
+  content: "";
+  height: 9px;
+  left: 50%;
+  margin-left: -3px;
+  margin-top: -6px;
+  position: absolute;
+  top: 50%;
+  transform: rotate(45deg);
+  width: 6px;
+}
+
+.form-checkbox input:indeterminate + .form-icon {
+  background: #5755d9;
+  border-color: #5755d9;
+}
+
+.form-checkbox input:indeterminate + .form-icon::before {
+  background: #fff;
+  content: "";
+  height: 2px;
+  left: 50%;
+  margin-left: -5px;
+  margin-top: -1px;
+  position: absolute;
+  top: 50%;
+  width: 10px;
+}
+
+.form-radio .form-icon {
+  border-radius: 50%;
+}
+
+.form-radio input:checked + .form-icon::before {
+  background: #fff;
+  border-radius: 50%;
+  content: "";
+  height: 6px;
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  width: 6px;
+}
+
+.form-switch {
+  padding-left: 2rem;
+}
+
+.form-switch .form-icon {
+  background: #bcc3ce;
+  background-clip: padding-box;
+  border-radius: .45rem;
+  height: .9rem;
+  left: 0;
+  top: .25rem;
+  width: 1.6rem;
+}
+
+.form-switch .form-icon::before {
+  background: #fff;
+  border-radius: 50%;
+  content: "";
+  display: block;
+  height: .8rem;
+  left: 0;
+  position: absolute;
+  top: 0;
+  transition: background .2s, border .2s, box-shadow .2s, color .2s, left .2s;
+  width: .8rem;
+}
+
+.form-switch input:checked + .form-icon::before {
+  left: 14px;
+}
+
+.form-switch input:active + .form-icon::before {
+  background: #f7f8f9;
+}
+
+.input-group {
+  display: -ms-flexbox;
+  display: flex;
+}
+
+.input-group .input-group-addon {
+  background: #f7f8f9;
+  border: .05rem solid #bcc3ce;
+  border-radius: .1rem;
+  line-height: 1.2rem;
+  padding: .25rem .4rem;
+  white-space: nowrap;
+}
+
+.input-group .input-group-addon.addon-sm {
+  font-size: .7rem;
+  padding: .05rem .3rem;
+}
+
+.input-group .input-group-addon.addon-lg {
+  font-size: .9rem;
+  padding: .35rem .6rem;
+}
+
+.input-group .form-input,
+.input-group .form-select {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+  width: 1%;
+}
+
+.input-group .input-group-btn {
+  z-index: 1;
+}
+
+.input-group .form-input:first-child:not(:last-child),
+.input-group .form-select:first-child:not(:last-child),
+.input-group .input-group-addon:first-child:not(:last-child),
+.input-group .input-group-btn:first-child:not(:last-child) {
+  border-bottom-right-radius: 0;
+  border-top-right-radius: 0;
+}
+
+.input-group .form-input:not(:first-child):not(:last-child),
+.input-group .form-select:not(:first-child):not(:last-child),
+.input-group .input-group-addon:not(:first-child):not(:last-child),
+.input-group .input-group-btn:not(:first-child):not(:last-child) {
+  border-radius: 0;
+  margin-left: -.05rem;
+}
+
+.input-group .form-input:last-child:not(:first-child),
+.input-group .form-select:last-child:not(:first-child),
+.input-group .input-group-addon:last-child:not(:first-child),
+.input-group .input-group-btn:last-child:not(:first-child) {
+  border-bottom-left-radius: 0;
+  border-top-left-radius: 0;
+  margin-left: -.05rem;
+}
+
+.input-group .form-input:focus,
+.input-group .form-select:focus,
+.input-group .input-group-addon:focus,
+.input-group .input-group-btn:focus {
+  z-index: 2;
+}
+
+.input-group .form-select {
+  width: auto;
+}
+
+.input-group.input-inline {
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+}
+
+.has-success .form-input,
+.form-input.is-success,
+.has-success .form-select,
+.form-select.is-success {
+  background: #f9fdfa;
+  border-color: #32b643;
+}
+
+.has-success .form-input:focus,
+.form-input.is-success:focus,
+.has-success .form-select:focus,
+.form-select.is-success:focus {
+  box-shadow: 0 0 0 .1rem rgba(50, 182, 67, .2);
+}
+
+.has-error .form-input,
+.form-input.is-error,
+.has-error .form-select,
+.form-select.is-error {
+  background: #fffaf7;
+  border-color: #e85600;
+}
+
+.has-error .form-input:focus,
+.form-input.is-error:focus,
+.has-error .form-select:focus,
+.form-select.is-error:focus {
+  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
+}
+
+.has-error .form-checkbox .form-icon,
+.form-checkbox.is-error .form-icon,
+.has-error .form-radio .form-icon,
+.form-radio.is-error .form-icon,
+.has-error .form-switch .form-icon,
+.form-switch.is-error .form-icon {
+  border-color: #e85600;
+}
+
+.has-error .form-checkbox input:checked + .form-icon,
+.form-checkbox.is-error input:checked + .form-icon,
+.has-error .form-radio input:checked + .form-icon,
+.form-radio.is-error input:checked + .form-icon,
+.has-error .form-switch input:checked + .form-icon,
+.form-switch.is-error input:checked + .form-icon {
+  background: #e85600;
+  border-color: #e85600;
+}
+
+.has-error .form-checkbox input:focus + .form-icon,
+.form-checkbox.is-error input:focus + .form-icon,
+.has-error .form-radio input:focus + .form-icon,
+.form-radio.is-error input:focus + .form-icon,
+.has-error .form-switch input:focus + .form-icon,
+.form-switch.is-error input:focus + .form-icon {
+  border-color: #e85600; 
+  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
+}
+
+.has-error .form-checkbox input:indeterminate + .form-icon,
+.form-checkbox.is-error input:indeterminate + .form-icon {
+  background: #e85600;
+  border-color: #e85600;
+}
+
+.form-input:not(:-ms-input-placeholder):invalid {
+  border-color: #e85600;
+}
+
+.form-input:not(:placeholder-shown):invalid {
+  border-color: #e85600;
+}
+
+.form-input:not(:-ms-input-placeholder):invalid:focus {
+  background: #fffaf7; 
+  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
+}
+
+.form-input:not(:placeholder-shown):invalid:focus {
+  background: #fffaf7; 
+  box-shadow: 0 0 0 .1rem rgba(232, 86, 0, .2);
+}
+
+.form-input:not(:-ms-input-placeholder):invalid + .form-input-hint {
+  color: #e85600;
+}
+
+.form-input:not(:placeholder-shown):invalid + .form-input-hint {
+  color: #e85600;
+}
+
+.form-input:disabled,
+.form-input.disabled,
+.form-select:disabled,
+.form-select.disabled {
+  background-color: #eef0f3;
+  cursor: not-allowed;
+  opacity: .5;
+}
+
+.form-input[readonly] {
+  background-color: #f7f8f9;
+}
+
+input:disabled + .form-icon,
+input.disabled + .form-icon {
+  background: #eef0f3;
+  cursor: not-allowed;
+  opacity: .5;
+}
+
+.form-switch input:disabled + .form-icon::before,
+.form-switch input.disabled + .form-icon::before {
+  background: #fff;
+}
+
+.form-horizontal {
+  padding: .4rem 0;
+}
+
+.form-horizontal .form-group {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+}
+
+.form-inline {
+  display: inline-block;
+}
+
+.label {
+  background: #eef0f3;
+  border-radius: .1rem;
+  color: #455060;
+  display: inline-block; 
+  line-height: 1.25;
+  padding: .1rem .2rem;
+}
+
+.label.label-rounded {
+  border-radius: 5rem;
+  padding-left: .4rem;
+  padding-right: .4rem;
+}
+
+.label.label-primary {
+  background: #5755d9;
+  color: #fff;
+}
+
+.label.label-secondary {
+  background: #f1f1fc;
+  color: #5755d9;
+}
+
+.label.label-success {
+  background: #32b643;
+  color: #fff;
+}
+
+.label.label-warning {
+  background: #ffb700;
+  color: #fff;
+}
+
+.label.label-error {
+  background: #e85600;
+  color: #fff;
+}
+
+code {
+  background: #fcf2f2;
+  border-radius: .1rem;
+  color: #d73e48;
+  font-size: 85%; 
+  line-height: 1.25;
+  padding: .1rem .2rem;
+}
+
+.code {
+  border-radius: .1rem;
+  color: #3b4351;
+  position: relative;
+}
+
+.code::before {
+  color: #bcc3ce;
+  content: attr(data-lang);
+  font-size: .7rem;
+  position: absolute;
+  right: .4rem;
+  top: .1rem;
+}
+
+.code code {
+  background: #f7f8f9;
+  color: inherit;
+  display: block;
+  line-height: 1.5;
+  overflow-x: auto;
+  padding: 1rem;
+  width: 100%;
+}
+
+.img-responsive {
+  display: block;
+  height: auto;
+  max-width: 100%;
+}
+
+.img-fit-cover {
+  object-fit: cover;
+}
+
+.img-fit-contain {
+  object-fit: contain;
+}
+
+.video-responsive {
+  display: block;
+  overflow: hidden;
+  padding: 0;
+  position: relative;
+  width: 100%;
+}
+
+.video-responsive::before {
+  content: "";
+  display: block;
+  padding-bottom: 56.25%;
+}
+
+.video-responsive iframe,
+.video-responsive object,
+.video-responsive embed {
+  border: 0;
+  bottom: 0;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+  width: 100%;
+}
+
+video.video-responsive {
+  height: auto;
+  max-width: 100%;
+}
+
+video.video-responsive::before {
+  content: none;
+}
+
+.video-responsive-4-3::before {
+  padding-bottom: 75%;
+}
+
+.video-responsive-1-1::before {
+  padding-bottom: 100%;
+}
+
+.figure {
+  margin: 0 0 .4rem 0;
+}
+
+.figure .figure-caption {
+  color: #66758c;
+  margin-top: .4rem;
+}
+
+.container {
+  margin-left: auto;
+  margin-right: auto;
+  padding-left: .4rem;
+  padding-right: .4rem;
+  width: 100%;
+}
+
+.container.grid-xl {
+  max-width: 1296px;
+}
+
+.container.grid-lg {
+  max-width: 976px;
+}
+
+.container.grid-md {
+  max-width: 856px;
+}
+
+.container.grid-sm {
+  max-width: 616px;
+}
+
+.container.grid-xs {
+  max-width: 496px;
+}
+
+.show-xs,
+.show-sm,
+.show-md,
+.show-lg,
+.show-xl {
+  display: none !important;
+}
+
+.cols,
+.columns {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+  margin-left: -.4rem;
+  margin-right: -.4rem;
+}
+
+.cols.col-gapless,
+.columns.col-gapless {
+  margin-left: 0;
+  margin-right: 0;
+}
+
+.cols.col-gapless > .column,
+.columns.col-gapless > .column {
+  padding-left: 0;
+  padding-right: 0;
+}
+
+.cols.col-oneline,
+.columns.col-oneline {
+  -ms-flex-wrap: nowrap;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+}
+
+[class~="col-"],
+.column {
+  -ms-flex: 1;
+  flex: 1;
+  max-width: 100%;
+  padding-left: .4rem;
+  padding-right: .4rem;
+}
+
+[class~="col-"].col-12,
+[class~="col-"].col-11,
+[class~="col-"].col-10,
+[class~="col-"].col-9,
+[class~="col-"].col-8,
+[class~="col-"].col-7,
+[class~="col-"].col-6,
+[class~="col-"].col-5,
+[class~="col-"].col-4,
+[class~="col-"].col-3,
+[class~="col-"].col-2,
+[class~="col-"].col-1,
+[class~="col-"].col-auto,
+.column.col-12,
+.column.col-11,
+.column.col-10,
+.column.col-9,
+.column.col-8,
+.column.col-7,
+.column.col-6,
+.column.col-5,
+.column.col-4,
+.column.col-3,
+.column.col-2,
+.column.col-1,
+.column.col-auto {
+  -ms-flex: none;
+  flex: none;
+}
+
+.col-12 {
+  width: 100%;
+}
+
+.col-11 {
+  width: 91.66666667%;
+}
+
+.col-10 {
+  width: 83.33333333%;
+}
+
+.col-9 {
+  width: 75%;
+}
+
+.col-8 {
+  width: 66.66666667%;
+}
+
+.col-7 {
+  width: 58.33333333%;
+}
+
+.col-6 {
+  width: 50%;
+}
+
+.col-5 {
+  width: 41.66666667%;
+}
+
+.col-4 {
+  width: 33.33333333%;
+}
+
+.col-3 {
+  width: 25%;
+}
+
+.col-2 {
+  width: 16.66666667%;
+}
+
+.col-1 {
+  width: 8.33333333%;
+}
+
+.col-auto {
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto;
+  max-width: none;
+  width: auto;
+}
+
+.col-mx-auto {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.col-ml-auto {
+  margin-left: auto;
+}
+
+.col-mr-auto {
+  margin-right: auto;
+}
+
+@media (max-width: 1280px) {
+  .col-xl-12,
+  .col-xl-11,
+  .col-xl-10,
+  .col-xl-9,
+  .col-xl-8,
+  .col-xl-7,
+  .col-xl-6,
+  .col-xl-5,
+  .col-xl-4,
+  .col-xl-3,
+  .col-xl-2,
+  .col-xl-1,
+  .col-xl-auto {
+    -ms-flex: none;
+    flex: none;
+  }
+  .col-xl-12 {
+    width: 100%;
+  }
+  .col-xl-11 {
+    width: 91.66666667%;
+  }
+  .col-xl-10 {
+    width: 83.33333333%;
+  }
+  .col-xl-9 {
+    width: 75%;
+  }
+  .col-xl-8 {
+    width: 66.66666667%;
+  }
+  .col-xl-7 {
+    width: 58.33333333%;
+  }
+  .col-xl-6 {
+    width: 50%;
+  }
+  .col-xl-5 {
+    width: 41.66666667%;
+  }
+  .col-xl-4 {
+    width: 33.33333333%;
+  }
+  .col-xl-3 {
+    width: 25%;
+  }
+  .col-xl-2 {
+    width: 16.66666667%;
+  }
+  .col-xl-1 {
+    width: 8.33333333%;
+  }
+  .col-xl-auto {
+    width: auto;
+  }
+  .hide-xl {
+    display: none !important;
+  }
+  .show-xl {
+    display: block !important;
+  }
+}
+
+@media (max-width: 960px) {
+  .col-lg-12,
+  .col-lg-11,
+  .col-lg-10,
+  .col-lg-9,
+  .col-lg-8,
+  .col-lg-7,
+  .col-lg-6,
+  .col-lg-5,
+  .col-lg-4,
+  .col-lg-3,
+  .col-lg-2,
+  .col-lg-1,
+  .col-lg-auto {
+    -ms-flex: none;
+    flex: none;
+  }
+  .col-lg-12 {
+    width: 100%;
+  }
+  .col-lg-11 {
+    width: 91.66666667%;
+  }
+  .col-lg-10 {
+    width: 83.33333333%;
+  }
+  .col-lg-9 {
+    width: 75%;
+  }
+  .col-lg-8 {
+    width: 66.66666667%;
+  }
+  .col-lg-7 {
+    width: 58.33333333%;
+  }
+  .col-lg-6 {
+    width: 50%;
+  }
+  .col-lg-5 {
+    width: 41.66666667%;
+  }
+  .col-lg-4 {
+    width: 33.33333333%;
+  }
+  .col-lg-3 {
+    width: 25%;
+  }
+  .col-lg-2 {
+    width: 16.66666667%;
+  }
+  .col-lg-1 {
+    width: 8.33333333%;
+  }
+  .col-lg-auto {
+    width: auto;
+  }
+  .hide-lg {
+    display: none !important;
+  }
+  .show-lg {
+    display: block !important;
+  }
+}
+
+@media (max-width: 840px) {
+  .col-md-12,
+  .col-md-11,
+  .col-md-10,
+  .col-md-9,
+  .col-md-8,
+  .col-md-7,
+  .col-md-6,
+  .col-md-5,
+  .col-md-4,
+  .col-md-3,
+  .col-md-2,
+  .col-md-1,
+  .col-md-auto {
+    -ms-flex: none;
+    flex: none;
+  }
+  .col-md-12 {
+    width: 100%;
+  }
+  .col-md-11 {
+    width: 91.66666667%;
+  }
+  .col-md-10 {
+    width: 83.33333333%;
+  }
+  .col-md-9 {
+    width: 75%;
+  }
+  .col-md-8 {
+    width: 66.66666667%;
+  }
+  .col-md-7 {
+    width: 58.33333333%;
+  }
+  .col-md-6 {
+    width: 50%;
+  }
+  .col-md-5 {
+    width: 41.66666667%;
+  }
+  .col-md-4 {
+    width: 33.33333333%;
+  }
+  .col-md-3 {
+    width: 25%;
+  }
+  .col-md-2 {
+    width: 16.66666667%;
+  }
+  .col-md-1 {
+    width: 8.33333333%;
+  }
+  .col-md-auto {
+    width: auto;
+  }
+  .hide-md {
+    display: none !important;
+  }
+  .show-md {
+    display: block !important;
+  }
+}
+
+@media (max-width: 600px) {
+  .col-sm-12,
+  .col-sm-11,
+  .col-sm-10,
+  .col-sm-9,
+  .col-sm-8,
+  .col-sm-7,
+  .col-sm-6,
+  .col-sm-5,
+  .col-sm-4,
+  .col-sm-3,
+  .col-sm-2,
+  .col-sm-1,
+  .col-sm-auto {
+    -ms-flex: none;
+    flex: none;
+  }
+  .col-sm-12 {
+    width: 100%;
+  }
+  .col-sm-11 {
+    width: 91.66666667%;
+  }
+  .col-sm-10 {
+    width: 83.33333333%;
+  }
+  .col-sm-9 {
+    width: 75%;
+  }
+  .col-sm-8 {
+    width: 66.66666667%;
+  }
+  .col-sm-7 {
+    width: 58.33333333%;
+  }
+  .col-sm-6 {
+    width: 50%;
+  }
+  .col-sm-5 {
+    width: 41.66666667%;
+  }
+  .col-sm-4 {
+    width: 33.33333333%;
+  }
+  .col-sm-3 {
+    width: 25%;
+  }
+  .col-sm-2 {
+    width: 16.66666667%;
+  }
+  .col-sm-1 {
+    width: 8.33333333%;
+  }
+  .col-sm-auto {
+    width: auto;
+  }
+  .hide-sm {
+    display: none !important;
+  }
+  .show-sm {
+    display: block !important;
+  }
+}
+
+@media (max-width: 480px) {
+  .col-xs-12,
+  .col-xs-11,
+  .col-xs-10,
+  .col-xs-9,
+  .col-xs-8,
+  .col-xs-7,
+  .col-xs-6,
+  .col-xs-5,
+  .col-xs-4,
+  .col-xs-3,
+  .col-xs-2,
+  .col-xs-1,
+  .col-xs-auto {
+    -ms-flex: none;
+    flex: none;
+  }
+  .col-xs-12 {
+    width: 100%;
+  }
+  .col-xs-11 {
+    width: 91.66666667%;
+  }
+  .col-xs-10 {
+    width: 83.33333333%;
+  }
+  .col-xs-9 {
+    width: 75%;
+  }
+  .col-xs-8 {
+    width: 66.66666667%;
+  }
+  .col-xs-7 {
+    width: 58.33333333%;
+  }
+  .col-xs-6 {
+    width: 50%;
+  }
+  .col-xs-5 {
+    width: 41.66666667%;
+  }
+  .col-xs-4 {
+    width: 33.33333333%;
+  }
+  .col-xs-3 {
+    width: 25%;
+  }
+  .col-xs-2 {
+    width: 16.66666667%;
+  }
+  .col-xs-1 {
+    width: 8.33333333%;
+  }
+  .col-xs-auto {
+    width: auto;
+  }
+  .hide-xs {
+    display: none !important;
+  }
+  .show-xs {
+    display: block !important;
+  }
+}
+
+.hero {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+  -ms-flex-pack: justify;
+  justify-content: space-between;
+  padding-bottom: 4rem;
+  padding-top: 4rem;
+}
+
+.hero.hero-sm {
+  padding-bottom: 2rem;
+  padding-top: 2rem;
+}
+
+.hero.hero-lg {
+  padding-bottom: 8rem;
+  padding-top: 8rem;
+}
+
+.hero .hero-body {
+  padding: .4rem;
+}
+
+.navbar {
+  align-items: stretch;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: stretch;
+  -ms-flex-pack: justify;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.navbar .navbar-section {
+  align-items: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex: 1 0 0;
+  flex: 1 0 0; 
+  -ms-flex-align: center;
+}
+
+.navbar .navbar-section:not(:first-child):last-child {
+  -ms-flex-pack: end;
+  justify-content: flex-end;
+}
+
+.navbar .navbar-center {
+  align-items: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto; 
+  -ms-flex-align: center;
+}
+
+.navbar .navbar-brand {
+  font-size: .9rem;
+  text-decoration: none;
+}
+
+.accordion input:checked ~ .accordion-header > .icon:first-child,
+.accordion[open] .accordion-header > .icon:first-child {
+  transform: rotate(90deg);
+}
+
+.accordion input:checked ~ .accordion-body,
+.accordion[open] .accordion-body {
+  max-height: 50rem;
+}
+
+.accordion .accordion-header {
+  display: block;
+  padding: .2rem .4rem;
+}
+
+.accordion .accordion-header .icon {
+  transition: transform .25s;
+}
+
+.accordion .accordion-body {
+  margin-bottom: .4rem;
+  max-height: 0;
+  overflow: hidden;
+  transition: max-height .25s;
+}
+
+summary.accordion-header::-webkit-details-marker {
+  display: none;
+}
+
+.avatar {
+  background: #5755d9;
+  border-radius: 50%;
+  color: rgba(255, 255, 255, .85);
+  display: inline-block;
+  font-size: .8rem;
+  font-weight: 300;
+  height: 1.6rem;
+  line-height: 1.25;
+  margin: 0;
+  position: relative;
+  vertical-align: middle; 
+  width: 1.6rem;
+}
+
+.avatar.avatar-xs {
+  font-size: .4rem;
+  height: .8rem;
+  width: .8rem;
+}
+
+.avatar.avatar-sm {
+  font-size: .6rem;
+  height: 1.2rem;
+  width: 1.2rem;
+}
+
+.avatar.avatar-lg {
+  font-size: 1.2rem;
+  height: 2.4rem;
+  width: 2.4rem;
+}
+
+.avatar.avatar-xl {
+  font-size: 1.6rem;
+  height: 3.2rem;
+  width: 3.2rem;
+}
+
+.avatar img {
+  border-radius: 50%;
+  height: 100%;
+  position: relative;
+  width: 100%;
+  z-index: 1;
+}
+
+.avatar .avatar-icon,
+.avatar .avatar-presence {
+  background: #fff;
+  bottom: 14.64%;
+  height: 50%;
+  padding: .1rem;
+  position: absolute;
+  right: 14.64%;
+  transform: translate(50%, 50%);
+  width: 50%;
+  z-index: 2;
+}
+
+.avatar .avatar-presence {
+  background: #bcc3ce;
+  border-radius: 50%;
+  box-shadow: 0 0 0 .1rem #fff;
+  height: .5em;
+  width: .5em;
+}
+
+.avatar .avatar-presence.online {
+  background: #32b643;
+}
+
+.avatar .avatar-presence.busy {
+  background: #e85600;
+}
+
+.avatar .avatar-presence.away {
+  background: #ffb700;
+}
+
+.avatar[data-initial]::before {
+  color: currentColor;
+  content: attr(data-initial);
+  left: 50%;
+  position: absolute;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  z-index: 1;
+}
+
+.badge {
+  position: relative;
+  white-space: nowrap;
+}
+
+.badge[data-badge]::after,
+.badge:not([data-badge])::after {
+  background: #5755d9;
+  background-clip: padding-box;
+  border-radius: .5rem;
+  box-shadow: 0 0 0 .1rem #fff;
+  color: #fff;
+  content: attr(data-badge);
+  display: inline-block;
+  transform: translate(-.05rem, -.5rem);
+}
+
+.badge[data-badge]::after {
+  font-size: .7rem;
+  height: .9rem;
+  line-height: 1;
+  min-width: .9rem;
+  padding: .1rem .2rem;
+  text-align: center;
+  white-space: nowrap;
+}
+
+.badge:not([data-badge])::after,
+.badge[data-badge=""]::after {
+  height: 6px;
+  min-width: 6px;
+  padding: 0;
+  width: 6px;
+}
+
+.badge.btn::after {
+  position: absolute;
+  right: 0;
+  top: 0;
+  transform: translate(50%, -50%);
+}
+
+.badge.avatar::after {
+  position: absolute;
+  right: 14.64%;
+  top: 14.64%;
+  transform: translate(50%, -50%);
+  z-index: 100;
+}
+
+.breadcrumb {
+  list-style: none;
+  margin: .2rem 0;
+  padding: .2rem 0;
+}
+
+.breadcrumb .breadcrumb-item {
+  color: #66758c;
+  display: inline-block;
+  margin: 0;
+  padding: .2rem 0;
+}
+
+.breadcrumb .breadcrumb-item:not(:last-child) {
+  margin-right: .2rem;
+}
+
+.breadcrumb .breadcrumb-item:not(:last-child) a {
+  color: #66758c;
+}
+
+.breadcrumb .breadcrumb-item:not(:first-child)::before {
+  color: #66758c;
+  content: "/";
+  padding-right: .4rem;
+}
+
+.bar {
+  background: #eef0f3;
+  border-radius: .1rem;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-wrap: nowrap;
+  flex-wrap: nowrap;
+  height: .8rem;
+  width: 100%;
+}
+
+.bar.bar-sm {
+  height: .2rem;
+}
+
+.bar .bar-item {
+  background: #5755d9;
+  color: #fff;
+  display: block;
+  -ms-flex-negative: 0;
+  flex-shrink: 0;
+  font-size: .7rem;
+  height: 100%;
+  line-height: .8rem;
+  position: relative;
+  text-align: center;
+  width: 0;
+}
+
+.bar .bar-item:first-child {
+  border-bottom-left-radius: .1rem;
+  border-top-left-radius: .1rem;
+}
+
+.bar .bar-item:last-child {
+  border-bottom-right-radius: .1rem;
+  border-top-right-radius: .1rem;
+  -ms-flex-negative: 1;
+  flex-shrink: 1;
+}
+
+.bar-slider {
+  height: .1rem;
+  margin: .4rem 0;
+  position: relative;
+}
+
+.bar-slider .bar-item {
+  left: 0;
+  padding: 0;
+  position: absolute;
+}
+
+.bar-slider .bar-item:not(:last-child):first-child {
+  background: #eef0f3;
+  z-index: 1;
+}
+
+.bar-slider .bar-slider-btn {
+  background: #5755d9;
+  border: 0;
+  border-radius: 50%;
+  height: .6rem;
+  padding: 0;
+  position: absolute;
+  right: 0;
+  top: 50%;
+  transform: translate(50%, -50%);
+  width: .6rem;
+}
+
+.bar-slider .bar-slider-btn:active {
+  box-shadow: 0 0 0 .1rem #5755d9;
+}
+
+.card {
+  background: #fff;
+  border: .05rem solid #dadee4;
+  border-radius: .1rem;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+}
+
+.card .card-header,
+.card .card-body,
+.card .card-footer {
+  padding: .8rem;
+  padding-bottom: 0;
+}
+
+.card .card-header:last-child,
+.card .card-body:last-child,
+.card .card-footer:last-child {
+  padding-bottom: .8rem;
+}
+
+.card .card-body {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+}
+
+.card .card-image {
+  padding-top: .8rem;
+}
+
+.card .card-image:first-child {
+  padding-top: 0;
+}
+
+.card .card-image:first-child img {
+  border-top-left-radius: .1rem;
+  border-top-right-radius: .1rem;
+}
+
+.card .card-image:last-child img {
+  border-bottom-left-radius: .1rem;
+  border-bottom-right-radius: .1rem;
+}
+
+.chip {
+  align-items: center;
+  background: #eef0f3;
+  border-radius: 5rem;
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+  -ms-flex-align: center;
+  font-size: 90%;
+  height: 1.2rem;
+  line-height: .8rem;
+  margin: .1rem;
+  max-width: 320px;
+  overflow: hidden;
+  padding: .2rem .4rem;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  vertical-align: middle;
+  white-space: nowrap;
+}
+
+.chip.active {
+  background: #5755d9;
+  color: #fff;
+}
+
+.chip .avatar {
+  margin-left: -.4rem;
+  margin-right: .2rem;
+}
+
+.chip .btn-clear {
+  border-radius: 50%;
+  transform: scale(.75);
+}
+
+.dropdown {
+  display: inline-block;
+  position: relative;
+}
+
+.dropdown .menu {
+  animation: slide-down .15s ease 1;
+  display: none;
+  left: 0;
+  max-height: 50vh;
+  overflow-y: auto;
+  position: absolute;
+  top: 100%;
+}
+
+.dropdown.dropdown-right .menu {
+  left: auto;
+  right: 0;
+}
+
+.dropdown.active .menu,
+.dropdown .dropdown-toggle:focus + .menu,
+.dropdown .menu:hover {
+  display: block;
+}
+
+.dropdown .btn-group .dropdown-toggle:nth-last-child(2) {
+  border-bottom-right-radius: .1rem;
+  border-top-right-radius: .1rem;
+}
+
+.empty {
+  background: #f7f8f9;
+  border-radius: .1rem;
+  color: #66758c;
+  padding: 3.2rem 1.6rem; 
+  text-align: center;
+}
+
+.empty .empty-icon {
+  margin-bottom: .8rem;
+}
+
+.empty .empty-title,
+.empty .empty-subtitle {
+  margin: .4rem auto;
+}
+
+.empty .empty-action {
+  margin-top: .8rem;
+}
+
+.menu {
+  background: #fff;
+  border-radius: .1rem;
+  box-shadow: 0 .05rem .2rem rgba(48, 55, 66, .3);
+  list-style: none;
+  margin: 0;
+  min-width: 180px;
+  padding: .4rem;
+  transform: translateY(.2rem);
+  z-index: 300;
+}
+
+.menu.menu-nav {
+  background: transparent;
+  box-shadow: none;
+}
+
+.menu .menu-item {
+  margin-top: 0;
+  padding: 0 .4rem;
+  position: relative;
+  text-decoration: none;
+}
+
+.menu .menu-item > a {
+  border-radius: .1rem;
+  color: inherit;
+  display: block;
+  margin: 0 -.4rem;
+  padding: .2rem .4rem;
+  text-decoration: none;
+}
+
+.menu .menu-item > a:focus,
+.menu .menu-item > a:hover {
+  background: #f1f1fc;
+  color: #5755d9;
+}
+
+.menu .menu-item > a:active,
+.menu .menu-item > a.active {
+  background: #f1f1fc;
+  color: #5755d9;
+}
+
+.menu .menu-item .form-checkbox,
+.menu .menu-item .form-radio,
+.menu .menu-item .form-switch {
+  margin: .1rem 0;
+}
+
+.menu .menu-item + .menu-item {
+  margin-top: .2rem;
+}
+
+.menu .menu-badge {
+  align-items: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  height: 100%;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+
+.menu .menu-badge .label {
+  margin-right: .4rem;
+}
+
+.modal {
+  align-items: center;
+  bottom: 0;
+  display: none;
+  -ms-flex-align: center;
+  -ms-flex-pack: center;
+  justify-content: center;
+  left: 0;
+  opacity: 0;
+  overflow: hidden;
+  padding: .4rem;
+  position: fixed;
+  right: 0;
+  top: 0;
+}
+
+.modal:target,
+.modal.active {
+  display: -ms-flexbox;
+  display: flex;
+  opacity: 1;
+  z-index: 400;
+}
+
+.modal:target .modal-overlay,
+.modal.active .modal-overlay {
+  background: rgba(247, 248, 249, .75);
+  bottom: 0;
+  cursor: default;
+  display: block;
+  left: 0;
+  position: absolute;
+  right: 0;
+  top: 0;
+}
+
+.modal:target .modal-container,
+.modal.active .modal-container {
+  animation: slide-down .2s ease 1;
+  z-index: 1;
+}
+
+.modal.modal-sm .modal-container {
+  max-width: 320px;
+  padding: 0 .4rem;
+}
+
+.modal.modal-lg .modal-overlay {
+  background: #fff;
+}
+
+.modal.modal-lg .modal-container {
+  box-shadow: none;
+  max-width: 960px;
+}
+
+.modal-container {
+  background: #fff;
+  border-radius: .1rem;
+  box-shadow: 0 .2rem .5rem rgba(48, 55, 66, .3);
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+  max-height: 75vh;
+  max-width: 640px;
+  padding: 0 .8rem;
+  width: 100%;
+}
+
+.modal-container.modal-fullheight {
+  max-height: 100vh;
+}
+
+.modal-container .modal-header {
+  color: #303742;
+  padding: .8rem;
+}
+
+.modal-container .modal-body {
+  overflow-y: auto;
+  padding: .8rem;
+  position: relative;
+}
+
+.modal-container .modal-footer {
+  padding: .8rem;
+  text-align: right;
+}
+
+.nav {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+  list-style: none;
+  margin: .2rem 0;
+}
+
+.nav .nav-item a {
+  color: #66758c;
+  padding: .2rem .4rem;
+  text-decoration: none;
+}
+
+.nav .nav-item a:focus,
+.nav .nav-item a:hover {
+  color: #5755d9;
+}
+
+.nav .nav-item.active > a {
+  color: #505c6e;
+  font-weight: bold;
+}
+
+.nav .nav-item.active > a:focus,
+.nav .nav-item.active > a:hover {
+  color: #5755d9;
+}
+
+.nav .nav {
+  margin-bottom: .4rem;
+  margin-left: .8rem;
+}
+
+.pagination {
+  display: -ms-flexbox;
+  display: flex;
+  list-style: none;
+  margin: .2rem 0;
+  padding: .2rem 0;
+}
+
+.pagination .page-item {
+  margin: .2rem .05rem;
+}
+
+.pagination .page-item span {
+  display: inline-block;
+  padding: .2rem .2rem;
+}
+
+.pagination .page-item a {
+  border-radius: .1rem;
+  display: inline-block;
+  padding: .2rem .4rem;
+  text-decoration: none;
+}
+
+.pagination .page-item a:focus,
+.pagination .page-item a:hover {
+  color: #5755d9;
+}
+
+.pagination .page-item.disabled a {
+  cursor: default;
+  opacity: .5;
+  pointer-events: none;
+}
+
+.pagination .page-item.active a {
+  background: #5755d9;
+  color: #fff;
+}
+
+.pagination .page-item.page-prev,
+.pagination .page-item.page-next {
+  -ms-flex: 1 0 50%;
+  flex: 1 0 50%;
+}
+
+.pagination .page-item.page-next {
+  text-align: right;
+}
+
+.pagination .page-item .page-item-title {
+  margin: 0;
+}
+
+.pagination .page-item .page-item-subtitle {
+  margin: 0;
+  opacity: .5;
+}
+
+.panel {
+  border: .05rem solid #dadee4;
+  border-radius: .1rem;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-direction: column;
+  flex-direction: column;
+}
+
+.panel .panel-header,
+.panel .panel-footer {
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto;
+  padding: .8rem;
+}
+
+.panel .panel-nav {
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto;
+}
+
+.panel .panel-body {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+  overflow-y: auto;
+  padding: 0 .8rem;
+}
+
+.popover {
+  display: inline-block;
+  position: relative;
+}
+
+.popover .popover-container {
+  left: 50%;
+  opacity: 0;
+  padding: .4rem;
+  position: absolute;
+  top: 0;
+  transform: translate(-50%, -50%) scale(0);
+  transition: transform .2s;
+  width: 320px;
+  z-index: 300;
+}
+
+.popover *:focus + .popover-container,
+.popover:hover .popover-container {
+  display: block;
+  opacity: 1;
+  transform: translate(-50%, -100%) scale(1);
+}
+
+.popover.popover-right .popover-container {
+  left: 100%;
+  top: 50%;
+}
+
+.popover.popover-right *:focus + .popover-container,
+.popover.popover-right:hover .popover-container {
+  transform: translate(0, -50%) scale(1);
+}
+
+.popover.popover-bottom .popover-container {
+  left: 50%;
+  top: 100%;
+}
+
+.popover.popover-bottom *:focus + .popover-container,
+.popover.popover-bottom:hover .popover-container {
+  transform: translate(-50%, 0) scale(1);
+}
+
+.popover.popover-left .popover-container {
+  left: 0;
+  top: 50%;
+}
+
+.popover.popover-left *:focus + .popover-container,
+.popover.popover-left:hover .popover-container {
+  transform: translate(-100%, -50%) scale(1);
+}
+
+.popover .card {
+  border: 0; 
+  box-shadow: 0 .2rem .5rem rgba(48, 55, 66, .3);
+}
+
+.step {
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-wrap: nowrap;
+  flex-wrap: nowrap;
+  list-style: none;
+  margin: .2rem 0;
+  width: 100%;
+}
+
+.step .step-item {
+  -ms-flex: 1 1 0;
+  flex: 1 1 0;
+  margin-top: 0;
+  min-height: 1rem;
+  position: relative; 
+  text-align: center;
+}
+
+.step .step-item:not(:first-child)::before {
+  background: #5755d9;
+  content: "";
+  height: 2px;
+  left: -50%;
+  position: absolute;
+  top: 9px;
+  width: 100%;
+}
+
+.step .step-item a {
+  color: #5755d9;
+  display: inline-block;
+  padding: 20px 10px 0;
+  text-decoration: none;
+}
+
+.step .step-item a::before {
+  background: #5755d9;
+  border: .1rem solid #fff;
+  border-radius: 50%;
+  content: "";
+  display: block;
+  height: .6rem;
+  left: 50%;
+  position: absolute;
+  top: .2rem;
+  transform: translateX(-50%);
+  width: .6rem;
+  z-index: 1;
+}
+
+.step .step-item.active a::before {
+  background: #fff;
+  border: .1rem solid #5755d9;
+}
+
+.step .step-item.active ~ .step-item::before {
+  background: #dadee4;
+}
+
+.step .step-item.active ~ .step-item a {
+  color: #bcc3ce;
+}
+
+.step .step-item.active ~ .step-item a::before {
+  background: #dadee4;
+}
+
+.tab {
+  align-items: center;
+  border-bottom: .05rem solid #dadee4;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  -ms-flex-wrap: wrap;
+  flex-wrap: wrap;
+  list-style: none;
+  margin: .2rem 0 .15rem 0;
+}
+
+.tab .tab-item {
+  margin-top: 0;
+}
+
+.tab .tab-item a {
+  border-bottom: .1rem solid transparent;
+  color: inherit;
+  display: block;
+  margin: 0 .4rem 0 0;
+  padding: .4rem .2rem .3rem .2rem;
+  text-decoration: none;
+}
+
+.tab .tab-item a:focus,
+.tab .tab-item a:hover {
+  color: #5755d9;
+}
+
+.tab .tab-item.active a,
+.tab .tab-item a.active {
+  border-bottom-color: #5755d9;
+  color: #5755d9;
+}
+
+.tab .tab-item.tab-action {
+  -ms-flex: 1 0 auto;
+  flex: 1 0 auto;
+  text-align: right;
+}
+
+.tab .tab-item .btn-clear {
+  margin-top: -.2rem;
+}
+
+.tab.tab-block .tab-item {
+  -ms-flex: 1 0 0;
+  flex: 1 0 0;
+  text-align: center;
+}
+
+.tab.tab-block .tab-item a {
+  margin: 0;
+}
+
+.tab.tab-block .tab-item .badge[data-badge]::after {
+  position: absolute;
+  right: .1rem;
+  top: .1rem;
+  transform: translate(0, 0);
+}
+
+.tab:not(.tab-block) .badge {
+  padding-right: 0;
+}
+
+.tile {
+  align-content: space-between;
+  align-items: flex-start;
+  display: -ms-flexbox;
+  display: flex; 
+  -ms-flex-align: start;
+  -ms-flex-line-pack: justify;
+}
+
+.tile .tile-icon,
+.tile .tile-action {
+  -ms-flex: 0 0 auto;
+  flex: 0 0 auto;
+}
+
+.tile .tile-content {
+  -ms-flex: 1 1 auto;
+  flex: 1 1 auto;
+}
+
+.tile .tile-content:not(:first-child) {
+  padding-left: .4rem;
+}
+
+.tile .tile-content:not(:last-child) {
+  padding-right: .4rem;
+}
+
+.tile .tile-title,
+.tile .tile-subtitle {
+  line-height: 1.2rem;
+}
+
+.tile.tile-centered {
+  align-items: center; 
+  -ms-flex-align: center;
+}
+
+.tile.tile-centered .tile-content {
+  overflow: hidden;
+}
+
+.tile.tile-centered .tile-title,
+.tile.tile-centered .tile-subtitle {
+  margin-bottom: 0; 
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.toast {
+  background: rgba(48, 55, 66, .95);
+  border: .05rem solid #303742;
+  border-color: #303742;
+  border-radius: .1rem;
+  color: #fff;
+  display: block;
+  padding: .4rem;
+  width: 100%;
+}
+
+.toast.toast-primary {
+  background: rgba(87, 85, 217, .95);
+  border-color: #5755d9;
+}
+
+.toast.toast-success {
+  background: rgba(50, 182, 67, .95);
+  border-color: #32b643;
+}
+
+.toast.toast-warning {
+  background: rgba(255, 183, 0, .95);
+  border-color: #ffb700;
+}
+
+.toast.toast-error {
+  background: rgba(232, 86, 0, .95);
+  border-color: #e85600;
+}
+
+.toast a {
+  color: #fff;
+  text-decoration: underline;
+}
+
+.toast a:focus,
+.toast a:hover,
+.toast a:active,
+.toast a.active {
+  opacity: .75;
+}
+
+.toast .btn-clear {
+  margin: .1rem;
+}
+
+.toast p:last-child {
+  margin-bottom: 0;
+}
+
+.tooltip {
+  position: relative;
+}
+
+.tooltip::after {
+  background: rgba(48, 55, 66, .95);
+  border-radius: .1rem;
+  bottom: 100%;
+  color: #fff;
+  content: attr(data-tooltip);
+  display: block;
+  font-size: .7rem;
+  left: 50%;
+  max-width: 320px;
+  opacity: 0;
+  overflow: hidden;
+  padding: .2rem .4rem;
+  pointer-events: none;
+  position: absolute;
+  text-overflow: ellipsis;
+  transform: translate(-50%, .4rem);
+  transition: opacity .2s, transform .2s;
+  white-space: pre;
+  z-index: 300;
+}
+
+.tooltip:focus::after,
+.tooltip:hover::after {
+  opacity: 1;
+  transform: translate(-50%, -.2rem);
+}
+
+.tooltip[disabled],
+.tooltip.disabled {
+  pointer-events: auto;
+}
+
+.tooltip.tooltip-right::after {
+  bottom: 50%;
+  left: 100%;
+  transform: translate(-.2rem, 50%);
+}
+
+.tooltip.tooltip-right:focus::after,
+.tooltip.tooltip-right:hover::after {
+  transform: translate(.2rem, 50%);
+}
+
+.tooltip.tooltip-bottom::after {
+  bottom: auto;
+  top: 100%;
+  transform: translate(-50%, -.4rem);
+}
+
+.tooltip.tooltip-bottom:focus::after,
+.tooltip.tooltip-bottom:hover::after {
+  transform: translate(-50%, .2rem);
+}
+
+.tooltip.tooltip-left::after {
+  bottom: 50%;
+  left: auto;
+  right: 100%;
+  transform: translate(.4rem, 50%);
+}
+
+.tooltip.tooltip-left:focus::after,
+.tooltip.tooltip-left:hover::after {
+  transform: translate(-.2rem, 50%);
+}
+
+@keyframes loading {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes slide-down {
+  0% {
+    opacity: 0;
+    transform: translateY(-1.6rem);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+.text-primary {
+  color: #5755d9 !important;
+}
+
+a.text-primary:focus,
+a.text-primary:hover {
+  color: #4240d4;
+}
+
+a.text-primary:visited {
+  color: #6c6ade;
+}
+
+.text-secondary {
+  color: #e5e5f9 !important;
+}
+
+a.text-secondary:focus,
+a.text-secondary:hover {
+  color: #d1d0f4;
+}
+
+a.text-secondary:visited {
+  color: #fafafe;
+}
+
+.text-gray {
+  color: #bcc3ce !important;
+}
+
+a.text-gray:focus,
+a.text-gray:hover {
+  color: #adb6c4;
+}
+
+a.text-gray:visited {
+  color: #cbd0d9;
+}
+
+.text-light {
+  color: #fff !important;
+}
+
+a.text-light:focus,
+a.text-light:hover {
+  color: #f2f2f2;
+}
+
+a.text-light:visited {
+  color: white;
+}
+
+.text-dark {
+  color: #3b4351 !important;
+}
+
+a.text-dark:focus,
+a.text-dark:hover {
+  color: #303742;
+}
+
+a.text-dark:visited {
+  color: #455060;
+}
+
+.text-success {
+  color: #32b643 !important;
+}
+
+a.text-success:focus,
+a.text-success:hover {
+  color: #2da23c;
+}
+
+a.text-success:visited {
+  color: #39c94b;
+}
+
+.text-warning {
+  color: #ffb700 !important;
+}
+
+a.text-warning:focus,
+a.text-warning:hover {
+  color: #e6a500;
+}
+
+a.text-warning:visited {
+  color: #ffbe1a;
+}
+
+.text-error {
+  color: #e85600 !important;
+}
+
+a.text-error:focus,
+a.text-error:hover {
+  color: #cf4d00;
+}
+
+a.text-error:visited {
+  color: #ff6003;
+}
+
+.bg-primary {
+  background: #5755d9 !important;
+  color: #fff;
+}
+
+.bg-secondary {
+  background: #f1f1fc !important;
+}
+
+.bg-dark {
+  background: #303742 !important;
+  color: #fff;
+}
+
+.bg-gray {
+  background: #f7f8f9 !important;
+}
+
+.bg-success {
+  background: #32b643 !important;
+  color: #fff;
+}
+
+.bg-warning {
+  background: #ffb700 !important;
+  color: #fff;
+}
+
+.bg-error {
+  background: #e85600 !important;
+  color: #fff;
+}
+
+.c-hand {
+  cursor: pointer;
+}
+
+.c-move {
+  cursor: move;
+}
+
+.c-zoom-in {
+  cursor: zoom-in;
+}
+
+.c-zoom-out {
+  cursor: zoom-out;
+}
+
+.c-not-allowed {
+  cursor: not-allowed;
+}
+
+.c-auto {
+  cursor: auto;
+}
+
+.d-block {
+  display: block;
+}
+
+.d-inline {
+  display: inline;
+}
+
+.d-inline-block {
+  display: inline-block;
+}
+
+.d-flex {
+  display: -ms-flexbox;
+  display: flex;
+}
+
+.d-inline-flex {
+  display: -ms-inline-flexbox;
+  display: inline-flex;
+}
+
+.d-none,
+.d-hide {
+  display: none !important;
+}
+
+.d-visible {
+  visibility: visible;
+}
+
+.d-invisible {
+  visibility: hidden;
+}
+
+.text-hide {
+  background: transparent;
+  border: 0;
+  color: transparent;
+  font-size: 0;
+  line-height: 0;
+  text-shadow: none;
+}
+
+.text-assistive {
+  border: 0;
+  clip: rect(0, 0, 0, 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  width: 1px;
+}
+
+.divider,
+.divider-vert {
+  display: block;
+  position: relative;
+}
+
+.divider[data-content]::after,
+.divider-vert[data-content]::after {
+  background: #fff;
+  color: #bcc3ce;
+  content: attr(data-content);
+  display: inline-block;
+  font-size: .7rem;
+  padding: 0 .4rem;
+  transform: translateY(-.65rem);
+}
+
+.divider {
+  border-top: .05rem solid #f1f3f5;
+  height: .05rem;
+  margin: .4rem 0;
+}
+
+.divider[data-content] {
+  margin: .8rem 0;
+}
+
+.divider-vert {
+  display: block;
+  padding: .8rem;
+}
+
+.divider-vert::before {
+  border-left: .05rem solid #dadee4;
+  bottom: .4rem;
+  content: "";
+  display: block;
+  left: 50%;
+  position: absolute;
+  top: .4rem;
+  transform: translateX(-50%);
+}
+
+.divider-vert[data-content]::after {
+  left: 50%;
+  padding: .2rem 0;
+  position: absolute;
+  top: 50%;
+  transform: translate(-50%, -50%);
+}
+
+.loading {
+  color: transparent !important;
+  min-height: .8rem;
+  pointer-events: none;
+  position: relative;
+}
+
+.loading::after {
+  animation: loading 500ms infinite linear;
+  background: transparent;
+  border: .1rem solid #5755d9;
+  border-radius: 50%;
+  border-right-color: transparent;
+  border-top-color: transparent;
+  content: "";
+  display: block;
+  height: .8rem;
+  left: 50%;
+  margin-left: -.4rem;
+  margin-top: -.4rem;
+  opacity: 1;
+  padding: 0;
+  position: absolute;
+  top: 50%;
+  width: .8rem;
+  z-index: 1;
+}
+
+.loading.loading-lg {
+  min-height: 2rem;
+}
+
+.loading.loading-lg::after {
+  height: 1.6rem;
+  margin-left: -.8rem;
+  margin-top: -.8rem;
+  width: 1.6rem;
+}
+
+.clearfix::after {
+  clear: both;
+  content: "";
+  display: table;
+}
+
+.float-left {
+  float: left !important;
+}
+
+.float-right {
+  float: right !important;
+}
+
+.p-relative {
+  position: relative !important;
+}
+
+.p-absolute {
+  position: absolute !important;
+}
+
+.p-fixed {
+  position: fixed !important;
+}
+
+.p-sticky {
+  position: -webkit-sticky !important;
+  position: sticky !important;
+}
+
+.p-centered {
+  display: block;
+  float: none;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.flex-centered {
+  align-items: center;
+  display: -ms-flexbox;
+  display: flex;
+  -ms-flex-align: center;
+  -ms-flex-pack: center;
+  justify-content: center;
+}
+
+.m-0 {
+  margin: 0 !important;
+}
+
+.mb-0 {
+  margin-bottom: 0 !important;
+}
+
+.ml-0 {
+  margin-left: 0 !important;
+}
+
+.mr-0 {
+  margin-right: 0 !important;
+}
+
+.mt-0 {
+  margin-top: 0 !important;
+}
+
+.mx-0 {
+  margin-left: 0 !important;
+  margin-right: 0 !important;
+}
+
+.my-0 {
+  margin-bottom: 0 !important;
+  margin-top: 0 !important;
+}
+
+.m-1 {
+  margin: .2rem !important;
+}
+
+.mb-1 {
+  margin-bottom: .2rem !important;
+}
+
+.ml-1 {
+  margin-left: .2rem !important;
+}
+
+.mr-1 {
+  margin-right: .2rem !important;
+}
+
+.mt-1 {
+  margin-top: .2rem !important;
+}
+
+.mx-1 {
+  margin-left: .2rem !important;
+  margin-right: .2rem !important;
+}
+
+.my-1 {
+  margin-bottom: .2rem !important;
+  margin-top: .2rem !important;
+}
+
+.m-2 {
+  margin: .4rem !important;
+}
+
+.mb-2 {
+  margin-bottom: .4rem !important;
+}
+
+.ml-2 {
+  margin-left: .4rem !important;
+}
+
+.mr-2 {
+  margin-right: .4rem !important;
+}
+
+.mt-2 {
+  margin-top: .4rem !important;
+}
+
+.mx-2 {
+  margin-left: .4rem !important;
+  margin-right: .4rem !important;
+}
+
+.my-2 {
+  margin-bottom: .4rem !important;
+  margin-top: .4rem !important;
+}
+
+.p-0 {
+  padding: 0 !important;
+}
+
+.pb-0 {
+  padding-bottom: 0 !important;
+}
+
+.pl-0 {
+  padding-left: 0 !important;
+}
+
+.pr-0 {
+  padding-right: 0 !important;
+}
+
+.pt-0 {
+  padding-top: 0 !important;
+}
+
+.px-0 {
+  padding-left: 0 !important;
+  padding-right: 0 !important;
+}
+
+.py-0 {
+  padding-bottom: 0 !important;
+  padding-top: 0 !important;
+}
+
+.p-1 {
+  padding: .2rem !important;
+}
+
+.pb-1 {
+  padding-bottom: .2rem !important;
+}
+
+.pl-1 {
+  padding-left: .2rem !important;
+}
+
+.pr-1 {
+  padding-right: .2rem !important;
+}
+
+.pt-1 {
+  padding-top: .2rem !important;
+}
+
+.px-1 {
+  padding-left: .2rem !important;
+  padding-right: .2rem !important;
+}
+
+.py-1 {
+  padding-bottom: .2rem !important;
+  padding-top: .2rem !important;
+}
+
+.p-2 {
+  padding: .4rem !important;
+}
+
+.pb-2 {
+  padding-bottom: .4rem !important;
+}
+
+.pl-2 {
+  padding-left: .4rem !important;
+}
+
+.pr-2 {
+  padding-right: .4rem !important;
+}
+
+.pt-2 {
+  padding-top: .4rem !important;
+}
+
+.px-2 {
+  padding-left: .4rem !important;
+  padding-right: .4rem !important;
+}
+
+.py-2 {
+  padding-bottom: .4rem !important;
+  padding-top: .4rem !important;
+}
+
+.s-rounded {
+  border-radius: .1rem;
+}
+
+.s-circle {
+  border-radius: 50%;
+}
+
+.text-left {
+  text-align: left;
+}
+
+.text-right {
+  text-align: right;
+}
+
+.text-center {
+  text-align: center;
+}
+
+.text-justify {
+  text-align: justify;
+}
+
+.text-lowercase {
+  text-transform: lowercase;
+}
+
+.text-uppercase {
+  text-transform: uppercase;
+}
+
+.text-capitalize {
+  text-transform: capitalize;
+}
+
+.text-normal {
+  font-weight: normal;
+}
+
+.text-bold {
+  font-weight: bold;
+}
+
+.text-italic {
+  font-style: italic;
+}
+
+.text-large {
+  font-size: 1.2em;
+}
+
+.text-small {
+  font-size: .9em;
+}
+
+.text-tiny {
+  font-size: .8em;
+}
+
+.text-muted {
+  opacity: .8;
+}
+
+.text-ellipsis {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.text-clip {
+  overflow: hidden;
+  text-overflow: clip;
+  white-space: nowrap;
+}
+
+.text-break {
+  -webkit-hyphens: auto;
+  -ms-hyphens: auto;
+  hyphens: auto;
+  word-break: break-word;
+  word-wrap: break-word;
+}
\ No newline at end of file
diff --git a/public/css/spectre-0.5.9/spectre.min.css b/public/css/spectre-0.5.9/spectre.min.css
new file mode 100644
index 0000000..0fe23d9
--- /dev/null
+++ b/public/css/spectre-0.5.9/spectre.min.css
@@ -0,0 +1 @@
+/*! Spectre.css v0.5.9 | MIT License | github.com/picturepan2/spectre */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}hr{box-sizing:content-box;height:0;overflow:visible}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}address{font-style:normal}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:"SF Mono","Segoe UI Mono","Roboto Mono",Menlo,Courier,monospace;font-size:1em}dfn{font-style:italic}small{font-size:80%;font-weight:400}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}fieldset{border:0;margin:0;padding:0}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item;outline:0}canvas{display:inline-block}template{display:none}[hidden]{display:none}*,::after,::before{box-sizing:inherit}html{box-sizing:border-box;font-size:20px;line-height:1.5;-webkit-tap-highlight-color:transparent}body{background:#fff;color:#3b4351;font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;font-size:.8rem;overflow-x:hidden;text-rendering:optimizeLegibility}a{color:#5755d9;outline:0;text-decoration:none}a:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}a.active,a:active,a:focus,a:hover{color:#302ecd;text-decoration:underline}a:visited{color:#807fe2}h1,h2,h3,h4,h5,h6{color:inherit;font-weight:500;line-height:1.2;margin-bottom:.5em;margin-top:0}.h1,.h2,.h3,.h4,.h5,.h6{font-weight:500}.h1,h1{font-size:2rem}.h2,h2{font-size:1.6rem}.h3,h3{font-size:1.4rem}.h4,h4{font-size:1.2rem}.h5,h5{font-size:1rem}.h6,h6{font-size:.8rem}p{margin:0 0 1.2rem}a,ins,u{-webkit-text-decoration-skip:ink edges;text-decoration-skip:ink edges}abbr[title]{border-bottom:.05rem dotted;cursor:help;text-decoration:none}kbd{background:#303742;border-radius:.1rem;color:#fff;font-size:.7rem;line-height:1.25;padding:.1rem .2rem}mark{background:#ffe9b3;border-bottom:.05rem solid #ffd367;border-radius:.1rem;color:#3b4351;padding:.05rem .1rem 0}blockquote{border-left:.1rem solid #dadee4;margin-left:0;padding:.4rem .8rem}blockquote p:last-child{margin-bottom:0}ol,ul{margin:.8rem 0 .8rem .8rem;padding:0}ol ol,ol ul,ul ol,ul ul{margin:.8rem 0 .8rem .8rem}ol li,ul li{margin-top:.4rem}ul{list-style:disc inside}ul ul{list-style-type:circle}ol{list-style:decimal inside}ol ol{list-style-type:lower-alpha}dl dt{font-weight:700}dl dd{margin:.4rem 0 .8rem 0}.lang-zh,.lang-zh-hans,html:lang(zh),html:lang(zh-Hans){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Helvetica Neue",sans-serif}.lang-zh-hant,html:lang(zh-Hant){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang TC","Hiragino Sans CNS","Microsoft JhengHei","Helvetica Neue",sans-serif}.lang-ja,html:lang(ja){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Hiragino Sans","Hiragino Kaku Gothic Pro","Yu Gothic",YuGothic,Meiryo,"Helvetica Neue",sans-serif}.lang-ko,html:lang(ko){font-family:-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic","Helvetica Neue",sans-serif}.lang-cjk ins,.lang-cjk u,:lang(ja) ins,:lang(ja) u,:lang(zh) ins,:lang(zh) u{border-bottom:.05rem solid;text-decoration:none}.lang-cjk del+del,.lang-cjk del+s,.lang-cjk ins+ins,.lang-cjk ins+u,.lang-cjk s+del,.lang-cjk s+s,.lang-cjk u+ins,.lang-cjk u+u,:lang(ja) del+del,:lang(ja) del+s,:lang(ja) ins+ins,:lang(ja) ins+u,:lang(ja) s+del,:lang(ja) s+s,:lang(ja) u+ins,:lang(ja) u+u,:lang(zh) del+del,:lang(zh) del+s,:lang(zh) ins+ins,:lang(zh) ins+u,:lang(zh) s+del,:lang(zh) s+s,:lang(zh) u+ins,:lang(zh) u+u{margin-left:.125em}.table{border-collapse:collapse;border-spacing:0;text-align:left;width:100%}.table.table-striped tbody tr:nth-of-type(odd){background:#f7f8f9}.table tbody tr.active,.table.table-striped tbody tr.active{background:#eef0f3}.table.table-hover tbody tr:hover{background:#eef0f3}.table.table-scroll{display:block;overflow-x:auto;padding-bottom:.75rem;white-space:nowrap}.table td,.table th{border-bottom:.05rem solid #dadee4;padding:.6rem .4rem}.table th{border-bottom-width:.1rem}.btn{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #5755d9;border-radius:.1rem;color:#5755d9;cursor:pointer;display:inline-block;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;text-align:center;text-decoration:none;transition:background .2s,border .2s,box-shadow .2s,color .2s;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.btn:focus{box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.btn:focus,.btn:hover{background:#f1f1fc;border-color:#4b48d6;text-decoration:none}.btn.active,.btn:active{background:#4b48d6;border-color:#3634d2;color:#fff;text-decoration:none}.btn.active.loading::after,.btn:active.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.disabled,.btn:disabled,.btn[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn.btn-primary{background:#5755d9;border-color:#4b48d6;color:#fff}.btn.btn-primary:focus,.btn.btn-primary:hover{background:#4240d4;border-color:#3634d2;color:#fff}.btn.btn-primary.active,.btn.btn-primary:active{background:#3a38d2;border-color:#302ecd;color:#fff}.btn.btn-primary.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-success{background:#32b643;border-color:#2faa3f;color:#fff}.btn.btn-success:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.btn.btn-success:focus,.btn.btn-success:hover{background:#30ae40;border-color:#2da23c;color:#fff}.btn.btn-success.active,.btn.btn-success:active{background:#2a9a39;border-color:#278e34;color:#fff}.btn.btn-success.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-error{background:#e85600;border-color:#d95000;color:#fff}.btn.btn-error:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.btn.btn-error:focus,.btn.btn-error:hover{background:#de5200;border-color:#cf4d00;color:#fff}.btn.btn-error.active,.btn.btn-error:active{background:#c44900;border-color:#b54300;color:#fff}.btn.btn-error.loading::after{border-bottom-color:#fff;border-left-color:#fff}.btn.btn-link{background:0 0;border-color:transparent;color:#5755d9}.btn.btn-link.active,.btn.btn-link:active,.btn.btn-link:focus,.btn.btn-link:hover{color:#302ecd}.btn.btn-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.btn.btn-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.btn.btn-block{display:block;width:100%}.btn.btn-action{padding-left:0;padding-right:0;width:1.8rem}.btn.btn-action.btn-sm{width:1.4rem}.btn.btn-action.btn-lg{width:2rem}.btn.btn-clear{background:0 0;border:0;color:currentColor;height:1rem;line-height:.8rem;margin-left:.2rem;margin-right:-2px;opacity:1;padding:.1rem;text-decoration:none;width:1rem}.btn.btn-clear:focus,.btn.btn-clear:hover{background:rgba(247,248,249,.5);opacity:.95}.btn.btn-clear::before{content:"\2715"}.btn-group{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.btn-group .btn{-ms-flex:1 0 auto;flex:1 0 auto}.btn-group .btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.btn-group .btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.btn-group .btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.btn-group .btn.active,.btn-group .btn:active,.btn-group .btn:focus,.btn-group .btn:hover{z-index:1}.btn-group.btn-group-block{display:-ms-flexbox;display:flex}.btn-group.btn-group-block .btn{-ms-flex:1 0 0;flex:1 0 0}.form-group:not(:last-child){margin-bottom:.4rem}fieldset{margin-bottom:.8rem}legend{font-size:.9rem;font-weight:500;margin-bottom:.8rem}.form-label{display:block;line-height:1.2rem;padding:.3rem 0}.form-label.label-sm{font-size:.7rem;padding:.1rem 0}.form-label.label-lg{font-size:.9rem;padding:.4rem 0}.form-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;background-image:none;border:.05rem solid #bcc3ce;border-radius:.1rem;color:#3b4351;display:block;font-size:.8rem;height:1.8rem;line-height:1.2rem;max-width:100%;outline:0;padding:.25rem .4rem;position:relative;transition:background .2s,border .2s,box-shadow .2s,color .2s;width:100%}.form-input:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-input:-ms-input-placeholder{color:#bcc3ce}.form-input::-ms-input-placeholder{color:#bcc3ce}.form-input::placeholder{color:#bcc3ce}.form-input.input-sm{font-size:.7rem;height:1.4rem;padding:.05rem .3rem}.form-input.input-lg{font-size:.9rem;height:2rem;padding:.35rem .6rem}.form-input.input-inline{display:inline-block;vertical-align:middle;width:auto}.form-input[type=file]{height:auto}textarea.form-input,textarea.form-input.input-lg,textarea.form-input.input-sm{height:auto}.form-input-hint{color:#bcc3ce;font-size:.7rem;margin-top:.2rem}.has-success .form-input-hint,.is-success+.form-input-hint{color:#32b643}.has-error .form-input-hint,.is-error+.form-input-hint{color:#e85600}.form-select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:#fff;border:.05rem solid #bcc3ce;border-radius:.1rem;color:inherit;font-size:.8rem;height:1.8rem;line-height:1.2rem;outline:0;padding:.25rem .4rem;vertical-align:middle;width:100%}.form-select:focus{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-select::-ms-expand{display:none}.form-select.select-sm{font-size:.7rem;height:1.4rem;padding:.05rem 1.1rem .05rem .3rem}.form-select.select-lg{font-size:.9rem;height:2rem;padding:.35rem 1.4rem .35rem .6rem}.form-select[multiple],.form-select[size]{height:auto;padding:.25rem .4rem}.form-select[multiple] option,.form-select[size] option{padding:.1rem .2rem}.form-select:not([multiple]):not([size]){background:#fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem;padding-right:1.2rem}.has-icon-left,.has-icon-right{position:relative}.has-icon-left .form-icon,.has-icon-right .form-icon{height:.8rem;margin:0 .25rem;position:absolute;top:50%;transform:translateY(-50%);width:.8rem;z-index:2}.has-icon-left .form-icon{left:.05rem}.has-icon-left .form-input{padding-left:1.3rem}.has-icon-right .form-icon{right:.05rem}.has-icon-right .form-input{padding-right:1.3rem}.form-checkbox,.form-radio,.form-switch{display:block;line-height:1.2rem;margin:.2rem 0;min-height:1.4rem;padding:.1rem .4rem .1rem 1.2rem;position:relative}.form-checkbox input,.form-radio input,.form-switch input{clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;position:absolute;width:1px}.form-checkbox input:focus+.form-icon,.form-radio input:focus+.form-icon,.form-switch input:focus+.form-icon{border-color:#5755d9;box-shadow:0 0 0 .1rem rgba(87,85,217,.2)}.form-checkbox input:checked+.form-icon,.form-radio input:checked+.form-icon,.form-switch input:checked+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox .form-icon,.form-radio .form-icon,.form-switch .form-icon{border:.05rem solid #bcc3ce;cursor:pointer;display:inline-block;position:absolute;transition:background .2s,border .2s,box-shadow .2s,color .2s}.form-checkbox.input-sm,.form-radio.input-sm,.form-switch.input-sm{font-size:.7rem;margin:0}.form-checkbox.input-lg,.form-radio.input-lg,.form-switch.input-lg{font-size:.9rem;margin:.3rem 0}.form-checkbox .form-icon,.form-radio .form-icon{background:#fff;height:.8rem;left:0;top:.3rem;width:.8rem}.form-checkbox input:active+.form-icon,.form-radio input:active+.form-icon{background:#eef0f3}.form-checkbox .form-icon{border-radius:.1rem}.form-checkbox input:checked+.form-icon::before{background-clip:padding-box;border:.1rem solid #fff;border-left-width:0;border-top-width:0;content:"";height:9px;left:50%;margin-left:-3px;margin-top:-6px;position:absolute;top:50%;transform:rotate(45deg);width:6px}.form-checkbox input:indeterminate+.form-icon{background:#5755d9;border-color:#5755d9}.form-checkbox input:indeterminate+.form-icon::before{background:#fff;content:"";height:2px;left:50%;margin-left:-5px;margin-top:-1px;position:absolute;top:50%;width:10px}.form-radio .form-icon{border-radius:50%}.form-radio input:checked+.form-icon::before{background:#fff;border-radius:50%;content:"";height:6px;left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);width:6px}.form-switch{padding-left:2rem}.form-switch .form-icon{background:#bcc3ce;background-clip:padding-box;border-radius:.45rem;height:.9rem;left:0;top:.25rem;width:1.6rem}.form-switch .form-icon::before{background:#fff;border-radius:50%;content:"";display:block;height:.8rem;left:0;position:absolute;top:0;transition:background .2s,border .2s,box-shadow .2s,color .2s,left .2s;width:.8rem}.form-switch input:checked+.form-icon::before{left:14px}.form-switch input:active+.form-icon::before{background:#f7f8f9}.input-group{display:-ms-flexbox;display:flex}.input-group .input-group-addon{background:#f7f8f9;border:.05rem solid #bcc3ce;border-radius:.1rem;line-height:1.2rem;padding:.25rem .4rem;white-space:nowrap}.input-group .input-group-addon.addon-sm{font-size:.7rem;padding:.05rem .3rem}.input-group .input-group-addon.addon-lg{font-size:.9rem;padding:.35rem .6rem}.input-group .form-input,.input-group .form-select{-ms-flex:1 1 auto;flex:1 1 auto;width:1%}.input-group .input-group-btn{z-index:1}.input-group .form-input:first-child:not(:last-child),.input-group .form-select:first-child:not(:last-child),.input-group .input-group-addon:first-child:not(:last-child),.input-group .input-group-btn:first-child:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0}.input-group .form-input:not(:first-child):not(:last-child),.input-group .form-select:not(:first-child):not(:last-child),.input-group .input-group-addon:not(:first-child):not(:last-child),.input-group .input-group-btn:not(:first-child):not(:last-child){border-radius:0;margin-left:-.05rem}.input-group .form-input:last-child:not(:first-child),.input-group .form-select:last-child:not(:first-child),.input-group .input-group-addon:last-child:not(:first-child),.input-group .input-group-btn:last-child:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0;margin-left:-.05rem}.input-group .form-input:focus,.input-group .form-select:focus,.input-group .input-group-addon:focus,.input-group .input-group-btn:focus{z-index:2}.input-group .form-select{width:auto}.input-group.input-inline{display:-ms-inline-flexbox;display:inline-flex}.form-input.is-success,.form-select.is-success,.has-success .form-input,.has-success .form-select{background:#f9fdfa;border-color:#32b643}.form-input.is-success:focus,.form-select.is-success:focus,.has-success .form-input:focus,.has-success .form-select:focus{box-shadow:0 0 0 .1rem rgba(50,182,67,.2)}.form-input.is-error,.form-select.is-error,.has-error .form-input,.has-error .form-select{background:#fffaf7;border-color:#e85600}.form-input.is-error:focus,.form-select.is-error:focus,.has-error .form-input:focus,.has-error .form-select:focus{box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error .form-icon,.form-radio.is-error .form-icon,.form-switch.is-error .form-icon,.has-error .form-checkbox .form-icon,.has-error .form-radio .form-icon,.has-error .form-switch .form-icon{border-color:#e85600}.form-checkbox.is-error input:checked+.form-icon,.form-radio.is-error input:checked+.form-icon,.form-switch.is-error input:checked+.form-icon,.has-error .form-checkbox input:checked+.form-icon,.has-error .form-radio input:checked+.form-icon,.has-error .form-switch input:checked+.form-icon{background:#e85600;border-color:#e85600}.form-checkbox.is-error input:focus+.form-icon,.form-radio.is-error input:focus+.form-icon,.form-switch.is-error input:focus+.form-icon,.has-error .form-checkbox input:focus+.form-icon,.has-error .form-radio input:focus+.form-icon,.has-error .form-switch input:focus+.form-icon{border-color:#e85600;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-checkbox.is-error input:indeterminate+.form-icon,.has-error .form-checkbox input:indeterminate+.form-icon{background:#e85600;border-color:#e85600}.form-input:not(:-ms-input-placeholder):invalid{border-color:#e85600}.form-input:not(:placeholder-shown):invalid{border-color:#e85600}.form-input:not(:-ms-input-placeholder):invalid:focus{background:#fffaf7;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-input:not(:placeholder-shown):invalid:focus{background:#fffaf7;box-shadow:0 0 0 .1rem rgba(232,86,0,.2)}.form-input:not(:-ms-input-placeholder):invalid+.form-input-hint{color:#e85600}.form-input:not(:placeholder-shown):invalid+.form-input-hint{color:#e85600}.form-input.disabled,.form-input:disabled,.form-select.disabled,.form-select:disabled{background-color:#eef0f3;cursor:not-allowed;opacity:.5}.form-input[readonly]{background-color:#f7f8f9}input.disabled+.form-icon,input:disabled+.form-icon{background:#eef0f3;cursor:not-allowed;opacity:.5}.form-switch input.disabled+.form-icon::before,.form-switch input:disabled+.form-icon::before{background:#fff}.form-horizontal{padding:.4rem 0}.form-horizontal .form-group{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap}.form-inline{display:inline-block}.label{background:#eef0f3;border-radius:.1rem;color:#455060;display:inline-block;line-height:1.25;padding:.1rem .2rem}.label.label-rounded{border-radius:5rem;padding-left:.4rem;padding-right:.4rem}.label.label-primary{background:#5755d9;color:#fff}.label.label-secondary{background:#f1f1fc;color:#5755d9}.label.label-success{background:#32b643;color:#fff}.label.label-warning{background:#ffb700;color:#fff}.label.label-error{background:#e85600;color:#fff}code{background:#fcf2f2;border-radius:.1rem;color:#d73e48;font-size:85%;line-height:1.25;padding:.1rem .2rem}.code{border-radius:.1rem;color:#3b4351;position:relative}.code::before{color:#bcc3ce;content:attr(data-lang);font-size:.7rem;position:absolute;right:.4rem;top:.1rem}.code code{background:#f7f8f9;color:inherit;display:block;line-height:1.5;overflow-x:auto;padding:1rem;width:100%}.img-responsive{display:block;height:auto;max-width:100%}.img-fit-cover{object-fit:cover}.img-fit-contain{object-fit:contain}.video-responsive{display:block;overflow:hidden;padding:0;position:relative;width:100%}.video-responsive::before{content:"";display:block;padding-bottom:56.25%}.video-responsive embed,.video-responsive iframe,.video-responsive object{border:0;bottom:0;height:100%;left:0;position:absolute;right:0;top:0;width:100%}video.video-responsive{height:auto;max-width:100%}video.video-responsive::before{content:none}.video-responsive-4-3::before{padding-bottom:75%}.video-responsive-1-1::before{padding-bottom:100%}.figure{margin:0 0 .4rem 0}.figure .figure-caption{color:#66758c;margin-top:.4rem}.container{margin-left:auto;margin-right:auto;padding-left:.4rem;padding-right:.4rem;width:100%}.container.grid-xl{max-width:1296px}.container.grid-lg{max-width:976px}.container.grid-md{max-width:856px}.container.grid-sm{max-width:616px}.container.grid-xs{max-width:496px}.show-lg,.show-md,.show-sm,.show-xl,.show-xs{display:none!important}.cols,.columns{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-left:-.4rem;margin-right:-.4rem}.cols.col-gapless,.columns.col-gapless{margin-left:0;margin-right:0}.cols.col-gapless>.column,.columns.col-gapless>.column{padding-left:0;padding-right:0}.cols.col-oneline,.columns.col-oneline{-ms-flex-wrap:nowrap;flex-wrap:nowrap;overflow-x:auto}.column,[class~=col-]{-ms-flex:1;flex:1;max-width:100%;padding-left:.4rem;padding-right:.4rem}.column.col-1,.column.col-10,.column.col-11,.column.col-12,.column.col-2,.column.col-3,.column.col-4,.column.col-5,.column.col-6,.column.col-7,.column.col-8,.column.col-9,.column.col-auto,[class~=col-].col-1,[class~=col-].col-10,[class~=col-].col-11,[class~=col-].col-12,[class~=col-].col-2,[class~=col-].col-3,[class~=col-].col-4,[class~=col-].col-5,[class~=col-].col-6,[class~=col-].col-7,[class~=col-].col-8,[class~=col-].col-9,[class~=col-].col-auto{-ms-flex:none;flex:none}.col-12{width:100%}.col-11{width:91.66666667%}.col-10{width:83.33333333%}.col-9{width:75%}.col-8{width:66.66666667%}.col-7{width:58.33333333%}.col-6{width:50%}.col-5{width:41.66666667%}.col-4{width:33.33333333%}.col-3{width:25%}.col-2{width:16.66666667%}.col-1{width:8.33333333%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;max-width:none;width:auto}.col-mx-auto{margin-left:auto;margin-right:auto}.col-ml-auto{margin-left:auto}.col-mr-auto{margin-right:auto}@media (max-width:1280px){.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{-ms-flex:none;flex:none}.col-xl-12{width:100%}.col-xl-11{width:91.66666667%}.col-xl-10{width:83.33333333%}.col-xl-9{width:75%}.col-xl-8{width:66.66666667%}.col-xl-7{width:58.33333333%}.col-xl-6{width:50%}.col-xl-5{width:41.66666667%}.col-xl-4{width:33.33333333%}.col-xl-3{width:25%}.col-xl-2{width:16.66666667%}.col-xl-1{width:8.33333333%}.col-xl-auto{width:auto}.hide-xl{display:none!important}.show-xl{display:block!important}}@media (max-width:960px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto{-ms-flex:none;flex:none}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-auto{width:auto}.hide-lg{display:none!important}.show-lg{display:block!important}}@media (max-width:840px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto{-ms-flex:none;flex:none}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-auto{width:auto}.hide-md{display:none!important}.show-md{display:block!important}}@media (max-width:600px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto{-ms-flex:none;flex:none}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-auto{width:auto}.hide-sm{display:none!important}.show-sm{display:block!important}}@media (max-width:480px){.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-auto{-ms-flex:none;flex:none}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-auto{width:auto}.hide-xs{display:none!important}.show-xs{display:block!important}}.hero{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:justify;justify-content:space-between;padding-bottom:4rem;padding-top:4rem}.hero.hero-sm{padding-bottom:2rem;padding-top:2rem}.hero.hero-lg{padding-bottom:8rem;padding-top:8rem}.hero .hero-body{padding:.4rem}.navbar{align-items:stretch;display:-ms-flexbox;display:flex;-ms-flex-align:stretch;-ms-flex-pack:justify;-ms-flex-wrap:wrap;flex-wrap:wrap;justify-content:space-between}.navbar .navbar-section{align-items:center;display:-ms-flexbox;display:flex;-ms-flex:1 0 0;flex:1 0 0;-ms-flex-align:center}.navbar .navbar-section:not(:first-child):last-child{-ms-flex-pack:end;justify-content:flex-end}.navbar .navbar-center{align-items:center;display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-align:center}.navbar .navbar-brand{font-size:.9rem;text-decoration:none}.accordion input:checked~.accordion-header>.icon:first-child,.accordion[open] .accordion-header>.icon:first-child{transform:rotate(90deg)}.accordion input:checked~.accordion-body,.accordion[open] .accordion-body{max-height:50rem}.accordion .accordion-header{display:block;padding:.2rem .4rem}.accordion .accordion-header .icon{transition:transform .25s}.accordion .accordion-body{margin-bottom:.4rem;max-height:0;overflow:hidden;transition:max-height .25s}summary.accordion-header::-webkit-details-marker{display:none}.avatar{background:#5755d9;border-radius:50%;color:rgba(255,255,255,.85);display:inline-block;font-size:.8rem;font-weight:300;height:1.6rem;line-height:1.25;margin:0;position:relative;vertical-align:middle;width:1.6rem}.avatar.avatar-xs{font-size:.4rem;height:.8rem;width:.8rem}.avatar.avatar-sm{font-size:.6rem;height:1.2rem;width:1.2rem}.avatar.avatar-lg{font-size:1.2rem;height:2.4rem;width:2.4rem}.avatar.avatar-xl{font-size:1.6rem;height:3.2rem;width:3.2rem}.avatar img{border-radius:50%;height:100%;position:relative;width:100%;z-index:1}.avatar .avatar-icon,.avatar .avatar-presence{background:#fff;bottom:14.64%;height:50%;padding:.1rem;position:absolute;right:14.64%;transform:translate(50%,50%);width:50%;z-index:2}.avatar .avatar-presence{background:#bcc3ce;border-radius:50%;box-shadow:0 0 0 .1rem #fff;height:.5em;width:.5em}.avatar .avatar-presence.online{background:#32b643}.avatar .avatar-presence.busy{background:#e85600}.avatar .avatar-presence.away{background:#ffb700}.avatar[data-initial]::before{color:currentColor;content:attr(data-initial);left:50%;position:absolute;top:50%;transform:translate(-50%,-50%);z-index:1}.badge{position:relative;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge]::after{background:#5755d9;background-clip:padding-box;border-radius:.5rem;box-shadow:0 0 0 .1rem #fff;color:#fff;content:attr(data-badge);display:inline-block;transform:translate(-.05rem,-.5rem)}.badge[data-badge]::after{font-size:.7rem;height:.9rem;line-height:1;min-width:.9rem;padding:.1rem .2rem;text-align:center;white-space:nowrap}.badge:not([data-badge])::after,.badge[data-badge=""]::after{height:6px;min-width:6px;padding:0;width:6px}.badge.btn::after{position:absolute;right:0;top:0;transform:translate(50%,-50%)}.badge.avatar::after{position:absolute;right:14.64%;top:14.64%;transform:translate(50%,-50%);z-index:100}.breadcrumb{list-style:none;margin:.2rem 0;padding:.2rem 0}.breadcrumb .breadcrumb-item{color:#66758c;display:inline-block;margin:0;padding:.2rem 0}.breadcrumb .breadcrumb-item:not(:last-child){margin-right:.2rem}.breadcrumb .breadcrumb-item:not(:last-child) a{color:#66758c}.breadcrumb .breadcrumb-item:not(:first-child)::before{color:#66758c;content:"/";padding-right:.4rem}.bar{background:#eef0f3;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-wrap:nowrap;flex-wrap:nowrap;height:.8rem;width:100%}.bar.bar-sm{height:.2rem}.bar .bar-item{background:#5755d9;color:#fff;display:block;-ms-flex-negative:0;flex-shrink:0;font-size:.7rem;height:100%;line-height:.8rem;position:relative;text-align:center;width:0}.bar .bar-item:first-child{border-bottom-left-radius:.1rem;border-top-left-radius:.1rem}.bar .bar-item:last-child{border-bottom-right-radius:.1rem;border-top-right-radius:.1rem;-ms-flex-negative:1;flex-shrink:1}.bar-slider{height:.1rem;margin:.4rem 0;position:relative}.bar-slider .bar-item{left:0;padding:0;position:absolute}.bar-slider .bar-item:not(:last-child):first-child{background:#eef0f3;z-index:1}.bar-slider .bar-slider-btn{background:#5755d9;border:0;border-radius:50%;height:.6rem;padding:0;position:absolute;right:0;top:50%;transform:translate(50%,-50%);width:.6rem}.bar-slider .bar-slider-btn:active{box-shadow:0 0 0 .1rem #5755d9}.card{background:#fff;border:.05rem solid #dadee4;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.card .card-body,.card .card-footer,.card .card-header{padding:.8rem;padding-bottom:0}.card .card-body:last-child,.card .card-footer:last-child,.card .card-header:last-child{padding-bottom:.8rem}.card .card-body{-ms-flex:1 1 auto;flex:1 1 auto}.card .card-image{padding-top:.8rem}.card .card-image:first-child{padding-top:0}.card .card-image:first-child img{border-top-left-radius:.1rem;border-top-right-radius:.1rem}.card .card-image:last-child img{border-bottom-left-radius:.1rem;border-bottom-right-radius:.1rem}.chip{align-items:center;background:#eef0f3;border-radius:5rem;display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;font-size:90%;height:1.2rem;line-height:.8rem;margin:.1rem;max-width:320px;overflow:hidden;padding:.2rem .4rem;text-decoration:none;text-overflow:ellipsis;vertical-align:middle;white-space:nowrap}.chip.active{background:#5755d9;color:#fff}.chip .avatar{margin-left:-.4rem;margin-right:.2rem}.chip .btn-clear{border-radius:50%;transform:scale(.75)}.dropdown{display:inline-block;position:relative}.dropdown .menu{animation:slide-down .15s ease 1;display:none;left:0;max-height:50vh;overflow-y:auto;position:absolute;top:100%}.dropdown.dropdown-right .menu{left:auto;right:0}.dropdown .dropdown-toggle:focus+.menu,.dropdown .menu:hover,.dropdown.active .menu{display:block}.dropdown .btn-group .dropdown-toggle:nth-last-child(2){border-bottom-right-radius:.1rem;border-top-right-radius:.1rem}.empty{background:#f7f8f9;border-radius:.1rem;color:#66758c;padding:3.2rem 1.6rem;text-align:center}.empty .empty-icon{margin-bottom:.8rem}.empty .empty-subtitle,.empty .empty-title{margin:.4rem auto}.empty .empty-action{margin-top:.8rem}.menu{background:#fff;border-radius:.1rem;box-shadow:0 .05rem .2rem rgba(48,55,66,.3);list-style:none;margin:0;min-width:180px;padding:.4rem;transform:translateY(.2rem);z-index:300}.menu.menu-nav{background:0 0;box-shadow:none}.menu .menu-item{margin-top:0;padding:0 .4rem;position:relative;text-decoration:none}.menu .menu-item>a{border-radius:.1rem;color:inherit;display:block;margin:0 -.4rem;padding:.2rem .4rem;text-decoration:none}.menu .menu-item>a:focus,.menu .menu-item>a:hover{background:#f1f1fc;color:#5755d9}.menu .menu-item>a.active,.menu .menu-item>a:active{background:#f1f1fc;color:#5755d9}.menu .menu-item .form-checkbox,.menu .menu-item .form-radio,.menu .menu-item .form-switch{margin:.1rem 0}.menu .menu-item+.menu-item{margin-top:.2rem}.menu .menu-badge{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;height:100%;position:absolute;right:0;top:0}.menu .menu-badge .label{margin-right:.4rem}.modal{align-items:center;bottom:0;display:none;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center;left:0;opacity:0;overflow:hidden;padding:.4rem;position:fixed;right:0;top:0}.modal.active,.modal:target{display:-ms-flexbox;display:flex;opacity:1;z-index:400}.modal.active .modal-overlay,.modal:target .modal-overlay{background:rgba(247,248,249,.75);bottom:0;cursor:default;display:block;left:0;position:absolute;right:0;top:0}.modal.active .modal-container,.modal:target .modal-container{animation:slide-down .2s ease 1;z-index:1}.modal.modal-sm .modal-container{max-width:320px;padding:0 .4rem}.modal.modal-lg .modal-overlay{background:#fff}.modal.modal-lg .modal-container{box-shadow:none;max-width:960px}.modal-container{background:#fff;border-radius:.1rem;box-shadow:0 .2rem .5rem rgba(48,55,66,.3);display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;max-height:75vh;max-width:640px;padding:0 .8rem;width:100%}.modal-container.modal-fullheight{max-height:100vh}.modal-container .modal-header{color:#303742;padding:.8rem}.modal-container .modal-body{overflow-y:auto;padding:.8rem;position:relative}.modal-container .modal-footer{padding:.8rem;text-align:right}.nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;list-style:none;margin:.2rem 0}.nav .nav-item a{color:#66758c;padding:.2rem .4rem;text-decoration:none}.nav .nav-item a:focus,.nav .nav-item a:hover{color:#5755d9}.nav .nav-item.active>a{color:#505c6e;font-weight:700}.nav .nav-item.active>a:focus,.nav .nav-item.active>a:hover{color:#5755d9}.nav .nav{margin-bottom:.4rem;margin-left:.8rem}.pagination{display:-ms-flexbox;display:flex;list-style:none;margin:.2rem 0;padding:.2rem 0}.pagination .page-item{margin:.2rem .05rem}.pagination .page-item span{display:inline-block;padding:.2rem .2rem}.pagination .page-item a{border-radius:.1rem;display:inline-block;padding:.2rem .4rem;text-decoration:none}.pagination .page-item a:focus,.pagination .page-item a:hover{color:#5755d9}.pagination .page-item.disabled a{cursor:default;opacity:.5;pointer-events:none}.pagination .page-item.active a{background:#5755d9;color:#fff}.pagination .page-item.page-next,.pagination .page-item.page-prev{-ms-flex:1 0 50%;flex:1 0 50%}.pagination .page-item.page-next{text-align:right}.pagination .page-item .page-item-title{margin:0}.pagination .page-item .page-item-subtitle{margin:0;opacity:.5}.panel{border:.05rem solid #dadee4;border-radius:.1rem;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column}.panel .panel-footer,.panel .panel-header{-ms-flex:0 0 auto;flex:0 0 auto;padding:.8rem}.panel .panel-nav{-ms-flex:0 0 auto;flex:0 0 auto}.panel .panel-body{-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;padding:0 .8rem}.popover{display:inline-block;position:relative}.popover .popover-container{left:50%;opacity:0;padding:.4rem;position:absolute;top:0;transform:translate(-50%,-50%) scale(0);transition:transform .2s;width:320px;z-index:300}.popover :focus+.popover-container,.popover:hover .popover-container{display:block;opacity:1;transform:translate(-50%,-100%) scale(1)}.popover.popover-right .popover-container{left:100%;top:50%}.popover.popover-right :focus+.popover-container,.popover.popover-right:hover .popover-container{transform:translate(0,-50%) scale(1)}.popover.popover-bottom .popover-container{left:50%;top:100%}.popover.popover-bottom :focus+.popover-container,.popover.popover-bottom:hover .popover-container{transform:translate(-50%,0) scale(1)}.popover.popover-left .popover-container{left:0;top:50%}.popover.popover-left :focus+.popover-container,.popover.popover-left:hover .popover-container{transform:translate(-100%,-50%) scale(1)}.popover .card{border:0;box-shadow:0 .2rem .5rem rgba(48,55,66,.3)}.step{display:-ms-flexbox;display:flex;-ms-flex-wrap:nowrap;flex-wrap:nowrap;list-style:none;margin:.2rem 0;width:100%}.step .step-item{-ms-flex:1 1 0;flex:1 1 0;margin-top:0;min-height:1rem;position:relative;text-align:center}.step .step-item:not(:first-child)::before{background:#5755d9;content:"";height:2px;left:-50%;position:absolute;top:9px;width:100%}.step .step-item a{color:#5755d9;display:inline-block;padding:20px 10px 0;text-decoration:none}.step .step-item a::before{background:#5755d9;border:.1rem solid #fff;border-radius:50%;content:"";display:block;height:.6rem;left:50%;position:absolute;top:.2rem;transform:translateX(-50%);width:.6rem;z-index:1}.step .step-item.active a::before{background:#fff;border:.1rem solid #5755d9}.step .step-item.active~.step-item::before{background:#dadee4}.step .step-item.active~.step-item a{color:#bcc3ce}.step .step-item.active~.step-item a::before{background:#dadee4}.tab{align-items:center;border-bottom:.05rem solid #dadee4;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-wrap:wrap;flex-wrap:wrap;list-style:none;margin:.2rem 0 .15rem 0}.tab .tab-item{margin-top:0}.tab .tab-item a{border-bottom:.1rem solid transparent;color:inherit;display:block;margin:0 .4rem 0 0;padding:.4rem .2rem .3rem .2rem;text-decoration:none}.tab .tab-item a:focus,.tab .tab-item a:hover{color:#5755d9}.tab .tab-item a.active,.tab .tab-item.active a{border-bottom-color:#5755d9;color:#5755d9}.tab .tab-item.tab-action{-ms-flex:1 0 auto;flex:1 0 auto;text-align:right}.tab .tab-item .btn-clear{margin-top:-.2rem}.tab.tab-block .tab-item{-ms-flex:1 0 0;flex:1 0 0;text-align:center}.tab.tab-block .tab-item a{margin:0}.tab.tab-block .tab-item .badge[data-badge]::after{position:absolute;right:.1rem;top:.1rem;transform:translate(0,0)}.tab:not(.tab-block) .badge{padding-right:0}.tile{align-content:space-between;align-items:flex-start;display:-ms-flexbox;display:flex;-ms-flex-align:start;-ms-flex-line-pack:justify}.tile .tile-action,.tile .tile-icon{-ms-flex:0 0 auto;flex:0 0 auto}.tile .tile-content{-ms-flex:1 1 auto;flex:1 1 auto}.tile .tile-content:not(:first-child){padding-left:.4rem}.tile .tile-content:not(:last-child){padding-right:.4rem}.tile .tile-subtitle,.tile .tile-title{line-height:1.2rem}.tile.tile-centered{align-items:center;-ms-flex-align:center}.tile.tile-centered .tile-content{overflow:hidden}.tile.tile-centered .tile-subtitle,.tile.tile-centered .tile-title{margin-bottom:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast{background:rgba(48,55,66,.95);border:.05rem solid #303742;border-color:#303742;border-radius:.1rem;color:#fff;display:block;padding:.4rem;width:100%}.toast.toast-primary{background:rgba(87,85,217,.95);border-color:#5755d9}.toast.toast-success{background:rgba(50,182,67,.95);border-color:#32b643}.toast.toast-warning{background:rgba(255,183,0,.95);border-color:#ffb700}.toast.toast-error{background:rgba(232,86,0,.95);border-color:#e85600}.toast a{color:#fff;text-decoration:underline}.toast a.active,.toast a:active,.toast a:focus,.toast a:hover{opacity:.75}.toast .btn-clear{margin:.1rem}.toast p:last-child{margin-bottom:0}.tooltip{position:relative}.tooltip::after{background:rgba(48,55,66,.95);border-radius:.1rem;bottom:100%;color:#fff;content:attr(data-tooltip);display:block;font-size:.7rem;left:50%;max-width:320px;opacity:0;overflow:hidden;padding:.2rem .4rem;pointer-events:none;position:absolute;text-overflow:ellipsis;transform:translate(-50%,.4rem);transition:opacity .2s,transform .2s;white-space:pre;z-index:300}.tooltip:focus::after,.tooltip:hover::after{opacity:1;transform:translate(-50%,-.2rem)}.tooltip.disabled,.tooltip[disabled]{pointer-events:auto}.tooltip.tooltip-right::after{bottom:50%;left:100%;transform:translate(-.2rem,50%)}.tooltip.tooltip-right:focus::after,.tooltip.tooltip-right:hover::after{transform:translate(.2rem,50%)}.tooltip.tooltip-bottom::after{bottom:auto;top:100%;transform:translate(-50%,-.4rem)}.tooltip.tooltip-bottom:focus::after,.tooltip.tooltip-bottom:hover::after{transform:translate(-50%,.2rem)}.tooltip.tooltip-left::after{bottom:50%;left:auto;right:100%;transform:translate(.4rem,50%)}.tooltip.tooltip-left:focus::after,.tooltip.tooltip-left:hover::after{transform:translate(-.2rem,50%)}@keyframes loading{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}@keyframes slide-down{0%{opacity:0;transform:translateY(-1.6rem)}100%{opacity:1;transform:translateY(0)}}.text-primary{color:#5755d9!important}a.text-primary:focus,a.text-primary:hover{color:#4240d4}a.text-primary:visited{color:#6c6ade}.text-secondary{color:#e5e5f9!important}a.text-secondary:focus,a.text-secondary:hover{color:#d1d0f4}a.text-secondary:visited{color:#fafafe}.text-gray{color:#bcc3ce!important}a.text-gray:focus,a.text-gray:hover{color:#adb6c4}a.text-gray:visited{color:#cbd0d9}.text-light{color:#fff!important}a.text-light:focus,a.text-light:hover{color:#f2f2f2}a.text-light:visited{color:#fff}.text-dark{color:#3b4351!important}a.text-dark:focus,a.text-dark:hover{color:#303742}a.text-dark:visited{color:#455060}.text-success{color:#32b643!important}a.text-success:focus,a.text-success:hover{color:#2da23c}a.text-success:visited{color:#39c94b}.text-warning{color:#ffb700!important}a.text-warning:focus,a.text-warning:hover{color:#e6a500}a.text-warning:visited{color:#ffbe1a}.text-error{color:#e85600!important}a.text-error:focus,a.text-error:hover{color:#cf4d00}a.text-error:visited{color:#ff6003}.bg-primary{background:#5755d9!important;color:#fff}.bg-secondary{background:#f1f1fc!important}.bg-dark{background:#303742!important;color:#fff}.bg-gray{background:#f7f8f9!important}.bg-success{background:#32b643!important;color:#fff}.bg-warning{background:#ffb700!important;color:#fff}.bg-error{background:#e85600!important;color:#fff}.c-hand{cursor:pointer}.c-move{cursor:move}.c-zoom-in{cursor:zoom-in}.c-zoom-out{cursor:zoom-out}.c-not-allowed{cursor:not-allowed}.c-auto{cursor:auto}.d-block{display:block}.d-inline{display:inline}.d-inline-block{display:inline-block}.d-flex{display:-ms-flexbox;display:flex}.d-inline-flex{display:-ms-inline-flexbox;display:inline-flex}.d-hide,.d-none{display:none!important}.d-visible{visibility:visible}.d-invisible{visibility:hidden}.text-hide{background:0 0;border:0;color:transparent;font-size:0;line-height:0;text-shadow:none}.text-assistive{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.divider,.divider-vert{display:block;position:relative}.divider-vert[data-content]::after,.divider[data-content]::after{background:#fff;color:#bcc3ce;content:attr(data-content);display:inline-block;font-size:.7rem;padding:0 .4rem;transform:translateY(-.65rem)}.divider{border-top:.05rem solid #f1f3f5;height:.05rem;margin:.4rem 0}.divider[data-content]{margin:.8rem 0}.divider-vert{display:block;padding:.8rem}.divider-vert::before{border-left:.05rem solid #dadee4;bottom:.4rem;content:"";display:block;left:50%;position:absolute;top:.4rem;transform:translateX(-50%)}.divider-vert[data-content]::after{left:50%;padding:.2rem 0;position:absolute;top:50%;transform:translate(-50%,-50%)}.loading{color:transparent!important;min-height:.8rem;pointer-events:none;position:relative}.loading::after{animation:loading .5s infinite linear;background:0 0;border:.1rem solid #5755d9;border-radius:50%;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:.8rem;left:50%;margin-left:-.4rem;margin-top:-.4rem;opacity:1;padding:0;position:absolute;top:50%;width:.8rem;z-index:1}.loading.loading-lg{min-height:2rem}.loading.loading-lg::after{height:1.6rem;margin-left:-.8rem;margin-top:-.8rem;width:1.6rem}.clearfix::after{clear:both;content:"";display:table}.float-left{float:left!important}.float-right{float:right!important}.p-relative{position:relative!important}.p-absolute{position:absolute!important}.p-fixed{position:fixed!important}.p-sticky{position:-webkit-sticky!important;position:sticky!important}.p-centered{display:block;float:none;margin-left:auto;margin-right:auto}.flex-centered{align-items:center;display:-ms-flexbox;display:flex;-ms-flex-align:center;-ms-flex-pack:center;justify-content:center}.m-0{margin:0!important}.mb-0{margin-bottom:0!important}.ml-0{margin-left:0!important}.mr-0{margin-right:0!important}.mt-0{margin-top:0!important}.mx-0{margin-left:0!important;margin-right:0!important}.my-0{margin-bottom:0!important;margin-top:0!important}.m-1{margin:.2rem!important}.mb-1{margin-bottom:.2rem!important}.ml-1{margin-left:.2rem!important}.mr-1{margin-right:.2rem!important}.mt-1{margin-top:.2rem!important}.mx-1{margin-left:.2rem!important;margin-right:.2rem!important}.my-1{margin-bottom:.2rem!important;margin-top:.2rem!important}.m-2{margin:.4rem!important}.mb-2{margin-bottom:.4rem!important}.ml-2{margin-left:.4rem!important}.mr-2{margin-right:.4rem!important}.mt-2{margin-top:.4rem!important}.mx-2{margin-left:.4rem!important;margin-right:.4rem!important}.my-2{margin-bottom:.4rem!important;margin-top:.4rem!important}.p-0{padding:0!important}.pb-0{padding-bottom:0!important}.pl-0{padding-left:0!important}.pr-0{padding-right:0!important}.pt-0{padding-top:0!important}.px-0{padding-left:0!important;padding-right:0!important}.py-0{padding-bottom:0!important;padding-top:0!important}.p-1{padding:.2rem!important}.pb-1{padding-bottom:.2rem!important}.pl-1{padding-left:.2rem!important}.pr-1{padding-right:.2rem!important}.pt-1{padding-top:.2rem!important}.px-1{padding-left:.2rem!important;padding-right:.2rem!important}.py-1{padding-bottom:.2rem!important;padding-top:.2rem!important}.p-2{padding:.4rem!important}.pb-2{padding-bottom:.4rem!important}.pl-2{padding-left:.4rem!important}.pr-2{padding-right:.4rem!important}.pt-2{padding-top:.4rem!important}.px-2{padding-left:.4rem!important;padding-right:.4rem!important}.py-2{padding-bottom:.4rem!important;padding-top:.4rem!important}.s-rounded{border-radius:.1rem}.s-circle{border-radius:50%}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-normal{font-weight:400}.text-bold{font-weight:700}.text-italic{font-style:italic}.text-large{font-size:1.2em}.text-small{font-size:.9em}.text-tiny{font-size:.8em}.text-muted{opacity:.8}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-clip{overflow:hidden;text-overflow:clip;white-space:nowrap}.text-break{-webkit-hyphens:auto;-ms-hyphens:auto;hyphens:auto;word-break:break-word;word-wrap:break-word}
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..c0037a8
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,9 @@
+<?php
+
+use App\Kernel;
+
+require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
+
+return static function (array $context) {
+    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
+};
diff --git a/public/js/inventory.js b/public/js/inventory.js
new file mode 100644
index 0000000..e7c9a5f
--- /dev/null
+++ b/public/js/inventory.js
@@ -0,0 +1,596 @@
+(function () {
+	'use strict';
+
+	var SYNC_INTERVAL = 30 * 1000;
+	var SYNC_BATCH = 500;
+	var LOG_LIMIT = 200;
+	var KEYS = {
+		books: 'inventory.books',
+		queue: 'inventory.queue',
+		log: 'inventory.log',
+		syncedAt: 'inventory.syncedAt'
+	};
+
+	var root = document.getElementById('inventory');
+	var messages = JSON.parse(document.getElementById('messages').textContent);
+	var config = {
+		booksUrl: root.dataset.booksUrl,
+		syncUrl: root.dataset.syncUrl,
+		swUrl: root.dataset.swUrl,
+		scope: root.dataset.scope,
+		bookUrl: root.dataset.bookUrl,
+		locale: root.dataset.locale
+	};
+	var el = {
+		form: document.getElementById('scan-form'),
+		input: document.getElementById('scan-input'),
+		location: document.getElementById('location'),
+		message: document.getElementById('message'),
+		connection: document.getElementById('connection'),
+		booksCount: document.getElementById('books-count'),
+		booksDate: document.getElementById('books-date'),
+		pendingCount: document.getElementById('pending-count'),
+		syncDate: document.getElementById('sync-date'),
+		refresh: document.getElementById('refresh'),
+		sync: document.getElementById('sync'),
+		log: document.getElementById('log'),
+		logEmpty: document.getElementById('log-empty'),
+		clearLog: document.getElementById('clear-log')
+	};
+
+	/* ---------- storage ---------- */
+
+	function load(key, fallback) {
+		try {
+			var value = localStorage.getItem(key);
+			return value === null ? fallback : JSON.parse(value);
+		} catch (e) {
+			return fallback;
+		}
+	}
+
+	function save(key, value) {
+		try {
+			localStorage.setItem(key, JSON.stringify(value));
+		} catch (e) {
+			showMessage('error', String(e));
+		}
+	}
+
+	var state = {
+		books: load(KEYS.books, {fetchedAt: null, list: []}),
+		queue: load(KEYS.queue, []),
+		location: null,
+		log: load(KEYS.log, []),
+		syncedAt: load(KEYS.syncedAt, null),
+		syncing: false,
+		syncFailed: false,
+		byId: {},
+		byIsbn: {},
+		byIssn: {}
+	};
+
+	function indexBooks() {
+		state.byId = {};
+		state.byIsbn = {};
+		state.byIssn = {};
+		state.books.list.forEach(function (book) {
+			state.byId[book.id] = book;
+			book.isbn.forEach(function (isbn) {
+				(state.byIsbn[isbn] = state.byIsbn[isbn] || []).push(book);
+			});
+			(book.issn || []).forEach(function (issn) {
+				(state.byIssn[issn] = state.byIssn[issn] || []).push(book);
+			});
+		});
+		// Scans not uploaded yet are newer than whatever the server sent.
+		state.queue.forEach(function (scan) {
+			if (state.byId[scan.id]) {
+				state.byId[scan.id].placement = scan.location;
+			}
+		});
+	}
+
+	/* ---------- input recognition ---------- */
+
+	/** Same rules as App\Model\Isbn: an ISBN-10 or ISBN-13 becomes an ISBN-13, anything else null. */
+	function toIsbn13(value) {
+		var digits = value.replace(/[- ]/g, '').toUpperCase();
+		var i, sum;
+
+		if (/^\d{9}[\dX]$/.test(digits)) {
+			for (sum = 0, i = 0; i < 10; i++) {
+				sum += (10 - i) * (digits[i] === 'X' ? 10 : +digits[i]);
+			}
+			if (sum % 11 !== 0) {
+				return null;
+			}
+			var stem = '978' + digits.substring(0, 9);
+			return stem + isbn13CheckDigit(stem);
+		}
+
+		if (/^97[89]\d{10}$/.test(digits) && digits[12] === isbn13CheckDigit(digits.substring(0, 12))) {
+			return digits;
+		}
+
+		return null;
+	}
+
+	/** Same rules as LabelType::ISSN: the compact form of an ISSN-8 or an ISSN-13 barcode, or null. */
+	function toIssn(value) {
+		var compact = value.replace(/[- ]/g, '').toUpperCase();
+
+		return /^(\d{7}[\dX]|\d{13})$/.test(compact) ? compact : null;
+	}
+
+	function isbn13CheckDigit(stem) {
+		for (var sum = 0, i = 0; i < 12; i++) {
+			sum += +stem[i] * (i % 2 === 0 ? 1 : 3);
+		}
+		return String((10 - sum % 10) % 10);
+	}
+
+	/**
+	 * @returns {{type: 'location', value: string}|{type: 'book', book: Object}|{type: 'error', message: string}}
+	 */
+	function recognize(input) {
+		if (/^\p{L}/u.test(input) || input.indexOf('/') !== -1) {
+			return {type: 'location', value: input};
+		}
+
+		if (/^\d{1,8}$/.test(input)) {
+			var book = state.byId[+input];
+			return book
+				? {type: 'book', book: book}
+				: {type: 'error', message: format(messages.unknown_id, {'%id%': +input})};
+		}
+
+		// A 13-digit barcode may be either number, so both are looked up.
+		var isbn = toIsbn13(input);
+		var issn = toIssn(input);
+		if (isbn !== null || issn !== null) {
+			var matches = [];
+			(isbn === null ? [] : state.byIsbn[isbn] || [])
+				.concat(issn === null ? [] : state.byIssn[issn] || [])
+				.forEach(function (book) {
+					if (matches.indexOf(book) === -1) {
+						matches.push(book);
+					}
+				});
+			var number = isbn !== null ? 'ISBN ' + isbn : 'ISSN ' + issn;
+
+			if (matches.length === 1) {
+				return {type: 'book', book: matches[0]};
+			}
+			return matches.length === 0
+				? {type: 'error', message: format(messages.unknown_number, {'%number%': number})}
+				: {type: 'error', message: format(messages.ambiguous_number, {
+					'%number%': number,
+					'%ids%': matches.map(function (b) { return b.id; }).join(', ')
+				})};
+		}
+
+		return {type: 'error', message: format(messages.unrecognized, {'%input%': input})};
+	}
+
+	/* ---------- actions ---------- */
+
+	function handleInput(raw) {
+		var input = raw.trim();
+		if (input === '') {
+			return;
+		}
+
+		var result = recognize(input);
+
+		if (result.type === 'location') {
+			state.location = result.value;
+			showMessage('primary', format(messages.location_set, {'%location%': result.value}));
+			beep(true);
+		} else if (result.type === 'error') {
+			showMessage('error', result.message);
+			beep(false);
+		} else if (state.location === null) {
+			showMessage('error', messages.no_location);
+			beep(false);
+		} else {
+			assign(result.book, state.location);
+			beep(true);
+		}
+
+		render();
+	}
+
+	function assign(book, location) {
+		var previous = book.placement;
+		var scan = {
+			key: Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10),
+			id: book.id,
+			location: location,
+			at: new Date().toISOString()
+		};
+
+		// Only the latest pending scan of a book matters.
+		state.queue = state.queue.filter(function (s) {
+			return s.id !== book.id;
+		});
+		state.queue.push(scan);
+		save(KEYS.queue, state.queue);
+
+		state.log.forEach(function (entry) {
+			if (entry.id === book.id && entry.status === 'pending') {
+				entry.status = 'replaced';
+			}
+		});
+		state.log = state.log.filter(function (entry) {
+			return entry.status !== 'replaced';
+		});
+		state.log.unshift({
+			key: scan.key,
+			id: book.id,
+			title: book.title,
+			author: book.author,
+			location: location,
+			previous: previous,
+			borrowed: !!book.borrowed,
+			at: scan.at,
+			status: 'pending'
+		});
+		state.log.length = Math.min(state.log.length, LOG_LIMIT);
+		save(KEYS.log, state.log);
+
+		book.placement = location;
+
+		var moved = previous && previous !== location;
+		var text = format(messages.assigned, {'%id%': book.id, '%title%': book.title, '%location%': location});
+		if (moved) {
+			text += ' ' + format(messages.moved, {'%previous%': previous});
+		}
+		// The server closes the loan when the scan arrives.
+		if (book.borrowed) {
+			text += ' ' + messages.will_return;
+		}
+		showMessage(moved || book.borrowed ? 'warning' : 'success', text);
+	}
+
+	function removeScan(key) {
+		state.queue = state.queue.filter(function (s) {
+			return s.key !== key;
+		});
+		save(KEYS.queue, state.queue);
+		state.log = state.log.filter(function (e) {
+			return e.key !== key;
+		});
+		save(KEYS.log, state.log);
+		// The local placement may now be wrong; the next refresh corrects it.
+		render();
+	}
+
+	function refreshBooks(quiet) {
+		return fetch(config.booksUrl, {headers: {Accept: 'application/json'}, cache: 'no-store'})
+			.then(function (response) {
+				if (!response.ok) {
+					throw new Error('HTTP ' + response.status);
+				}
+				return response.json();
+			})
+			.then(function (data) {
+				state.books = {fetchedAt: new Date().toISOString(), list: data.books};
+				save(KEYS.books, state.books);
+				indexBooks();
+				if (!quiet) {
+					showMessage('success', format(messages.books_loaded, {'%count%': data.books.length}));
+				}
+				render();
+			})
+			.catch(function () {
+				if (!quiet || state.books.list.length === 0) {
+					showMessage('error', messages.books_failed);
+				}
+			});
+	}
+
+	function sync(manual) {
+		if (state.syncing || state.queue.length === 0) {
+			if (manual && state.queue.length === 0) {
+				showMessage('success', format(messages.synced, {'%count%': 0}));
+			}
+			return Promise.resolve();
+		}
+
+		state.syncing = true;
+		render();
+		// The queue holds at most one scan per book, so it maps directly to {id: location}.
+		var batch = state.queue.slice(0, SYNC_BATCH);
+		var body = {};
+		var sentKeys = {};
+		batch.forEach(function (scan) {
+			body[scan.id] = scan.location;
+			sentKeys[scan.key] = scan.id;
+		});
+
+		return fetch(config.syncUrl, {
+			method: 'POST',
+			headers: {'Content-Type': 'application/json', Accept: 'application/json'},
+			body: JSON.stringify(body)
+		})
+			.then(function (response) {
+				if (!response.ok) {
+					throw new Error('HTTP ' + response.status);
+				}
+				return response.json();
+			})
+			.then(function (data) {
+				var results = data.results || {};
+				var rejected = 0;
+				var booksChanged = false;
+
+				function resultFor(key) {
+					// A book scanned again while the request was running keeps its newer scan.
+					return Object.prototype.hasOwnProperty.call(sentKeys, key) ? results[sentKeys[key]] : undefined;
+				}
+
+				state.queue = state.queue.filter(function (scan) {
+					return resultFor(scan.key) === undefined;
+				});
+				save(KEYS.queue, state.queue);
+
+				state.log.forEach(function (entry) {
+					var result = resultFor(entry.key);
+					if (result !== undefined) {
+						entry.status = result;
+						if (result === 'returned' && state.byId[entry.id]) {
+							state.byId[entry.id].borrowed = false;
+							booksChanged = true;
+						} else if (result !== 'saved') {
+							rejected++;
+						}
+					}
+				});
+				save(KEYS.log, state.log);
+				if (booksChanged) {
+					save(KEYS.books, state.books);
+				}
+
+				state.syncedAt = new Date().toISOString();
+				save(KEYS.syncedAt, state.syncedAt);
+				state.syncFailed = false;
+
+				if (rejected > 0) {
+					showMessage('error', format(messages.sync_rejected, {'%count%': rejected}));
+				} else if (manual) {
+					showMessage('success', format(messages.synced, {'%count%': batch.length}));
+				}
+			})
+			.catch(function () {
+				state.syncFailed = true;
+				if (manual) {
+					showMessage('error', messages.sync_failed);
+				}
+			})
+			.then(function () {
+				state.syncing = false;
+				render();
+				// More than one batch was waiting.
+				if (!state.syncFailed && state.queue.length > 0 && batch.length === SYNC_BATCH) {
+					return sync(manual);
+				}
+			});
+	}
+
+	/* ---------- rendering ---------- */
+
+	function format(template, params) {
+		return Object.keys(params).reduce(function (text, key) {
+			return text.split(key).join(String(params[key]));
+		}, template);
+	}
+
+	function formatDate(iso) {
+		if (!iso) {
+			return messages.never;
+		}
+		try {
+			return new Date(iso).toLocaleString(config.locale);
+		} catch (e) {
+			return new Date(iso).toLocaleString();
+		}
+	}
+
+	var messageTimer = null;
+
+	function showMessage(type, text) {
+		el.message.className = 'toast message toast-' + type;
+		el.message.textContent = text;
+		el.message.hidden = false;
+		clearTimeout(messageTimer);
+		if (type !== 'error') {
+			messageTimer = setTimeout(function () {
+				el.message.hidden = true;
+			}, 8000);
+		}
+	}
+
+	function render() {
+		el.location.textContent = state.location === null ? '–' : state.location;
+		el.booksCount.textContent = state.books.list.length;
+		el.booksDate.textContent = '(' + formatDate(state.books.fetchedAt) + ')';
+		el.pendingCount.textContent = state.queue.length;
+		el.syncDate.textContent = '(' + formatDate(state.syncedAt) + ')';
+		el.sync.classList.toggle('loading', state.syncing);
+		el.sync.disabled = state.syncing;
+
+		var online = navigator.onLine;
+		el.connection.textContent = online ? messages.online : messages.offline;
+		el.connection.className = 'label label-rounded ' + (online ? 'label-success' : 'label-warning');
+
+		el.logEmpty.hidden = state.log.length > 0;
+		el.clearLog.hidden = state.log.length === 0;
+		el.log.textContent = '';
+		state.log.forEach(function (entry) {
+			el.log.appendChild(renderLogEntry(entry));
+		});
+	}
+
+	function cell(row, content, className) {
+		var td = document.createElement('td');
+		if (className) {
+			td.className = className;
+		}
+		if (content instanceof Node) {
+			td.appendChild(content);
+		} else {
+			td.textContent = content;
+		}
+		row.appendChild(td);
+		return td;
+	}
+
+	var STATUS_CLASS = {
+		pending: 'label-warning',
+		saved: 'label-success',
+		returned: 'label-success',
+		unknown_book: 'label-error',
+		invalid: 'label-error'
+	};
+
+	function renderLogEntry(entry) {
+		var row = document.createElement('tr');
+
+		var link = document.createElement('a');
+		link.href = config.bookUrl.replace('{id}', entry.id);
+		link.textContent = entry.id;
+		cell(row, link);
+
+		var title = document.createElement('span');
+		title.textContent = entry.title + ' · ' + entry.author;
+		cell(row, title, 'log-title');
+
+		var location = document.createElement('span');
+		location.textContent = entry.location;
+		if (entry.previous && entry.previous !== entry.location) {
+			var previous = document.createElement('del');
+			previous.className = 'text-gray';
+			previous.textContent = entry.previous;
+			location = wrap([previous, document.createTextNode(' → '), location]);
+		}
+		cell(row, location);
+
+		cell(row, formatDate(entry.at), 'text-gray log-date');
+
+		var status = document.createElement('span');
+		status.className = 'label label-rounded ' + (STATUS_CLASS[entry.status] || '');
+		status.textContent = messages['status_' + entry.status] || entry.status;
+		var actions = [status];
+		if (entry.status === 'pending') {
+			var remove = document.createElement('button');
+			remove.type = 'button';
+			remove.className = 'btn btn-link btn-sm';
+			remove.textContent = messages.remove;
+			remove.addEventListener('click', function () {
+				removeScan(entry.key);
+				el.input.focus();
+			});
+			actions.push(remove);
+		}
+		cell(row, wrap(actions), 'text-right');
+
+		return row;
+	}
+
+	function wrap(nodes) {
+		var span = document.createElement('span');
+		nodes.forEach(function (node) {
+			span.appendChild(node);
+		});
+		return span;
+	}
+
+	/* ---------- feedback sound, so the screen need not be watched while scanning ---------- */
+
+	var audio = null;
+
+	function beep(ok) {
+		try {
+			audio = audio || new (window.AudioContext || window.webkitAudioContext)();
+			var oscillator = audio.createOscillator();
+			var gain = audio.createGain();
+			oscillator.frequency.value = ok ? 880 : 220;
+			oscillator.type = ok ? 'sine' : 'square';
+			gain.gain.value = 0.1;
+			oscillator.connect(gain).connect(audio.destination);
+			oscillator.start();
+			oscillator.stop(audio.currentTime + (ok ? 0.08 : 0.4));
+		} catch (e) {
+			// No sound available; the message is enough.
+		}
+	}
+
+	/* ---------- wiring ---------- */
+
+	el.form.addEventListener('submit', function (event) {
+		event.preventDefault();
+		handleInput(el.input.value);
+		el.input.value = '';
+		el.input.focus();
+	});
+	el.refresh.addEventListener('click', function () {
+		refreshBooks(false);
+		el.input.focus();
+	});
+	el.sync.addEventListener('click', function () {
+		sync(true);
+		el.input.focus();
+	});
+	el.clearLog.addEventListener('click', function () {
+		if (confirm(messages.confirm_clear)) {
+			// Pending scans stay in the queue; only their history goes.
+			state.log = state.log.filter(function (e) {
+				return e.status === 'pending';
+			});
+			save(KEYS.log, state.log);
+			render();
+		}
+		el.input.focus();
+	});
+	window.addEventListener('online', function () {
+		render();
+		sync(false);
+	});
+	window.addEventListener('offline', render);
+	window.addEventListener('storage', function () {
+		// Another tab scanned something; take over its state.
+		state.books = load(KEYS.books, state.books);
+		state.queue = load(KEYS.queue, []);
+		state.log = load(KEYS.log, []);
+		state.syncedAt = load(KEYS.syncedAt, null);
+		indexBooks();
+		render();
+	});
+	window.addEventListener('beforeunload', function () {
+		if (state.queue.length > 0 && navigator.onLine) {
+			sync(false);
+		}
+	});
+	setInterval(function () {
+		if (navigator.onLine) {
+			sync(false);
+		}
+	}, SYNC_INTERVAL);
+
+	if ('serviceWorker' in navigator) {
+		navigator.serviceWorker.register(config.swUrl, {scope: config.scope}).catch(function () {
+			// Without a service worker (plain HTTP on a remote host) the page still works offline
+			// once open, it just cannot be reopened without a connection.
+		});
+	}
+
+	indexBooks();
+	render();
+	if (state.books.list.length === 0) {
+		showMessage('warning', messages.no_books);
+	}
+	refreshBooks(true).then(function () {
+		return sync(false);
+	});
+})();
diff --git a/search.php b/search.php
deleted file mode 100644
index 57c7e0a..0000000
--- a/search.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Wyszukiwanie książki';
-include('./design/top.php');
-?>
-
-<h3> Znalezione elementy </h3>
-
-<?php
-gotowe::lista();
-
-include('./design/bottom.php');
-?>
diff --git a/src/Command/ImportLegacyCommand.php b/src/Command/ImportLegacyCommand.php
new file mode 100644
index 0000000..2c6ae74
--- /dev/null
+++ b/src/Command/ImportLegacyCommand.php
@@ -0,0 +1,109 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Command;
+
+use App\LegacyImport\CoverImporter;
+use App\LegacyImport\CoverImportReport;
+use App\LegacyImport\ImportReport;
+use App\LegacyImport\LegacyImporter;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(
+	name: 'app:import-legacy',
+	description: 'Imports books and covers from the old database schema',
+	help: <<<'HELP'
+        Reads the <info>ksiazki</info>, <info>pozycz</info> and <info>pozycz_historia</info> tables from the database at
+        <info>LEGACY_DATABASE_URL</info> and writes them to the database at <info>DATABASE_URL</info>.
+
+        Books already in the target, by ID, are kept as they are along with their loans, so the import can
+        be run again to pick up books added to the legacy database since. Rows the new schema cannot hold
+        are left out and listed in the output.
+
+        Use <info>--dry-run</info> to run the whole import and roll it back.
+
+        With <info>--covers=DIR</info>, legacy cover files in <info>DIR</info> are attached to the books already
+        in the target database.
+        HELP,
+)]
+class ImportLegacyCommand {
+	public function __construct(
+		private readonly LegacyImporter $importer,
+		private readonly CoverImporter  $coverImporter,
+	) {
+	}
+
+	public function __invoke(
+		SymfonyStyle                                                                       $io,
+		#[Option('Run the import, then roll it back')] bool                                $dryRun = false,
+		#[Option('Import the cover files from directory into the existing books')] ?string $covers = null,
+	): int {
+		$this->import($io, $dryRun);
+
+		if ($covers !== null) {
+			$this->importCovers($io, $covers, $dryRun);
+		}
+
+		return Command::SUCCESS;
+	}
+
+	private function import(SymfonyStyle $io, bool $dryRun): void {
+		$report = $this->importer->import($dryRun);
+		$this->printReport($io, $report);
+		$io->success($dryRun ? 'Dry run finished; nothing was saved.' : 'Import finished.');
+	}
+
+	private function printReport(SymfonyStyle $io, ImportReport $report): void {
+		$io->table(['Target table', 'Rows'], [
+			['book', $report->books],
+			['ousted_book', $report->oustedBooks],
+			['borrow', $report->borrows],
+			['borrow_history', $report->borrowHistory],
+			['ousted_borrow_history', $report->oustedBorrowHistory],
+			['already in target, kept', $report->alreadyPresent],
+		]);
+
+		if ($report->invalidIsbns) {
+			$io->warning(\sprintf('%d numbers are not valid ISBNs and were kept as text labels:', \count($report->invalidIsbns)));
+			$io->listing($report->invalidIsbns);
+		}
+
+		if ($report->skipped) {
+			$io->warning(\sprintf('%d rows were left out:', \count($report->skipped)));
+			$io->listing($report->skipped);
+		}
+	}
+
+	private function importCovers(SymfonyStyle $io, string $directory, bool $dryRun): void {
+		$report = $this->coverImporter->import($directory, $dryRun);
+		$this->printCoverReport($io, $report);
+		$io->success($dryRun ? 'Dry run finished; nothing was saved.' : \sprintf('%d covers imported.', $report->imported()));
+	}
+
+	private function printCoverReport(SymfonyStyle $io, CoverImportReport $report): void {
+		$io->table(['Source', 'Books'], [
+			['own/, by book ID', $report->ownById],
+			['own/, by ISBN', $report->ownByIsbn],
+			['by ISBN', $report->byIsbn],
+			['already had a cover, kept', $report->alreadyCovered],
+		]);
+
+		if ($report->failed) {
+			$io->warning(\sprintf('%d files could not be imported:', \count($report->failed)));
+			$io->listing($report->failed);
+		}
+
+		if ($report->unmatched) {
+			$io->note(\sprintf('%d files match no book:', \count($report->unmatched)));
+			$io->listing($report->unmatched);
+		}
+
+		if ($report->ignored) {
+			$io->note(\sprintf('%d files are named neither by ISBN-13 nor by book ID, and were ignored:', \count($report->ignored)));
+			$io->listing($report->ignored);
+		}
+	}
+}
diff --git a/src/Controller/BookController.php b/src/Controller/BookController.php
new file mode 100644
index 0000000..66d8751
--- /dev/null
+++ b/src/Controller/BookController.php
@@ -0,0 +1,110 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Book;
+use App\Form\AddLabelType;
+use App\Form\BookType;
+use App\Form\BorrowType;
+use App\Form\CoverType;
+use App\Form\OustType;
+use App\Repository\BookRepository;
+use App\Service\Library;
+use Doctrine\ORM\EntityManagerInterface;
+use Knp\Component\Pager\PaginatorInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Requirement\Requirement;
+
+class BookController extends AbstractController {
+	#[Route('/', name: 'home', methods: ['GET'])]
+	#[Route('/list', name: 'book_list', methods: ['GET'])]
+	public function list(Request $request, BookRepository $books, PaginatorInterface $paginator): Response {
+		return $this->render('book/list.html.twig', [
+			'pagination' => $paginator->paginate(
+				$books->createListQueryBuilder(),
+				max(1, $request->query->getInt('page', 1)),
+			),
+		]);
+	}
+
+	#[Route('/book/{id}', name: 'book_show', requirements: ['id' => Requirement::DIGITS], methods: ['GET'])]
+	public function show(Book $book): Response {
+		return $this->render('book/show.html.twig', [
+			'book' => $book,
+			'borrow_form' => $this->createForm(BorrowType::class, null, [
+				'action' => $this->generateUrl('book_lend', ['id' => $book->getId()]),
+			]),
+			'cover_form' => $this->createForm(CoverType::class, null, [
+				'action' => $this->generateUrl('cover_upload', ['id' => $book->getId()]),
+			]),
+			'label_form' => $this->createForm(AddLabelType::class, null, [
+				'action' => $this->generateUrl('label_add', ['id' => $book->getId()]),
+			]),
+			'oust_form' => $this->createForm(OustType::class, null, [
+				'action' => $this->generateUrl('book_oust', ['id' => $book->getId()]),
+			]),
+		]);
+	}
+
+	#[Route('/add', name: 'book_add', methods: ['GET'])]
+	public function add(): Response {
+		return $this->render('book/add.html.twig');
+	}
+
+	#[Route('/add/form', name: 'book_new', methods: ['GET', 'POST'])]
+	public function new(Request $request, EntityManagerInterface $entityManager, Library $library): Response {
+		$book = $this->prefilledBook($request);
+		$form = $this->createForm(BookType::class, $book, ['validation_groups' => ['Default', 'creation']]);
+		$form->handleRequest($request);
+
+		if ($form->isSubmitted() && $form->isValid()) {
+			$entityManager->persist($book);
+			$entityManager->flush();
+			$this->addFlash('success', 'book_saved');
+
+			if ($library->downloadCover($book)) {
+				$this->addFlash('success', 'cover_downloaded');
+			}
+
+			return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+		}
+
+		return $this->render('book/new.html.twig', ['form' => $form]);
+	}
+
+	#[Route('/book/{id}/edit', name: 'book_edit', requirements: ['id' => Requirement::DIGITS], methods: ['GET', 'POST'])]
+	public function edit(Request $request, Book $book, EntityManagerInterface $entityManager): Response {
+		$form = $this->createForm(BookType::class, $book, ['lock_id' => true]);
+		$form->handleRequest($request);
+
+		if ($form->isSubmitted() && $form->isValid()) {
+			$entityManager->flush();
+			$this->addFlash('success', 'book_saved');
+
+			return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+		}
+
+		return $this->render('book/edit.html.twig', ['book' => $book, 'form' => $form]);
+	}
+
+	private function prefilledBook(Request $request): Book {
+		$query = $request->query;
+		$id = $query->get('id', '');
+		$year = $query->get('publicationYear', '');
+
+		return (new Book())
+			->setId(ctype_digit($id) ? (int)$id : null)
+			->setTitle($query->get('title'))
+			->setAuthor($query->get('author'))
+			->setLanguage($query->get('language'))
+			->setPublisher($query->get('publisher'))
+			->setPublicationLoc($query->get('publicationLoc'))
+			->setPublicationYear(ctype_digit($year) ? (int)$year : null)
+			->setIsbn($query->get('isbn'))
+			->setIssn($query->get('issn'));
+	}
+}
diff --git a/src/Controller/BorrowController.php b/src/Controller/BorrowController.php
new file mode 100644
index 0000000..e810883
--- /dev/null
+++ b/src/Controller/BorrowController.php
@@ -0,0 +1,54 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Book;
+use App\Form\BorrowType;
+use App\Repository\BorrowRepository;
+use App\Service\Library;
+use Knp\Component\Pager\PaginatorInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Requirement\Requirement;
+
+class BorrowController extends AbstractController {
+	#[Route('/borrowed', name: 'borrow_list', methods: ['GET'])]
+	public function list(Request $request, BorrowRepository $borrows, PaginatorInterface $paginator): Response {
+		return $this->render('borrow/list.html.twig', [
+			'pagination' => $paginator->paginate(
+				$borrows->createListQueryBuilder(),
+				max(1, $request->query->getInt('page', 1)),
+			),
+		]);
+	}
+
+	#[Route('/book/{id}/lend', name: 'book_lend', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function lend(Request $request, Book $book, Library $library): Response {
+		$form = $this->createForm(BorrowType::class);
+		$form->handleRequest($request);
+
+		if (!$book->isBorrowed() && $form->isSubmitted() && $form->isValid()) {
+			$library->lend($book, $form->getData()['who']);
+			$this->addFlash('success', 'borrow_lent');
+		} else {
+			$this->addFlash('error', 'action_rejected');
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+
+	#[Route('/book/{id}/return', name: 'book_return', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function return(Request $request, Book $book, Library $library): Response {
+		if ($book->isBorrowed() && $this->isCsrfTokenValid('return' . $book->getId(), $request->getPayload()->getString('_token'))) {
+			$library->giveBack($book);
+			$this->addFlash('success', 'borrow_returned');
+		} else {
+			$this->addFlash('error', 'action_rejected');
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+}
diff --git a/src/Controller/CoverController.php b/src/Controller/CoverController.php
new file mode 100644
index 0000000..dc489e2
--- /dev/null
+++ b/src/Controller/CoverController.php
@@ -0,0 +1,64 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Book;
+use App\Form\CoverType;
+use App\Service\CoverProcessingException;
+use App\Service\CoverStorage;
+use App\Service\Library;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Requirement\Requirement;
+
+class CoverController extends AbstractController {
+	#[Route('/cover/{name}', name: 'cover_original', requirements: ['name' => '[0-9a-f]{32}\.(jpg|png|webp)'], methods: ['GET'])]
+	public function original(string $name, CoverStorage $covers): Response {
+		$path = $covers->originalPath($name);
+
+		if (!is_file($path)) {
+			throw $this->createNotFoundException();
+		}
+
+		$response = new BinaryFileResponse($path);
+		$response->headers->set('X-Content-Type-Options', 'nosniff');
+
+		return $response;
+	}
+
+	#[Route('/book/{id}/cover', name: 'cover_upload', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function upload(Request $request, Book $book, Library $library): Response {
+		$form = $this->createForm(CoverType::class);
+		$form->handleRequest($request);
+
+		if (!$form->isSubmitted()) {
+			$this->addFlash('error', 'cover_too_large');
+		} elseif (!$form->isValid()) {
+			$this->addFlash('error', $form->getErrors(true)->current()->getMessage());
+		} else {
+			try {
+				$library->changeCover($book, $form->getData()['cover']);
+				$this->addFlash('success', 'cover_saved');
+			} catch (CoverProcessingException) {
+				$this->addFlash('error', 'cover_failed');
+			}
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+
+	#[Route('/book/{id}/cover/remove', name: 'cover_remove', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function remove(Request $request, Book $book, Library $library): Response {
+		if ($this->isCsrfTokenValid('remove-cover' . $book->getId(), $request->getPayload()->getString('_token'))) {
+			$library->changeCover($book, null);
+		} else {
+			$this->addFlash('error', 'action_rejected');
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+}
diff --git a/src/Controller/InventoryController.php b/src/Controller/InventoryController.php
new file mode 100644
index 0000000..65d1a91
--- /dev/null
+++ b/src/Controller/InventoryController.php
@@ -0,0 +1,100 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Repository\BookRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+class InventoryController extends AbstractController {
+	public const int MAX_SCANS_PER_REQUEST = 500;
+
+	public const string SAVED = 'saved';
+	public const string RETURNED = 'returned';
+	public const string UNKNOWN_BOOK = 'unknown_book';
+	public const string INVALID = 'invalid';
+
+	#[Route('/inventory/', name: 'inventory', methods: ['GET'])]
+	public function index(): Response {
+		return $this->render('inventory/index.html.twig');
+	}
+
+	/**
+	 * The service worker that caches the page. It is served by the application rather than from
+	 * public/ so that its scope covers /inventory/ and so that it knows the URLs to cache.
+	 */
+	#[Route('/inventory/sw.js', name: 'inventory_service_worker', methods: ['GET'])]
+	public function serviceWorker(): Response {
+		$response = $this->render('inventory/sw.js.twig');
+		$response->headers->set('Content-Type', 'text/javascript; charset=UTF-8');
+		$response->headers->set('Cache-Control', 'no-cache');
+
+		return $response;
+	}
+
+	#[Route('/inventory/books', name: 'inventory_books', methods: ['GET'])]
+	public function books(BookRepository $books): JsonResponse {
+		$response = $this->json(['books' => $books->findInventoryData()]);
+		$response->headers->set('Cache-Control', 'no-store');
+
+		return $response;
+	}
+
+	/**
+	 * Accepts {"<book ID>": "<location>", ...} and answers {"results": {"<book ID>": "<status>"}}.
+	 * Every listed book is marked as seen now.
+	 * A book found on the shelf while on loan is marked as returned.
+	 */
+	#[Route('/inventory/sync', name: 'inventory_sync', methods: ['POST'])]
+	public function sync(Request $request, BookRepository $books, EntityManagerInterface $entityManager): JsonResponse {
+		if ($request->getContentTypeFormat() !== 'json') {
+			return $this->json(['error' => 'Expected a JSON body.'], Response::HTTP_UNSUPPORTED_MEDIA_TYPE);
+		}
+
+		// Decoded as an object: as an array, {"0": "..."} would be indistinguishable from a list.
+		try {
+			$payload = json_decode($request->getContent(), false, 2, \JSON_THROW_ON_ERROR);
+		} catch (\JsonException) {
+			$payload = null;
+		}
+
+		$scans = $payload instanceof \stdClass ? get_object_vars($payload) : null;
+		if ($scans === null || \count($scans) > self::MAX_SCANS_PER_REQUEST) {
+			return $this->json(['error' => 'Expected an object mapping at most ' . self::MAX_SCANS_PER_REQUEST . ' book IDs to locations.'], Response::HTTP_BAD_REQUEST);
+		}
+
+		$valid = [];
+		$results = [];
+		foreach ($scans as $id => $location) {
+			$location = \is_string($location) ? trim($location) : '';
+
+			if (!\is_int($id) || $id < 0 || $location === '' || mb_strlen($location) > 255) {
+				$results[$id] = self::INVALID;
+			} else {
+				$valid[$id] = $location;
+				$results[$id] = self::UNKNOWN_BOOK;
+			}
+		}
+
+		$now = new \DateTimeImmutable();
+		foreach ($valid === [] ? [] : $books->findForInventorySync(array_keys($valid)) as $book) {
+			$results[$book->getId()] = self::SAVED;
+
+			if ($book->isBorrowed()) {
+				$book->giveBack($now);
+				$results[$book->getId()] = self::RETURNED;
+			}
+
+			$book->seenAt($valid[$book->getId()], $now);
+		}
+
+		$entityManager->flush();
+
+		return $this->json(['results' => (object)$results]);
+	}
+}
diff --git a/src/Controller/LabelController.php b/src/Controller/LabelController.php
new file mode 100644
index 0000000..1f311f2
--- /dev/null
+++ b/src/Controller/LabelController.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Book;
+use App\Entity\Label;
+use App\Form\AddLabelType;
+use App\Repository\BookRepository;
+use App\Repository\LabelRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Knp\Component\Pager\PaginatorInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Requirement\Requirement;
+
+class LabelController extends AbstractController {
+	#[Route('/book/{id}/labels', name: 'label_add', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function add(Request $request, Book $book, EntityManagerInterface $entityManager): Response {
+		$form = $this->createForm(AddLabelType::class);
+		$form->handleRequest($request);
+
+		if ($form->isSubmitted() && $form->isValid()) {
+			$data = $form->getData();
+			$book->addLabel($data['label'], $data['type']);
+			$entityManager->flush();
+		} else {
+			$this->addFlash('error', 'action_rejected');
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+
+	#[Route('/label/{id}/remove', name: 'label_remove', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function remove(Request $request, Label $label, EntityManagerInterface $entityManager): Response {
+		$book = $label->getBook();
+
+		if ($this->isCsrfTokenValid('remove-label' . $label->getId(), $request->getPayload()->getString('_token'))) {
+			$book->removeLabel($label);
+			$entityManager->flush();
+		} else {
+			$this->addFlash('error', 'action_rejected');
+		}
+
+		return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+	}
+}
diff --git a/src/Controller/LookupController.php b/src/Controller/LookupController.php
new file mode 100644
index 0000000..fc9adfc
--- /dev/null
+++ b/src/Controller/LookupController.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Lookup\BookLookupInterface;
+use App\Lookup\LookupFailedException;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+class LookupController extends AbstractController {
+	#[Route('/add/lookup', name: 'book_lookup', methods: ['GET'])]
+	public function lookup(Request $request, BookLookupInterface $lookup): Response {
+		$query = $request->query;
+		$number = trim($query->getString('isn'));
+		$title = trim($query->getString('title'));
+		$author = trim($query->getString('author'));
+		$publisher = trim($query->getString('publisher'));
+
+		if ($number === '' && $title === '' && $author === '' && $publisher === '') {
+			return $this->redirectToRoute('book_add');
+		}
+
+		try {
+			$candidates = $number !== ''
+				? $lookup->findByStandardNumber($number)
+				: $lookup->findByDescription($title, $author, $publisher);
+		} catch (LookupFailedException) {
+			$this->addFlash('error', 'lookup_failed');
+
+			return $this->redirectToRoute('book_add');
+		}
+
+		return $this->render('book/lookup.html.twig', [
+			'candidates' => $candidates,
+			'book_id' => $query->getString('id'),
+		]);
+	}
+}
diff --git a/src/Controller/OustedBookController.php b/src/Controller/OustedBookController.php
new file mode 100644
index 0000000..3bc8107
--- /dev/null
+++ b/src/Controller/OustedBookController.php
@@ -0,0 +1,120 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use App\Form\BulkOustType;
+use App\Form\OustType;
+use App\Repository\BookRepository;
+use App\Repository\OustedBookRepository;
+use App\Service\Library;
+use Knp\Component\Pager\PaginatorInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Routing\Requirement\Requirement;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class OustedBookController extends AbstractController {
+	#[Route('/ousted', name: 'ousted_list', methods: ['GET'])]
+	public function list(Request $request, OustedBookRepository $books, PaginatorInterface $paginator): Response {
+		return $this->render('ousted/list.html.twig', [
+			'pagination' => $paginator->paginate(
+				$books->createListQueryBuilder(),
+				max(1, $request->query->getInt('page', 1)),
+			),
+		]);
+	}
+
+	#[Route('/ousted/{id}', name: 'ousted_show', requirements: ['id' => Requirement::DIGITS], methods: ['GET'])]
+	public function show(OustedBook $book): Response {
+		return $this->render('ousted/show.html.twig', ['book' => $book]);
+	}
+
+	#[Route('/book/{id}/oust', name: 'book_oust', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function oust(Request $request, Book $book, Library $library): Response {
+		$form = $this->createForm(OustType::class);
+		$form->handleRequest($request);
+
+		if ($book->isBorrowed() || !$form->isSubmitted() || !$form->isValid()) {
+			$this->addFlash('error', 'action_rejected');
+
+			return $this->redirectToRoute('book_show', ['id' => $book->getId()]);
+		}
+
+		$ousted = $library->oust($book, $form->getData()['reason']);
+		$this->addFlash('success', 'ousted_done');
+
+		return $this->redirectToRoute('ousted_show', ['id' => $ousted->getId()]);
+	}
+
+	/**
+	 * Ousts the books that an inventory did not find: those not seen on or after a date.
+	 */
+	#[Route('/ousted/bulk', name: 'ousted_bulk', methods: ['GET', 'POST'])]
+	public function bulk(Request $request, BookRepository $books, Library $library, PaginatorInterface $paginator, TranslatorInterface $translator): Response {
+		if ($request->isMethod('POST')) {
+			$form = $this->createForm(BulkOustType::class, null, ['confirm' => true]);
+			$form->handleRequest($request);
+
+			if (!$form->isSubmitted() || !$form->isValid()) {
+				$this->addFlash('error', 'action_rejected');
+
+				return $this->redirectToRoute('ousted_bulk');
+			}
+
+			['notSeenSince' => $since, 'reason' => $reason, 'selection' => $selection] = $form->getData();
+			$back = ['bulk_oust' => ['notSeenSince' => $since->format('Y-m-d'), 'reason' => $reason]];
+
+			if (!hash_equals($books->notSeenSinceFingerprint($since), (string)$selection)) {
+				$this->addFlash('error', 'bulk_oust_changed');
+
+				return $this->redirectToRoute('ousted_bulk', $back);
+			}
+
+			$ousted = $library->oustAll($books->createNotSeenSinceQueryBuilder($since)->getQuery()->getResult(), $reason);
+			$this->addFlash('success', $translator->trans('bulk_oust_done', ['%count%' => $ousted]));
+
+			return $this->redirectToRoute('ousted_list');
+		}
+
+		$form = $this->createForm(BulkOustType::class);
+		$form->handleRequest($request);
+		$parameters = ['form' => $form, 'pagination' => null];
+
+		if ($form->isSubmitted() && $form->isValid()) {
+			['notSeenSince' => $since, 'reason' => $reason] = $form->getData();
+
+			$parameters['since'] = $since;
+			$parameters['borrowed'] = $books->countNotSeenSince($since, borrowed: true);
+			$parameters['pagination'] = $paginator->paginate(
+				$books->createNotSeenSinceQueryBuilder($since),
+				max(1, $request->query->getInt('page', 1)),
+			);
+			$parameters['confirm_form'] = $this->createForm(BulkOustType::class, [
+				'notSeenSince' => $since,
+				'reason' => $reason,
+				'selection' => $books->notSeenSinceFingerprint($since),
+			], ['confirm' => true, 'action' => $this->generateUrl('ousted_bulk')]);
+		}
+
+		return $this->render('ousted/bulk.html.twig', $parameters);
+	}
+
+	#[Route('/ousted/{id}/restore', name: 'ousted_restore', requirements: ['id' => Requirement::DIGITS], methods: ['POST'])]
+	public function restore(Request $request, OustedBook $book, Library $library): Response {
+		if (!$this->isCsrfTokenValid('restore' . $book->getId(), $request->getPayload()->getString('_token'))) {
+			$this->addFlash('error', 'action_rejected');
+
+			return $this->redirectToRoute('ousted_show', ['id' => $book->getId()]);
+		}
+
+		$restored = $library->restore($book);
+		$this->addFlash('success', 'ousted_restored');
+
+		return $this->redirectToRoute('book_show', ['id' => $restored->getId()]);
+	}
+}
diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php
new file mode 100644
index 0000000..26354eb
--- /dev/null
+++ b/src/Controller/SearchController.php
@@ -0,0 +1,34 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Controller;
+
+use App\Form\BookSearchType;
+use App\Model\BookSearch;
+use App\Repository\BookRepository;
+use Knp\Component\Pager\PaginatorInterface;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+class SearchController extends AbstractController {
+	#[Route('/search', name: 'search', methods: ['GET'])]
+	public function search(Request $request, BookRepository $books, PaginatorInterface $paginator): Response {
+		$search = new BookSearch();
+		$form = $this->createForm(BookSearchType::class, $search);
+		$form->handleRequest($request);
+
+		$quickQuery = trim($request->query->getString('q'));
+		$builder = match (true) {
+			'' !== $quickQuery => $books->createQuickSearchQueryBuilder($quickQuery),
+			!$search->isEmpty() => $books->createSearchQueryBuilder($search),
+			default => null,
+		};
+
+		return $this->render('search/index.html.twig', [
+			'form' => $form,
+			'pagination' => $builder ? $paginator->paginate($builder, max(1, $request->query->getInt('page', 1))) : null,
+		]);
+	}
+}
diff --git a/src/Entity/AbstractBook.php b/src/Entity/AbstractBook.php
new file mode 100644
index 0000000..a0cd097
--- /dev/null
+++ b/src/Entity/AbstractBook.php
@@ -0,0 +1,306 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use App\Validator\AvailableBookId;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+
+/**
+ * Bibliographic data shared by books on the shelves and books taken out of the collection.
+ *
+ * The ID is assigned by hand: it is the code on the sticker put on the physical book.
+ */
+#[ORM\MappedSuperclass]
+abstract class AbstractBook {
+	#[ORM\Id]
+	#[ORM\Column]
+	#[Assert\NotNull]
+	#[Assert\PositiveOrZero]
+	#[AvailableBookId(groups: ['creation'])]
+	protected ?int $id = null;
+
+	#[ORM\Column(length: 255)]
+	#[Assert\NotBlank]
+	#[Assert\Length(max: 255)]
+	protected ?string $title = null;
+
+	#[ORM\Column(length: 255)]
+	#[Assert\NotBlank]
+	#[Assert\Length(max: 255)]
+	protected ?string $author = null;
+
+	#[ORM\Column(length: 255, nullable: true)]
+	#[Assert\Length(max: 255)]
+	protected ?string $language = null;
+
+	#[ORM\Column(length: 255, nullable: true)]
+	#[Assert\Length(max: 255)]
+	protected ?string $edition = null;
+
+	#[ORM\Column(length: 255, nullable: true)]
+	#[Assert\Length(max: 255)]
+	protected ?string $publisher = null;
+
+	#[ORM\Column(length: 255, nullable: true)]
+	#[Assert\Length(max: 255)]
+	protected ?string $publicationLoc = null;
+
+	#[ORM\Column(type: Types::INTEGER, nullable: true)]
+	#[Assert\Range(min: 1000, max: 2200)]
+	protected ?int $publicationYear = null;
+
+	#[ORM\Column(length: 255, nullable: true)]
+	#[Assert\Length(max: 255)]
+	protected ?string $placement = null;
+
+	#[ORM\Column(length: 64, nullable: true)]
+	protected ?string $cover = null;
+
+	/**
+	 * When the book was last found on its shelf during an inventory.
+	 */
+	#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+	protected ?\DateTimeImmutable $lastSeen = null;
+
+	public function getId(): ?int {
+		return $this->id;
+	}
+
+	/**
+	 * File name of the original cover, see CoverStorage.
+	 */
+	public function getCover(): ?string {
+		return $this->cover;
+	}
+
+	public function setCover(?string $cover): static {
+		$this->cover = $cover;
+
+		return $this;
+	}
+
+	public function setId(?int $id): static {
+		$this->id = $id;
+
+		return $this;
+	}
+
+	public function getTitle(): ?string {
+		return $this->title;
+	}
+
+	public function setTitle(?string $title): static {
+		$this->title = $title;
+
+		return $this;
+	}
+
+	public function getAuthor(): ?string {
+		return $this->author;
+	}
+
+	public function setAuthor(?string $author): static {
+		$this->author = $author;
+
+		return $this;
+	}
+
+	public function getLanguage(): ?string {
+		return $this->language;
+	}
+
+	public function setLanguage(?string $language): static {
+		$this->language = $language;
+
+		return $this;
+	}
+
+	public function getEdition(): ?string {
+		return $this->edition;
+	}
+
+	public function setEdition(?string $edition): static {
+		$this->edition = $edition;
+
+		return $this;
+	}
+
+	public function getPublisher(): ?string {
+		return $this->publisher;
+	}
+
+	public function setPublisher(?string $publisher): static {
+		$this->publisher = $publisher;
+
+		return $this;
+	}
+
+	public function getPublicationLoc(): ?string {
+		return $this->publicationLoc;
+	}
+
+	public function setPublicationLoc(?string $publicationLoc): static {
+		$this->publicationLoc = $publicationLoc;
+
+		return $this;
+	}
+
+	public function getPublicationYear(): ?int {
+		return $this->publicationYear;
+	}
+
+	public function setPublicationYear(?int $publicationYear): static {
+		$this->publicationYear = $publicationYear;
+
+		return $this;
+	}
+
+	#[Assert\Callback]
+	public function validateStandardNumbers(ExecutionContextInterface $context): void {
+		foreach ([LabelType::ISBN, LabelType::ISSN] as $type) {
+			$value = $this->firstLabel($type)?->getLabel();
+
+			if ($value !== null && !$type->isValid($value)) {
+				$context->buildViolation('label_invalid_number')
+					->setTranslationDomain('messages')
+					->atPath($type->value)
+					->addViolation();
+			}
+		}
+	}
+
+	public function getIsbn(): ?string {
+		return $this->firstLabel(LabelType::ISBN)?->getLabel();
+	}
+
+	public function setIsbn(?string $isbn): static {
+		$this->replaceFirstLabel(LabelType::ISBN, $isbn);
+
+		return $this;
+	}
+
+	public function getIssn(): ?string {
+		return $this->firstLabel(LabelType::ISSN)?->getLabel();
+	}
+
+	public function setIssn(?string $issn): static {
+		$this->replaceFirstLabel(LabelType::ISSN, $issn);
+
+		return $this;
+	}
+
+	/**
+	 * @return Collection<int, covariant AbstractLabel>
+	 */
+	abstract public function getLabels(): Collection;
+
+	abstract protected function createLabel(string $label, LabelType $type): AbstractLabel;
+
+	/**
+	 * @return list<AbstractLabel>
+	 */
+	public function getTextLabels(): array {
+		return array_values($this->getLabels()->filter(
+			static fn(AbstractLabel $label): bool => $label->getType() === LabelType::TEXT,
+		)->toArray());
+	}
+
+	public function addLabel(string $text, LabelType $type = LabelType::TEXT): void {
+		$text = $type->normalize($text);
+
+		foreach ($this->getLabels() as $label) {
+			if ($label->getType() === $type && $label->getLabel() === $text) {
+				return;
+			}
+		}
+
+		$this->getLabels()->add($this->createLabel($text, $type));
+	}
+
+	public function removeLabel(AbstractLabel $label): void {
+		$this->getLabels()->removeElement($label);
+	}
+
+	public function getPlacement(): ?string {
+		return $this->placement;
+	}
+
+	public function setPlacement(?string $placement): static {
+		$this->placement = $placement;
+
+		return $this;
+	}
+
+	public function getLastSeen(): ?\DateTimeImmutable {
+		return $this->lastSeen;
+	}
+
+	public function setLastSeen(?\DateTimeImmutable $lastSeen): static {
+		$this->lastSeen = $lastSeen;
+
+		return $this;
+	}
+
+	/**
+	 * Records that the book was found at $placement during an inventory.
+	 */
+	public function seenAt(string $placement, \DateTimeImmutable $at): void {
+		$this->placement = $placement;
+		$this->lastSeen = $at;
+	}
+
+	/**
+	 * Copies the bibliographic data and labels, keeping the ID; used when a book moves between
+	 * collections.
+	 */
+	protected function copyDataFrom(self $source): void {
+		$this->id = $source->id;
+		$this->title = $source->title;
+		$this->author = $source->author;
+		$this->language = $source->language;
+		$this->edition = $source->edition;
+		$this->publisher = $source->publisher;
+		$this->publicationLoc = $source->publicationLoc;
+		$this->publicationYear = $source->publicationYear;
+		$this->placement = $source->placement;
+		$this->cover = $source->cover;
+		$this->lastSeen = $source->lastSeen;
+
+		foreach ($source->getLabels() as $label) {
+			$this->addLabel($label->getLabel(), $label->getType());
+		}
+	}
+
+	private function firstLabel(LabelType $type): ?AbstractLabel {
+		foreach ($this->getLabels() as $label) {
+			if ($label->getType() === $type) {
+				return $label;
+			}
+		}
+
+		return null;
+	}
+
+	private function replaceFirstLabel(LabelType $type, ?string $value): void {
+		$value = $type->normalize((string)$value);
+		$current = $this->firstLabel($type);
+
+		if ($current?->getLabel() === $value) {
+			return;
+		}
+
+		if ($current !== null) {
+			$this->removeLabel($current);
+		}
+
+		if ($value !== '') {
+			$this->addLabel($value, $type);
+		}
+	}
+}
diff --git a/src/Entity/AbstractBorrowHistory.php b/src/Entity/AbstractBorrowHistory.php
new file mode 100644
index 0000000..4b6ce6f
--- /dev/null
+++ b/src/Entity/AbstractBorrowHistory.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * A finished loan. It has a surrogate key because Doctrine cannot use dates in identifiers;
+ * the (book, at) pair stays unique through a constraint on each concrete table.
+ */
+#[ORM\MappedSuperclass]
+abstract class AbstractBorrowHistory {
+	#[ORM\Id]
+	#[ORM\GeneratedValue]
+	#[ORM\Column]
+	private ?int $id = null;
+
+	#[ORM\Column(length: 255)]
+	private string $who;
+
+	#[ORM\Column]
+	private \DateTimeImmutable $at;
+
+	#[ORM\Column]
+	private \DateTimeImmutable $till;
+
+	public function __construct(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
+		$this->who = $who;
+		$this->at = $at;
+		$this->till = $till;
+	}
+
+	public function getId(): ?int {
+		return $this->id;
+	}
+
+	public function getWho(): string {
+		return $this->who;
+	}
+
+	public function getAt(): \DateTimeImmutable {
+		return $this->at;
+	}
+
+	public function getTill(): \DateTimeImmutable {
+		return $this->till;
+	}
+}
diff --git a/src/Entity/AbstractLabel.php b/src/Entity/AbstractLabel.php
new file mode 100644
index 0000000..160fe60
--- /dev/null
+++ b/src/Entity/AbstractLabel.php
@@ -0,0 +1,50 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\MappedSuperclass]
+#[ORM\HasLifecycleCallbacks]
+abstract class AbstractLabel {
+	#[ORM\Id]
+	#[ORM\GeneratedValue]
+	#[ORM\Column]
+	private ?int $id = null;
+
+	#[ORM\Column(length: 16, enumType: LabelType::class)]
+	private LabelType $type;
+
+	#[ORM\Column(length: 255)]
+	private string $label;
+
+	public function __construct(string $label, LabelType $type) {
+		$this->label = $label;
+		$this->type = $type;
+	}
+
+	/**
+	 * Last line of defence: forms validate first, but no path may store a malformed number.
+	 * Labels are immutable, so checking them once is enough.
+	 */
+	#[ORM\PrePersist]
+	public function assertValid(): void {
+		if (!$this->type->isValid($this->label)) {
+			throw new \DomainException(\sprintf('"%s" is not a valid %s label.', $this->label, $this->type->value));
+		}
+	}
+
+	public function getId(): ?int {
+		return $this->id;
+	}
+
+	public function getType(): LabelType {
+		return $this->type;
+	}
+
+	public function getLabel(): string {
+		return $this->label;
+	}
+}
diff --git a/src/Entity/Book.php b/src/Entity/Book.php
new file mode 100644
index 0000000..b7ad23d
--- /dev/null
+++ b/src/Entity/Book.php
@@ -0,0 +1,85 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use App\Repository\BookRepository;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: BookRepository::class)]
+class Book extends AbstractBook {
+	#[ORM\OneToOne(targetEntity: Borrow::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
+	private ?Borrow $borrow = null;
+
+	/** @var Collection<int, Label> */
+	#[ORM\OneToMany(targetEntity: Label::class, mappedBy: 'book', cascade: ['persist', 'remove'], fetch: 'EAGER', orphanRemoval: true)]
+	#[ORM\OrderBy(['label' => 'ASC'])]
+	private Collection $labels;
+
+	/** @var Collection<int, BorrowHistory> */
+	#[ORM\OneToMany(targetEntity: BorrowHistory::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
+	#[ORM\OrderBy(['at' => 'DESC'])]
+	private Collection $borrowHistory;
+
+	public function __construct() {
+		$this->labels = new ArrayCollection();
+		$this->borrowHistory = new ArrayCollection();
+	}
+
+	public static function fromOusted(OustedBook $ousted): self {
+		$book = new self();
+		$book->copyDataFrom($ousted);
+
+		return $book;
+	}
+
+	public function getBorrow(): ?Borrow {
+		return $this->borrow;
+	}
+
+	public function isBorrowed(): bool {
+		return $this->borrow !== null;
+	}
+
+	public function lendTo(string $who, \DateTimeImmutable $at): void {
+		if ($this->isBorrowed()) {
+			throw new \LogicException(\sprintf('Book %d is already borrowed.', $this->id));
+		}
+
+		$this->borrow = new Borrow($this, $who, $at);
+	}
+
+	public function giveBack(\DateTimeImmutable $at): void {
+		if (!$this->borrow instanceof Borrow) {
+			throw new \LogicException(\sprintf('Book %d is not borrowed.', $this->id));
+		}
+
+		$this->borrowHistory->add(new BorrowHistory($this, $this->borrow->getWho(), $this->borrow->getAt(), $at));
+		$this->borrow = null;
+	}
+
+	/**
+	 * @return Collection<int, Label>
+	 */
+	public function getLabels(): Collection {
+		return $this->labels;
+	}
+
+	protected function createLabel(string $label, LabelType $type): Label {
+		return new Label($this, $label, $type);
+	}
+
+	/**
+	 * @return Collection<int, BorrowHistory>
+	 */
+	public function getBorrowHistory(): Collection {
+		return $this->borrowHistory;
+	}
+
+	public function addBorrowHistory(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till): void {
+		$this->borrowHistory->add(new BorrowHistory($this, $who, $at, $till));
+	}
+}
diff --git a/src/Entity/Borrow.php b/src/Entity/Borrow.php
new file mode 100644
index 0000000..0815d18
--- /dev/null
+++ b/src/Entity/Borrow.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Repository\BorrowRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: BorrowRepository::class)]
+class Borrow {
+	#[ORM\Id]
+	#[ORM\OneToOne(targetEntity: Book::class, inversedBy: 'borrow')]
+	#[ORM\JoinColumn(nullable: false)]
+	private Book $book;
+
+	#[ORM\Column(length: 255)]
+	private string $who;
+
+	#[ORM\Column]
+	private \DateTimeImmutable $at;
+
+	public function __construct(Book $book, string $who, \DateTimeImmutable $at) {
+		$this->book = $book;
+		$this->who = $who;
+		$this->at = $at;
+	}
+
+	public function getBook(): Book {
+		return $this->book;
+	}
+
+	public function getWho(): string {
+		return $this->who;
+	}
+
+	public function getAt(): \DateTimeImmutable {
+		return $this->at;
+	}
+}
diff --git a/src/Entity/BorrowHistory.php b/src/Entity/BorrowHistory.php
new file mode 100644
index 0000000..fb8eb8f
--- /dev/null
+++ b/src/Entity/BorrowHistory.php
@@ -0,0 +1,24 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Repository\BorrowHistoryRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: BorrowHistoryRepository::class)]
+#[ORM\UniqueConstraint(name: 'borrow_history_book_at', columns: ['book_id', 'at'])]
+class BorrowHistory extends AbstractBorrowHistory {
+	#[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'borrowHistory')]
+	#[ORM\JoinColumn(nullable: false)]
+	private Book $book;
+
+	public function __construct(Book $book, string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
+		parent::__construct($who, $at, $till);
+		$this->book = $book;
+	}
+
+	public function getBook(): Book {
+		return $this->book;
+	}
+}
diff --git a/src/Entity/Label.php b/src/Entity/Label.php
new file mode 100644
index 0000000..f57dc0a
--- /dev/null
+++ b/src/Entity/Label.php
@@ -0,0 +1,26 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use App\Repository\LabelRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: LabelRepository::class)]
+#[ORM\UniqueConstraint(name: 'label_book_type_label', columns: ['book_id', 'type', 'label'])]
+#[ORM\Index(name: 'label_label', columns: ['label'])]
+class Label extends AbstractLabel {
+	#[ORM\ManyToOne(targetEntity: Book::class, inversedBy: 'labels')]
+	#[ORM\JoinColumn(nullable: false)]
+	private Book $book;
+
+	public function __construct(Book $book, string $label, LabelType $type = LabelType::TEXT) {
+		parent::__construct($label, $type);
+		$this->book = $book;
+	}
+
+	public function getBook(): Book {
+		return $this->book;
+	}
+}
diff --git a/src/Entity/OustedBook.php b/src/Entity/OustedBook.php
new file mode 100644
index 0000000..5a94e68
--- /dev/null
+++ b/src/Entity/OustedBook.php
@@ -0,0 +1,71 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use App\Repository\OustedBookRepository;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * A book taken out of the collection: given away, lost, destroyed.
+ */
+#[ORM\Entity(repositoryClass: OustedBookRepository::class)]
+class OustedBook extends AbstractBook {
+	#[ORM\Column(length: 255)]
+	#[Assert\NotBlank]
+	#[Assert\Length(max: 255)]
+	private string $reason;
+
+	/** @var Collection<int, OustedLabel> */
+	#[ORM\OneToMany(targetEntity: OustedLabel::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
+	#[ORM\OrderBy(['label' => 'ASC'])]
+	private Collection $labels;
+
+	/** @var Collection<int, OustedBorrowHistory> */
+	#[ORM\OneToMany(targetEntity: OustedBorrowHistory::class, mappedBy: 'book', cascade: ['persist', 'remove'], orphanRemoval: true)]
+	#[ORM\OrderBy(['at' => 'DESC'])]
+	private Collection $borrowHistory;
+
+	public function __construct(string $reason) {
+		$this->reason = $reason;
+		$this->labels = new ArrayCollection();
+		$this->borrowHistory = new ArrayCollection();
+	}
+
+	public static function fromBook(Book $book, string $reason): self {
+		$ousted = new self($reason);
+		$ousted->copyDataFrom($book);
+
+		return $ousted;
+	}
+
+	public function getReason(): string {
+		return $this->reason;
+	}
+
+	/**
+	 * @return Collection<int, OustedLabel>
+	 */
+	public function getLabels(): Collection {
+		return $this->labels;
+	}
+
+	protected function createLabel(string $label, LabelType $type): OustedLabel {
+		return new OustedLabel($this, $label, $type);
+	}
+
+	/**
+	 * @return Collection<int, OustedBorrowHistory>
+	 */
+	public function getBorrowHistory(): Collection {
+		return $this->borrowHistory;
+	}
+
+	public function addBorrowHistory(string $who, \DateTimeImmutable $at, \DateTimeImmutable $till): void {
+		$this->borrowHistory->add(new OustedBorrowHistory($this, $who, $at, $till));
+	}
+}
diff --git a/src/Entity/OustedBorrowHistory.php b/src/Entity/OustedBorrowHistory.php
new file mode 100644
index 0000000..5464720
--- /dev/null
+++ b/src/Entity/OustedBorrowHistory.php
@@ -0,0 +1,24 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Repository\OustedBorrowHistoryRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: OustedBorrowHistoryRepository::class)]
+#[ORM\UniqueConstraint(name: 'ousted_borrow_history_book_at', columns: ['book_id', 'at'])]
+class OustedBorrowHistory extends AbstractBorrowHistory {
+	#[ORM\ManyToOne(targetEntity: OustedBook::class, inversedBy: 'borrowHistory')]
+	#[ORM\JoinColumn(nullable: false)]
+	private OustedBook $book;
+
+	public function __construct(OustedBook $book, string $who, \DateTimeImmutable $at, \DateTimeImmutable $till) {
+		parent::__construct($who, $at, $till);
+		$this->book = $book;
+	}
+
+	public function getBook(): OustedBook {
+		return $this->book;
+	}
+}
diff --git a/src/Entity/OustedLabel.php b/src/Entity/OustedLabel.php
new file mode 100644
index 0000000..e9f9965
--- /dev/null
+++ b/src/Entity/OustedLabel.php
@@ -0,0 +1,25 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Entity;
+
+use App\Enum\LabelType;
+use App\Repository\OustedLabelRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: OustedLabelRepository::class)]
+#[ORM\UniqueConstraint(name: 'ousted_label_book_type_label', columns: ['book_id', 'type', 'label'])]
+class OustedLabel extends AbstractLabel {
+	#[ORM\ManyToOne(targetEntity: OustedBook::class, inversedBy: 'labels')]
+	#[ORM\JoinColumn(nullable: false)]
+	private OustedBook $book;
+
+	public function __construct(OustedBook $book, string $label, LabelType $type = LabelType::TEXT) {
+		parent::__construct($label, $type);
+		$this->book = $book;
+	}
+
+	public function getBook(): OustedBook {
+		return $this->book;
+	}
+}
diff --git a/src/Enum/LabelType.php b/src/Enum/LabelType.php
new file mode 100644
index 0000000..b7a31d5
--- /dev/null
+++ b/src/Enum/LabelType.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum;
+
+use App\Model\Isbn;
+
+enum LabelType: string {
+	case TEXT = 'text';
+	case ISBN = 'isbn';
+	case ISSN = 'issn';
+
+	/**
+	 * Brings a value to its stored form; an ISBN-10 becomes an ISBN-13. A value that cannot be
+	 * normalized is returned as it is, and isValid() then rejects it.
+	 */
+	public function normalize(string $value): string {
+		$compact = strtoupper(str_replace(['-', ' '], '', $value));
+
+		return match ($this) {
+			self::TEXT => trim($value),
+			self::ISBN => Isbn::toIsbn13($compact) ?? $compact,
+			self::ISSN => $compact,
+		};
+	}
+
+	public function isValid(string $normalizedValue): bool {
+		return match ($this) {
+			self::TEXT => $normalizedValue !== '',
+			self::ISBN => $normalizedValue === Isbn::toIsbn13($normalizedValue) && \strlen($normalizedValue) === 13,
+			self::ISSN => preg_match('/^(\d{7}[\dX]|\d{13})$/', $normalizedValue) === 1,
+		};
+	}
+}
diff --git a/src/Form/AddLabelType.php b/src/Form/AddLabelType.php
new file mode 100644
index 0000000..c499c8f
--- /dev/null
+++ b/src/Form/AddLabelType.php
@@ -0,0 +1,52 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use App\Enum\LabelType;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\Callback;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+
+/**
+ * @extends AbstractType<array{type: LabelType, label: string}>
+ */
+class AddLabelType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('type', EnumType::class, [
+				'class' => LabelType::class,
+				'label' => 'label_type_header',
+				'choice_label' => static fn(LabelType $type): string => 'label_type_' . $type->value,
+			])
+			->add('label', TextType::class, [
+				'label' => 'label_header',
+				'constraints' => [new NotBlank(), new Length(max: 255)],
+			])
+			->add('save', SubmitType::class, ['label' => 'label_action_add']);
+	}
+
+	public function configureOptions(OptionsResolver $resolver): void {
+		$resolver->setDefaults([
+			'constraints' => [new Callback(self::validateFormat(...))],
+		]);
+	}
+
+	/**
+	 * @param array{type: ?LabelType, label: ?string} $data
+	 */
+	public static function validateFormat(array $data, ExecutionContextInterface $context): void {
+		$type = $data['type'];
+
+		if ($type !== null && !$type->isValid($type->normalize((string)$data['label']))) {
+			$context->buildViolation('label_invalid_number')->setTranslationDomain('messages')->atPath('[label]')->addViolation();
+		}
+	}
+}
diff --git a/src/Form/BookSearchType.php b/src/Form/BookSearchType.php
new file mode 100644
index 0000000..fcb3d9a
--- /dev/null
+++ b/src/Form/BookSearchType.php
@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use App\Model\BookSearch;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @extends AbstractType<BookSearch>
+ */
+class BookSearchType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('author', TextType::class, ['label' => 'book_author_header', 'required' => false])
+			->add('title', TextType::class, ['label' => 'book_title_header', 'required' => false])
+			->add('edition', TextType::class, ['label' => 'book_edition_header', 'required' => false])
+			->add('publisher', TextType::class, ['label' => 'book_publisher_header', 'required' => false]);
+	}
+
+	public function configureOptions(OptionsResolver $resolver): void {
+		$resolver->setDefaults([
+			'data_class' => BookSearch::class,
+			'method' => 'GET',
+			'csrf_protection' => false,
+		]);
+	}
+
+	public function getBlockPrefix(): string {
+		return '';
+	}
+}
diff --git a/src/Form/BookType.php b/src/Form/BookType.php
new file mode 100644
index 0000000..1146277
--- /dev/null
+++ b/src/Form/BookType.php
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use App\Entity\Book;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\IntegerType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @extends AbstractType<Book>
+ */
+class BookType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('id', IntegerType::class, ['label' => 'book_id_header', 'disabled' => $options['lock_id']])
+			->add('title', TextType::class, ['label' => 'book_title_header'])
+			->add('author', TextType::class, ['label' => 'book_author_header'])
+			->add('language', TextType::class, ['label' => 'book_language_header', 'required' => false])
+			->add('edition', TextType::class, ['label' => 'book_edition_header', 'required' => false])
+			->add('publisher', TextType::class, ['label' => 'book_publisher_header', 'required' => false])
+			->add('publicationLoc', TextType::class, ['label' => 'book_publication_loc_header', 'required' => false])
+			->add('publicationYear', IntegerType::class, ['label' => 'book_publication_year_header', 'required' => false])
+			->add('isbn', TextType::class, ['label' => 'book_isbn_header', 'required' => false])
+			->add('issn', TextType::class, ['label' => 'book_issn_header', 'required' => false])
+			->add('placement', TextType::class, ['label' => 'book_placement_header', 'required' => false])
+			->add('save', SubmitType::class, ['label' => 'book_action_save']);
+	}
+
+	public function configureOptions(OptionsResolver $resolver): void {
+		$resolver->setDefaults([
+			'data_class' => Book::class,
+			'lock_id' => false,
+		]);
+		$resolver->setAllowedTypes('lock_id', 'bool');
+	}
+}
diff --git a/src/Form/BorrowType.php b/src/Form/BorrowType.php
new file mode 100644
index 0000000..24d0794
--- /dev/null
+++ b/src/Form/BorrowType.php
@@ -0,0 +1,25 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+
+/**
+ * @extends AbstractType<array{who: string}>
+ */
+class BorrowType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('who', TextType::class, [
+				'label' => 'borrow_who_header',
+				'constraints' => [new NotBlank(), new Length(max: 255)],
+			])
+			->add('save', SubmitType::class, ['label' => 'borrow_action_lend']);
+	}
+}
diff --git a/src/Form/BulkOustType.php b/src/Form/BulkOustType.php
new file mode 100644
index 0000000..4e1c454
--- /dev/null
+++ b/src/Form/BulkOustType.php
@@ -0,0 +1,48 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\DateType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotNull;
+
+class BulkOustType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$confirm = $options['confirm'];
+
+		$builder
+			->add('notSeenSince', DateType::class, [
+				'label' => 'bulk_oust_not_seen_since_header',
+				'widget' => 'single_text',
+				'input' => 'datetime_immutable',
+				'constraints' => [new NotNull()],
+			])
+			->add('reason', TextType::class, [
+				'label' => 'ousted_reason_header',
+				'constraints' => [new NotBlank(), new Length(max: 255)],
+			]);
+
+		if ($confirm) {
+			$builder
+				->add('selection', HiddenType::class, ['constraints' => [new NotBlank()]])
+				->add('save', SubmitType::class, ['label' => 'bulk_oust_action_oust', 'attr' => ['class' => 'btn-error']]);
+		} else {
+			$builder->add('preview', SubmitType::class, ['label' => 'bulk_oust_action_preview']);
+		}
+	}
+
+	public function configureOptions(OptionsResolver $resolver): void {
+		$resolver->setDefaults(['confirm' => false]);
+		$resolver->setAllowedTypes('confirm', 'bool');
+		$resolver->setNormalizer('method', static fn($options): string => $options['confirm'] ? 'POST' : 'GET');
+		$resolver->setNormalizer('csrf_protection', static fn($options, $value): bool => $options['confirm'] && $value);
+	}
+}
diff --git a/src/Form/CoverType.php b/src/Form/CoverType.php
new file mode 100644
index 0000000..8ae538b
--- /dev/null
+++ b/src/Form/CoverType.php
@@ -0,0 +1,38 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use App\Service\CoverStorage;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\Validator\Constraints\Image;
+use Symfony\Component\Validator\Constraints\NotNull;
+
+/**
+ * @extends AbstractType<array{cover: UploadedFile}>
+ */
+class CoverType extends AbstractType {
+	private const int MAX_PIXELS = 20_000_000;
+
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('cover', FileType::class, [
+				'label' => 'cover_file_header',
+				'attr' => ['accept' => implode(',', array_keys(CoverStorage::EXTENSIONS))],
+				'constraints' => [
+					new NotNull(),
+					new Image(
+						maxSize: '10M',
+						mimeTypes: array_keys(CoverStorage::EXTENSIONS),
+						maxPixels: self::MAX_PIXELS,
+						detectCorrupted: true,
+					),
+				],
+			])
+			->add('save', SubmitType::class, ['label' => 'cover_action_upload']);
+	}
+}
diff --git a/src/Form/OustType.php b/src/Form/OustType.php
new file mode 100644
index 0000000..575698c
--- /dev/null
+++ b/src/Form/OustType.php
@@ -0,0 +1,25 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Form;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+
+/**
+ * @extends AbstractType<array{reason: string}>
+ */
+class OustType extends AbstractType {
+	public function buildForm(FormBuilderInterface $builder, array $options): void {
+		$builder
+			->add('reason', TextType::class, [
+				'label' => 'ousted_reason_header',
+				'constraints' => [new NotBlank(), new Length(max: 255)],
+			])
+			->add('save', SubmitType::class, ['label' => 'ousted_action_oust']);
+	}
+}
diff --git a/src/Kernel.php b/src/Kernel.php
new file mode 100644
index 0000000..b0dc18e
--- /dev/null
+++ b/src/Kernel.php
@@ -0,0 +1,11 @@
+<?php
+declare(strict_types=1);
+
+namespace App;
+
+use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Component\HttpKernel\Kernel as BaseKernel;
+
+class Kernel extends BaseKernel {
+	use MicroKernelTrait;
+}
diff --git a/src/LegacyImport/CoverImportReport.php b/src/LegacyImport/CoverImportReport.php
new file mode 100644
index 0000000..3fc81d6
--- /dev/null
+++ b/src/LegacyImport/CoverImportReport.php
@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\LegacyImport;
+
+class CoverImportReport {
+	/** Books that got a photo of the copy itself, found by the book ID. */
+	public int $ownById = 0;
+
+	/** Books that got a photo of the copy itself, found by ISBN. */
+	public int $ownByIsbn = 0;
+
+	/** Books that got the publisher's cover, found by ISBN. */
+	public int $byIsbn = 0;
+
+	/** Books that already had a cover, which was kept. */
+	public int $alreadyCovered = 0;
+
+	/** @var list<string> Files whose name does not match a book. */
+	public array $unmatched = [];
+
+	/** @var list<string> Files that are neither an ISBN-13 nor a book ID. */
+	public array $ignored = [];
+
+	/** @var list<string> Files that could not be stored as covers, with the reason. */
+	public array $failed = [];
+
+	public function imported(): int {
+		return $this->ownById + $this->ownByIsbn + $this->byIsbn;
+	}
+}
diff --git a/src/LegacyImport/CoverImporter.php b/src/LegacyImport/CoverImporter.php
new file mode 100644
index 0000000..7ff199b
--- /dev/null
+++ b/src/LegacyImport/CoverImporter.php
@@ -0,0 +1,221 @@
+<?php
+declare(strict_types=1);
+
+namespace App\LegacyImport;
+
+use App\Entity\AbstractBook;
+use App\Entity\AbstractLabel;
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use App\Enum\LabelType;
+use App\Service\CoverProcessingException;
+use App\Service\CoverStorage;
+use Doctrine\ORM\EntityManagerInterface;
+
+class CoverImporter {
+	private const string OWN_DIR = 'own';
+	private const string ISBN_PATTERN = '/^\d{13}$/';
+	private const string ID_PATTERN = '/^\d{8}$/';
+
+	public function __construct(
+		private readonly EntityManagerInterface $entityManager,
+		private readonly CoverStorage           $covers,
+	) {
+	}
+
+	/**
+	 * @throws \InvalidArgumentException when the directory cannot be read
+	 */
+	public function import(string $directory, bool $dryRun = false): CoverImportReport {
+		$directory = rtrim($directory, '/');
+
+		if (!is_dir($directory) || !is_readable($directory)) {
+			throw new \InvalidArgumentException(\sprintf('"%s" is not a readable directory.', $directory));
+		}
+
+		$report = new CoverImportReport();
+		$byIsbn = $this->listFiles($directory, self::ISBN_PATTERN, null, $report);
+		$ownByIsbn = [];
+		$ownById = [];
+
+		if (is_dir($directory . '/' . self::OWN_DIR)) {
+			$ownByIsbn = $this->listFiles($directory . '/' . self::OWN_DIR, self::ISBN_PATTERN, self::ID_PATTERN, $report, $ownById);
+		}
+
+		$report->ignored = array_map(static fn(string $path): string => self::relative($directory, $path), $report->ignored);
+		$used = [];
+		$failed = [];
+		$stored = [];
+
+		$connection = $this->entityManager->getConnection();
+		$connection->beginTransaction();
+
+		try {
+			foreach ($this->books() as $book) {
+				$candidates = self::candidates($book, $ownById, $ownByIsbn, $byIsbn);
+
+				foreach ($candidates as $path) {
+					$used[$path] = true;
+				}
+
+				if ($candidates === []) {
+					continue;
+				}
+
+				if ($book->getCover() !== null) {
+					++$report->alreadyCovered;
+					continue;
+				}
+
+				// A file that is not an image gives way to the next one in order of preference.
+				foreach ($candidates as $source => $path) {
+					if (isset($failed[$path])) {
+						continue;
+					}
+
+					try {
+						$name = $this->covers->store((string)file_get_contents($path));
+					} catch (CoverProcessingException $exception) {
+						$failed[$path] = true;
+						$report->failed[] = \sprintf('%s: %s', self::relative($directory, $path), $exception->getMessage());
+						continue;
+					}
+
+					$stored[] = $name;
+					$book->setCover($name);
+					++$report->{$source};
+					break;
+				}
+			}
+
+			$this->entityManager->flush();
+			$dryRun ? $connection->rollBack() : $connection->commit();
+		} catch (\Throwable $exception) {
+			if ($connection->isTransactionActive()) {
+				$connection->rollBack();
+			}
+			$this->deleteAll($stored);
+
+			throw $exception;
+		}
+
+		if ($dryRun) {
+			$this->deleteAll($stored);
+		}
+
+		$this->entityManager->clear();
+
+		foreach ([...$byIsbn, ...$ownByIsbn, ...$ownById] as $path) {
+			if (!isset($used[$path])) {
+				$report->unmatched[] = self::relative($directory, $path);
+			}
+		}
+		sort($report->unmatched);
+
+		return $report;
+	}
+
+	/**
+	 * @return iterable<AbstractBook>
+	 */
+	private function books(): iterable {
+		yield from $this->entityManager->getRepository(Book::class)->findBy([], ['id' => 'ASC']);
+		yield from $this->entityManager->getRepository(OustedBook::class)->findBy([], ['id' => 'ASC']);
+	}
+
+	/**
+	 * @param array<int, string> $byId receives the files named by book ID, when $idPattern is given
+	 *
+	 * @return array<string, string> paths of the files named by ISBN, by ISBN
+	 */
+	private function listFiles(string $directory, string $isbnPattern, ?string $idPattern, CoverImportReport $report, array &$byId = []): array {
+		$byIsbn = [];
+		$names = scandir($directory);
+
+		if ($names === false) {
+			throw new \InvalidArgumentException(\sprintf('"%s" could not be listed.', $directory));
+		}
+
+		sort($names, \SORT_STRING);
+
+		foreach ($names as $name) {
+			$path = $directory . '/' . $name;
+
+			// Empty files are placeholders for missing covers, not images.
+			if (str_starts_with($name, '.') || is_dir($path) || filesize($path) === 0) {
+				continue;
+			}
+
+			if (preg_match($isbnPattern, $name) === 1) {
+				$byIsbn[$name] = $path;
+			} elseif ($idPattern !== null && preg_match($idPattern, $name) === 1) {
+				$byId[(int)$name] = $path;
+			} else {
+				$report->ignored[] = $path;
+			}
+		}
+
+		return $byIsbn;
+	}
+
+	/**
+	 * @param array<int, string> $ownById
+	 * @param array<string, string> $ownByIsbn
+	 * @param array<string, string> $byIsbn
+	 *
+	 * @return array<'ownById'|'ownByIsbn'|'byIsbn', string> matching files in order of preference,
+	 *                                                       by the report counter each adds to
+	 */
+	private static function candidates(AbstractBook $book, array $ownById, array $ownByIsbn, array $byIsbn): array {
+		$isbns = self::isbns($book);
+
+		return array_filter([
+			'ownById' => $ownById[(int)$book->getId()] ?? null,
+			'ownByIsbn' => self::firstMatch($ownByIsbn, $isbns),
+			'byIsbn' => self::firstMatch($byIsbn, $isbns),
+		], static fn(?string $path): bool => $path !== null);
+	}
+
+	/**
+	 * @return list<string>
+	 */
+	private static function isbns(AbstractBook $book): array {
+		$isbns = [];
+
+		/** @var AbstractLabel $label */
+		foreach ($book->getLabels() as $label) {
+			if ($label->getType() === LabelType::ISBN) {
+				$isbns[] = $label->getLabel();
+			}
+		}
+
+		return $isbns;
+	}
+
+	/**
+	 * @param array<string, string> $files
+	 * @param list<string> $isbns
+	 */
+	private static function firstMatch(array $files, array $isbns): ?string {
+		foreach ($isbns as $isbn) {
+			if (isset($files[$isbn])) {
+				return $files[$isbn];
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param list<string> $names
+	 */
+	private function deleteAll(array $names): void {
+		foreach ($names as $name) {
+			$this->covers->delete($name);
+		}
+	}
+
+	private static function relative(string $directory, string $path): string {
+		return substr($path, \strlen($directory) + 1);
+	}
+}
diff --git a/src/LegacyImport/ImportReport.php b/src/LegacyImport/ImportReport.php
new file mode 100644
index 0000000..195a1c4
--- /dev/null
+++ b/src/LegacyImport/ImportReport.php
@@ -0,0 +1,21 @@
+<?php
+declare(strict_types=1);
+
+namespace App\LegacyImport;
+
+class ImportReport {
+	public int $books = 0;
+	public int $oustedBooks = 0;
+	public int $borrows = 0;
+	public int $borrowHistory = 0;
+	public int $oustedBorrowHistory = 0;
+
+	/** Legacy books whose ID is already taken in the target; they and their loans were left as they are. */
+	public int $alreadyPresent = 0;
+
+	/** @var list<string> Rows that have no place in the new schema and were left out. */
+	public array $skipped = [];
+
+	/** @var list<string> Numbers that are not valid ISBNs, kept as text labels instead. */
+	public array $invalidIsbns = [];
+}
diff --git a/src/LegacyImport/LegacyImporter.php b/src/LegacyImport/LegacyImporter.php
new file mode 100644
index 0000000..5a67adf
--- /dev/null
+++ b/src/LegacyImport/LegacyImporter.php
@@ -0,0 +1,278 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\LegacyImport;
+
+use App\Entity\AbstractBook;
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use App\Enum\LabelType;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Copies the old "ksiazki", "pozycz" and "pozycz_historia" tables into the current schema.
+ *
+ * The source is never written to. The import is one transaction, so a failure leaves the
+ * target untouched.
+ *
+ * The import can be repeated: a legacy book whose ID is already taken in the target, as a book or
+ * as an ousted book, is left as it is along with its loans, so edits made since are kept and only
+ * new books are added.
+ */
+class LegacyImporter
+{
+    private const int ISBN10_LENGTH = 10;
+    private const int ISSN_LENGTH = 8;
+
+    public function __construct(
+        private readonly Connection $legacyConnection,
+        private readonly EntityManagerInterface $entityManager,
+    ) {
+    }
+
+    public function import(bool $dryRun = false): ImportReport
+    {
+        $existingIds = $this->existingIds();
+
+        // Everything is read up front: the source and the target may be the same SQLite file,
+        // which could not be written to during an open read.
+        $bookRows = $this->fetchRows('ksiazki', ['id']);
+        $borrowRows = $this->fetchRows('pozycz', ['id', 'od']);
+        $historyRows = $this->fetchRows('pozycz_historia', ['id', 'od']);
+
+        $connection = $this->entityManager->getConnection();
+        $connection->beginTransaction();
+
+        try {
+            $report = new ImportReport();
+            [$books, $oustedBooks] = $this->importBooks($bookRows, $existingIds, $report);
+            $this->importBorrows($borrowRows, $books, $oustedBooks, $existingIds, $report);
+            $this->importHistory($historyRows, $books, $oustedBooks, $existingIds, $report);
+            $this->entityManager->flush();
+
+            $dryRun ? $connection->rollBack() : $connection->commit();
+        } catch (\Throwable $exception) {
+            if ($connection->isTransactionActive()) {
+                $connection->rollBack();
+            }
+
+            throw $exception;
+        }
+
+        $this->entityManager->clear();
+
+        return $report;
+    }
+
+    /**
+     * @return array<int, true> the IDs of the books and ousted books already in the target
+     */
+    private function existingIds(): array
+    {
+        $ids = [];
+
+        foreach ([Book::class, OustedBook::class] as $entity) {
+            $rows = $this->entityManager->createQueryBuilder()
+                ->select('b.id')
+                ->from($entity, 'b')
+                ->getQuery()
+                ->getSingleColumnResult();
+
+            foreach ($rows as $id) {
+                $ids[(int) $id] = true;
+            }
+        }
+
+        return $ids;
+    }
+
+    /**
+     * @param list<string> $orderBy
+     *
+     * @return list<array<string, mixed>> rows with lower-case keys, whatever case the source reports
+     */
+    private function fetchRows(string $table, array $orderBy): array
+    {
+        $rows = $this->legacyConnection->createQueryBuilder()
+            ->select('*')
+            ->from($this->legacyConnection->quoteSingleIdentifier($table))
+            ->orderBy(implode(', ', $orderBy))
+            ->executeQuery()
+            ->fetchAllAssociative();
+
+        return array_map(static fn (array $row): array => array_change_key_case($row, \CASE_LOWER), $rows);
+    }
+
+    /**
+     * @param list<array<string, mixed>> $rows
+     * @param array<int, true>           $existingIds
+     *
+     * @return array{array<int, Book>, array<int, OustedBook>}
+     */
+    private function importBooks(array $rows, array $existingIds, ImportReport $report): array
+    {
+        $books = [];
+        $oustedBooks = [];
+
+        foreach ($rows as $row) {
+            $id = (int) $row['id'];
+
+            if (isset($existingIds[$id])) {
+                ++$report->alreadyPresent;
+                continue;
+            }
+
+            if ((string) $row['wycofana'] === '1') {
+                $book = new OustedBook((string) $row['powod']);
+                $oustedBooks[$id] = $book;
+                ++$report->oustedBooks;
+            } else {
+                $book = new Book();
+                $books[$id] = $book;
+                ++$report->books;
+            }
+
+            $this->fillBook($book, $row, $report);
+            $this->entityManager->persist($book);
+        }
+
+        return [$books, $oustedBooks];
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private function fillBook(AbstractBook $book, array $row, ImportReport $report): void
+    {
+        $year = (int) $row['rok'];
+
+        $book
+            ->setId((int) $row['id'])
+            ->setTitle((string) $row['tytul'])
+            ->setAuthor((string) $row['autor'])
+            ->setLanguage(self::text($row['jezyk']))
+            ->setEdition(self::text($row['wydanie']))
+            ->setPublisher(self::text($row['wydawnictwo']))
+            ->setPublicationLoc(self::text($row['miejsce']))
+            ->setPublicationYear($year === 0 ? null : $year)
+            ->setIssn(self::standardNumber($row['issn'], self::ISSN_LENGTH))
+            ->setPlacement(self::placement($row['regal'], $row['polka'], $row['rzad']));
+
+        $isbn = self::standardNumber($row['isbn'], self::ISBN10_LENGTH);
+
+        if ($isbn === null) {
+            return;
+        }
+
+        if (LabelType::ISBN->isValid(LabelType::ISBN->normalize($isbn))) {
+            $book->setIsbn($isbn);
+        } else {
+            $book->addLabel($isbn);
+            $report->invalidIsbns[] = \sprintf('book %d: %s', $row['id'], $isbn);
+        }
+    }
+
+    /**
+     * @param list<array<string, mixed>> $rows
+     * @param array<int, Book>           $books
+     * @param array<int, OustedBook>     $oustedBooks
+     * @param array<int, true>           $existingIds books kept as they are, whose loans are not imported
+     */
+    private function importBorrows(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
+    {
+        // The old key was (id, od), so a book could be lent twice at once; only one loan fits
+        // now, and the rows are sorted, so reversing keeps the most recent.
+        foreach (array_reverse($rows) as $row) {
+            $id = (int) $row['id'];
+
+            if (isset($existingIds[$id])) {
+                continue;
+            }
+
+            $book = $books[$id] ?? null;
+
+            if ($book === null) {
+                $report->skipped[] = isset($oustedBooks[$id])
+                    ? \sprintf('pozycz: book %d is on loan but ousted', $id)
+                    : \sprintf('pozycz: book %d does not exist', $id);
+                continue;
+            }
+
+            if ($book->isBorrowed()) {
+                $report->skipped[] = \sprintf('pozycz: book %d has more than one open loan, kept the latest', $id);
+                continue;
+            }
+
+            $book->lendTo((string) $row['kto'], self::dateTime($row['od']));
+            ++$report->borrows;
+        }
+    }
+
+    /**
+     * @param list<array<string, mixed>> $rows
+     * @param array<int, Book>           $books
+     * @param array<int, OustedBook>     $oustedBooks
+     * @param array<int, true>           $existingIds books kept as they are, whose loans are not imported
+     */
+    private function importHistory(array $rows, array $books, array $oustedBooks, array $existingIds, ImportReport $report): void
+    {
+        foreach ($rows as $row) {
+            $id = (int) $row['id'];
+
+            if (isset($existingIds[$id])) {
+                continue;
+            }
+
+            $book = $books[$id] ?? $oustedBooks[$id] ?? null;
+
+            if ($book === null) {
+                $report->skipped[] = \sprintf('pozycz_historia: book %d does not exist', $id);
+                continue;
+            }
+
+            if ($row['do'] === null) {
+                $report->skipped[] = \sprintf('pozycz_historia: loan of book %d from %s has no end date', $id, self::dateTime($row['od'])->format('Y-m-d'));
+                continue;
+            }
+
+            $book->addBorrowHistory((string) $row['kto'], self::dateTime($row['od']), self::dateTime($row['do']));
+            $book instanceof Book ? ++$report->borrowHistory : ++$report->oustedBorrowHistory;
+        }
+    }
+
+    private static function text(mixed $value): ?string
+    {
+        return $value === null || (string) $value === '' ? null : (string) $value;
+    }
+
+    /**
+     * Numbers were stored as DECIMAL, which dropped leading zeros.
+     */
+    private static function standardNumber(mixed $value, int $shortLength): ?string
+    {
+        $number = (string) $value;
+
+        return $number === '' || $number === '0' ? null : str_pad($number, $shortLength, '0', \STR_PAD_LEFT);
+    }
+
+    /**
+     * Joins bookcase, shelf and row as "HOL/4/1", without trailing separators for missing parts.
+     */
+    private static function placement(mixed $bookcase, mixed $shelf, mixed $row): ?string
+    {
+        if ($bookcase === null) {
+            return null;
+        }
+
+        return rtrim(implode('/', [(string) $bookcase, (string) $shelf, (string) $row]), '/');
+    }
+
+    private static function dateTime(mixed $timestamp): \DateTimeImmutable
+    {
+        // A "@" date is in UTC, but Doctrine stores the wall clock of the object it is given.
+        return new \DateTimeImmutable('@'.(int) $timestamp)
+            ->setTimezone(new \DateTimeZone(date_default_timezone_get()));
+    }
+}
diff --git a/src/Lookup/BookCandidate.php b/src/Lookup/BookCandidate.php
new file mode 100644
index 0000000..1b591c6
--- /dev/null
+++ b/src/Lookup/BookCandidate.php
@@ -0,0 +1,18 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Lookup;
+
+readonly class BookCandidate {
+	public function __construct(
+		public string  $title,
+		public ?string $author = null,
+		public ?string $language = null,
+		public ?string $publisher = null,
+		public ?string $publicationLoc = null,
+		public ?int    $publicationYear = null,
+		public ?string $isbn = null,
+		public ?string $issn = null,
+	) {
+	}
+}
diff --git a/src/Lookup/BookLookupInterface.php b/src/Lookup/BookLookupInterface.php
new file mode 100644
index 0000000..2e8de16
--- /dev/null
+++ b/src/Lookup/BookLookupInterface.php
@@ -0,0 +1,18 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Lookup;
+
+interface BookLookupInterface {
+	/**
+	 * @return BookCandidate[]
+	 * @throws LookupFailedException
+	 */
+	public function findByStandardNumber(string $number): array;
+
+	/**
+	 * @return BookCandidate[]
+	 * @throws LookupFailedException
+	 */
+	public function findByDescription(string $title, string $author, string $publisher): array;
+}
diff --git a/src/Lookup/LookupFailedException.php b/src/Lookup/LookupFailedException.php
new file mode 100644
index 0000000..72b5e26
--- /dev/null
+++ b/src/Lookup/LookupFailedException.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Lookup;
+
+final class LookupFailedException extends \RuntimeException {
+}
diff --git a/src/Lookup/NationalLibraryLookup.php b/src/Lookup/NationalLibraryLookup.php
new file mode 100644
index 0000000..13d3f2f
--- /dev/null
+++ b/src/Lookup/NationalLibraryLookup.php
@@ -0,0 +1,86 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Lookup;
+
+use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * Looks books up in the Polish National Library catalogue (https://data.bn.org.pl).
+ */
+class NationalLibraryLookup implements BookLookupInterface {
+	private const int LIMIT = 10;
+
+	public function __construct(private readonly HttpClientInterface $bnClient) {
+	}
+
+	public function findByStandardNumber(string $number): array {
+		return $this->fetch(['isbnIssn' => str_replace(['-', ' '], '', $number)]);
+	}
+
+	public function findByDescription(string $title, string $author, string $publisher): array {
+		return $this->fetch(array_filter(
+			['title' => $title, 'author' => $author, 'publisher' => $publisher],
+			static fn(string $value): bool => $value !== '',
+		));
+	}
+
+	/**
+	 * @param array<string, string> $query
+	 *
+	 * @return list<BookCandidate>
+	 */
+	private function fetch(array $query): array {
+		try {
+			$response = $this->bnClient->request('GET', '/api/networks/bibs.json', [
+				'query' => $query + ['limit' => self::LIMIT, 'kind' => 'książka'],
+			]);
+			$records = $response->toArray()['bibs'] ?? [];
+		} catch (ExceptionInterface $exception) {
+			throw new LookupFailedException('The catalogue request failed.', previous: $exception);
+		}
+
+		return array_map(self::toCandidate(...), $records);
+	}
+
+	/**
+	 * @param array<string, mixed> $record
+	 */
+	private static function toCandidate(array $record): BookCandidate {
+		$number = preg_replace('/[^\dX]/', '', strtoupper((string)($record['isbnIssn'] ?? ''))) ?: null;
+		$isIssn = $number !== null && \strlen($number) === 8;
+		$year = (string)($record['publicationYear'] ?? '');
+
+		return new BookCandidate(
+			title: trim((string)($record['title'] ?? ''), " \t\n\r/:;,."),
+			author: self::firstPerson((string)($record['author'] ?? '')),
+			language: self::blankToNull((string)($record['language'] ?? '')),
+			publisher: self::singlePublisher((string)($record['publisher'] ?? '')),
+			publicationLoc: self::blankToNull(trim(explode(':', (string)($record['placeOfPublication'] ?? ''))[0])),
+			publicationYear: preg_match('/\d{4}/', $year, $match) === 1 ? (int)$match[0] : null,
+			isbn: $isIssn ? null : $number,
+			issn: $isIssn ? $number : null,
+		);
+	}
+
+	/**
+	 * "MacLean, Alistair (1922-1987) Ginalski, Robert ..." becomes "Alistair MacLean".
+	 */
+	private static function firstPerson(string $authors): ?string {
+		$first = preg_split('/\s*\(\d{4}/', $authors, 2)[0];
+		$parts = array_map(trim(...), explode(',', rtrim($first, " .\t\n\r")));
+
+		return self::blankToNull(\count($parts) === 2 ? $parts[1] . ' ' . $parts[0] : implode(', ', $parts));
+	}
+
+	private static function singlePublisher(string $publisher): ?string {
+		$publisher = trim($publisher, " \t\n\r\",;:.");
+
+		return self::blankToNull(preg_replace('/^(.+)\s+\1$/u', '$1', $publisher) ?? $publisher);
+	}
+
+	private static function blankToNull(string $value): ?string {
+		return trim($value) === '' ? null : trim($value);
+	}
+}
diff --git a/src/Model/BookSearch.php b/src/Model/BookSearch.php
new file mode 100644
index 0000000..0399f0a
--- /dev/null
+++ b/src/Model/BookSearch.php
@@ -0,0 +1,15 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Model;
+
+class BookSearch {
+	public ?string $author = null;
+	public ?string $title = null;
+	public ?string $edition = null;
+	public ?string $publisher = null;
+
+	public function isEmpty(): bool {
+		return trim(implode('', [$this->author, $this->title, $this->edition, $this->publisher])) === '';
+	}
+}
diff --git a/src/Model/Isbn.php b/src/Model/Isbn.php
new file mode 100644
index 0000000..77f1fac
--- /dev/null
+++ b/src/Model/Isbn.php
@@ -0,0 +1,48 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Model;
+
+class Isbn {
+	/**
+	 * Returns the ISBN-13 form of a valid ISBN-10 or ISBN-13, or null if the value is neither.
+	 */
+	public static function toIsbn13(string $value): ?string {
+		$digits = strtoupper(str_replace(['-', ' '], '', $value));
+
+		if (preg_match('/^\d{9}[\dX]$/', $digits) === 1) {
+			if (!self::isValidIsbn10($digits)) {
+				return null;
+			}
+
+			$stem = '978' . substr($digits, 0, 9);
+
+			return $stem . self::isbn13CheckDigit($stem);
+		}
+
+		if (preg_match('/^97[89]\d{10}$/', $digits) === 1
+			&& $digits[12] === self::isbn13CheckDigit(substr($digits, 0, 12))) {
+			return $digits;
+		}
+
+		return null;
+	}
+
+	private static function isValidIsbn10(string $digits): bool {
+		$sum = 0;
+		foreach (str_split($digits) as $position => $digit) {
+			$sum += (10 - $position) * ($digit === 'X' ? 10 : (int)$digit);
+		}
+
+		return $sum % 11 === 0;
+	}
+
+	private static function isbn13CheckDigit(string $stem): string {
+		$sum = 0;
+		foreach (str_split($stem) as $position => $digit) {
+			$sum += (int)$digit * ($position % 2 === 0 ? 1 : 3);
+		}
+
+		return (string)((10 - $sum % 10) % 10);
+	}
+}
diff --git a/src/Repository/BookRepository.php b/src/Repository/BookRepository.php
new file mode 100644
index 0000000..2922dbb
--- /dev/null
+++ b/src/Repository/BookRepository.php
@@ -0,0 +1,170 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\Book;
+use App\Enum\LabelType;
+use App\Model\BookSearch;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\QueryBuilder;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<Book>
+ */
+class BookRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, Book::class);
+	}
+
+	public function createListQueryBuilder(): QueryBuilder {
+		// Book::$borrow is the inverse side of a one-to-one, so Doctrine would otherwise load it
+		// row by row. Labels are batch-loaded through their EAGER mapping.
+		return $this->createQueryBuilder('b')
+			->addSelect('br')
+			->leftJoin('b.borrow', 'br')
+			->orderBy('b.id', 'ASC');
+	}
+
+	public function createSearchQueryBuilder(BookSearch $search): QueryBuilder {
+		$builder = $this->createListQueryBuilder();
+
+		$fields = [
+			'title' => $search->title,
+			'author' => $search->author,
+			'edition' => $search->edition,
+			'publisher' => $search->publisher,
+		];
+
+		foreach ($fields as $field => $value) {
+			if (trim((string)$value) !== '') {
+				$builder
+					->andWhere(\sprintf("b.%s LIKE :%s ESCAPE '!'", $field, $field))
+					->setParameter($field, self::containsPattern($value));
+			}
+		}
+
+		return $builder;
+	}
+
+	/**
+	 * Quick search: an ID or ISBN/ISSN matches exactly, anything else word by word.
+	 */
+	public function createQuickSearchQueryBuilder(string $query): QueryBuilder {
+		$builder = $this->createListQueryBuilder();
+		$words = preg_split('/\s+/u', trim($query), flags: \PREG_SPLIT_NO_EMPTY) ?: [];
+
+		foreach ($words as $index => $word) {
+			$conditions = [
+				\sprintf("b.title LIKE :word%d ESCAPE '!'", $index),
+				\sprintf("b.author LIKE :word%d ESCAPE '!'", $index),
+				\sprintf("b.publisher LIKE :word%d ESCAPE '!'", $index),
+			];
+			$builder->setParameter('word' . $index, self::containsPattern($word));
+
+			if (\count($words) === 1) {
+				$conditions[] = 'EXISTS (SELECT 1 FROM App\Entity\Label n WHERE n.book = b AND n.type <> :textType AND n.label = :number)';
+				$builder
+					->setParameter('textType', LabelType::TEXT->value)
+					->setParameter('number', LabelType::ISBN->normalize($word));
+
+				if (ctype_digit($word) && \strlen($word) <= 9) {
+					$conditions[] = 'b.id = :id';
+					$builder->setParameter('id', (int)$word);
+				}
+			}
+
+			$builder->andWhere($builder->expr()->orX(...$conditions));
+		}
+
+		return $builder;
+	}
+
+	/**
+	 * @param list<int> $ids
+	 * @return list<Book>
+	 */
+	public function findForInventorySync(array $ids): array {
+		return $this->createListQueryBuilder()
+			->andWhere('b.id IN (:ids)')
+			->setParameter('ids', $ids)
+			->getQuery()
+			->getResult();
+	}
+
+	/**
+	 * Books on the shelves (not lent out) that were not seen during an inventory on or after
+	 * $since, including those never seen at all.
+	 */
+	public function createNotSeenSinceQueryBuilder(\DateTimeImmutable $since): QueryBuilder {
+		return $this->createListQueryBuilder()
+			->andWhere('b.lastSeen IS NULL OR b.lastSeen < :since')
+			->andWhere('br.book IS NULL')
+			->setParameter('since', $since, Types::DATETIME_IMMUTABLE);
+	}
+
+	public function countNotSeenSince(\DateTimeImmutable $since, bool $borrowed = false): int {
+		return (int)$this->createQueryBuilder('b')
+			->select('COUNT(b.id)')
+			->leftJoin('b.borrow', 'br')
+			->andWhere('b.lastSeen IS NULL OR b.lastSeen < :since')
+			->andWhere($borrowed ? 'br.book IS NOT NULL' : 'br.book IS NULL')
+			->setParameter('since', $since, Types::DATETIME_IMMUTABLE)
+			->getQuery()
+			->getSingleScalarResult();
+	}
+
+	/**
+	 * A short fingerprint of exactly which books createNotSeenSinceQueryBuilder() selects.
+	 */
+	public function notSeenSinceFingerprint(\DateTimeImmutable $since): string {
+		$ids = $this->createNotSeenSinceQueryBuilder($since)
+			->select('b.id')
+			->getQuery()
+			->getSingleColumnResult();
+
+		return hash('sha256', implode(',', $ids));
+	}
+
+	/**
+	 * Everything the offline inventory page needs, as plain arrays: hydrating thousands of books
+	 * with their labels would be far slower.
+	 *
+	 * @return list<array{id: int, title: string, author: string, placement: ?string, lastSeen: ?string, borrowed: bool, isbn: list<string>, issn: list<string>}>
+	 */
+	public function findInventoryData(): array {
+		$entityManager = $this->getEntityManager();
+
+		$numbers = [];
+		$labels = $entityManager->createQuery('SELECT IDENTITY(l.book) AS book, l.type, l.label FROM App\Entity\Label l WHERE l.type IN (:types) ORDER BY l.label')
+			->setParameter('types', [LabelType::ISBN->value, LabelType::ISSN->value])
+			->getArrayResult();
+		foreach ($labels as $label) {
+			$numbers[(int)$label['book']][$label['type']->value][] = $label['label'];
+		}
+
+		$rows = $this->createQueryBuilder('b')
+			->select('b.id, b.title, b.author, b.placement, b.lastSeen, IDENTITY(br.book) AS borrowedBook')
+			->leftJoin('b.borrow', 'br')
+			->orderBy('b.id', 'ASC')
+			->getQuery()
+			->getArrayResult();
+
+		return array_map(static fn(array $row): array => [
+			'id' => $row['id'],
+			'title' => $row['title'],
+			'author' => $row['author'],
+			'placement' => $row['placement'],
+			'lastSeen' => $row['lastSeen']?->format(\DateTimeInterface::ATOM),
+			'borrowed' => $row['borrowedBook'] !== null,
+			'isbn' => $numbers[$row['id']][LabelType::ISBN->value] ?? [],
+			'issn' => $numbers[$row['id']][LabelType::ISSN->value] ?? [],
+		], $rows);
+	}
+
+	private static function containsPattern(string $value): string {
+		return '%' . strtr(trim($value), ['!' => '!!', '%' => '!%', '_' => '!_']) . '%';
+	}
+}
diff --git a/src/Repository/BorrowHistoryRepository.php b/src/Repository/BorrowHistoryRepository.php
new file mode 100644
index 0000000..2923afd
--- /dev/null
+++ b/src/Repository/BorrowHistoryRepository.php
@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\BorrowHistory;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<BorrowHistory>
+ */
+class BorrowHistoryRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, BorrowHistory::class);
+	}
+}
diff --git a/src/Repository/BorrowRepository.php b/src/Repository/BorrowRepository.php
new file mode 100644
index 0000000..19e9c36
--- /dev/null
+++ b/src/Repository/BorrowRepository.php
@@ -0,0 +1,25 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\Borrow;
+use Doctrine\ORM\QueryBuilder;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<Borrow>
+ */
+class BorrowRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, Borrow::class);
+	}
+
+	public function createListQueryBuilder(): QueryBuilder {
+		return $this->createQueryBuilder('br')
+			->addSelect('b')
+			->join('br.book', 'b')
+			->orderBy('br.at', 'ASC');
+	}
+}
diff --git a/src/Repository/LabelRepository.php b/src/Repository/LabelRepository.php
new file mode 100644
index 0000000..3960481
--- /dev/null
+++ b/src/Repository/LabelRepository.php
@@ -0,0 +1,33 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\Label;
+use App\Enum\LabelType;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<Label>
+ */
+class LabelRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, Label::class);
+	}
+
+	/**
+	 * @return list<array{label: string, books: int}>
+	 */
+	public function findUsage(): array {
+		/** @var list<array{label: string, books: int}> */
+		return $this->createQueryBuilder('l')
+			->select('l.label AS label', 'COUNT(l.id) AS books')
+			->where('l.type = :type')
+			->setParameter('type', LabelType::TEXT->value)
+			->groupBy('l.label')
+			->orderBy('l.label', 'ASC')
+			->getQuery()
+			->getResult();
+	}
+}
diff --git a/src/Repository/OustedBookRepository.php b/src/Repository/OustedBookRepository.php
new file mode 100644
index 0000000..f1d684e
--- /dev/null
+++ b/src/Repository/OustedBookRepository.php
@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\OustedBook;
+use Doctrine\ORM\QueryBuilder;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<OustedBook>
+ */
+class OustedBookRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, OustedBook::class);
+	}
+
+	public function createListQueryBuilder(): QueryBuilder {
+		return $this->createQueryBuilder('b')->orderBy('b.id', 'ASC');
+	}
+}
diff --git a/src/Repository/OustedBorrowHistoryRepository.php b/src/Repository/OustedBorrowHistoryRepository.php
new file mode 100644
index 0000000..5014a18
--- /dev/null
+++ b/src/Repository/OustedBorrowHistoryRepository.php
@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\OustedBorrowHistory;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<OustedBorrowHistory>
+ */
+class OustedBorrowHistoryRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, OustedBorrowHistory::class);
+	}
+}
diff --git a/src/Repository/OustedLabelRepository.php b/src/Repository/OustedLabelRepository.php
new file mode 100644
index 0000000..161b7d5
--- /dev/null
+++ b/src/Repository/OustedLabelRepository.php
@@ -0,0 +1,17 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository;
+
+use App\Entity\OustedLabel;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @extends ServiceEntityRepository<OustedLabel>
+ */
+class OustedLabelRepository extends ServiceEntityRepository {
+	public function __construct(ManagerRegistry $registry) {
+		parent::__construct($registry, OustedLabel::class);
+	}
+}
diff --git a/src/Service/CoverProcessingException.php b/src/Service/CoverProcessingException.php
new file mode 100644
index 0000000..4aa4695
--- /dev/null
+++ b/src/Service/CoverProcessingException.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+final class CoverProcessingException extends \RuntimeException {
+}
diff --git a/src/Service/CoverStorage.php b/src/Service/CoverStorage.php
new file mode 100644
index 0000000..a0e8de3
--- /dev/null
+++ b/src/Service/CoverStorage.php
@@ -0,0 +1,76 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Filesystem\Filesystem;
+
+class CoverStorage {
+	public const array EXTENSIONS = [
+		'image/jpeg' => 'jpg',
+		'image/png' => 'png',
+		'image/webp' => 'webp',
+	];
+
+	public function __construct(
+		#[Autowire('%app.cover_originals_dir%')]
+		private readonly string           $originalsDir,
+		#[Autowire('%app.cover_thumbnails_dir%')]
+		private readonly string           $thumbnailsDir,
+		private readonly CoverThumbnailer $thumbnailer,
+		private readonly Filesystem       $filesystem,
+	) {
+	}
+
+	/**
+	 * @param string $contents the image, kept byte for byte as the original
+	 *
+	 * @return string the name of the stored cover
+	 *
+	 * @throws CoverProcessingException
+	 */
+	public function store(string $contents): string {
+		// The extension comes from the detected content, never from the client's file name.
+		$extension = self::EXTENSIONS[@getimagesizefromstring($contents)['mime'] ?? ''] ?? null;
+
+		if ($extension === null) {
+			throw new CoverProcessingException('Unsupported image type.');
+		}
+
+		$name = bin2hex(random_bytes(16)) . '.' . $extension;
+		$this->filesystem->mkdir([$this->originalsDir, $this->thumbnailsDir]);
+
+		try {
+			$this->thumbnailer->createThumbnails(
+				$contents,
+				fn(string $size): string => $this->thumbnailPath($name, $size),
+			);
+			$this->filesystem->dumpFile($this->originalPath($name), $contents);
+		} catch (\Throwable $exception) {
+			$this->delete($name);
+
+			throw $exception;
+		}
+
+		return $name;
+	}
+
+	public function delete(string $name): void {
+		$this->filesystem->remove([
+			$this->originalPath($name),
+			...array_map(
+				fn(string $size): string => $this->thumbnailPath($name, $size),
+				array_keys(CoverThumbnailer::SIZES),
+			),
+		]);
+	}
+
+	public function originalPath(string $name): string {
+		return $this->originalsDir . '/' . $name;
+	}
+
+	public function thumbnailPath(string $name, string $size): string {
+		return $this->thumbnailsDir . '/' . $name . '.' . $size . '.jpg';
+	}
+}
diff --git a/src/Service/CoverThumbnailer.php b/src/Service/CoverThumbnailer.php
new file mode 100644
index 0000000..8a0f10d
--- /dev/null
+++ b/src/Service/CoverThumbnailer.php
@@ -0,0 +1,82 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+/**
+ * Scales covers down to JPEG thumbnails. Smaller images are never enlarged.
+ */
+class CoverThumbnailer {
+	private const int JPEG_QUALITY = 85;
+
+	/**
+	 * Sizes are twice the display size, for high-density screens.
+	 *
+	 * @var array<string, array{int, int}> maximum width and height by size name
+	 */
+	public const array SIZES = [
+		'list' => [150, 225],
+		'details' => [600, 900],
+	];
+
+	/**
+	 * @param string $data the image file's contents
+	 * @param callable(string): string $targetPath gives the file for a size name
+	 *
+	 * @throws CoverProcessingException
+	 */
+	public function createThumbnails(string $data, callable $targetPath): void {
+		$image = $data === '' ? false : @imagecreatefromstring($data);
+
+		if ($image === false) {
+			throw new CoverProcessingException('The file could not be read as an image.');
+		}
+
+		$image = self::applyExifOrientation($image, $data);
+
+		foreach (self::SIZES as $name => [$maxWidth, $maxHeight]) {
+			if (!imagejpeg(self::scale($image, $maxWidth, $maxHeight), $targetPath($name), self::JPEG_QUALITY)) {
+				throw new CoverProcessingException('The thumbnail could not be written.');
+			}
+		}
+	}
+
+	private static function scale(\GdImage $image, int $maxWidth, int $maxHeight): \GdImage {
+		$width = imagesx($image);
+		$height = imagesy($image);
+		$ratio = min(1, $maxWidth / $width, $maxHeight / $height);
+		$targetWidth = max(1, (int)round($width * $ratio));
+		$targetHeight = max(1, (int)round($height * $ratio));
+
+		// JPEG has no transparency, so transparent PNG and WebP covers go on a white background.
+		$thumbnail = imagecreatetruecolor($targetWidth, $targetHeight);
+		imagefill($thumbnail, 0, 0, (int)imagecolorallocate($thumbnail, 255, 255, 255));
+		imagecopyresampled($thumbnail, $image, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
+
+		return $thumbnail;
+	}
+
+	/**
+	 * Phones store the rotation of a photo as metadata, which thumbnails would otherwise lose.
+	 */
+	private static function applyExifOrientation(\GdImage $image, string $data): \GdImage {
+		if (\IMAGETYPE_JPEG !== (@getimagesizefromstring($data)[2] ?? null)) {
+			return $image;
+		}
+
+		$orientation = @exif_read_data('data://image/jpeg;base64,' . base64_encode($data))['Orientation'] ?? 1;
+
+		if (\in_array($orientation, [2, 4, 5, 7], true)) {
+			imageflip($image, \IMG_FLIP_HORIZONTAL);
+		}
+
+		$angle = match ($orientation) {
+			3, 4 => 180,
+			5, 8 => 90,
+			6, 7 => 270,
+			default => 0,
+		};
+
+		return $angle === 0 ? $image : imagerotate($image, $angle, 0);
+	}
+}
diff --git a/src/Service/Library.php b/src/Service/Library.php
new file mode 100644
index 0000000..7f4c9f2
--- /dev/null
+++ b/src/Service/Library.php
@@ -0,0 +1,132 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+/**
+ * Operations that change where a book is: lent out, back on the shelf, out of the collection.
+ */
+class Library {
+	public function __construct(
+		private readonly EntityManagerInterface $entityManager,
+		private readonly CoverStorage           $covers,
+		private readonly LibraryThingCovers     $libraryThing,
+	) {
+	}
+
+	/**
+	 * Gives a book without a cover the one LibraryThing has for its ISBN, if any.
+	 *
+	 * @return bool whether a cover was added
+	 */
+	public function downloadCover(Book $book): bool {
+		$isbn = $book->getIsbn();
+
+		if ($book->getCover() !== null || $isbn === null || !$this->libraryThing->isEnabled()) {
+			return false;
+		}
+
+		$data = $this->libraryThing->download($isbn);
+
+		if ($data === null) {
+			return false;
+		}
+
+		try {
+			$book->setCover($this->covers->store($data));
+		} catch (CoverProcessingException) {
+			// LibraryThing may send a format that cannot be stored, such as GIF.
+			return false;
+		}
+
+		$this->entityManager->flush();
+
+		return true;
+	}
+
+	/**
+	 * Stores a new cover and drops the previous one; null only removes it.
+	 *
+	 * @throws CoverProcessingException
+	 */
+	public function changeCover(Book $book, ?UploadedFile $file): void {
+		$previous = $book->getCover();
+		$book->setCover($file ? $this->covers->store($file->getContent()) : null);
+		$this->entityManager->flush();
+
+		if ($previous !== null) {
+			$this->covers->delete($previous);
+		}
+	}
+
+	public function lend(Book $book, string $who): void {
+		$book->lendTo($who, new \DateTimeImmutable());
+		$this->entityManager->flush();
+	}
+
+	public function giveBack(Book $book): void {
+		$book->giveBack(new \DateTimeImmutable());
+		$this->entityManager->flush();
+	}
+
+	/**
+	 * Moves a book to the ousted collection with its labels and loan history.
+	 */
+	public function oust(Book $book, string $reason): OustedBook {
+		if ($book->isBorrowed()) {
+			throw new \LogicException(\sprintf('Book %d must be returned before it is ousted.', $book->getId()));
+		}
+
+		$ousted = OustedBook::fromBook($book, $reason);
+		foreach ($book->getBorrowHistory() as $entry) {
+			$ousted->addBorrowHistory($entry->getWho(), $entry->getAt(), $entry->getTill());
+		}
+
+		$this->entityManager->wrapInTransaction(function () use ($book, $ousted): void {
+			$this->entityManager->remove($book);
+			$this->entityManager->flush();
+			$this->entityManager->persist($ousted);
+		});
+
+		return $ousted;
+	}
+
+	/**
+	 * Ousts all the given books, or none of them if one fails.
+	 *
+	 * @param iterable<Book> $books
+	 * @return int how many books were ousted
+	 */
+	public function oustAll(iterable $books, string $reason): int {
+		return $this->entityManager->wrapInTransaction(function () use ($books, $reason): int {
+			$count = 0;
+			foreach ($books as $book) {
+				$this->oust($book, $reason);
+				$count++;
+			}
+			$this->entityManager->flush();
+
+			return $count;
+		});
+	}
+
+	public function restore(OustedBook $ousted): Book {
+		$book = Book::fromOusted($ousted);
+		foreach ($ousted->getBorrowHistory() as $entry) {
+			$book->addBorrowHistory($entry->getWho(), $entry->getAt(), $entry->getTill());
+		}
+
+		$this->entityManager->wrapInTransaction(function () use ($book, $ousted): void {
+			$this->entityManager->remove($ousted);
+			$this->entityManager->flush();
+			$this->entityManager->persist($book);
+		});
+
+		return $book;
+	}
+}
diff --git a/src/Service/LibraryThingCovers.php b/src/Service/LibraryThingCovers.php
new file mode 100644
index 0000000..674a2c9
--- /dev/null
+++ b/src/Service/LibraryThingCovers.php
@@ -0,0 +1,70 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service;
+
+use Psr\Log\LoggerInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\DependencyInjection\Attribute\Target;
+use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * Downloads covers from LibraryThing (https://www.librarything.com/services/covers.php).
+ */
+class LibraryThingCovers {
+	private const int MAX_BYTES = 10 * 1024 * 1024;
+
+	public function __construct(
+		#[Target('librarything.client')]
+		private readonly HttpClientInterface $libraryThingClient,
+		private readonly LoggerInterface     $logger,
+		#[Autowire('%app.librarything_api_key%')]
+		private readonly ?string             $apiKey,
+	) {
+	}
+
+	public function isEnabled(): bool {
+		return trim((string)$this->apiKey) !== '';
+	}
+
+	/**
+	 * @return string|null the image data, or null when there is no cover or it could not be fetched
+	 */
+	public function download(string $isbn): ?string {
+		if (!$this->isEnabled() || preg_match('/^[\dX]{10,13}$/', $isbn) !== 1) {
+			return null;
+		}
+
+		try {
+			$response = $this->libraryThingClient->request(
+				'GET',
+				\sprintf('/devkey/%s/large/isbn/%s', rawurlencode(trim((string)$this->apiKey)), $isbn),
+			);
+
+			if ($response->getStatusCode() !== 200) {
+				$this->logger->warning('LibraryThing returned HTTP {status} for ISBN {isbn}.', [
+					'status' => $response->getStatusCode(),
+					'isbn' => $isbn,
+				]);
+
+				return null;
+			}
+
+			$data = $response->getContent();
+		} catch (ExceptionInterface $exception) {
+			// The message would contain the URL, and with it the key.
+			$this->logger->warning('The LibraryThing cover for ISBN {isbn} could not be downloaded.', ['isbn' => $isbn]);
+
+			return null;
+		}
+
+		if (\strlen($data) > self::MAX_BYTES) {
+			return null;
+		}
+
+		$size = @getimagesizefromstring($data);
+
+		return $size === false || $size[0] <= 1 || $size[1] <= 1 ? null : $data;
+	}
+}
diff --git a/src/Validator/AvailableBookId.php b/src/Validator/AvailableBookId.php
new file mode 100644
index 0000000..c27aa0e
--- /dev/null
+++ b/src/Validator/AvailableBookId.php
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Validator;
+
+use Symfony\Component\Validator\Attribute\HasNamedArguments;
+use Symfony\Component\Validator\Constraint;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+class AvailableBookId extends Constraint {
+	public string $message = 'book_id_taken';
+
+	/**
+	 * @param list<string>|null $groups
+	 */
+	#[HasNamedArguments]
+	public function __construct(?array $groups = null, mixed $payload = null) {
+		parent::__construct(null, $groups, $payload);
+	}
+}
diff --git a/src/Validator/AvailableBookIdValidator.php b/src/Validator/AvailableBookIdValidator.php
new file mode 100644
index 0000000..2d41c93
--- /dev/null
+++ b/src/Validator/AvailableBookIdValidator.php
@@ -0,0 +1,32 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Validator;
+
+use App\Repository\BookRepository;
+use App\Repository\OustedBookRepository;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+
+class AvailableBookIdValidator extends ConstraintValidator {
+	public function __construct(
+		private readonly BookRepository       $books,
+		private readonly OustedBookRepository $oustedBooks,
+	) {
+	}
+
+	public function validate(mixed $value, Constraint $constraint): void {
+		if (!$constraint instanceof AvailableBookId) {
+			throw new UnexpectedTypeException($constraint, AvailableBookId::class);
+		}
+
+		if ($value === null) {
+			return;
+		}
+
+		if ($this->books->find($value) || $this->oustedBooks->find($value)) {
+			$this->context->buildViolation($constraint->message)->setTranslationDomain('messages')->addViolation();
+		}
+	}
+}
diff --git a/sync.php b/sync.php
deleted file mode 100644
index 4cfb87a..0000000
--- a/sync.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-ini_set('zlib.output_compression', TRUE);
-
-include('./includes/std.php');
-
-$query = sql::query('SELECT * FROM ksiazki');
-
-$row = sql::fetchonea($query);
-
-echo implode("\0", array_keys($row))."\n";
-
-do {
-	echo implode("\0", $row)."\n";
-	
-	$row = sql::fetchonea($query);
-} while($row !== FALSE);
-?>
diff --git a/templates/base.html.twig b/templates/base.html.twig
new file mode 100644
index 0000000..9bb682d
--- /dev/null
+++ b/templates/base.html.twig
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="{{ app.locale }}">
+<head>
+	<title>{% trans %}general_app_name{% endtrans %}
+		- {% block title %}{% trans %}general_app_slogan{% endtrans %}{% endblock %}</title>
+	<meta charset="utf-8"/>
+	<meta name="viewport" content="width=device-width, initial-scale=1"/>
+	<link rel="stylesheet" href="{{ asset('css/spectre/spectre.min.css') }}"/>
+	<link rel="stylesheet" href="{{ asset('css/spectre/spectre-icons.min.css') }}"/>
+	<link rel="stylesheet" href="{{ asset('css/custom.css') }}"/>
+</head>
+<body>
+{% set route = app.current_route %}
+<header class="navbar">
+	<section class="navbar-section">
+		<a href="{{ path('home') }}" class="navbar-brand mr-2">{% trans %}general_app_name{% endtrans %}</a>
+	</section>
+	<section class="navbar-section">
+		<form action="{{ path('search') }}" method="get" class="input-group input-inline">
+			<input class="form-input" type="text" name="q" value="{{ app.request.query.get('q') }}"
+				   placeholder="{% trans %}general_search_placeholder{% endtrans %}">
+			<button class="btn btn-primary input-group-btn">{% trans %}general_search_button{% endtrans %}</button>
+		</form>
+	</section>
+</header>
+
+<ul class="tab tab-block">
+	<li class="tab-item{% if route in ['home', 'book_list', 'book_show', 'book_edit'] %} active{% endif %}">
+		<a href="{{ path('book_list') }}">{% trans %}menu_list{% endtrans %}</a>
+	</li>
+	<li class="tab-item{% if route == 'search' %} active{% endif %}">
+		<a href="{{ path('search') }}">{% trans %}menu_search{% endtrans %}</a>
+	</li>
+	<li class="tab-item{% if route in ['book_add', 'book_new', 'book_lookup'] %} active{% endif %}">
+		<a href="{{ path('book_add') }}">{% trans %}menu_add{% endtrans %}</a>
+	</li>
+	<li class="tab-item{% if route == 'borrow_list' %} active{% endif %}">
+		<a href="{{ path('borrow_list') }}">{% trans %}menu_borrowed{% endtrans %}</a>
+	</li>
+	<li class="tab-item{% if route in ['ousted_list', 'ousted_show', 'ousted_bulk'] %} active{% endif %}">
+		<a href="{{ path('ousted_list') }}">{% trans %}menu_ousted{% endtrans %}</a>
+	</li>
+	<li class="tab-item">
+		<a href="{{ path('inventory') }}">{% trans %}menu_inventory{% endtrans %}</a>
+	</li>
+</ul>
+
+{% for type, messages in app.flashes %}
+	{% for message in messages %}
+		<div class="toast toast-{{ type }} flash">{{ message|trans }}</div>
+	{% endfor %}
+{% endfor %}
+
+{% block content %}{% endblock %}
+
+</body>
+</html>
diff --git a/templates/book/_cover.html.twig b/templates/book/_cover.html.twig
new file mode 100644
index 0000000..a28a858
--- /dev/null
+++ b/templates/book/_cover.html.twig
@@ -0,0 +1,3 @@
+<a href="{{ path('cover_original', {name: book.cover}) }}" title="{% trans %}cover_show_original{% endtrans %}">
+	<img class="book-cover" src="{{ asset('covers/' ~ book.cover ~ '.details.jpg') }}" alt="{{ book.title }}"/>
+</a>
diff --git a/templates/book/_details.html.twig b/templates/book/_details.html.twig
new file mode 100644
index 0000000..e2210cb
--- /dev/null
+++ b/templates/book/_details.html.twig
@@ -0,0 +1,42 @@
+<div class="panel book-panel">
+	<div class="panel-header">
+		<div class="panel-title h5">{% trans %}book_details_title{% endtrans %}</div>
+	</div>
+	<div class="panel-body">
+		<table class="table">
+			<tr>
+				<th>{% trans %}book_id_header{% endtrans %}</th>
+				<td>{{ book.id }}</td>
+			</tr>
+			<tr>
+				<th>{% trans %}book_title_header{% endtrans %}</th>
+				<td>{{ book.title }}</td>
+			</tr>
+			<tr>
+				<th>{% trans %}book_author_header{% endtrans %}</th>
+				<td>{{ book.author }}</td>
+			</tr>
+			{% for key, value in {
+				book_language_header: book.language,
+				book_edition_header: book.edition,
+				book_publisher_header: book.publisher,
+				book_publication_loc_header: book.publicationLoc,
+				book_publication_year_header: book.publicationYear,
+				book_isbn_header: book.isbn,
+				book_issn_header: book.issn,
+				book_placement_header: book.placement,
+			} %}
+				{% if value is not null %}
+					<tr>
+					<th>{{ key|trans }}</th>
+					<td>{{ value }}</td></tr>{% endif %}
+			{% endfor %}
+			<tr class="book-last-seen">
+				<th>{% trans %}book_last_seen_header{% endtrans %}</th>
+				<td>{% if book.lastSeen %}{{ book.lastSeen|date('Y-m-d H:i') }}{% else %}<span
+							class="text-gray">{% trans %}book_last_seen_never{% endtrans %}</span>{% endif %}</td>
+			</tr>
+		</table>
+	</div>
+	{% block footer %}{% endblock %}
+</div>
diff --git a/templates/book/_history.html.twig b/templates/book/_history.html.twig
new file mode 100644
index 0000000..424cc9c
--- /dev/null
+++ b/templates/book/_history.html.twig
@@ -0,0 +1,29 @@
+<div class="panel book-panel">
+	<div class="panel-header">
+		<div class="panel-title h5">{% trans %}borrow_history_title{% endtrans %}</div>
+	</div>
+	<div class="panel-body">
+		{% if history is empty %}
+			<p class="text-gray">{% trans %}borrow_history_empty{% endtrans %}</p>
+		{% else %}
+			<table class="table">
+				<thead>
+				<tr>
+					<th>{% trans %}borrow_who_header{% endtrans %}</th>
+					<th>{% trans %}borrow_at_header{% endtrans %}</th>
+					<th>{% trans %}borrow_till_header{% endtrans %}</th>
+				</tr>
+				</thead>
+				<tbody>
+				{% for entry in history %}
+					<tr>
+						<td>{{ entry.who }}</td>
+						<td>{{ entry.at|date('Y-m-d') }}</td>
+						<td>{{ entry.till|date('Y-m-d') }}</td>
+					</tr>
+				{% endfor %}
+				</tbody>
+			</table>
+		{% endif %}
+	</div>
+</div>
diff --git a/templates/book/_results.html.twig b/templates/book/_results.html.twig
new file mode 100644
index 0000000..8c0a9d9
--- /dev/null
+++ b/templates/book/_results.html.twig
@@ -0,0 +1,7 @@
+<div class="results">
+	{% for book in pagination %}
+		{{ include('book/_tile.html.twig') }}
+	{% endfor %}
+
+	{{ knp_pagination_render(pagination) }}
+</div>
diff --git a/templates/book/_tile.html.twig b/templates/book/_tile.html.twig
new file mode 100644
index 0000000..520af7f
--- /dev/null
+++ b/templates/book/_tile.html.twig
@@ -0,0 +1,35 @@
+{% set show_url = path(show_path|default('book_show'), {id: book.id}) %}
+<div class="tile{% if book.borrowed|default(false) %} tile-borrowed{% endif %}">
+	<a class="tile-icon" href="{{ show_url }}" tabindex="-1" aria-hidden="true">
+		{% if book.cover %}
+			<img class="tile-cover" src="{{ asset('covers/' ~ book.cover ~ '.list.jpg') }}" alt="" loading="lazy"/>
+		{% else %}
+			<i class="icon icon-photo centered"></i>
+		{% endif %}
+	</a>
+	<div class="tile-content">
+		<div class="tile-title"><a href="{{ show_url }}"><strong>{{ book.title }}</strong></a> · {{ book.author }}</div>
+		<div class="tile-subtitle">{% if book.edition %}{% trans %}book_list_edition_pre{% endtrans %} {{ book.edition }} {% trans %}book_list_edition_post{% endtrans %} · {% endif %}
+			<span class="text-gray">{% if book.publisher %}{{ book.publisher }} · {% endif %}{{ book.publicationLoc }}{% if book.publicationYear %} {{ book.publicationYear }}{% endif %}</span>
+		</div>
+		<div class="tile-subtitle">
+			<span class="label label-rounded label-primary">{{ book.id }}</span>
+			{% if book.placement %}<span
+					class="label label-rounded label-secondary">{{ book.placement }}</span>{% endif %}
+			{% block status %}
+				{% if book.borrowed %}<span
+						class="label label-rounded label-warning">{% trans %}borrow_status_lent{% endtrans %}</span>{% endif %}
+			{% endblock %}
+			{% for label in book.textLabels %}<span class="label label-rounded">{{ label.label }}</span> {% endfor %}
+		</div>
+		{% block details %}{% endblock %}
+	</div>
+	<div class="tile-action">
+		{% block actions %}
+			<a class="btn btn-link"
+			   href="{{ path('book_edit', {id: book.id}) }}">{% trans %}book_action_edit{% endtrans %}</a>
+			<br/>
+			<a class="btn btn-link" href="{{ show_url }}">{% trans %}book_action_more{% endtrans %}</a>
+		{% endblock %}
+	</div>
+</div>
diff --git a/templates/book/add.html.twig b/templates/book/add.html.twig
new file mode 100644
index 0000000..016ef81
--- /dev/null
+++ b/templates/book/add.html.twig
@@ -0,0 +1,100 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}add_page_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="add page_content">
+		<h2>{% trans %}add_page_title{% endtrans %}</h2>
+
+		<p>{% trans %}add_page_description{% endtrans %}</p>
+
+		<div class="columns">
+			<div class="column col-4 col-md-12">
+				<div class="card">
+					<div class="card-header">
+						<div class="card-title h5">{% trans %}add_section_isn_title{% endtrans %}</div>
+						<div class="card-subtitle text-gray">{% trans %}add_section_isn_desc{% endtrans %}</div>
+					</div>
+					<div class="card-body">
+						<form class="form-horizontal" action="{{ path('book_lookup') }}" method="get">
+							<div class="form-group">
+								<div class="col-12">
+									<label class="form-label"
+										   for="isn-id">{% trans %}book_id_header{% endtrans %}</label>
+								</div>
+								<div class="col-12">
+									<input class="form-input" type="text" id="isn-id" name="id"
+										   placeholder="{% trans %}book_id_header{% endtrans %}"/>
+								</div>
+							</div>
+							<div class="form-group">
+								<div class="col-12">
+									<label class="form-label"
+										   for="isn-isn">{% trans %}book_isn_header{% endtrans %}</label>
+								</div>
+								<div class="col-12">
+									<input class="form-input" type="text" id="isn-isn" name="isn" required
+										   placeholder="{% trans %}book_isn_description{% endtrans %}"/>
+								</div>
+							</div>
+							<div class="form-group">
+								<button class="btn btn-primary">{% trans %}add_action_add_isn{% endtrans %}</button>
+							</div>
+						</form>
+					</div>
+				</div>
+			</div>
+			<div class="column col-4 col-md-12">
+				<div class="card">
+					<div class="card-header">
+						<div class="card-title h5">{% trans %}add_section_search_title{% endtrans %}</div>
+						<div class="card-subtitle text-gray">{% trans %}add_section_search_desc{% endtrans %}</div>
+					</div>
+					<div class="card-body">
+						<form class="form-horizontal" action="{{ path('book_lookup') }}" method="get">
+							{% for name, label in {id: 'book_id_header', title: 'book_title_header', author: 'book_author_header', publisher: 'book_publisher_header'} %}
+								<div class="form-group">
+									<div class="col-12">
+										<label class="form-label" for="search-{{ name }}">{{ label|trans }}</label>
+									</div>
+									<div class="col-12">
+										<input class="form-input" type="text" id="search-{{ name }}" name="{{ name }}"
+											   placeholder="{{ label|trans }}"/>
+									</div>
+								</div>
+							{% endfor %}
+							<div class="form-group">
+								<button class="btn btn-primary">{% trans %}add_action_add_search{% endtrans %}</button>
+							</div>
+						</form>
+					</div>
+				</div>
+			</div>
+			<div class="column col-4 col-md-12">
+				<div class="card">
+					<div class="card-header">
+						<div class="card-title h5">{% trans %}add_section_manual_title{% endtrans %}</div>
+						<div class="card-subtitle text-gray">{% trans %}add_section_manual_desc{% endtrans %}</div>
+					</div>
+					<div class="card-body">
+						<form class="form-horizontal" action="{{ path('book_new') }}" method="get">
+							<div class="form-group">
+								<div class="col-12">
+									<label class="form-label"
+										   for="manual-id">{% trans %}book_id_header{% endtrans %}</label>
+								</div>
+								<div class="col-12">
+									<input class="form-input" type="text" id="manual-id" name="id"
+										   placeholder="{% trans %}book_id_header{% endtrans %}"/>
+								</div>
+							</div>
+							<div class="form-group">
+								<button class="btn btn-primary">{% trans %}add_action_add_manual{% endtrans %}</button>
+							</div>
+						</form>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+{% endblock %}
diff --git a/templates/book/edit.html.twig b/templates/book/edit.html.twig
new file mode 100644
index 0000000..eb54998
--- /dev/null
+++ b/templates/book/edit.html.twig
@@ -0,0 +1,13 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}book_action_edit{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="add page_content">
+		<h2>{% trans %}book_action_edit{% endtrans %}</h2>
+
+		<p>{% trans %}add_form_description{% endtrans %}</p>
+
+		{{ form(form) }}
+	</div>
+{% endblock %}
diff --git a/templates/book/list.html.twig b/templates/book/list.html.twig
new file mode 100644
index 0000000..b387038
--- /dev/null
+++ b/templates/book/list.html.twig
@@ -0,0 +1,27 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}list_page_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	{% if pagination.getTotalItemCount() == 0 %}
+		<div class="results">
+			<div class="empty">
+				<div class="empty-icon">
+					<i class="icon icon-bookmark icon-2x"></i>
+				</div>
+				<p class="empty-title h5">{% trans %}list_no_books{% endtrans %}</p>
+				<p class="empty-subtitle">{% trans %}list_no_books_add{% endtrans %}</p>
+				<div class="empty-action">
+					<a class="btn btn-primary"
+					   href="{{ path('book_add') }}">{% trans %}list_no_books_add_button{% endtrans %}</a>
+				</div>
+			</div>
+		</div>
+	{% else %}
+		<div class="page_content">
+			<h2>{% trans %}list_page_title{% endtrans %}</h2>
+		</div>
+
+		{{ include('book/_results.html.twig') }}
+	{% endif %}
+{% endblock %}
diff --git a/templates/book/lookup.html.twig b/templates/book/lookup.html.twig
new file mode 100644
index 0000000..8041099
--- /dev/null
+++ b/templates/book/lookup.html.twig
@@ -0,0 +1,49 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}lookup_page_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{% trans %}lookup_page_title{% endtrans %}</h2>
+
+		{% if candidates is empty %}
+			<p>{% trans %}lookup_no_results{% endtrans %}</p>
+			<a class="btn btn-primary"
+			   href="{{ path('book_new', {id: book_id}) }}">{% trans %}add_action_add_manual{% endtrans %}</a>
+		{% else %}
+			<p>{% trans %}lookup_pick{% endtrans %}</p>
+			<table class="table">
+				<thead>
+				<tr>
+					<th>{% trans %}book_title_header{% endtrans %}</th>
+					<th>{% trans %}book_author_header{% endtrans %}</th>
+					<th>{% trans %}book_publisher_header{% endtrans %}</th>
+					<th>{% trans %}book_publication_year_header{% endtrans %}</th>
+					<th></th>
+				</tr>
+				</thead>
+				<tbody>
+				{% for candidate in candidates %}
+					<tr>
+						<td>{{ candidate.title }}</td>
+						<td>{{ candidate.author }}</td>
+						<td>{{ candidate.publisher }}</td>
+						<td>{{ candidate.publicationYear }}</td>
+						<td><a class="btn btn-sm" href="{{ path('book_new', {
+								id: book_id,
+								title: candidate.title,
+								author: candidate.author,
+								language: candidate.language,
+								publisher: candidate.publisher,
+								publicationLoc: candidate.publicationLoc,
+								publicationYear: candidate.publicationYear,
+								isbn: candidate.isbn,
+								issn: candidate.issn,
+							}) }}">{% trans %}lookup_use{% endtrans %}</a></td>
+					</tr>
+				{% endfor %}
+				</tbody>
+			</table>
+		{% endif %}
+	</div>
+{% endblock %}
diff --git a/templates/book/new.html.twig b/templates/book/new.html.twig
new file mode 100644
index 0000000..ddabc0b
--- /dev/null
+++ b/templates/book/new.html.twig
@@ -0,0 +1,13 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}add_form_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="add page_content">
+		<h2>{% trans %}add_form_title{% endtrans %}</h2>
+
+		<p>{% trans %}add_form_description{% endtrans %}</p>
+
+		{{ form(form) }}
+	</div>
+{% endblock %}
diff --git a/templates/book/show.html.twig b/templates/book/show.html.twig
new file mode 100644
index 0000000..95ad203
--- /dev/null
+++ b/templates/book/show.html.twig
@@ -0,0 +1,106 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{{ book.title }}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{{ book.title }}</h2>
+
+		<div class="columns">
+			<div class="column col-7 col-md-12">
+				{% embed 'book/_details.html.twig' %}
+					{% block footer %}
+						<div class="panel-footer">
+							<a class="btn"
+							   href="{{ path('book_edit', {id: book.id}) }}">{% trans %}book_action_edit{% endtrans %}</a>
+						</div>
+					{% endblock %}
+				{% endembed %}
+
+				{{ include('book/_history.html.twig', {history: book.borrowHistory}) }}
+			</div>
+			<div class="column col-5 col-md-12">
+				<div class="panel book-panel">
+					<div class="panel-header">
+						<div class="panel-title h5">{% trans %}cover_section_title{% endtrans %}</div>
+					</div>
+					{% if book.cover %}
+						<div class="panel-body">
+							{{ include('book/_cover.html.twig') }}
+							<form action="{{ path('cover_remove', {id: book.id}) }}" method="post">
+								<input type="hidden" name="_token" value="{{ csrf_token('remove-cover' ~ book.id) }}"/>
+								<button class="btn btn-link btn-sm">{% trans %}cover_action_remove{% endtrans %}</button>
+							</form>
+						</div>
+					{% endif %}
+					<div class="panel-footer">
+						{{ form(cover_form) }}
+					</div>
+				</div>
+
+				<div class="panel book-panel">
+					<div class="panel-header">
+						<div class="panel-title h5">{% trans %}borrow_section_title{% endtrans %}</div>
+					</div>
+					{% if book.borrow %}
+						<div class="panel-body">
+							<p>{{ 'borrow_lent_to'|trans({'%who%': book.borrow.who, '%date%': book.borrow.at|date('Y-m-d')}) }}</p>
+						</div>
+						<div class="panel-footer">
+							<form action="{{ path('book_return', {id: book.id}) }}" method="post">
+								<input type="hidden" name="_token" value="{{ csrf_token('return' ~ book.id) }}"/>
+								<button class="btn btn-primary">{% trans %}borrow_action_return{% endtrans %}</button>
+							</form>
+						</div>
+					{% else %}
+						<div class="panel-footer">
+							{{ form(borrow_form) }}
+						</div>
+					{% endif %}
+				</div>
+
+				<div class="panel book-panel">
+					<div class="panel-header">
+						<div class="panel-title h5">{% trans %}menu_labels{% endtrans %}</div>
+					</div>
+					{% if book.labels is not empty %}
+						<div class="panel-body">
+							<ul class="book-labels">
+								{% for label in book.labels %}
+									<li>
+										{% if label.type.value == 'text' %}
+											<span class="label label-rounded">{{ label.label }}</span>
+										{% else %}
+											<span class="label label-rounded">{{ ('label_type_' ~ label.type.value)|trans }}: {{ label.label }}</span>
+										{% endif %}
+										<form class="inline" action="{{ path('label_remove', {id: label.id}) }}"
+											  method="post">
+											<input type="hidden" name="_token"
+												   value="{{ csrf_token('remove-label' ~ label.id) }}"/>
+											<button class="btn btn-link btn-sm">{% trans %}label_action_remove{% endtrans %}</button>
+										</form>
+									</li>
+								{% endfor %}
+							</ul>
+						</div>
+					{% endif %}
+					<div class="panel-footer">
+						{{ form(label_form) }}
+					</div>
+				</div>
+
+				{% if not book.borrowed %}
+					<div class="panel book-panel">
+						<div class="panel-header">
+							<div class="panel-title h5">{% trans %}ousted_section_title{% endtrans %}</div>
+							<div class="panel-subtitle text-gray">{% trans %}ousted_section_desc{% endtrans %}</div>
+						</div>
+						<div class="panel-footer">
+							{{ form(oust_form) }}
+						</div>
+					</div>
+				{% endif %}
+			</div>
+		</div>
+	</div>
+{% endblock %}
diff --git a/templates/borrow/list.html.twig b/templates/borrow/list.html.twig
new file mode 100644
index 0000000..3817c2d
--- /dev/null
+++ b/templates/borrow/list.html.twig
@@ -0,0 +1,27 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}borrow_list_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{% trans %}borrow_list_title{% endtrans %}</h2>
+
+		{% if pagination.getTotalItemCount() == 0 %}
+			<p>{% trans %}borrow_list_empty{% endtrans %}</p>
+		{% endif %}
+	</div>
+
+	{% if pagination.getTotalItemCount() > 0 %}
+		<div class="results">
+			{% for borrow in pagination %}
+				{% embed 'book/_tile.html.twig' with {book: borrow.book} %}
+					{% block status %}
+						<span class="label label-rounded label-warning">{{ 'borrow_lent_to'|trans({'%who%': borrow.who, '%date%': borrow.at|date('Y-m-d')}) }}</span>
+					{% endblock %}
+				{% endembed %}
+			{% endfor %}
+
+			{{ knp_pagination_render(pagination) }}
+		</div>
+	{% endif %}
+{% endblock %}
diff --git a/templates/inventory/index.html.twig b/templates/inventory/index.html.twig
new file mode 100644
index 0000000..90e83ef
--- /dev/null
+++ b/templates/inventory/index.html.twig
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<html lang="{{ app.locale }}">
+<head>
+	<title>{% trans %}general_app_name{% endtrans %} - {% trans %}inventory_page_title{% endtrans %}</title>
+	<meta charset="utf-8"/>
+	<meta name="viewport" content="width=device-width, initial-scale=1"/>
+	<link rel="stylesheet" href="{{ asset('css/spectre/spectre.min.css') }}"/>
+	<link rel="stylesheet" href="{{ asset('css/inventory.css') }}"/>
+</head>
+<body>
+<header class="navbar">
+	<section class="navbar-section">
+		<a href="{{ path('home') }}" class="navbar-brand mr-2">{% trans %}general_app_name{% endtrans %}</a>
+		<span class="text-gray">{% trans %}inventory_page_title{% endtrans %}</span>
+	</section>
+	<section class="navbar-section">
+		<span id="connection" class="label label-rounded"></span>
+	</section>
+</header>
+
+<main id="inventory"
+	  data-books-url="{{ path('inventory_books') }}"
+	  data-sync-url="{{ path('inventory_sync') }}"
+	  data-sw-url="{{ path('inventory_service_worker') }}"
+	  data-scope="{{ path('inventory') }}"
+	  data-book-url="{{ path('book_show', {id: 0})|replace({'/0': '/{id}'}) }}"
+	  data-locale="{{ app.locale }}">
+
+	<form id="scan-form" class="scan-form" autocomplete="off">
+		<div class="input-group">
+			<input id="scan-input" class="form-input input-lg" type="text" inputmode="text" autofocus
+				   placeholder="{% trans %}inventory_input_placeholder{% endtrans %}"
+				   aria-label="{% trans %}inventory_input_placeholder{% endtrans %}"/>
+			<button class="btn btn-primary btn-lg input-group-btn">{% trans %}inventory_action_enter{% endtrans %}</button>
+		</div>
+		<p class="form-input-hint">{% trans %}inventory_input_hint{% endtrans %}</p>
+	</form>
+
+	<div class="current-location">
+		<span class="text-gray">{% trans %}inventory_current_location{% endtrans %}</span>
+		<strong id="location">–</strong>
+	</div>
+
+	<div id="message" class="toast message" hidden></div>
+
+	<div class="status columns">
+		<div class="column col-6 col-sm-12">
+			{% trans %}inventory_books_count{% endtrans %} <strong id="books-count">0</strong>
+			<small class="text-gray" id="books-date"></small>
+			<button id="refresh" type="button" class="btn btn-sm">{% trans %}inventory_action_refresh{% endtrans %}</button>
+		</div>
+		<div class="column col-6 col-sm-12">
+			{% trans %}inventory_pending_count{% endtrans %} <strong id="pending-count">0</strong>
+			<small class="text-gray" id="sync-date"></small>
+			<button id="sync" type="button" class="btn btn-sm btn-primary">{% trans %}inventory_action_sync{% endtrans %}</button>
+		</div>
+	</div>
+
+	<h5>{% trans %}inventory_log_title{% endtrans %}</h5>
+	<p id="log-empty" class="text-gray">{% trans %}inventory_log_empty{% endtrans %}</p>
+	<table class="table table-striped log">
+		<tbody id="log"></tbody>
+	</table>
+	<button id="clear-log" type="button" class="btn btn-link btn-sm" hidden>{% trans %}inventory_action_clear_log{% endtrans %}</button>
+</main>
+
+<script type="application/json" id="messages">{{ {
+	location_set: 'inventory_msg_location_set'|trans,
+	no_location: 'inventory_msg_no_location'|trans,
+	assigned: 'inventory_msg_assigned'|trans,
+	moved: 'inventory_msg_moved'|trans,
+	unknown_id: 'inventory_msg_unknown_id'|trans,
+	unknown_number: 'inventory_msg_unknown_number'|trans,
+	ambiguous_number: 'inventory_msg_ambiguous_number'|trans,
+	unrecognized: 'inventory_msg_unrecognized'|trans,
+	no_books: 'inventory_msg_no_books'|trans,
+	books_loaded: 'inventory_msg_books_loaded'|trans,
+	books_failed: 'inventory_msg_books_failed'|trans,
+	synced: 'inventory_msg_synced'|trans,
+	sync_failed: 'inventory_msg_sync_failed'|trans,
+	sync_rejected: 'inventory_msg_sync_rejected'|trans,
+	online: 'inventory_online'|trans,
+	offline: 'inventory_offline'|trans,
+	never: 'inventory_never'|trans,
+	status_pending: 'inventory_status_pending'|trans,
+	status_saved: 'inventory_status_saved'|trans,
+	status_returned: 'inventory_status_returned'|trans,
+	will_return: 'inventory_msg_will_return'|trans,
+	status_unknown_book: 'inventory_status_unknown_book'|trans,
+	status_invalid: 'inventory_status_invalid'|trans,
+	remove: 'inventory_action_remove'|trans,
+	confirm_clear: 'inventory_confirm_clear'|trans,
+}|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_HEX_TAG') b-or constant('JSON_UNESCAPED_UNICODE'))|raw }}</script>
+<script src="{{ asset('js/inventory.js') }}"></script>
+</body>
+</html>
diff --git a/templates/inventory/sw.js.twig b/templates/inventory/sw.js.twig
new file mode 100644
index 0000000..1cd86c8
--- /dev/null
+++ b/templates/inventory/sw.js.twig
@@ -0,0 +1,46 @@
+'use strict';
+
+const CACHE = 'inventory-v1';
+const SHELL = {{ [
+	path('inventory'),
+	asset('js/inventory.js'),
+	asset('css/inventory.css'),
+	asset('css/spectre/spectre.min.css'),
+]|json_encode(constant('JSON_UNESCAPED_SLASHES'))|raw }};
+const SHELL_URLS = new Set(SHELL.map((url) => new URL(url, self.location).href));
+
+self.addEventListener('install', (event) => {
+	event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
+});
+
+self.addEventListener('activate', (event) => {
+	event.waitUntil(
+		caches.keys()
+			.then((keys) => Promise.all(keys.filter((key) => key.startsWith('inventory-') && key !== CACHE).map((key) => caches.delete(key))))
+			.then(() => self.clients.claim()),
+	);
+});
+
+self.addEventListener('fetch', (event) => {
+	const request = event.request;
+	const url = new URL(request.url);
+	url.search = '';
+
+	// The book list and the uploads are handled by the page itself.
+	if (request.method !== 'GET' || !SHELL_URLS.has(url.href)) {
+		return;
+	}
+
+	event.respondWith(
+		fetch(request)
+			.then((response) => {
+				if (response.ok) {
+					const copy = response.clone();
+					caches.open(CACHE).then((cache) => cache.put(url.href, copy));
+				}
+
+				return response;
+			})
+			.catch(() => caches.match(url.href).then((cached) => cached || Response.error())),
+	);
+});
diff --git a/templates/ousted/bulk.html.twig b/templates/ousted/bulk.html.twig
new file mode 100644
index 0000000..45fdf91
--- /dev/null
+++ b/templates/ousted/bulk.html.twig
@@ -0,0 +1,57 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}bulk_oust_page_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{% trans %}bulk_oust_page_title{% endtrans %}</h2>
+		<p>{% trans %}bulk_oust_page_desc{% endtrans %}</p>
+
+		{{ form(form) }}
+	</div>
+
+	{% if pagination %}
+		<div class="page_content">
+			{% if pagination.getTotalItemCount() == 0 %}
+				<p class="toast toast-success">{{ 'bulk_oust_none'|trans({'%date%': since|date('Y-m-d')}) }}</p>
+			{% else %}
+				<div class="panel book-panel bulk-oust-confirm">
+					<div class="panel-header">
+						<div class="panel-title h5">{{ 'bulk_oust_found'|trans({'%count%': pagination.getTotalItemCount(), '%date%': since|date('Y-m-d')}) }}</div>
+						<div class="panel-subtitle">{{ 'bulk_oust_confirm_reason'|trans({'%reason%': confirm_form.reason.vars.value}) }}</div>
+						<div class="panel-subtitle text-gray">{% trans %}bulk_oust_confirm_desc{% endtrans %}</div>
+					</div>
+					<div class="panel-footer">
+						{{ form_start(confirm_form) }}
+						{# Repeats what the person previewed; the date and reason are shown above. #}
+						<div hidden>
+							{{ form_row(confirm_form.notSeenSince) }}
+							{{ form_row(confirm_form.reason) }}
+						</div>
+						{{ form_end(confirm_form) }}
+					</div>
+				</div>
+			{% endif %}
+			{% if borrowed > 0 %}
+				<p class="text-gray bulk-oust-borrowed">{{ 'bulk_oust_borrowed_skipped'|trans({'%count%': borrowed}) }}</p>
+			{% endif %}
+		</div>
+
+		{% if pagination.getTotalItemCount() > 0 %}
+			<div class="results">
+				{% for book in pagination %}
+					{% embed 'book/_tile.html.twig' %}
+						{% block details %}
+							<div class="tile-subtitle text-gray">
+								{% trans %}book_last_seen_header{% endtrans %}:
+								{{ book.lastSeen ? book.lastSeen|date('Y-m-d H:i') : 'book_last_seen_never'|trans }}
+							</div>
+						{% endblock %}
+					{% endembed %}
+				{% endfor %}
+
+				{{ knp_pagination_render(pagination) }}
+			</div>
+		{% endif %}
+	{% endif %}
+{% endblock %}
diff --git a/templates/ousted/list.html.twig b/templates/ousted/list.html.twig
new file mode 100644
index 0000000..0424bb4
--- /dev/null
+++ b/templates/ousted/list.html.twig
@@ -0,0 +1,31 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}ousted_list_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{% trans %}ousted_list_title{% endtrans %}</h2>
+		<p><a class="btn" href="{{ path('ousted_bulk') }}">{% trans %}bulk_oust_page_title{% endtrans %}</a></p>
+
+		{% if pagination.getTotalItemCount() == 0 %}
+			<p>{% trans %}ousted_list_empty{% endtrans %}</p>
+		{% endif %}
+	</div>
+
+	{% if pagination.getTotalItemCount() > 0 %}
+		<div class="results">
+			{% for book in pagination %}
+				{% embed 'book/_tile.html.twig' with {show_path: 'ousted_show'} %}
+					{% block status %}
+						<span class="label label-rounded label-error">{% trans %}ousted_status{% endtrans %}: {{ book.reason }}</span>
+					{% endblock %}
+					{% block actions %}
+						<a class="btn btn-link" href="{{ show_url }}">{% trans %}book_action_more{% endtrans %}</a>
+					{% endblock %}
+				{% endembed %}
+			{% endfor %}
+
+			{{ knp_pagination_render(pagination) }}
+		</div>
+	{% endif %}
+{% endblock %}
diff --git a/templates/ousted/show.html.twig b/templates/ousted/show.html.twig
new file mode 100644
index 0000000..93c7e0b
--- /dev/null
+++ b/templates/ousted/show.html.twig
@@ -0,0 +1,55 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{{ book.title }}{% endblock %}
+
+{% block content %}
+	<div class="page_content">
+		<h2>{{ book.title }}</h2>
+
+		<div class="columns">
+			<div class="column col-7 col-md-12">
+				{% embed 'book/_details.html.twig' %}
+					{% block footer %}
+						{% if book.textLabels is not empty %}
+							<div class="panel-footer">
+								{% for label in book.textLabels %}<span
+										class="label label-rounded">{{ label.label }}</span> {% endfor %}
+							</div>
+						{% endif %}
+					{% endblock %}
+				{% endembed %}
+
+				{{ include('book/_history.html.twig', {history: book.borrowHistory}) }}
+			</div>
+			<div class="column col-5 col-md-12">
+				<div class="panel book-panel">
+					<div class="panel-header">
+						<div class="panel-title h5"><span
+									class="label label-rounded label-warning">{% trans %}ousted_status{% endtrans %}</span>
+						</div>
+					</div>
+					<div class="panel-body">
+						<p>{% trans %}ousted_reason_header{% endtrans %}: {{ book.reason }}</p>
+					</div>
+					<div class="panel-footer">
+						<form action="{{ path('ousted_restore', {id: book.id}) }}" method="post">
+							<input type="hidden" name="_token" value="{{ csrf_token('restore' ~ book.id) }}"/>
+							<button class="btn">{% trans %}ousted_action_restore{% endtrans %}</button>
+						</form>
+					</div>
+				</div>
+
+				{% if book.cover %}
+					<div class="panel book-panel">
+						<div class="panel-header">
+							<div class="panel-title h5">{% trans %}cover_section_title{% endtrans %}</div>
+						</div>
+						<div class="panel-body">
+							{{ include('book/_cover.html.twig') }}
+						</div>
+					</div>
+				{% endif %}
+			</div>
+		</div>
+	</div>
+{% endblock %}
diff --git a/templates/search/index.html.twig b/templates/search/index.html.twig
new file mode 100644
index 0000000..21efea5
--- /dev/null
+++ b/templates/search/index.html.twig
@@ -0,0 +1,39 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{% trans %}search_page_title{% endtrans %}{% endblock %}
+
+{% block content %}
+	<div class="search page_content">
+		<h2>{% trans %}search_page_title{% endtrans %}</h2>
+
+		{{ form_start(form) }}
+		{{ form_widget(form) }}
+		<div>
+			<div class="form-group">
+				<input class="btn btn-primary" type="submit" value="{% trans %}search_action_search{% endtrans %}"/>
+				<a class="btn" href="{{ path('search') }}">{% trans %}search_action_reset{% endtrans %}</a>
+			</div>
+		</div>
+		{{ form_end(form) }}
+	</div>
+
+	{% if pagination %}
+		{% if pagination.getTotalItemCount() == 0 %}
+			<div class="results">
+				<div class="empty">
+					<div class="empty-icon">
+						<i class="icon icon-bookmark icon-2x"></i>
+					</div>
+					<p class="empty-title h5">{% trans %}search_no_books{% endtrans %}</p>
+					<p class="empty-subtitle">{% trans %}search_no_books_refine{% endtrans %}</p>
+				</div>
+			</div>
+		{% else %}
+			<div class="page_content">
+				<h3>{% trans %}search_results_page_title{% endtrans %}</h3>
+			</div>
+
+			{{ include('book/_results.html.twig') }}
+		{% endif %}
+	{% endif %}
+{% endblock %}
diff --git a/templates/spectre/form.html.twig b/templates/spectre/form.html.twig
new file mode 100644
index 0000000..9f2d4e5
--- /dev/null
+++ b/templates/spectre/form.html.twig
@@ -0,0 +1,73 @@
+{% use "form_div_layout.html.twig" %}
+
+{% block button_row -%}
+	<div{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
+		{{- form_widget(form) -}}
+	</div>
+{%- endblock button_row %}
+
+{% block button_widget -%}
+	{%- set attr = attr|merge({class: (attr.class|default('btn-secondary') ~ ' btn')|trim}) -%}
+	{{- parent() -}}
+{%- endblock button_widget %}
+
+{% block submit_widget -%}
+	{%- set attr = attr|merge({class: (attr.class|default('btn-primary'))|trim}) -%}
+	{{- parent() -}}
+{%- endblock submit_widget %}
+
+{% block form_label -%}
+	{% if label is not same as(false) -%}
+		{%- if compound is defined and compound -%}
+			{%- set element = 'legend' -%}
+			{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-label')|trim}) -%}
+		{%- else -%}
+			{%- set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' form-label')|trim}) -%}
+			{%- set label_attr = label_attr|merge({for: id}) -%}
+		{%- endif -%}
+		{% if label is empty -%}
+			{%- if label_format is not empty -%}
+				{% set label = label_format|replace({
+					'%name%': name,
+					'%id%': id,
+				}) %}
+			{%- else -%}
+				{% set label = name|humanize %}
+			{%- endif -%}
+		{%- endif -%}
+		<{{ element|default('label') }}{% if label_attr %}{% with { attr: label_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}>{{ translation_domain is same as(false) ? label : label|trans(label_translation_parameters, translation_domain) }}{% block form_label_errors %}{{- form_errors(form) -}}{% endblock form_label_errors %}</{{ element|default('label') }}>
+	{%- else -%}
+		{%- if errors|length > 0 -%}
+			<div id="{{ id }}_errors" class="mb-2">
+				{{- form_errors(form) -}}
+			</div>
+		{%- endif -%}
+	{%- endif -%}
+{%- endblock form_label %}
+
+{% block form_widget_simple -%}
+	{% if type is not defined or type != 'hidden' %}
+		{%- set attr = attr|merge({class: (attr.class|default('') ~ (type|default('') == 'file' ? ' custom-file-input' : ' form-input'))|trim}) -%}
+	{% endif %}
+	{%- if type is defined and (type == 'range' or type == 'color') %}
+		{# Attribute "required" is not supported #}
+		{%- set required = false -%}
+	{% endif %}
+	{{- parent() -}}
+{%- endblock form_widget_simple %}
+
+{% block form_row -%}
+	{%- if compound is defined and compound -%}
+		{%- set element = 'fieldset' -%}
+	{%- endif -%}
+	{%- set widget_attr = {} -%}
+	{%- if help is not empty -%}
+		{%- set widget_attr = {attr: {'aria-describedby': id ~"_help"}} -%}
+	{%- endif -%}
+	<{{ element|default('div') }}{% with {attr: row_attr|merge({class: (row_attr.class|default('') ~ ' form-group')|trim})} %}{{ block('attributes') }}{% endwith %}>
+	{{- form_label(form) -}}
+	{{- form_widget(form, widget_attr) -}}
+	{{- form_help(form) -}}
+	</{{ element|default('div') }}>
+{%- endblock form_row %}
+
diff --git a/templates/spectre/knp_paginator_pagination.html.twig b/templates/spectre/knp_paginator_pagination.html.twig
new file mode 100644
index 0000000..54a08c1
--- /dev/null
+++ b/templates/spectre/knp_paginator_pagination.html.twig
@@ -0,0 +1,31 @@
+{% if pageCount > 1 %}
+	<ul class="pagination">
+		{% if first is defined and current != first %}
+			<li class="page-item">
+				<a href="{{ path(route, query|merge({(pageParameterName): first})) }}">{% trans %}pagination_first{% endtrans %}</a>
+			</li>
+		{% endif %}
+		{% if previous is defined %}
+			<li class="page-item">
+				<a href="{{ path(route, query|merge({(pageParameterName): previous})) }}">{% trans %}pagination_prev{% endtrans %}</a>
+			</li>
+		{% endif %}
+
+		{% for page in pagesInRange %}
+			<li class="page-item{% if page == current %} active{% endif %}">
+				<a href="{{ path(route, query|merge({(pageParameterName): page})) }}">{{ page }}</a>
+			</li>
+		{% endfor %}
+
+		{% if next is defined %}
+			<li class="page-item">
+				<a href="{{ path(route, query|merge({(pageParameterName): next})) }}">{% trans %}pagination_next{% endtrans %}</a>
+			</li>
+		{% endif %}
+		{% if last is defined and current != last %}
+			<li class="page-item">
+				<a href="{{ path(route, query|merge({(pageParameterName): last})) }}">{% trans %}pagination_last{% endtrans %}</a>
+			</li>
+		{% endif %}
+	</ul>
+{% endif %}
diff --git a/test.php b/test.php
deleted file mode 100644
index 815f15f..0000000
--- a/test.php
+++ /dev/null
@@ -1,146 +0,0 @@
-<?php
-include('./includes/std.php');
-
-$title = 'Test systemu';
-include('./design/top.php');
-
-define('OK', '<b style="color:green">OK</b>');
-define('NT', '<b style="color:blue">NIE SPRAWDZANE</b>');
-define('FAIL', '<b style="color:red">BRAK</b>');
-define('SFAIL', '<b style="color:lightcoral">BRAK<br />NIE WYMAGANE</b>');
-?>
-
-<style type="text/css">
-th, td {border: 1px solid black; padding: 5px;}
-th {text-align: left;}
-th.head {text-align: center; background: lightblue;}
-</style>
-
-<table>
-
-
-<tr> <th class="head" colspan="2">PHP</th> </tr>
-<tr> <th>PHP 5.2 lub nowsze</th> <td><?php
-if(version_compare(phpversion(), '5.2') >= 0) echo OK;
-else echo FAIL;
-?></td> </tr>
-<tr> <th>magic_quotes_gpc = Off</th> <td><?php
-if(get_magic_quotes_gpc()) echo FAIL;
-else echo OK;
-?></td> </tr>
-
-
-<tr> <th class="head" colspan="2">Baza danych</th> </tr>
-<tr> <th>Ustawienia bazy<br />(/includes/config.php)</th> <td><?php echo NT; ?></td> </tr>
-<tr> <th>Rozszerzenie PDO</th> <td><?php
-if(!extension_loaded('pdo')) {
-	echo FAIL;
-	$mysql = FALSE;
-}
-else echo OK;
-?></td> </tr>
-<tr> <th>Rozszerzenie PDO MySQL</th> <td><?php
-if(!extension_loaded('pdo_mysql')) {
-	echo FAIL;
-	$mysql = FALSE;
-}
-else echo OK;
-?></td> </tr>
-<tr> <th>Tabela książek</th> <td><?php
-$num = db2::num('ksiazki', '*', NULL);
-if($mysql===FALSE) echo NT;
-elseif($num===FALSE) echo FAIL;
-else echo OK; ?></td> </tr>
-<tr> <th>Tabela wypożyczeń</th> <td><?php
-$num = db2::num('pozycz', '*', NULL);
-if($mysql===FALSE) echo NT;
-elseif($num===FALSE) echo FAIL;
-else echo OK; ?></td> </tr>
-
-
-<tr> <th class="head" colspan="2">Zewnętrzne bazy danych</th> </tr>
-<tr> <th>Rozszerzenie YAZ</th> <td><?php
-if(!extension_loaded('yaz')) {
-	echo SFAIL;
-	$yaz = FALSE;
-}
-else
-	echo OK;
-?></td> </tr>
-<tr> <th>Próba pobrania</th> <td><?php
-if($yaz===FALSE)
-	echo NT;
-else {
-	$ibd = new ibd_BN;
-	if($ibd->szukaj_ISBN('9788301121365'))
-		echo OK;
-	else
-		echo SFAIL;
-}
-?></td> </tr>
-
-
-<tr> <th class="head" colspan="2"> Okładki LibraryThing </th> </tr>
-<tr> <th>Rozszerzenie cURL</th> <td><?php
-if(!extension_loaded('curl')) {
-	echo SFAIL;
-	$curl = FALSE;
-} else echo OK; ?></td> </tr>
-<tr> <th>LibraryThing API key<br />(/includes/config.php)</th> <td><?php
-if($curl === FALSE)
-	echo NT;
-elseif(!empty(config::$lt_api))
-	echo OK;
-else
-	echo SFAIL;
-?></td> </tr>
-<tr> <th>Uprawnienia dla katalogu<br />
-/covers</th> <td><?php
-if($curl === FALSE) echo NT;
-elseif(is_readable('./covers') && is_writable('./covers')) echo OK;
-else echo FAIL;
-?></td> </tr>
-<tr> <th>Uprawnienia dla katalogu<br />
-/covers_big</th> <td><?php
-if($curl === FALSE) echo NT;
-elseif(is_readable('./covers_big') && is_writable('./covers_big')) echo OK;
-else echo FAIL;
-?></td> </tr>
-
-
-<tr> <th class="head" colspan="2"> Okładki własne </th> </tr>
-<tr> <th>file_uploads = On</th> <td><?php
-if(strtolower(ini_get('file_uploads')) == 'on' || ini_get('file_uploads') == 1) {
-	echo OK;
-}
-else
-{
-	echo FAIL;
-	$upload = FALSE;
-}
-?></td> </tr>
-<tr> <th>Rozszerzenie GD2</th> <td><?php
-if($upload === FALSE) echo NT;
-elseif(!extension_loaded('gd')) {
-	echo SFAIL;
-	$gd = FALSE;
-}
-else echo OK;
-?></td> </tr>
-<tr> <th>Uprawnienia dla katalogu<br />
-/covers/own</th> <td><?php
-if($gd === FALSE || $upload === FALSE) echo NT;
-elseif(is_readable('./covers/own') && is_writable('./covers/own')) echo OK;
-else echo FAIL;
-?></td> </tr>
-<tr> <th>Uprawnienia dla katalogu<br />
-/covers_big/own</th> <td><?php
-if($gd === FALSE || $upload === FALSE) echo NT;
-elseif(is_readable('./covers_big/own') && is_writable('./covers_big/own')) echo OK;
-else echo FAIL;
-?></td> </tr>
-</table>
-
-<?
-include('./design/bottom.php');
-?>
\ No newline at end of file
diff --git a/tests/DatabaseTestCase.php b/tests/DatabaseTestCase.php
new file mode 100644
index 0000000..fbeb725
--- /dev/null
+++ b/tests/DatabaseTestCase.php
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests;
+
+use App\Entity\Book;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Tools\SchemaTool;
+use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+
+abstract class DatabaseTestCase extends WebTestCase {
+	protected KernelBrowser $client;
+
+	protected function setUp(): void {
+		$this->client = static::createClient();
+
+		$entityManager = $this->entityManager();
+		$metadata = $entityManager->getMetadataFactory()->getAllMetadata();
+		$tool = new SchemaTool($entityManager);
+		$tool->dropSchema($metadata);
+		$tool->createSchema($metadata);
+	}
+
+	protected function entityManager(): EntityManagerInterface {
+		return static::getContainer()->get(EntityManagerInterface::class);
+	}
+
+	protected function createBook(int $id, string $title = 'Title', string $author = 'Author'): Book {
+		$book = (new Book())->setId($id)->setTitle($title)->setAuthor($author);
+		$this->entityManager()->persist($book);
+		$this->entityManager()->flush();
+
+		return $book;
+	}
+
+	protected function findBook(int $id): ?Book {
+		$this->entityManager()->clear();
+
+		return $this->entityManager()->find(Book::class, $id);
+	}
+}
diff --git a/tests/Fixtures/rotated-orientation-6.jpg b/tests/Fixtures/rotated-orientation-6.jpg
new file mode 100644
index 0000000..40db857
--- /dev/null
+++ b/tests/Fixtures/rotated-orientation-6.jpg
Binary files differ
diff --git a/tests/Functional/BookTest.php b/tests/Functional/BookTest.php
new file mode 100644
index 0000000..48f3d44
--- /dev/null
+++ b/tests/Functional/BookTest.php
@@ -0,0 +1,144 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\OustedBook;
+use App\Enum\LabelType;
+use App\Tests\DatabaseTestCase;
+
+final class BookTest extends DatabaseTestCase {
+	public function testEmptyListInvitesToAddABook(): void {
+		$this->client->request('GET', '/');
+
+		self::assertResponseIsSuccessful();
+		self::assertSelectorTextContains('.empty-title', 'There are no books');
+	}
+
+	public function testListIsPaginated(): void {
+		for ($id = 1; $id <= 11; ++$id) {
+			$this->createBook($id, 'Book ' . $id);
+		}
+
+		$crawler = $this->client->request('GET', '/list');
+		self::assertCount(10, $crawler->filter('.tile'));
+
+		$crawler = $this->client->request('GET', '/list?page=2');
+		self::assertCount(1, $crawler->filter('.tile'));
+	}
+
+	public function testBookCanBeAddedWithPrefilledForm(): void {
+		$crawler = $this->client->request('GET', '/add/form?id=7&title=Prefilled');
+		self::assertResponseIsSuccessful();
+
+		$form = $crawler->selectButton('Save')->form();
+		self::assertSame('7', $form['book[id]']->getValue());
+		self::assertSame('Prefilled', $form['book[title]']->getValue());
+
+		$form['book[author]'] = 'Someone';
+		$form['book[isbn]'] = '978-83-7384-345-5';
+		$this->client->submit($form);
+
+		self::assertResponseRedirects('/book/7');
+		self::assertSame('9788373843455', $this->findBook(7)?->getIsbn());
+	}
+
+	public function testIsbnIsStoredAsATypedLabelAndCanBeClearedInTheForm(): void {
+		$this->createBook(1)->setIsbn('9788373843455');
+		$this->entityManager()->flush();
+
+		$book = $this->findBook(1);
+		self::assertSame(LabelType::ISBN, $book?->getLabels()->first()->getType());
+
+		$crawler = $this->client->request('GET', '/book/1/edit');
+		$form = $crawler->selectButton('Save')->form();
+		$form['book[isbn]'] = '';
+		$this->client->submit($form);
+
+		self::assertCount(0, $this->findBook(1)?->getLabels() ?? []);
+	}
+
+	public function testIsbn10IsSavedAsIsbn13(): void {
+		$crawler = $this->client->request('GET', '/add/form');
+		$this->client->submit($crawler->selectButton('Save')->form([
+			'book[id]' => '1',
+			'book[title]' => 'Title',
+			'book[author]' => 'Author',
+			'book[isbn]' => '0-306-40615-2',
+		]));
+
+		self::assertResponseRedirects('/book/1');
+		self::assertSame('9780306406157', $this->findBook(1)?->getIsbn());
+	}
+
+	public function testMalformedIsbnCannotReachTheDatabase(): void {
+		$book = new \App\Entity\Book()->setId(1)->setTitle('Title')->setAuthor('Author')->setIsbn('123');
+
+		$this->expectException(\DomainException::class);
+		$this->entityManager()->persist($book);
+	}
+
+	public function testInvalidIsbnIsRejected(): void {
+		$crawler = $this->client->request('GET', '/add/form');
+		$this->client->submit($crawler->selectButton('Save')->form([
+			'book[id]' => '1',
+			'book[title]' => 'Title',
+			'book[author]' => 'Author',
+			'book[isbn]' => 'abc',
+		]));
+
+		self::assertResponseStatusCodeSame(422);
+	}
+
+	public function testIdZeroIsAValidSticker(): void {
+		$this->createBook(0, 'Zero');
+
+		$this->client->request('GET', '/book/0');
+
+		self::assertResponseIsSuccessful();
+		self::assertSelectorTextContains('h2', 'Zero');
+	}
+
+	public function testIdMustBeFreeInBothCollections(): void {
+		$this->createBook(1);
+		$ousted = (new OustedBook('Lost'))->setId(2)->setTitle('Gone')->setAuthor('Someone');
+		$this->entityManager()->persist($ousted);
+		$this->entityManager()->flush();
+
+		foreach ([1, 2] as $id) {
+			$crawler = $this->client->request('GET', '/add/form');
+			$this->client->submit($crawler->selectButton('Save')->form([
+				'book[id]' => (string)$id,
+				'book[title]' => 'Duplicate',
+				'book[author]' => 'Someone',
+			]));
+
+			self::assertResponseStatusCodeSame(422);
+			self::assertSelectorTextContains('body', 'already used by another book');
+		}
+	}
+
+	public function testEditKeepsTheId(): void {
+		$this->createBook(3, 'Before');
+
+		$crawler = $this->client->request('GET', '/book/3/edit');
+		$form = $crawler->selectButton('Save')->form();
+		$form['book[title]'] = 'After';
+		$this->client->submit($form);
+
+		self::assertResponseRedirects('/book/3');
+		self::assertSame('After', $this->findBook(3)?->getTitle());
+	}
+
+	public function testUnknownBookIsNotFound(): void {
+		$this->client->request('GET', '/book/999');
+
+		self::assertResponseStatusCodeSame(404);
+	}
+
+	public function testPolishIsServedFromAcceptLanguage(): void {
+		$this->client->request('GET', '/list', server: ['HTTP_ACCEPT_LANGUAGE' => 'pl']);
+
+		self::assertSelectorTextContains('.navbar-brand', 'Biblioteczka');
+	}
+}
diff --git a/tests/Functional/BulkOustTest.php b/tests/Functional/BulkOustTest.php
new file mode 100644
index 0000000..30b7b00
--- /dev/null
+++ b/tests/Functional/BulkOustTest.php
@@ -0,0 +1,95 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\OustedBook;
+use App\Tests\DatabaseTestCase;
+use Symfony\Component\DomCrawler\Crawler;
+
+final class BulkOustTest extends DatabaseTestCase {
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->createBook(1, 'Never seen');
+		$this->createBook(2, 'Seen long ago')->setLastSeen(new \DateTimeImmutable('2026-01-10 12:00'));
+		$this->createBook(3, 'Seen on the day')->setLastSeen(new \DateTimeImmutable('2026-11-01 00:00'));
+		$this->createBook(4, 'Seen later')->setLastSeen(new \DateTimeImmutable('2026-11-05 18:00'));
+		$this->createBook(5, 'Lent out')->lendTo('A Reader', new \DateTimeImmutable('2026-01-01'));
+		$this->entityManager()->flush();
+	}
+
+	public function testPreviewListsBooksNotSeenSinceTheDate(): void {
+		$crawler = $this->preview('2026-11-01');
+
+		self::assertResponseIsSuccessful();
+		self::assertSame([1, 2], $crawler->filter('.results .tile .label-primary')->each(static fn($node): int => (int)$node->text()));
+		self::assertSelectorTextContains('.bulk-oust-confirm', '2 books were not seen on or after 2026-11-01');
+		self::assertSelectorTextContains('.bulk-oust-borrowed', '1 borrowed books');
+		self::assertNotNull($this->findBook(1));
+	}
+
+	public function testConfirmingOustsTheListedBooks(): void {
+		$crawler = $this->preview('2026-11-01');
+		$this->client->submit($crawler->selectButton('Take them all out')->form());
+
+		self::assertResponseRedirects('/ousted');
+		$this->client->followRedirect();
+		self::assertSelectorTextContains('.flash', '2 books have been taken out');
+
+		self::assertNull($this->findBook(1));
+		self::assertNull($this->findBook(2));
+		self::assertNotNull($this->findBook(3));
+		self::assertNotNull($this->findBook(4));
+		self::assertNotNull($this->findBook(5));
+		self::assertSame('Inventory 2026', $this->entityManager()->find(OustedBook::class, 2)?->getReason());
+	}
+
+	public function testNothingIsOustedIfTheSelectionChanged(): void {
+		$crawler = $this->preview('2026-11-01');
+		$form = $crawler->selectButton('Take them all out')->form();
+
+		// One book found and another added: the count stays the same, the books do not.
+		$this->findBook(2)?->setLastSeen(new \DateTimeImmutable('2026-11-02'));
+		$this->entityManager()->flush();
+		$this->createBook(6, 'Added meanwhile');
+		$this->client->submit($form);
+
+		self::assertResponseRedirects();
+		$this->client->followRedirect();
+		self::assertSelectorTextContains('.flash', 'has changed');
+		self::assertNotNull($this->findBook(1));
+		self::assertNotNull($this->findBook(6));
+	}
+
+	public function testConfirmationRequiresAValidCsrfToken(): void {
+		$this->client->request('POST', '/ousted/bulk', ['bulk_oust' => [
+			'notSeenSince' => '2026-11-01',
+			'reason' => 'Forged',
+			'selection' => 'anything',
+			'_token' => 'forged',
+		]]);
+
+		self::assertResponseRedirects('/ousted/bulk');
+		self::assertNotNull($this->findBook(1));
+	}
+
+	public function testPreviewWithNothingToOust(): void {
+		$this->findBook(1)?->setLastSeen(new \DateTimeImmutable('2026-03-01'));
+		$this->entityManager()->flush();
+
+		$this->preview('2026-01-01');
+
+		self::assertSelectorTextContains('.toast-success', 'Every book on the shelves was seen');
+		self::assertSelectorNotExists('.bulk-oust-confirm');
+	}
+
+	private function preview(string $date): Crawler {
+		$crawler = $this->client->request('GET', '/ousted/bulk');
+
+		return $this->client->submit($crawler->selectButton('Show the books')->form([
+			'bulk_oust[notSeenSince]' => $date,
+			'bulk_oust[reason]' => 'Inventory 2026',
+		]));
+	}
+}
diff --git a/tests/Functional/CoverTest.php b/tests/Functional/CoverTest.php
new file mode 100644
index 0000000..526f861
--- /dev/null
+++ b/tests/Functional/CoverTest.php
@@ -0,0 +1,174 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\OustedBook;
+use App\Service\CoverStorage;
+use App\Tests\DatabaseTestCase;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use Symfony\Component\Filesystem\Filesystem;
+
+#[RequiresPhpExtension('gd')]
+final class CoverTest extends DatabaseTestCase {
+	private string $workDir;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->workDir = sys_get_temp_dir() . '/book-cover-test-' . bin2hex(random_bytes(4));
+		$filesystem = new Filesystem();
+		$filesystem->mkdir($this->workDir);
+		$filesystem->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
+
+		$this->createBook(1, 'Covered');
+	}
+
+	protected function tearDown(): void {
+		new Filesystem()->remove($this->workDir);
+
+		parent::tearDown();
+	}
+
+	public function testOriginalIsKeptAndThumbnailsAreCreated(): void {
+		$file = $this->createImage(1000, 2000, 'cover.png');
+
+		$name = $this->uploadCover($file);
+
+		self::assertMatchesRegularExpression('/^[0-9a-f]{32}\.png$/', $name);
+		self::assertFileEquals($file, $this->storage()->originalPath($name));
+
+		self::assertSame([113, 225], $this->imageSize($this->storage()->thumbnailPath($name, 'list')));
+		self::assertSame([450, 900], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
+		self::assertSame(\IMAGETYPE_JPEG, exif_imagetype($this->storage()->thumbnailPath($name, 'list')));
+	}
+
+	public function testSmallImagesAreNotEnlarged(): void {
+		$name = $this->uploadCover($this->createImage(80, 100, 'small.png'));
+
+		self::assertSame([80, 100], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
+	}
+
+	public function testThumbnailsAreShownOnListAndDetails(): void {
+		$name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
+
+		$crawler = $this->client->request('GET', '/list');
+		self::assertSame('/covers/' . $name . '.list.jpg', $crawler->filter('.tile-cover')->attr('src'));
+
+		$crawler = $this->client->request('GET', '/book/1');
+		self::assertSame('/covers/' . $name . '.details.jpg', $crawler->filter('.book-cover')->attr('src'));
+		self::assertSame('/cover/' . $name, $crawler->filter('.book-cover')->ancestors()->first()->attr('href'));
+	}
+
+	public function testOriginalCanBeDownloaded(): void {
+		$file = $this->createImage(300, 400, 'cover.png');
+		$name = $this->uploadCover($file);
+
+		$this->client->request('GET', '/cover/' . $name);
+
+		self::assertResponseIsSuccessful();
+		self::assertResponseHeaderSame('Content-Type', 'image/png');
+		self::assertResponseHeaderSame('X-Content-Type-Options', 'nosniff');
+	}
+
+	public function testNewCoverReplacesTheOldOne(): void {
+		$first = $this->uploadCover($this->createImage(300, 400, 'first.png'));
+		$second = $this->uploadCover($this->createImage(300, 400, 'second.png'));
+
+		self::assertNotSame($first, $second);
+		self::assertFileDoesNotExist($this->storage()->originalPath($first));
+		self::assertFileDoesNotExist($this->storage()->thumbnailPath($first, 'list'));
+		self::assertFileExists($this->storage()->originalPath($second));
+	}
+
+	public function testCoverCanBeRemoved(): void {
+		$name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Remove cover')->form());
+
+		self::assertNull($this->findBook(1)?->getCover());
+		self::assertFileDoesNotExist($this->storage()->originalPath($name));
+		self::assertFileDoesNotExist($this->storage()->thumbnailPath($name, 'details'));
+	}
+
+	public function testExifRotationIsApplied(): void {
+		$fixture = \dirname(__DIR__) . '/Fixtures/rotated-orientation-6.jpg';
+		$file = $this->workDir . '/photo.jpg';
+		copy($fixture, $file);
+
+		$name = $this->uploadCover($file);
+
+		self::assertSame([20, 40], $this->imageSize($this->storage()->thumbnailPath($name, 'details')));
+	}
+
+	public function testFilesThatAreNotImagesAreRejected(): void {
+		$file = $this->workDir . '/cover.png';
+		file_put_contents($file, '<?php echo "not an image";');
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$form = $crawler->selectButton('Upload cover')->form();
+		$form['cover[cover]']->upload($file);
+		$this->client->submit($form);
+		$this->client->followRedirect();
+
+		self::assertSelectorExists('.toast-error');
+		self::assertNull($this->findBook(1)?->getCover());
+	}
+
+	public function testCoverFollowsTheBookWhenOusted(): void {
+		$name = $this->uploadCover($this->createImage(300, 400, 'cover.png'));
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
+
+		$this->entityManager()->clear();
+		self::assertSame($name, $this->entityManager()->find(OustedBook::class, 1)?->getCover());
+		self::assertFileExists($this->storage()->originalPath($name));
+
+		$this->client->request('GET', '/ousted/1');
+		self::assertSelectorExists('.book-cover');
+	}
+
+	public function testOriginalsCannotBeReachedOutsideTheCoverPattern(): void {
+		$this->client->request('GET', '/cover/..%2F..%2F.env');
+
+		self::assertResponseStatusCodeSame(404);
+	}
+
+	private function uploadCover(string $file): string {
+		$crawler = $this->client->request('GET', '/book/1');
+		$form = $crawler->selectButton('Upload cover')->form();
+		$form['cover[cover]']->upload($file);
+		$this->client->submit($form);
+
+		self::assertResponseRedirects('/book/1');
+		$name = $this->findBook(1)?->getCover();
+		self::assertNotNull($name);
+
+		return $name;
+	}
+
+	private function createImage(int $width, int $height, string $fileName): string {
+		$path = $this->workDir . '/' . $fileName;
+		$image = imagecreatetruecolor($width, $height);
+		// Random pixels make each generated file unique.
+		imagesetpixel($image, 0, 0, random_int(1, 0xFFFFFF));
+		imagepng($image, $path);
+
+		return $path;
+	}
+
+	/**
+	 * @return array{int, int}
+	 */
+	private function imageSize(string $path): array {
+		[$width, $height] = getimagesize($path);
+
+		return [$width, $height];
+	}
+
+	private function storage(): CoverStorage {
+		return static::getContainer()->get(CoverStorage::class);
+	}
+}
diff --git a/tests/Functional/ImportLegacyCommandTest.php b/tests/Functional/ImportLegacyCommandTest.php
new file mode 100644
index 0000000..f18b62c
--- /dev/null
+++ b/tests/Functional/ImportLegacyCommandTest.php
@@ -0,0 +1,209 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use App\Tests\DatabaseTestCase;
+use App\Tests\LegacyDatabase;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+
+final class ImportLegacyCommandTest extends DatabaseTestCase {
+	use LegacyDatabase;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->createLegacyTables();
+	}
+
+	public function testImportMapsBooks(): void {
+		$this->addLegacyBook(['id' => 0, 'tytul' => 'Zerowa', 'autor' => 'A', 'jezyk' => 'polski', 'rok' => 2005, 'miejsce' => 'Wrocław', 'wydawnictwo' => 'W', 'wydanie' => '2', 'ISBN' => '9788373843455', 'regal' => 'HOL', 'polka' => '4', 'rzad' => '1']);
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Bez danych', 'autor' => 'B', 'jezyk' => '', 'rok' => 0]);
+		$this->addLegacyBook(['id' => 2, 'tytul' => 'Częściowa lokalizacja', 'autor' => 'C', 'jezyk' => 'eng', 'regal' => 'K', 'ISBN' => '306406152', 'ISSN' => '3161234']);
+		$this->addLegacyBook(['id' => 3, 'tytul' => 'Wycofana', 'autor' => 'D', 'jezyk' => 'polski', 'regal' => 'JK', 'polka' => '1', 'ISBN' => '9788303001795', 'wycofana' => '1', 'powod' => 'Zniszczona']);
+
+		$tester = $this->runImport();
+
+		$tester->assertCommandIsSuccessful();
+		$this->entityManager()->clear();
+
+		$full = $this->findBook(0);
+		self::assertSame('Zerowa', $full?->getTitle());
+		self::assertSame('polski', $full?->getLanguage());
+		self::assertSame(2005, $full?->getPublicationYear());
+		self::assertSame('9788373843455', $full?->getIsbn());
+		self::assertSame('HOL/4/1', $full?->getPlacement());
+
+		$sparse = $this->findBook(1);
+		self::assertNull($sparse?->getLanguage());
+		self::assertNull($sparse?->getPublicationYear());
+		self::assertNull($sparse?->getPlacement());
+
+		$partial = $this->findBook(2);
+		self::assertSame('K', $partial?->getPlacement());
+		self::assertSame('9780306406157', $partial?->getIsbn());
+		self::assertSame('03161234', $partial?->getIssn());
+
+		$ousted = $this->entityManager()->find(OustedBook::class, 3);
+		self::assertSame('Zniszczona', $ousted?->getReason());
+		self::assertSame('JK/1', $ousted?->getPlacement());
+		self::assertSame('9788303001795', $ousted?->getIsbn());
+		self::assertNull($this->findBook(3));
+	}
+
+	public function testInvalidIsbnIsKeptAsATextLabel(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Zły numer', 'autor' => 'A', 'jezyk' => 'polski', 'ISBN' => '9788373843456']);
+
+		$tester = $this->runImport();
+
+		$tester->assertCommandIsSuccessful();
+		self::assertStringContainsString('book 1: 9788373843456', $tester->getDisplay());
+		$book = $this->findBook(1);
+		self::assertNull($book?->getIsbn());
+		self::assertSame('9788373843456', $book?->getTextLabels()[0]->getLabel());
+	}
+
+	public function testImportedBooksAreSearchable(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Łódź', 'autor' => 'A', 'jezyk' => 'polski']);
+
+		$this->runImport();
+
+		$crawler = $this->client->request('GET', '/search', ['q' => 'Łódź']);
+		self::assertCount(1, $crawler->filter('.tile'));
+	}
+
+	public function testImportMapsLoans(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Na półce', 'autor' => 'A', 'jezyk' => 'polski']);
+		$this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
+		$this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 1556909792]);
+		$this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba B', 'od' => 1212076475, '"do"' => 1212176475]);
+		$this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba C', 'od' => 1212076475, '"do"' => 1212176475]);
+
+		$tester = $this->runImport();
+		$tester->assertCommandIsSuccessful();
+
+		$book = $this->findBook(1);
+		self::assertSame('Osoba A', $book?->getBorrow()?->getWho());
+		self::assertSame(1556909792, $book?->getBorrow()?->getAt()->getTimestamp());
+
+		$entry = $book?->getBorrowHistory()->first();
+		self::assertSame('Osoba B', $entry->getWho());
+		self::assertSame(1212176475, $entry->getTill()->getTimestamp());
+
+		self::assertCount(1, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? []);
+		self::assertStringNotContainsString('left out', $tester->getDisplay());
+	}
+
+	public function testRowsWithoutAPlaceInTheNewSchemaAreReported(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Dwa razy', 'autor' => 'A', 'jezyk' => 'polski']);
+		$this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
+		$this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Starsze', 'od' => 1000]);
+		$this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Nowsze', 'od' => 2000]);
+		$this->legacy->insert('pozycz', ['id' => 2, 'kto' => 'Osoba', 'od' => 1000]);
+		$this->legacy->insert('pozycz', ['id' => 99, 'kto' => 'Osoba', 'od' => 1000]);
+		$this->legacy->insert('pozycz_historia', ['id' => 99, 'kto' => 'Osoba', 'od' => 1000, '"do"' => 2000]);
+		$this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba', 'od' => 1000, '"do"' => null]);
+
+		$tester = $this->runImport();
+
+		$tester->assertCommandIsSuccessful();
+		self::assertSame('Nowsze', $this->findBook(1)?->getBorrow()?->getWho());
+
+		$display = preg_replace('/\s+/', ' ', $tester->getDisplay());
+		self::assertStringContainsString('5 rows were left out', $display);
+		self::assertStringContainsString('book 1 has more than one open loan', $display);
+		self::assertStringContainsString('book 2 is on loan but ousted', $display);
+		self::assertStringContainsString('pozycz: book 99 does not exist', $display);
+		self::assertStringContainsString('pozycz_historia: book 99 does not exist', $display);
+		self::assertStringContainsString('has no end date', $display);
+	}
+
+	public function testDryRunSavesNothing(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski']);
+
+		$tester = $this->runImport(['--dry-run' => true]);
+
+		$tester->assertCommandIsSuccessful();
+		self::assertStringContainsString('nothing was saved', $tester->getDisplay());
+		self::assertSame(0, $this->entityManager()->getRepository(Book::class)->count());
+	}
+
+	public function testImportKeepsBooksAlreadyInTheTarget(): void {
+		$this->createBook(1, 'Edited title');
+		$ousted = new OustedBook('Lost')->setId(2)->setTitle('Ousted since')->setAuthor('B');
+		$this->entityManager()->persist($ousted);
+		$this->entityManager()->flush();
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Legacy title', 'autor' => 'A', 'jezyk' => 'polski']);
+		$this->addLegacyBook(['id' => 2, 'tytul' => 'Na półce', 'autor' => 'B', 'jezyk' => 'polski']);
+		$this->addLegacyBook(['id' => 3, 'tytul' => 'Nowa', 'autor' => 'C', 'jezyk' => 'polski']);
+		$this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 1000]);
+		$this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba B', 'od' => 1000, '"do"' => 2000]);
+
+		$tester = $this->runImport();
+
+		$tester->assertCommandIsSuccessful();
+		$book = $this->findBook(1);
+		self::assertSame('Edited title', $book?->getTitle());
+		self::assertNull($book?->getBorrow());
+		self::assertNull($this->findBook(2));
+		self::assertCount(0, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? [1]);
+		self::assertSame('Nowa', $this->findBook(3)?->getTitle());
+		self::assertMatchesRegularExpression('/already in target, kept\s+2/', $tester->getDisplay());
+		self::assertStringNotContainsString('left out', $tester->getDisplay());
+	}
+
+	public function testRunningTheImportTwiceChangesNothing(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski', 'ISBN' => '9788373843456']);
+		$this->addLegacyBook(['id' => 2, 'tytul' => 'Wycofana', 'autor' => 'B', 'jezyk' => 'polski', 'wycofana' => '1', 'powod' => 'Prezent']);
+		$this->legacy->insert('pozycz', ['id' => 1, 'kto' => 'Osoba A', 'od' => 2000]);
+		$this->legacy->insert('pozycz_historia', ['id' => 1, 'kto' => 'Osoba B', 'od' => 1000, '"do"' => 1500]);
+		$this->legacy->insert('pozycz_historia', ['id' => 2, 'kto' => 'Osoba C', 'od' => 1000, '"do"' => 1500]);
+		$this->runImport()->assertCommandIsSuccessful();
+
+		$tester = $this->runImport();
+
+		$tester->assertCommandIsSuccessful();
+		self::assertSame(1, $this->entityManager()->getRepository(Book::class)->count());
+		self::assertSame(1, $this->entityManager()->getRepository(OustedBook::class)->count());
+		$book = $this->findBook(1);
+		self::assertSame('Osoba A', $book?->getBorrow()?->getWho());
+		self::assertCount(1, $book?->getBorrowHistory() ?? []);
+		self::assertCount(1, $book?->getTextLabels() ?? []);
+		self::assertCount(1, $this->entityManager()->find(OustedBook::class, 2)?->getBorrowHistory() ?? []);
+		self::assertMatchesRegularExpression('/already in target, kept\s+2/', $tester->getDisplay());
+	}
+
+	public function testFailureLeavesTheTargetUntouched(): void {
+		$this->addLegacyBook(['id' => 1, 'tytul' => 'Książka', 'autor' => 'A', 'jezyk' => 'polski']);
+		$this->legacy->executeStatement('ALTER TABLE pozycz RENAME TO pozycz_missing');
+
+		try {
+			$this->runImport();
+			self::fail('The import should have failed.');
+		} catch (\Doctrine\DBAL\Exception) {
+			self::assertSame(0, $this->entityManager()->getRepository(Book::class)->count());
+		}
+	}
+
+	/**
+	 * @param array<string, mixed> $row
+	 */
+	private function addLegacyBook(array $row): void {
+		$this->legacy->insert('ksiazki', $row);
+	}
+
+	/**
+	 * @param array<string, mixed> $input
+	 */
+	private function runImport(array $input = []): CommandTester {
+		$application = new Application(static::$kernel);
+		$tester = new CommandTester($application->find('app:import-legacy'));
+		$tester->execute($input);
+
+		return $tester;
+	}
+}
diff --git a/tests/Functional/ImportLegacyCoversTest.php b/tests/Functional/ImportLegacyCoversTest.php
new file mode 100644
index 0000000..bd80601
--- /dev/null
+++ b/tests/Functional/ImportLegacyCoversTest.php
@@ -0,0 +1,169 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\Book;
+use App\Entity\OustedBook;
+use App\Service\CoverStorage;
+use App\Tests\DatabaseTestCase;
+use App\Tests\LegacyDatabase;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+
+#[RequiresPhpExtension('gd')]
+final class ImportLegacyCoversTest extends DatabaseTestCase {
+	use LegacyDatabase;
+
+	private const string ISBN_A = '9788373843455';
+	private const string ISBN_B = '9788303001795';
+	private const string ISBN_C = '9780306406157';
+
+	private string $coversDir;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		// The table import runs first and must find nothing to add.
+		$this->createLegacyTables();
+		$this->coversDir = sys_get_temp_dir() . '/legacy-covers-test-' . bin2hex(random_bytes(4));
+		$filesystem = new Filesystem();
+		$filesystem->mkdir($this->coversDir . '/own');
+		$filesystem->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
+	}
+
+	protected function tearDown(): void {
+		new Filesystem()->remove($this->coversDir);
+
+		parent::tearDown();
+	}
+
+	public function testCoversArePickedByPreference(): void {
+		$this->createBookWithIsbn(1, self::ISBN_A);
+		$this->createBookWithIsbn(2, self::ISBN_B);
+		$this->createBookWithIsbn(3, self::ISBN_C);
+		$this->createBook(4);
+
+		$generic = $this->createImage(self::ISBN_A);
+		$this->createImage(self::ISBN_B);
+		$this->createImage(self::ISBN_C);
+		$ownByIsbn = $this->createImage('own/' . self::ISBN_B);
+		$this->createImage('own/' . self::ISBN_C);
+		$ownById = $this->createImage('own/00000003');
+		$this->createImage('own/00000004');
+
+		$tester = $this->runImport(['--covers' => $this->coversDir]);
+
+		$tester->assertCommandIsSuccessful();
+		self::assertStringContainsString('4 covers imported', $tester->getDisplay());
+		self::assertFileEquals($generic, $this->originalOf(1));
+		self::assertFileEquals($ownByIsbn, $this->originalOf(2));
+		self::assertFileEquals($ownById, $this->originalOf(3));
+		self::assertNotNull($this->findBook(4)?->getCover());
+		self::assertFileExists($generic, 'The source files stay in place.');
+	}
+
+	public function testOustedBooksGetCoversToo(): void {
+		$book = new OustedBook('Zniszczona')->setId(7)->setTitle('T')->setAuthor('A')->setIsbn(self::ISBN_A);
+		$this->entityManager()->persist($book);
+		$this->entityManager()->flush();
+		$this->createImage(self::ISBN_A);
+
+		$this->runImport(['--covers' => $this->coversDir])->assertCommandIsSuccessful();
+
+		$this->entityManager()->clear();
+		self::assertNotNull($this->entityManager()->find(OustedBook::class, 7)?->getCover());
+	}
+
+	public function testUnreadableFilesGiveWayAndOddFilesAreReported(): void {
+		$this->createBookWithIsbn(1, self::ISBN_A);
+		$generic = $this->createImage(self::ISBN_A);
+		$this->createImage(self::ISBN_B);
+		$this->createImage('own/00000099');
+		file_put_contents($this->coversDir . '/notes.txt', 'x');
+		file_put_contents($this->coversDir . '/own/00000001', 'not an image');
+
+		$tester = $this->runImport(['--covers' => $this->coversDir]);
+
+		$tester->assertCommandIsSuccessful();
+		self::assertFileEquals($generic, $this->originalOf(1));
+		$display = preg_replace('/\s+/', ' ', $tester->getDisplay());
+		self::assertStringContainsString('1 files could not be imported', $display);
+		self::assertStringContainsString('own/00000001: Unsupported image type.', $display);
+		self::assertStringContainsString('2 files match no book', $display);
+		self::assertStringContainsString(self::ISBN_B, $display);
+		self::assertStringContainsString('own/00000099', $display);
+		self::assertStringContainsString('1 files are named neither', $display);
+		self::assertStringContainsString('notes.txt', $display);
+	}
+
+	public function testBooksWithACoverKeepIt(): void {
+		$this->createBookWithIsbn(1, self::ISBN_A);
+		$this->createImage(self::ISBN_A);
+		$this->runImport(['--covers' => $this->coversDir])->assertCommandIsSuccessful();
+		$cover = $this->findBook(1)?->getCover();
+
+		$this->createImage('own/00000001');
+		$tester = $this->runImport(['--covers' => $this->coversDir]);
+
+		$tester->assertCommandIsSuccessful();
+		self::assertSame($cover, $this->findBook(1)?->getCover());
+		self::assertStringContainsString('0 covers imported', $tester->getDisplay());
+	}
+
+	public function testDryRunStoresNothing(): void {
+		$this->createBookWithIsbn(1, self::ISBN_A);
+		$this->createImage(self::ISBN_A);
+
+		$tester = $this->runImport(['--covers' => $this->coversDir, '--dry-run' => true]);
+
+		$tester->assertCommandIsSuccessful();
+		self::assertNull($this->findBook(1)?->getCover());
+		$originals = static::getContainer()->getParameter('app.cover_originals_dir');
+		self::assertSame([], is_dir($originals) ? array_diff(scandir($originals), ['.', '..']) : []);
+	}
+
+	public function testMissingDirectoryFails(): void {
+		$this->expectException(\InvalidArgumentException::class);
+		$this->expectExceptionMessage('is not a readable directory');
+
+		$this->runImport(['--covers' => $this->coversDir . '/missing']);
+	}
+
+	private function createBookWithIsbn(int $id, string $isbn): Book {
+		$book = $this->createBook($id)->setIsbn($isbn);
+		$this->entityManager()->flush();
+
+		return $book;
+	}
+
+	private function createImage(string $name): string {
+		$path = $this->coversDir . '/' . $name;
+		$image = imagecreatetruecolor(20, 30);
+		// Random pixels make each generated file unique.
+		imagesetpixel($image, 0, 0, random_int(1, 0xFFFFFF));
+		imagepng($image, $path);
+
+		return $path;
+	}
+
+	private function originalOf(int $id): string {
+		$cover = $this->findBook($id)?->getCover();
+		self::assertNotNull($cover);
+
+		return static::getContainer()->get(CoverStorage::class)->originalPath($cover);
+	}
+
+	/**
+	 * @param array<string, mixed> $input
+	 */
+	private function runImport(array $input): CommandTester {
+		$application = new Application(static::$kernel);
+		$tester = new CommandTester($application->find('app:import-legacy'));
+		$tester->execute($input);
+
+		return $tester;
+	}
+}
diff --git a/tests/Functional/InventoryTest.php b/tests/Functional/InventoryTest.php
new file mode 100644
index 0000000..e469007
--- /dev/null
+++ b/tests/Functional/InventoryTest.php
@@ -0,0 +1,139 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\OustedBook;
+use App\Tests\DatabaseTestCase;
+
+final class InventoryTest extends DatabaseTestCase {
+	public function testPageAndServiceWorkerAreServed(): void {
+		$this->client->request('GET', '/inventory/');
+		self::assertResponseIsSuccessful();
+		self::assertSelectorExists('#scan-input');
+
+		$this->client->request('GET', '/inventory/sw.js');
+		self::assertResponseIsSuccessful();
+		self::assertResponseHeaderSame('Content-Type', 'text/javascript; charset=UTF-8');
+		self::assertStringContainsString('"/js/inventory.js"', (string)$this->client->getResponse()->getContent());
+	}
+
+	public function testBookListContainsStandardNumbersAndPlacement(): void {
+		$this->createBook(2, 'Solaris', 'Stanisław Lem')->setIsbn('9788373843455')->setIssn('9770867041003')->setPlacement('HOL/4/1');
+		$this->createBook(1, 'Plain')->lendTo('A Reader', new \DateTimeImmutable());
+		$this->entityManager()->flush();
+
+		$this->client->request('GET', '/inventory/books');
+
+		self::assertResponseIsSuccessful();
+		self::assertSame(['books' => [
+			['id' => 1, 'title' => 'Plain', 'author' => 'Author', 'placement' => null, 'lastSeen' => null, 'borrowed' => true, 'isbn' => [], 'issn' => []],
+			['id' => 2, 'title' => 'Solaris', 'author' => 'Stanisław Lem', 'placement' => 'HOL/4/1', 'lastSeen' => null, 'borrowed' => false, 'isbn' => ['9788373843455'], 'issn' => ['9770867041003']],
+		]], $this->jsonResponse());
+	}
+
+	public function testSyncAssignsLocationsAndMarksBooksAsSeen(): void {
+		$this->createBook(1)->setPlacement('OLD/1');
+		$this->createBook(2);
+		$before = new \DateTimeImmutable('-1 second');
+
+		$this->sync(['1' => 'HOL/4/1', '2' => ' Attic ', '99' => 'X', '3' => '']);
+
+		self::assertResponseIsSuccessful();
+		self::assertSame(['results' => [
+			'1' => 'saved',
+			'2' => 'saved',
+			'99' => 'unknown_book',
+			'3' => 'invalid',
+		]], $this->jsonResponse());
+
+		$book = $this->findBook(1);
+		self::assertSame('HOL/4/1', $book?->getPlacement());
+		self::assertGreaterThanOrEqual($before, $book?->getLastSeen());
+		self::assertSame('Attic', $this->findBook(2)?->getPlacement());
+	}
+
+	public function testScanningABorrowedBookMarksItAsReturned(): void {
+		$lentAt = new \DateTimeImmutable('2026-10-01 10:00');
+		$this->createBook(1)->lendTo('A Reader', $lentAt);
+		$this->entityManager()->flush();
+		$this->entityManager()->clear();
+
+		$this->sync(['1' => 'HOL/4/1']);
+
+		self::assertSame(['results' => ['1' => 'returned']], $this->jsonResponse());
+		$book = $this->findBook(1);
+		self::assertFalse($book?->isBorrowed());
+		self::assertSame('HOL/4/1', $book?->getPlacement());
+
+		$history = $book?->getBorrowHistory()->first();
+		self::assertSame('A Reader', $history ? $history->getWho() : null);
+		self::assertEquals($lentAt, $history ? $history->getAt() : null);
+		self::assertEquals($book?->getLastSeen(), $history ? $history->getTill() : null);
+
+		$this->sync(['1' => 'HOL/4/1']);
+		self::assertSame(['results' => ['1' => 'saved']], $this->jsonResponse());
+		self::assertCount(1, $this->findBook(1)?->getBorrowHistory() ?? []);
+	}
+
+	public function testSyncAcceptsBookZero(): void {
+		$this->createBook(0);
+
+		$this->sync((object)['0' => 'HOL/1/1']);
+
+		self::assertResponseIsSuccessful();
+		self::assertSame('HOL/1/1', $this->findBook(0)?->getPlacement());
+	}
+
+	public function testSyncRejectsAnythingButAJsonMap(): void {
+		$this->createBook(1);
+
+		$this->client->request('POST', '/inventory/sync', ['1' => 'HOL/4/1']);
+		self::assertResponseStatusCodeSame(415);
+
+		$this->sync([['id' => 1, 'location' => 'HOL/4/1']]);
+		self::assertResponseStatusCodeSame(400);
+
+		$this->sync(['1' => ['nested']]);
+		self::assertResponseStatusCodeSame(400);
+
+		self::assertNull($this->findBook(1)?->getPlacement());
+	}
+
+	public function testDetailsSayWhenBookWasNeverSeen(): void {
+		$this->createBook(1);
+
+		$this->client->request('GET', '/book/1');
+
+		self::assertSelectorTextContains('.book-last-seen td', 'not seen during an inventory yet');
+	}
+
+	public function testOustingKeepsLastSeen(): void {
+		$this->createBook(1);
+		$this->sync(['1' => 'HOL/4/1']);
+		$seen = $this->findBook(1)?->getLastSeen();
+
+		$crawler = $this->client->request('GET', '/book/1');
+		self::assertSelectorTextContains('.book-last-seen td', (string)$seen?->format('Y-m-d H:i'));
+		$this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
+
+		$this->entityManager()->clear();
+		self::assertEquals($seen, $this->entityManager()->find(OustedBook::class, 1)?->getLastSeen());
+		$this->client->request('GET', '/ousted/1');
+		self::assertSelectorTextContains('.book-last-seen td', (string)$seen?->format('Y-m-d H:i'));
+	}
+
+	/**
+	 * @param array<mixed>|object $body
+	 */
+	private function sync(array|object $body): void {
+		$this->client->request('POST', '/inventory/sync', server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($body, \JSON_THROW_ON_ERROR));
+	}
+
+	/**
+	 * @return array<mixed>
+	 */
+	private function jsonResponse(): array {
+		return json_decode((string)$this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR);
+	}
+}
diff --git a/tests/Functional/LibraryThingCoverTest.php b/tests/Functional/LibraryThingCoverTest.php
new file mode 100644
index 0000000..5d285f9
--- /dev/null
+++ b/tests/Functional/LibraryThingCoverTest.php
@@ -0,0 +1,116 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Service\CoverStorage;
+use App\Tests\DatabaseTestCase;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+
+#[RequiresPhpExtension('gd')]
+final class LibraryThingCoverTest extends DatabaseTestCase {
+	private const string ISBN = '9788373843455';
+
+	/** @var list<string> */
+	private array $requestedUrls = [];
+
+	/** @var array{mixed, mixed} the key in $_ENV and $_SERVER before the test */
+	private array $previousKey;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->previousKey = [$_ENV['LIBRARYTHING_API_KEY'] ?? null, $_SERVER['LIBRARYTHING_API_KEY'] ?? null];
+		new Filesystem()->remove(static::getContainer()->getParameter('kernel.project_dir') . '/var/test/covers');
+	}
+
+	protected function tearDown(): void {
+		[$_ENV['LIBRARYTHING_API_KEY'], $_SERVER['LIBRARYTHING_API_KEY']] = $this->previousKey;
+
+		parent::tearDown();
+	}
+
+	public function testNewBookGetsTheLibraryThingCover(): void {
+		$image = self::png(120, 180);
+		$this->mockLibraryThing('secret-key', new MockResponse($image, ['response_headers' => ['content-type' => 'image/png']]));
+
+		$this->addBook(self::ISBN);
+
+		self::assertSame(['https://covers.librarything.com/devkey/secret-key/large/isbn/' . self::ISBN], $this->requestedUrls);
+		$cover = $this->findBook(5)?->getCover();
+		self::assertNotNull($cover);
+		self::assertStringEqualsFile(static::getContainer()->get(CoverStorage::class)->originalPath($cover), $image);
+		$this->client->followRedirect();
+		self::assertAnySelectorTextContains('.flash', 'downloaded from LibraryThing');
+	}
+
+	public function testPlaceholderMeansNoCover(): void {
+		$this->mockLibraryThing('secret-key', new MockResponse(self::png(1, 1)));
+
+		$this->addBook(self::ISBN);
+
+		self::assertCount(1, $this->requestedUrls);
+		self::assertNull($this->findBook(5)?->getCover());
+	}
+
+	public function testFailedDownloadStillSavesTheBook(): void {
+		$this->mockLibraryThing('secret-key', new MockResponse('', ['http_code' => 503]));
+
+		$this->addBook(self::ISBN);
+
+		self::assertResponseRedirects('/book/5');
+		self::assertNull($this->findBook(5)?->getCover());
+	}
+
+	public function testNothingIsRequestedWithoutAKey(): void {
+		$this->mockLibraryThing('', new MockResponse(self::png(120, 180)));
+
+		$this->addBook(self::ISBN);
+
+		self::assertSame([], $this->requestedUrls);
+		self::assertNull($this->findBook(5)?->getCover());
+	}
+
+	public function testNothingIsRequestedWithoutAnIsbn(): void {
+		$this->mockLibraryThing('secret-key', new MockResponse(self::png(120, 180)));
+
+		$this->addBook('');
+
+		self::assertSame([], $this->requestedUrls);
+	}
+
+	private function mockLibraryThing(string $key, MockResponse $response): void {
+		$_ENV['LIBRARYTHING_API_KEY'] = $_SERVER['LIBRARYTHING_API_KEY'] = $key;
+		// A reboot between the form and its submission would drop the mock.
+		$this->client->disableReboot();
+
+		static::getContainer()->set('librarything.client', new MockHttpClient(
+			function (string $method, string $url) use ($response): MockResponse {
+				$this->requestedUrls[] = $url;
+
+				return $response;
+			},
+			'https://covers.librarything.com',
+		));
+	}
+
+	private function addBook(string $isbn): void {
+		$crawler = $this->client->request('GET', '/add/form?id=5');
+		$form = $crawler->selectButton('Save')->form();
+		$form['book[title]'] = 'Title';
+		$form['book[author]'] = 'Author';
+		$form['book[isbn]'] = $isbn;
+		$this->client->submit($form);
+	}
+
+	private static function png(int $width, int $height): string {
+		$image = imagecreatetruecolor($width, $height);
+		ob_start();
+		imagepng($image);
+
+		return (string)ob_get_clean();
+	}
+}
diff --git a/tests/Functional/LoanTest.php b/tests/Functional/LoanTest.php
new file mode 100644
index 0000000..1d3768c
--- /dev/null
+++ b/tests/Functional/LoanTest.php
@@ -0,0 +1,116 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Entity\OustedBook;
+use App\Tests\DatabaseTestCase;
+
+final class LoanTest extends DatabaseTestCase {
+	public function testBookCanBeLentAndReturned(): void {
+		$this->createBook(1);
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Lend')->form(['borrow[who]' => 'A Reader']));
+		self::assertResponseRedirects('/book/1');
+		self::assertSame('A Reader', $this->findBook(1)?->getBorrow()?->getWho());
+
+		$crawler = $this->client->request('GET', '/borrowed');
+		self::assertSelectorTextContains('.results .tile', 'A Reader');
+		self::assertSelectorExists('.results .tile-title a[href="/book/1"]');
+
+		$crawler = $this->client->request('GET', '/book/1');
+		self::assertCount(0, $crawler->selectButton('Take out'));
+		$this->client->submit($crawler->selectButton('Mark as returned')->form());
+
+		$book = $this->findBook(1);
+		self::assertFalse($book?->isBorrowed());
+		self::assertCount(1, $book?->getBorrowHistory() ?? []);
+	}
+
+	public function testActionsRequireAValidCsrfToken(): void {
+		$this->createBook(1)->lendTo('A Reader', new \DateTimeImmutable());
+		$this->entityManager()->flush();
+
+		$this->client->request('POST', '/book/1/return', ['_token' => 'forged']);
+
+		self::assertTrue($this->findBook(1)?->isBorrowed());
+	}
+
+	public function testOustedBookKeepsHistoryAndCanBeRestored(): void {
+		$book = $this->createBook(1, 'Moved');
+		$book->addBorrowHistory('A Reader', new \DateTimeImmutable('2020-01-01'), new \DateTimeImmutable('2020-02-01'));
+		$this->entityManager()->flush();
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
+		self::assertResponseRedirects('/ousted/1');
+
+		self::assertNull($this->findBook(1));
+		$ousted = $this->entityManager()->find(OustedBook::class, 1);
+		self::assertSame('Lost', $ousted?->getReason());
+		self::assertCount(1, $ousted?->getBorrowHistory() ?? []);
+
+		$this->client->request('GET', '/ousted');
+		self::assertSelectorTextContains('.results .tile', 'Lost');
+		self::assertSelectorExists('.results .tile-title a[href="/ousted/1"]');
+		self::assertSelectorNotExists('.results a[href="/book/1/edit"]');
+
+		$crawler = $this->client->request('GET', '/ousted/1');
+		$this->client->submit($crawler->selectButton('Return to the collection')->form());
+		self::assertResponseRedirects('/book/1');
+
+		self::assertCount(1, $this->findBook(1)?->getBorrowHistory() ?? []);
+		self::assertNull($this->entityManager()->find(OustedBook::class, 1));
+	}
+
+	public function testBorrowedBookCannotBeOusted(): void {
+		$this->createBook(1);
+		$crawler = $this->client->request('GET', '/book/1');
+		$form = $crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']);
+
+		$this->findBook(1)?->lendTo('A Reader', new \DateTimeImmutable());
+		$this->entityManager()->flush();
+		$this->client->submit($form);
+
+		self::assertNotNull($this->findBook(1));
+	}
+
+	public function testTypedLabelsCanBeAddedAndAreValidated(): void {
+		$this->createBook(1);
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Add label')->form([
+			'add_label[type]' => 'isbn',
+			'add_label[label]' => '978-83-7384-345-5',
+		]));
+		self::assertSame('9788373843455', $this->findBook(1)?->getIsbn());
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Add label')->form([
+			'add_label[type]' => 'issn',
+			'add_label[label]' => 'not a number',
+		]));
+		self::assertNull($this->findBook(1)?->getIssn());
+	}
+
+	public function testOustingKeepsLabels(): void {
+		$book = $this->createBook(1)->setIsbn('9788373843455');
+		$book->addLabel('crime');
+		$this->entityManager()->flush();
+
+		$crawler = $this->client->request('GET', '/book/1');
+		$this->client->submit($crawler->selectButton('Take out')->form(['oust[reason]' => 'Lost']));
+
+		$this->entityManager()->clear();
+		$ousted = $this->entityManager()->find(OustedBook::class, 1);
+		self::assertSame('9788373843455', $ousted?->getIsbn());
+		self::assertCount(2, $ousted?->getLabels() ?? []);
+
+		$crawler = $this->client->request('GET', '/ousted/1');
+		$this->client->submit($crawler->selectButton('Return to the collection')->form());
+
+		self::assertSame('9788373843455', $this->findBook(1)?->getIsbn());
+		self::assertCount(2, $this->findBook(1)?->getLabels() ?? []);
+	}
+}
diff --git a/tests/Functional/LookupTest.php b/tests/Functional/LookupTest.php
new file mode 100644
index 0000000..5aaf1bc
--- /dev/null
+++ b/tests/Functional/LookupTest.php
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Tests\DatabaseTestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\JsonMockResponse;
+use Symfony\Component\HttpClient\Response\MockResponse;
+
+final class LookupTest extends DatabaseTestCase {
+	public function testCandidatePrefillsTheAddForm(): void {
+		$this->mockCatalogue(new JsonMockResponse(['bibs' => [[
+			'title' => 'Trzynaście zagadek / Thirteen problems, Seria z Gawronem.',
+			'author' => 'Christie, Agatha (1890-1976) Weiss, Maria Wydawnictwo Dolnośląskie',
+			'publisher' => 'Da Capo Da Capo,',
+			'placeOfPublication' => 'Wrocław : Polska',
+			'publicationYear' => '1994',
+			'language' => 'polski',
+			'isbnIssn' => '83-7023-382-1',
+		]]]));
+
+		$crawler = $this->client->request('GET', '/add/lookup', ['id' => '5', 'isn' => '8370233821']);
+		self::assertResponseIsSuccessful();
+
+		$crawler = $this->client->click($crawler->selectLink('Use')->link());
+		$form = $crawler->selectButton('Save')->form();
+
+		self::assertSame('5', $form['book[id]']->getValue());
+		self::assertSame('Trzynaście zagadek / Thirteen problems, Seria z Gawronem', $form['book[title]']->getValue());
+		self::assertSame('Agatha Christie', $form['book[author]']->getValue());
+		self::assertSame('Da Capo', $form['book[publisher]']->getValue());
+		self::assertSame('Wrocław', $form['book[publicationLoc]']->getValue());
+		self::assertSame('1994', $form['book[publicationYear]']->getValue());
+		self::assertSame('9788370233822', $form['book[isbn]']->getValue());
+	}
+
+	public function testNoResultsOffersManualEntry(): void {
+		$this->mockCatalogue(new JsonMockResponse(['bibs' => []]));
+
+		$this->client->request('GET', '/add/lookup', ['title' => 'Nothing']);
+
+		self::assertResponseIsSuccessful();
+		self::assertSelectorTextContains('.page_content', 'no matching books');
+	}
+
+	public function testCatalogueFailureFallsBackToTheAddPage(): void {
+		$this->mockCatalogue(new MockResponse('', ['http_code' => 503]));
+
+		$this->client->request('GET', '/add/lookup', ['isn' => '8370233821']);
+
+		self::assertResponseRedirects('/add');
+		$this->client->followRedirect();
+		self::assertSelectorTextContains('.flash', 'catalogue is unavailable');
+	}
+
+	private function mockCatalogue(MockResponse $response): void {
+		static::getContainer()->set('bn.client', new MockHttpClient($response, 'https://data.bn.org.pl'));
+	}
+}
diff --git a/tests/Functional/SearchTest.php b/tests/Functional/SearchTest.php
new file mode 100644
index 0000000..d968906
--- /dev/null
+++ b/tests/Functional/SearchTest.php
@@ -0,0 +1,65 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Functional;
+
+use App\Tests\DatabaseTestCase;
+
+final class SearchTest extends DatabaseTestCase {
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->createBook(1, 'Łódź nocą', 'Agatha Christie');
+		$this->createBook(2, 'Rabat 100%', 'Stanisław Lem');
+		$this->createBook(3, 'Solaris', 'Stanisław Lem')->setIsbn('9788373843455')->setPublisher('Wydawnictwo Literackie');
+		$this->entityManager()->flush();
+	}
+
+	/**
+	 * @return iterable<string, array{string, list<int>}>
+	 */
+	public static function quickQueries(): iterable {
+		yield 'non-ASCII title' => ['Łódź', [1]];
+		yield 'several books' => ['Stanisław', [2, 3]];
+		yield 'words may hit different columns' => ['lem solaris', [3]];
+		yield 'publisher' => ['literackie', [3]];
+		yield 'ISBN with hyphens' => ['978-83-7384-345-5', [3]];
+		yield 'ISBN-10 form of a stored ISBN-13' => ['83-7384-345-0', [3]];
+		yield 'ID' => ['2', [2]];
+		yield 'percent sign is literal' => ['100%', [2]];
+		yield 'underscore is literal' => ['S_laris', []];
+		yield 'no match' => ['nothing', []];
+	}
+
+	/**
+	 * @param list<int> $expectedIds
+	 */
+	#[\PHPUnit\Framework\Attributes\DataProvider('quickQueries')]
+	public function testQuickSearch(string $query, array $expectedIds): void {
+		$crawler = $this->client->request('GET', '/search', ['q' => $query]);
+
+		self::assertResponseIsSuccessful();
+		$ids = $crawler->filter('.tile .label-primary')->each(static fn($node): int => (int)$node->text());
+		self::assertSame($expectedIds, $ids);
+	}
+
+	public function testFieldsAreCombined(): void {
+		$crawler = $this->client->request('GET', '/search', ['author' => 'lem', 'title' => 'SOLARIS']);
+
+		self::assertCount(1, $crawler->filter('.tile'));
+	}
+
+	public function testFieldsDoNotMatchOtherColumns(): void {
+		$crawler = $this->client->request('GET', '/search', ['title' => 'lem']);
+
+		self::assertCount(0, $crawler->filter('.tile'));
+		self::assertSelectorExists('.empty');
+	}
+
+	public function testSearchPageWithoutCriteriaShowsNoResults(): void {
+		$crawler = $this->client->request('GET', '/search');
+
+		self::assertResponseIsSuccessful();
+		self::assertCount(0, $crawler->filter('.tile'));
+	}
+}
diff --git a/tests/LegacyDatabase.php b/tests/LegacyDatabase.php
new file mode 100644
index 0000000..b5dbab3
--- /dev/null
+++ b/tests/LegacyDatabase.php
@@ -0,0 +1,24 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests;
+
+use Doctrine\DBAL\Connection;
+
+/**
+ * Gives a test empty legacy tables, an SQLite stand-in for orig-books.sql with the same column names and meanings.
+ */
+trait LegacyDatabase {
+	private Connection $legacy;
+
+	private function createLegacyTables(): void {
+		$this->legacy = static::getContainer()->get('doctrine.dbal.legacy_connection');
+		foreach (['ksiazki', 'pozycz', 'pozycz_historia', 'pozycz_missing'] as $table) {
+			$this->legacy->executeStatement('DROP TABLE IF EXISTS ' . $table);
+		}
+
+		$this->legacy->executeStatement('CREATE TABLE ksiazki (id INTEGER PRIMARY KEY, tytul TEXT NOT NULL, autor TEXT NOT NULL, jezyk TEXT NOT NULL, rok INTEGER, miejsce TEXT, wydawnictwo TEXT, wydanie TEXT, ISBN TEXT, ISSN TEXT, regal TEXT, polka TEXT, rzad TEXT, wycofana TEXT NOT NULL DEFAULT \'0\', powod TEXT)');
+		$this->legacy->executeStatement('CREATE TABLE pozycz (id INTEGER NOT NULL, kto TEXT NOT NULL, od INTEGER NOT NULL, PRIMARY KEY (id, od))');
+		$this->legacy->executeStatement('CREATE TABLE pozycz_historia (id INTEGER NOT NULL, kto TEXT NOT NULL, od INTEGER NOT NULL, "do" INTEGER, PRIMARY KEY (id, od))');
+	}
+}
diff --git a/tests/Unit/IsbnTest.php b/tests/Unit/IsbnTest.php
new file mode 100644
index 0000000..32c4f9f
--- /dev/null
+++ b/tests/Unit/IsbnTest.php
@@ -0,0 +1,39 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Tests\Unit;
+
+use App\Enum\LabelType;
+use App\Model\Isbn;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+final class IsbnTest extends TestCase {
+	/**
+	 * @return iterable<string, array{string, ?string}>
+	 */
+	public static function values(): iterable {
+		yield 'ISBN-10' => ['0306406152', '9780306406157'];
+		yield 'ISBN-10 with hyphens' => ['0-306-40615-2', '9780306406157'];
+		yield 'ISBN-10 ending in X' => ['080442957X', '9780804429573'];
+		yield 'lower-case X' => ['080442957x', '9780804429573'];
+		yield 'ISBN-13' => ['9788373843455', '9788373843455'];
+		yield 'ISBN-13 with hyphens' => ['978-83-7384-345-5', '9788373843455'];
+		yield '979 prefix' => ['9791034900107', '9791034900107'];
+		yield 'bad ISBN-10 check digit' => ['0306406153', null];
+		yield 'bad ISBN-13 check digit' => ['9788373843456', null];
+		yield 'not a book prefix' => ['9770406063008', null];
+		yield 'wrong length' => ['12345', null];
+		yield 'text' => ['not a number', null];
+	}
+
+	#[DataProvider('values')]
+	public function testConversion(string $value, ?string $expected): void {
+		self::assertSame($expected, Isbn::toIsbn13($value));
+	}
+
+	public function testOnlyIsbn13PassesLabelValidation(): void {
+		self::assertTrue(LabelType::ISBN->isValid('9780306406157'));
+		self::assertFalse(LabelType::ISBN->isValid('0306406152'));
+	}
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..ae76ef1
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,14 @@
+<?php
+declare(strict_types=1);
+
+use Symfony\Component\Dotenv\Dotenv;
+
+require dirname(__DIR__) . '/vendor/autoload.php';
+
+if (method_exists(Dotenv::class, 'bootEnv')) {
+	(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
+}
+
+if ($_SERVER['APP_DEBUG']) {
+	umask(0000);
+}
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
new file mode 100644
index 0000000..d81f893
--- /dev/null
+++ b/translations/messages.en.yaml
@@ -0,0 +1,176 @@
+general_app_name: Book Shelf
+general_app_slogan: book management system
+
+general_search_placeholder: ID, ISBN, title, author
+general_search_button: Search
+
+menu_list: List
+menu_search: Search
+menu_add: Add
+menu_borrowed: Borrowed
+menu_ousted: Ousted
+menu_inventory: Inventory
+
+book_id_header: ID
+book_title_header: Title
+book_author_header: Author
+book_edition_header: Edition
+book_publisher_header: Publisher
+book_language_header: Language
+book_publication_loc_header: Place of publication
+book_publication_year_header: Publication year
+book_isn_header: IS*N
+book_isn_description: ISBN or ISSN
+book_isbn_header: ISBN
+book_issn_header: ISSN
+book_placement_header: Location
+book_last_seen_header: Last seen
+book_last_seen_never: not seen during an inventory yet
+
+book_action_edit: Edit
+book_action_more: More
+book_action_save: Save
+
+book_list_edition_pre: Edition
+book_list_edition_post: ''
+
+list_page_title: List of books
+list_no_books: There are no books in the library
+list_no_books_add: Use a link below to add your first book
+list_no_books_add_button: Add a book
+
+search_page_title: Search
+search_action_search: Search
+search_action_reset: Reset
+
+search_results_page_title: Search results
+search_no_books: Search returned no results
+search_no_books_refine: You may try refining your query
+search_no_books_refine_button: Search again
+
+add_page_title: Add a book
+add_page_description: Stick the label with ID to the book and fill the form under one of the available options.
+add_section_isn_title: Use IS*N
+add_section_isn_desc: Add a book by searching the databases for given ISBN or ISSN.
+add_action_add_isn: Search and add
+add_section_search_title: By searching
+add_section_search_desc: Add a book by searching the databases for given title and author.
+add_action_add_search: Search and add
+add_section_manual_title: Manually
+add_section_manual_desc: Add a book by entering all the information manually.
+add_action_add_manual: Add manually
+
+add_form_title: Adding a book
+add_form_description: Fill in the following form
+
+pagination_first: '<<'
+pagination_prev: '<'
+pagination_next: '>'
+pagination_last: '>>'
+
+book_saved: The book has been saved.
+book_id_taken: This ID is already used by another book.
+action_rejected: The action could not be completed. Reload the page and try again.
+
+borrow_section_title: Loan
+borrow_who_header: Borrower
+borrow_at_header: Borrowed on
+borrow_till_header: Returned on
+borrow_status_lent: Borrowed
+borrow_lent_to: Borrowed by %who% since %date%
+borrow_action_lend: Lend
+borrow_action_return: Mark as returned
+borrow_lent: The loan has been recorded.
+borrow_returned: The book has been returned.
+book_details_title: Details
+borrow_history_title: Loan history
+borrow_history_empty: This book has not been borrowed yet.
+borrow_list_title: Borrowed books
+borrow_list_empty: No books are currently borrowed.
+
+cover_section_title: Cover
+cover_file_header: Image file (JPEG, PNG or WebP)
+cover_action_upload: Upload cover
+cover_action_remove: Remove cover
+cover_show_original: Show the original image
+cover_saved: The cover has been saved.
+cover_downloaded: The cover has been downloaded from LibraryThing.
+cover_failed: The image could not be processed.
+cover_too_large: The file is too large.
+
+label_header: Label
+label_type_header: Type
+label_type_text: Text
+label_type_isbn: ISBN
+label_type_issn: ISSN
+label_invalid_number: This is not a valid number of the selected type.
+label_action_add: Add label
+label_action_remove: Remove
+
+ousted_section_title: Take out of the collection
+ousted_section_desc: Use this for books given away, lost or destroyed.
+ousted_reason_header: Reason
+ousted_action_oust: Take out
+ousted_action_restore: Return to the collection
+ousted_status: Ousted
+ousted_done: The book has been taken out of the collection.
+ousted_restored: The book is back in the collection.
+ousted_list_title: Ousted books
+ousted_list_empty: No books have been taken out of the collection.
+
+lookup_page_title: Catalogue search
+lookup_no_results: The catalogue has no matching books.
+lookup_pick: Pick the matching record to fill in the form.
+lookup_use: Use
+lookup_failed: The catalogue is unavailable. Try again or add the book manually.
+
+inventory_page_title: Inventory
+inventory_input_placeholder: Location, ID, ISBN or ISSN
+inventory_input_hint: "A location starts with a letter or contains a slash (e.g. HOL/4/1). A number of up to 8 digits is a book ID; anything else is looked up as an ISBN or ISSN (enter an 8-digit ISSN with its hyphen). Books are assigned to the last entered location."
+inventory_action_enter: OK
+inventory_action_refresh: Refresh list
+inventory_action_sync: Send now
+inventory_action_remove: Undo
+inventory_action_clear_log: Clear history
+inventory_current_location: "Current location:"
+inventory_books_count: "Books:"
+inventory_pending_count: "Not sent:"
+inventory_log_title: Recent scans
+inventory_log_empty: Nothing scanned yet.
+inventory_online: Online
+inventory_offline: Offline
+inventory_never: never
+inventory_status_pending: Not sent
+inventory_status_saved: Saved
+inventory_status_returned: Saved, loan closed
+inventory_msg_will_return: It was on loan and will be marked as returned.
+inventory_status_unknown_book: Unknown book
+inventory_status_invalid: Rejected
+inventory_confirm_clear: Clear the history? Scans not sent yet are kept.
+inventory_msg_location_set: "Location set to %location%."
+inventory_msg_no_location: Enter a location first.
+inventory_msg_assigned: "%id% %title% → %location%."
+inventory_msg_moved: "Previously at %previous%."
+inventory_msg_unknown_id: "There is no book with ID %id%. Refresh the list if it was added recently."
+inventory_msg_unknown_number: "No book has %number%."
+inventory_msg_ambiguous_number: "%number% belongs to several books (%ids%). Enter the ID instead."
+inventory_msg_unrecognized: "\"%input%\" is not a location, an ID, a valid ISBN or an ISSN."
+inventory_msg_no_books: The book list has not been downloaded yet or there are no books in the system.
+inventory_msg_books_loaded: "%count% books downloaded."
+inventory_msg_books_failed: The book list could not be downloaded; the saved copy is used.
+inventory_msg_synced: "%count% scans sent."
+inventory_msg_sync_failed: The server cannot be reached. The scans are kept and will be sent later.
+inventory_msg_sync_rejected: "The server rejected %count% scans; see the history."
+
+bulk_oust_page_title: Take out books not seen
+bulk_oust_page_desc: After an inventory, take out of the collection every book that was not found. Books never seen during an inventory count as not found; borrowed books are left alone.
+bulk_oust_not_seen_since_header: Not seen on or after
+bulk_oust_action_preview: Show the books
+bulk_oust_action_oust: Take them all out
+bulk_oust_found: "%count% books were not seen on or after %date%."
+bulk_oust_none: "Every book on the shelves was seen on or after %date%."
+bulk_oust_confirm_reason: "Reason: %reason%"
+bulk_oust_confirm_desc: They are listed below. Each can later be returned to the collection from its page.
+bulk_oust_borrowed_skipped: "%count% borrowed books not seen since then are not included."
+bulk_oust_changed: The list of books has changed since it was shown. Check it again.
+bulk_oust_done: "%count% books have been taken out of the collection."
diff --git a/translations/messages.pl.yaml b/translations/messages.pl.yaml
new file mode 100644
index 0000000..9d6dedc
--- /dev/null
+++ b/translations/messages.pl.yaml
@@ -0,0 +1,176 @@
+general_app_name: Biblioteczka
+general_app_slogan: zarządzanie księgozbiorem
+
+general_search_placeholder: KOD, ISBN, tytuł, autor
+general_search_button: Szukaj
+
+menu_list: Książki
+menu_search: Szukaj
+menu_add: Dodaj
+menu_borrowed: Wypożyczone
+menu_ousted: Wycofane
+menu_inventory: Inwentaryzacja
+
+book_id_header: KOD
+book_title_header: Tytuł
+book_author_header: Autor
+book_edition_header: Wydanie
+book_publisher_header: Wydawca
+book_language_header: Język
+book_publication_loc_header: Miejsce wydania
+book_publication_year_header: Rok wydania
+book_isn_header: IS*N
+book_isn_description: ISBN lub ISSN
+book_isbn_header: ISBN
+book_issn_header: ISSN
+book_placement_header: Lokalizacja
+book_last_seen_header: Ostatnio widziana
+book_last_seen_never: jeszcze nie widziana podczas inwentaryzacji
+
+book_action_edit: Modyfikuj
+book_action_more: Więcej
+book_action_save: Zapisz
+
+book_list_edition_pre: Wydanie
+book_list_edition_post: ''
+
+list_page_title: Lista książek
+list_no_books: Biblioteka jest pusta
+list_no_books_add: Użyj poniższego linku, by dodać pierwszą książkę
+list_no_books_add_button: Dodaj książkę
+
+search_page_title: Wyszukiwanie
+search_action_search: Szukaj
+search_action_reset: Wyczyść
+
+search_results_page_title: Wyniki wyszukiwania
+search_no_books: Wyszukiwanie nie zwróciło trafień
+search_no_books_refine: Spróbuj zmienić kryteria wyszukiwania
+search_no_books_refine_button: Wyszukaj ponownie
+
+add_page_title: Dodaj książkę
+add_page_description: Przyklej etykietę z KODEM na książkę, a następnie wybierz jedną z opcji poniżej i uzupełnij wymagane pola.
+add_section_isn_title: Używając IS*N
+add_section_isn_desc: Dodaj książkę, wyszukując w katalogu podany ISBN lub ISSN.
+add_action_add_isn: Wyszukaj i dodaj
+add_section_search_title: Wyszukując
+add_section_search_desc: Dodaj książkę, wyszukując w katalogu tytuł i autora.
+add_action_add_search: Wyszukaj i dodaj
+add_section_manual_title: Ręcznie
+add_section_manual_desc: Dodaj książkę, wpisując wszystkie dane samodzielnie.
+add_action_add_manual: Dodaj
+
+add_form_title: Dodawanie książki
+add_form_description: Wypełnij pola w poniższym formularzu
+
+pagination_first: '<<'
+pagination_prev: '<'
+pagination_next: '>'
+pagination_last: '>>'
+
+book_saved: Książka została zapisana.
+book_id_taken: Ten KOD jest już przypisany do innej książki.
+action_rejected: Nie udało się wykonać operacji. Odśwież stronę i spróbuj ponownie.
+
+borrow_section_title: Wypożyczenie
+borrow_who_header: Wypożyczający
+borrow_at_header: Data wypożyczenia
+borrow_till_header: Data zwrotu
+borrow_status_lent: Wypożyczona
+borrow_lent_to: 'Wypożyczona przez: %who%, od %date%'
+borrow_action_lend: Wypożycz
+borrow_action_return: Oznacz jako zwróconą
+borrow_lent: Wypożyczenie zostało zapisane.
+borrow_returned: Książka została zwrócona.
+book_details_title: Szczegóły
+borrow_history_title: Historia wypożyczeń
+borrow_history_empty: Ta książka nie była jeszcze wypożyczana.
+borrow_list_title: Wypożyczone książki
+borrow_list_empty: Żadna książka nie jest obecnie wypożyczona.
+
+cover_section_title: Okładka
+cover_file_header: Plik graficzny (JPEG, PNG lub WebP)
+cover_action_upload: Prześlij okładkę
+cover_action_remove: Usuń okładkę
+cover_show_original: Pokaż oryginalny obraz
+cover_saved: Okładka została zapisana.
+cover_downloaded: Okładka została pobrana z LibraryThing.
+cover_failed: Nie udało się przetworzyć obrazu.
+cover_too_large: Plik jest zbyt duży.
+
+label_header: Etykieta
+label_type_header: Rodzaj
+label_type_text: Tekst
+label_type_isbn: ISBN
+label_type_issn: ISSN
+label_invalid_number: To nie jest poprawny numer wybranego rodzaju.
+label_action_add: Dodaj etykietę
+label_action_remove: Usuń
+
+ousted_section_title: Wycofanie z księgozbioru
+ousted_section_desc: Dotyczy książek oddanych, zagubionych lub zniszczonych.
+ousted_reason_header: Powód
+ousted_action_oust: Wycofaj
+ousted_action_restore: Przywróć do księgozbioru
+ousted_status: Wycofana
+ousted_done: Książka została wycofana z księgozbioru.
+ousted_restored: Książka wróciła do księgozbioru.
+ousted_list_title: Wycofane książki
+ousted_list_empty: Żadna książka nie została wycofana.
+
+lookup_page_title: Wyszukiwanie w katalogu
+lookup_no_results: W katalogu nie ma pasujących książek.
+lookup_pick: Wybierz pasujący rekord, aby uzupełnić formularz.
+lookup_use: Wybierz
+lookup_failed: Katalog jest niedostępny. Spróbuj ponownie lub dodaj książkę ręcznie.
+
+inventory_page_title: Inwentaryzacja
+inventory_input_placeholder: Lokalizacja, KOD, ISBN lub ISSN
+inventory_input_hint: "Lokalizacja zaczyna się od litery lub zawiera ukośnik (np. HOL/4/1). Liczba do 8 cyfr to KOD książki, pozostałe wartości są wyszukiwane jako ISBN lub ISSN (8-cyfrowy ISSN należy podać z łącznikiem). Książki trafiają do ostatnio podanej lokalizacji."
+inventory_action_enter: OK
+inventory_action_refresh: Odśwież listę
+inventory_action_sync: Wyślij teraz
+inventory_action_remove: Cofnij
+inventory_action_clear_log: Wyczyść historię
+inventory_current_location: "Bieżąca lokalizacja:"
+inventory_books_count: "Książki:"
+inventory_pending_count: "Niewysłane:"
+inventory_log_title: Ostatnie skany
+inventory_log_empty: Nic jeszcze nie zeskanowano.
+inventory_online: Połączono
+inventory_offline: Brak połączenia
+inventory_never: nigdy
+inventory_status_pending: Niewysłane
+inventory_status_saved: Zapisane
+inventory_status_returned: Zapisane, zwrócona
+inventory_msg_will_return: Była wypożyczona i zostanie oznaczona jako zwrócona.
+inventory_status_unknown_book: Nieznana książka
+inventory_status_invalid: Odrzucone
+inventory_confirm_clear: Wyczyścić historię? Niewysłane skany zostaną zachowane.
+inventory_msg_location_set: "Bieżąca lokalizacja: %location%."
+inventory_msg_no_location: Najpierw podaj lokalizację.
+inventory_msg_assigned: "%id% %title% → %location%."
+inventory_msg_moved: "Poprzednio: %previous%."
+inventory_msg_unknown_id: "Nie ma książki o KODZIE %id%. Odśwież listę, jeśli została niedawno dodana."
+inventory_msg_unknown_number: "Żadna książka nie ma numeru %number%."
+inventory_msg_ambiguous_number: "Numer %number% ma kilka książek (%ids%). Podaj KOD."
+inventory_msg_unrecognized: "\"%input%\" nie jest lokalizacją, KODEM, poprawnym ISBN ani ISSN."
+inventory_msg_no_books: Lista książek nie została jeszcze pobrana lub brak książek w biblioteczce.
+inventory_msg_books_loaded: "Pobrano książek: %count%."
+inventory_msg_books_failed: Nie udało się pobrać listy książek; używana jest zapisana kopia.
+inventory_msg_synced: "Wysłano skanów: %count%."
+inventory_msg_sync_failed: Brak połączenia z serwerem. Skany zostały zachowane i zostaną wysłane później.
+inventory_msg_sync_rejected: "Serwer odrzucił skanów: %count%; szczegóły w historii."
+
+bulk_oust_page_title: Wycofaj niewidziane książki
+bulk_oust_page_desc: Po inwentaryzacji wycofaj z księgozbioru wszystkie książki, których nie znaleziono. Książki nigdy niewidziane podczas inwentaryzacji liczą się jako nieznalezione; wypożyczone są pomijane.
+bulk_oust_not_seen_since_header: Niewidziane od dnia
+bulk_oust_action_preview: Pokaż książki
+bulk_oust_action_oust: Wycofaj wszystkie
+bulk_oust_found: "Książki niewidziane od %date%: %count%."
+bulk_oust_none: "Wszystkie książki na półkach widziano od %date%."
+bulk_oust_confirm_reason: "Powód: %reason%"
+bulk_oust_confirm_desc: Lista jest poniżej. Każdą można później przywrócić do księgozbioru z jej strony.
+bulk_oust_borrowed_skipped: "Pominięto wypożyczone książki niewidziane od tego dnia: %count%."
+bulk_oust_changed: Lista książek zmieniła się od jej wyświetlenia. Sprawdź ją ponownie.
+bulk_oust_done: "Wycofano książek: %count%."

--
Gitblit v1.10.0