129 files added
85 files deleted
| New file |
| | |
| | | # 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= |
| New file |
| | |
| | | |
| | | ###> symfony/framework-bundle ### |
| | | APP_SECRET=dfe52648fee5c8f57d208278d991605a |
| | | ###< symfony/framework-bundle ### |
| New file |
| | |
| | | KERNEL_CLASS='App\Kernel' |
| | | APP_SECRET='$ecretf0rt3st' |
| | | LEGACY_DATABASE_URL="sqlite:///%kernel.project_dir%/var/legacy_test.db" |
| New file |
| | |
| | | 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 ### |
| New file |
| | |
| | | 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. |
| New file |
| | |
| | | # 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 |
| | | ``` |
| New file |
| | |
| | | #!/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); |
| | | }; |
| New file |
| | |
| | | #!/usr/bin/env php |
| | | <?php |
| | | declare(strict_types=1); |
| | | |
| | | require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit'; |
| New file |
| | |
| | | { |
| | | "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" |
| | | } |
| | | } |
| New file |
| | |
| | | <?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], |
| | | ]; |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | # Session-backed tokens: the app has no JavaScript for the stateless double-submit flow. |
| | | framework: |
| | | form: |
| | | csrf_protection: |
| | | token_id: submit |
| | | |
| | | csrf_protection: true |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | # 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 |
| New file |
| | |
| | | knp_paginator: |
| | | page_range: 10 |
| | | default_options: |
| | | page_name: page |
| | | distinct: false |
| | | template: |
| | | pagination: 'spectre/knp_paginator_pagination.html.twig' |
| New file |
| | |
| | | framework: |
| | | property_info: |
| | | with_constructor_extractor: true |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | framework: |
| | | default_locale: en |
| | | enabled_locales: [en, pl] |
| | | set_locale_from_accept_language: true |
| | | translator: |
| | | default_path: '%kernel.project_dir%/translations' |
| | | fallbacks: |
| | | - en |
| New file |
| | |
| | | twig: |
| | | file_name_pattern: '*.twig' |
| | | form_themes: ['spectre/form.html.twig'] |
| | | |
| | | when@test: |
| | | twig: |
| | | strict_variables: true |
| New file |
| | |
| | | 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 |
| New file |
| | |
| | | <?php |
| | | declare(strict_types=1); |
| | | |
| | | foreach (glob(dirname(__DIR__) . '/var/cache/prod/*.preload.php') ?: [] as $file) { |
| | | require $file; |
| | | } |
| New file |
| | |
| | | # 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 |
| New file |
| | |
| | | when@dev: |
| | | _errors: |
| | | resource: '@FrameworkBundle/Resources/config/routing/errors.php' |
| | | prefix: /_error |
| New file |
| | |
| | | # 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 |
| New file |
| | |
| | | <?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']); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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> |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | [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; |
| | | } |
| New file |
| | |
| | | /*! 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; |
| | | } |
| New file |
| | |
| | | /*! 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} |
| New file |
| | |
| | | /*! 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; |
| | | } |
| New file |
| | |
| | | /*! 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} |
| New file |
| | |
| | | /*! 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; |
| | | } |
| New file |
| | |
| | | /*! 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} |
| New file |
| | |
| | | <?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']); |
| | | }; |
| New file |
| | |
| | | (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); |
| | | }); |
| | | })(); |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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')); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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()]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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()]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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()]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'), |
| | | ]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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()]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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, |
| | | ]); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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)); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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)); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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, |
| | | }; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <?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 ''; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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']); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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']); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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']); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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 = []; |
| | | } |
| New file |
| | |
| | | <?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())); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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, |
| | | ) { |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| New file |
| | |
| | | <?php |
| | | declare(strict_types=1); |
| | | |
| | | namespace App\Lookup; |
| | | |
| | | final class LookupFailedException extends \RuntimeException { |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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])) === ''; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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), ['!' => '!!', '%' => '!%', '_' => '!_']) . '%'; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | declare(strict_types=1); |
| | | |
| | | namespace App\Service; |
| | | |
| | | final class CoverProcessingException extends \RuntimeException { |
| | | } |
| New file |
| | |
| | | <?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'; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <!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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <div class="results"> |
| | | {% for book in pagination %} |
| | | {{ include('book/_tile.html.twig') }} |
| | | {% endfor %} |
| | | |
| | | {{ knp_pagination_render(pagination) }} |
| | | </div> |
| New file |
| | |
| | | {% 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> |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | <!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> |
| New file |
| | |
| | | '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())), |
| | | ); |
| | | }); |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | {% 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 %} |
| | | |
| New file |
| | |
| | | {% 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 %} |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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'); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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', |
| | | ])); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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(); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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() ?? []); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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')); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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')); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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))'); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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')); |
| | | } |
| | | } |
| New file |
| | |
| | | <?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); |
| | | } |
| New file |
| | |
| | | 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." |
| New file |
| | |
| | | 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%." |