commit 8a85c15372a3e65adf616cc64eeeeb9a0a81540d Author: Cedric Abonnel Date: Wed May 13 23:41:58 2026 +0200 fix #29 : envoyer le lien magique par email (envoyer_mail_smtp) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bef216d --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Credentials +.env + +# Composer dependencies +vendor/ + +# OS +.DS_Store +Thumbs.db + +# Fichiers uploadés et cache générés (propriété www-data) +data/*/files/ +data/_cache/ +_cache/ diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache new file mode 100644 index 0000000..08840a1 --- /dev/null +++ b/.php-cs-fixer.cache @@ -0,0 +1 @@ +{"php":"8.3.6","version":"3.89.1:v3.89.1#f34967da2866ace090a2b447de1f357356474573","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"at_least_single_space"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":{"tokens":["use"]},"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"imports_order":["class","function","const"],"sort_algorithm":"none"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"strict_param":true,"declare_strict_types":true,"no_unused_imports":true,"single_quote":true},"hashes":{"templates\/header.php":"f4b64c4ecb4dadec166cb7935309096a","templates\/footer.php":"3111b4701ea698ba11c3423260657e28","public\/login\/oidc.php":"8ec86d6f3af33f64d586109ec17f817d","public\/login\/config.php":"5b7b3e2937b349c76a2fd239c3ae06f8","src\/Infrastructure\/DbAdapter.php":"3899a835130c146e2d30dbcca88d8f33","src\/Infrastructure\/Database.php":"6f2848ed70b29d9c2e2d259be611b9b0","src\/Infrastructure\/Session.php":"3538a1147cc81678c470d45ea8574a95","src\/Domain\/User.php":"02213454f7edf43f4afae3f2f81aaf01","src\/Http\/Csrf.php":"55631812cab4b1192f8e30c5d35fd5eb","src\/FileManager.php":"a51dda44f293f238aea295fd56b2fa99","src\/PostManager.php":"25f0179c4d96e9aa04218d54bf45a029","src\/helpers.php":"3a83a4872b1e3e3c58898b54f51e72b4","src\/db.php":"8888b7fbc9740eb3c60dd2374d0cb5d6","src\/ConfigRepo.php":"c2dcee160a272d27725d480a90e76dcf","src\/Parsedown.php":"85da2b47eca1a703fdfe44753bf912df","src\/Service\/Validator.php":"7c267b8b9f3f1bac0f2520dd10364831","src\/Service\/UiFormRenderer.php":"065617191c6d680ce97588f4fa159688","src\/mailer.php":"17e6b19103c880cc9a6c6634486506c2","src\/Repository\/DictionnaryRepository.php":"f937e98cf0f27b59ae00e430b52a586d","src\/Repository\/ProfileRepository.php":"b1cd483652500ee4e2aaaa9e0330ff1d","versions.php":"51a72261e1a507d3435b4a24e5f5fc09","config\/config.php":"a8b7698b01ab9b40eea655e8fcc194fc","phpstan-bootstrap.php":"d74864c2f107b740523f070d077d715e","src\/Service\/MailQueue.php":"20db418b83dcf426b7c6ad6787644cde","src\/Service\/AuthService.php":"f95a9ab097dcfc4ac6cbcf908cf4cd90","src\/Repository\/UserRepository.php":"d0ccc80374b54a5c4f20cb04c00fb083","src\/Service\/MailService.php":"7bff5df8cac3274a1a4ab8fe137514f2","templates\/licenses.php":"c1b8db32773de42bb4392063c50155e2","bootstrap.php":"23e657accae3b860a9ffc227daa30d27","public\/file.php":"4e9de9cbe565e895e7bd809028754cb9","public\/logout.php":"68bc31b06a9e23aa7a43ca7642365bcd","src\/ArticleManager.php":"43e0061b20b22037596aeac8c96de3ae","templates\/post_view.php":"73d3588e0b4c3470e4ce5b6182dfb2fa","templates\/post_list.php":"5ad535f5bdb6de8a6cf9630cefc48e58","templates\/layout.php":"5a9c8a96723eabafc186d6c23dd5cfa3","templates\/contact.php":"9d2026fd4250e0cef3ff736812cd2b12","templates\/post_form.php":"b444d7937eaa5d3b068e25d5d14bc2f7","templates\/legal.php":"b8eaf0c118bb06c92171aaecca091550","templates\/about.php":"022d078aaa40d2067352c81509144182","public\/login\/magic.php":"3d9447ed551e6401c3d43429b1600247","public\/login\/index.php":"7a27b066b232e943e0a98850f0993c52","public\/route.php":"8a478892d21a95352cae93b85169f424","public\/oidc\/callback.php":"be3e7a4e00b40d956c6a54de38fcfe50","public\/oidc\/start.php":"0ecd2496ed9b591e177abe7354a7809f","public\/oidc\/me.php":"d979853cfab834a645ed0c6df5297ed7","public\/index.php":"d48d76494172e7f172ef82010ea8ce93","src\/auth.php":"73d056745086cd0e63053eab808d2b5e"}} \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..53b4e00 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,12 @@ +in(__DIR__); +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'strict_param' => true, + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'single_quote' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ab1a914 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,140 @@ +# Changelog — varlog + +## [Unreleased] — 2026-05-13 + +### Performances + +- **Cache multi-niveaux pour les vues d'articles** : temps de chargement réduit + de +5 s à ~0,4 s sur 1 062 articles. + - Mémoïsation de `getAll()` et `getSearchIndex()` dans la requête PHP + (`$allCache`, `$searchIndexCache`) — évite les appels répétés. + - Cache disque par article (`_cache/articles/{uuid}.json`) avec invalidation + par comparaison `mtime` — 1 lecture au lieu de 2 par article. + - Slug index (`_cache/slug_index.json`) : `getBySlug()` en O(1) sans scanner + tous les articles ; construit depuis `search_index.json` en un seul fichier. + - `getCategories()` et `$_allPublished` chargés depuis `search_index.json` + au lieu de `getAll()` — 1 fichier lu quelle que soit la taille du catalogue. + - `search_index.json` enrichi avec `cover`, `created_at`, `author` ; rebuild + automatique si le format est obsolète. + - `SearchEngine::scorePool()` : tokenise chaque article une seule fois pour + N mots de titre (vs N passes séparées qui retokenisaient chaque article + N fois et calculaient la similarité trigramme sur le contenu). + - Le nombre de lectures de fichiers par vue d'article est désormais constant + (~4), indépendamment du nombre total d'articles. + - Documentation : `docs/cache-architecture.md`. + +### Corrigé + +- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement. + Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans + `/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`. + Le handler `add_files` détecte désormais le dépassement et affiche un message + d'erreur explicite au lieu de rediriger sans rien faire. + +### Fonctionnalités + +- **Réactions visiteurs** : trois boutons (👍 Utile / 🔥 Important / 🤔 À creuser) + affichés sous chaque article. Toggle : recliquer retire la réaction. Accessible sans + compte via un cookie UUID (`vl_vid`, 1 an, `HttpOnly`). Comportement async fetch avec + fallback formulaire natif (compatible CSP `script-src 'self'`). Routes : + `POST /react`. Table BDD : `article_reactions`. + +- **Commentaires avec vérification email** : formulaire nom + email (non publié) + + texte (2 000 caractères max). Protection honeypot + CSRF en session. Un code à + 6 chiffres est envoyé par email (expire 24 h) ; le commentaire est auto-publié au clic + sur le lien de confirmation. Routes : `POST /comment`, + `GET /verify-comment/<6chiffres>`. Table BDD : `comments`. + +- **Modération commentaires** : onglet **Commentaires** dans `/admin/comments` listant + tous les commentaires avec statut (vérifié / publié) et actions masquer/republier. + Route : `POST /comment-moderate`. + +- **Page de confirmation à l'enregistrement** : cliquer sur "Enregistrer" affiche une + page intermédiaire avec le diff du contenu, le slug (déplacé ici depuis le formulaire, + avec suggestion auto si le titre a changé), un commentaire de révision pré-rempli + d'après les modifications détectées, et un aperçu SEO (snippet Google). La + sauvegarde effective n'a lieu qu'après confirmation. + +- **URLs propres** : toutes les routes internes migrent vers des chemins lisibles. + Les anciennes URLs `/?action=…` restent fonctionnelles (compatibilité). + | Ancienne URL | Nouvelle URL | + |---|---| + | `/?action=edit&uuid=` | `/edit/` | + | `/?action=sources&uuid=` | `/sources/` | + | `/?action=diff&uuid=&rev=` | `/diff//` | + | `/?action=create` | `/new` | + | `/?action=admin[&tab=]` | `/admin[/]` | + | `/?action=categories` | `/categories` | + | `/?action=profile` | `/profile` | + | `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… | + | `/?action=regen_thumbs` | `/admin/regen-thumbs` | + | `/?action=add_files&uuid=` | `/files//add` | + | `/?action=import_image&uuid=` | `/import/` | + | `/?cat=` | `/categorie/` | + | `/?cursor=` | `/page/` | + +- **Moteur de recherche** : index trigram+substring pré-construit (`search_index.json`, + reconstruit à chaque écriture), accessible depuis la navbar. + +### Corrections + +- **Métadonnées fichiers (sources)** : `addFileMeta()` ne sauvegardait pas l'auteur et + l'URL source en raison d'un guard `file_exists()` trop strict — supprimé. +- **Authentification OIDC** (`State invalide.`) : `session_start()` était appelé avant + `bootstrap.php` dans les fichiers OIDC, écrasant les paramètres de cookie + (`SameSite=Lax`, `Secure`, `HttpOnly`) — corrigé dans `start.php`, `callback.php` + et `me.php`. +- **Sidebar droite de l'article** : classe Bootstrap `flex-nowrap-lg` inexistante, + remplacée par `flex-lg-nowrap` — la sidebar ne tombe plus en bas de page. +- **Date d'affichage en liste** : `created_at` affiché à la place de `published_at` + — corrigé avec fallback approprié. +- **Formulaire d'édition** : "Fichiers existants" déplacé dans la colonne de droite ; + attribution auteur/source étendue à tous les types de fichiers (pas seulement images). +- **Historique des révisions** : plus de révision créée si le contenu et le titre + sont inchangés. Ajout des boutons de suppression par révision et suppression globale. +- **Canonical URL catégorie** : passe de `/?cat=…` à `/categorie/…`. +- **Flux RSS** : `/rss` et `/rss.xml` redirigent en 301 vers `/feed` (URL + canonique) ; les articles des catégories privées sont exclus du flux ; + la description est convertie depuis Markdown en texte brut. + +--- + +## 2026-05-09 + +### Fonctionnalités + +- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` / + `WebSite`), `noindex` sur les pages d'administration. +- **Recherche** : page de résultats avec score de pertinence, mise en évidence des + termes, lien vers la catégorie depuis les résultats. +- **Support HEIC/HEIF** : conversion automatique en JPEG à l'upload. +- **Support SVG** : upload autorisé, servi avec Content-Type correct. +- **Avant-première** : article visible en liste mais verrouillé avant sa date de + publication. +- **Pagination curseur** : navigation par UUID de dernier article vu, sans offset SQL. +- **Layout article 3 colonnes** : sidebar gauche (catégorie), contenu central, + sidebar droite (pièces jointes, liens externes, articles liés). +- **Import depuis URL** : téléchargement de fichiers distants avec extraction + automatique des métadonnées (EXIF, OpenGraph, PDF). +- **Gestion des pièces jointes** dans le formulaire d'édition, avec attribution + auteur/source affichée dans la vue article. + +### Corrections + +- Login intégré dans `layout.php`, chemins CSS en absolu. +- Redéclaration de `url()` dans `config.php` — fatal error corrigée. +- Correction permissions `www-data` sur `data/`. + +--- + +## 2026-04 et antérieur + +- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery. +- Stockage des articles en fichiers Markdown (migration depuis base de données). +- SSO via Keycloak/OIDC avec PKCE. +- Images de couverture (liste, vue article, `og:image`). +- Brouillons visibles uniquement par l'auteur. +- Formulaire de contact (CSRF, honeypot, rate-limit). +- Pages : mentions légales (LCEN/RGPD), licences, à propos. +- Auto-hébergement Bootstrap 5, police Inter, favicon SVG. +- Headers HTTP de sécurité, CSP stricte. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9e1275a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024–2026 Cédric Abonnel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..c123030 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,19 @@ + 0, + 'path' => '/', + 'secure' => $isHttps, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..11782bb --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "varlog/folio", + "description": "Folio — moteur de blog PHP minimaliste", + "type": "project", + "license": "MIT", + "require": { + "ext-pdo": "*", + "php": ">=8.2", + "vlucas/phpdotenv": "^5.6", + "phpmailer/phpmailer": "^6.11", + "jumbojett/openid-connect-php": "^1.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "friendsofphp/php-cs-fixer": "^3.64" + }, + "scripts": { + "fix": "php-cs-fixer fix --config=.php-cs-fixer.dist.php", + "stan": "phpstan analyse" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..7e7fffd --- /dev/null +++ b/composer.lock @@ -0,0 +1,3304 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "66835353a1516d406913941be6780955", + "packages": [ + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "jumbojett/openid-connect-php", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/jumbojett/OpenID-Connect-PHP.git", + "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jumbojett/OpenID-Connect-PHP/zipball/f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51", + "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "php": ">=7.0", + "phpseclib/phpseclib": "^3.0.7" + }, + "require-dev": { + "phpunit/phpunit": "<10", + "roave/security-advisories": "dev-latest", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Bare-bones OpenID Connect client", + "support": { + "issues": "https://github.com/jumbojett/OpenID-Connect-PHP/issues", + "source": "https://github.com/jumbojett/OpenID-Connect-PHP/tree/v1.0.2" + }, + "time": "2024-09-13T07:08:11+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpmailer/phpmailer", + "version": "v6.12.0", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-only" + ], + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "support": { + "issues": "https://github.com/PHPMailer/PHPMailer/issues", + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" + }, + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "time": "2025-10-15T16:49:08+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.4", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-08-21T11:53:16+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.47", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-10-06T01:07:24+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.89.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "f34967da2866ace090a2b447de1f357356474573" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/f34967da2866ace090a2b447de1f357356474573", + "reference": "f34967da2866ace090a2b447de1f357356474573", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7", + "infection/infection": "^0.31.0", + "justinrainbow/json-schema": "^6.5", + "keradus/cli-executor": "^2.2", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.8", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", + "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2", + "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/Fixer/Internal/*" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.1" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2025-10-24T12:05:10+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-30T10:16:31+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.6", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-01-01T16:37:48+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-13T14:18:03+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.16.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-07-26T10:38:09+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "symfony/console", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/cdb80fa5869653c83cfe1a9084a673b6daf57ea7", + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-14T15:46:26+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-13T11:49:31+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-07T08:17:47+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-15T18:45:57+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-08-05T10:16:07+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T10:12:26+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-25T09:37:31+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", + "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-24T10:49:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f96476035142921000338bad71e5247fbc138872" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T14:36:48+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..0971b05 --- /dev/null +++ b/config/config.php @@ -0,0 +1,40 @@ +load(); + +if (!$_ENV['APP_URL']) { + http_response_code(500); + echo 'Configuration manquante : définis APP_URL ou APP_URL dans le .env'; + exit; +} + +// Normalise: toujours un trailing slash unique +define('APP_URL', rtrim($_ENV['APP_URL'], '/') . '/'); + +// (Optionnel) Expose dans $_ENV si besoin +$_ENV['APP_URL'] = APP_URL; + +/** + * Helper pour construire des liens absolus propres. + * url('ressources/user/login.php') + * url('api/items', ['page'=>2]) + */ +if (!function_exists('url')) { + function url(string $path = '', array $qs = []): string + { + $u = APP_URL . ltrim($path, '/'); + if ($qs) { + $u .= (str_contains($u, '?') ? '&' : '?') . http_build_query($qs); + } + return $u; + } +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/site/about.json b/data/site/about.json new file mode 100644 index 0000000..3aa3cd5 --- /dev/null +++ b/data/site/about.json @@ -0,0 +1,18 @@ +{ + "uuid": "a3d8f2c1-7b4e-4f9a-8c3d-2e5a9b6f1d4c", + "slug": "about", + "title": "À propos", + "author": "cedric@abonnel.fr", + "published": true, + "published_at": "2021-01-16 04:02:40", + "created_at": "2021-01-16 04:02:40", + "updated_at": "2026-05-13 00:00:00", + "revisions": [], + "cover": "", + "files_meta": [], + "external_links": [], + "seo_title": "", + "seo_description": "", + "og_image": "", + "category": "" +} diff --git a/data/site/about.md b/data/site/about.md new file mode 100644 index 0000000..fc749f8 --- /dev/null +++ b/data/site/about.md @@ -0,0 +1,39 @@ +# À propos + +Qui se cache derrière varlog ? + +Je m'appelle **Cédric**. Passionné d'informatique depuis longtemps, je gère un **HomeLab** à la maison — un petit laboratoire personnel où je fais tourner des serveurs, expérimente des configs réseau et casse des choses pour mieux les comprendre. + +varlog est mon carnet de bord technique. J'y documente ce que je fais, ce que j'apprends, et parfois ce qui tourne mal — les incidents sont souvent les meilleures leçons. + +Le blog a été lancé publiquement aux **JDLL 2025** (Journées Du Logiciel Libre), à Lyon. + +## Ce dont je parle ici + +### HomeLab & infrastructure + +Proxmox, virtualisation, domotique (Zigbee, MQTT, Home Assistant), supervision avec Uptime Kuma, auto-hébergement de services (Gitea, Keycloak…), incidents réseau et leurs post-mortems. + +### Réseaux & télécom + +Passionné par les réseaux mobiles (3G/4G/5G/6G), la fibre optique (50G-PON), les stratégies des opérateurs et les infrastructures qui font fonctionner tout ça sans qu'on y pense. + +### Linux & développement + +Debian au quotidien, scripts, administration système, et un peu de PHP — dont ce blog lui-même, développé maison sous le nom de code *Folio*. + +### Numérique & société + +Souveraineté numérique, données personnelles, IA et plateformes qui monétisent nos contenus — des sujets qui m'intéressent autant qu'ils m'inquiètent. + +### Le reste + +Bricolage, travaux, anecdotes techniques, lectures, liseuses Kobo, et quelques billets qui n'entrent dans aucune case. La vie ne se range pas en catégories. + +## Contact + +Vous pouvez me joindre via le [formulaire de contact](/contact). Je lis tous les messages, même si je ne réponds pas toujours vite. + +--- + +Le contenu de ce blog est publié sous licence [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) sauf mention contraire. Le moteur *Folio* est distribué sous [licence MIT](/LICENSE). diff --git a/data/site/legal.json b/data/site/legal.json new file mode 100644 index 0000000..e59bdfe --- /dev/null +++ b/data/site/legal.json @@ -0,0 +1,18 @@ +{ + "uuid": "b2c7e1f4-4a3d-4e8b-9f2a-1d6c8e3f5a7b", + "slug": "legal", + "title": "Mentions légales", + "author": "cedric@abonnel.fr", + "published": true, + "published_at": "2021-01-16 04:02:40", + "created_at": "2021-01-16 04:02:40", + "updated_at": "2026-05-13 00:00:00", + "revisions": [], + "cover": "", + "files_meta": [], + "external_links": [], + "seo_title": "", + "seo_description": "", + "og_image": "", + "category": "" +} diff --git a/data/site/legal.md b/data/site/legal.md new file mode 100644 index 0000000..48c8597 --- /dev/null +++ b/data/site/legal.md @@ -0,0 +1,43 @@ +# Mentions légales + +Conformément à la loi n° 2004-575 du 21 juin 2004 pour la confiance dans l'économie numérique (LCEN). + +## Éditeur du site + +**Responsable de publication :** Cédric Abonnel +**Qualité :** Particulier — site personnel non commercial +**Contact :** [formulaire de contact](/contact) + +## Hébergement + +**Type :** Auto-hébergement sur infrastructure personnelle (HomeLab) +**Exploitant :** Cédric Abonnel +**Fournisseur d'accès à internet :** Infrastructure personnelle auto-hébergée + +## Propriété intellectuelle + +Le **contenu éditorial** de ce site (articles, textes, images produites par l'auteur) est publié sous licence [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/), sauf mention contraire. + +Le **moteur du site** (*Folio*) est un logiciel libre distribué sous [licence MIT](/LICENSE). + +Les composants tiers (Bootstrap, PHPMailer, police Inter…) sont soumis à leurs licences respectives, détaillées sur la [page des licences](/licenses). + +## Données personnelles (RGPD) + +Ce site est un blog personnel **sans publicité, sans pistage, sans système de commentaires** ni inscription publique. + +Les seules données traitées automatiquement sont les **journaux de connexion du serveur web** (adresse IP, horodatage, page demandée), conservés conformément aux obligations légales (article L34-1 du Code des postes et des communications électroniques — durée maximale : 1 an). + +Ces données ne sont ni vendues, ni transmises à des tiers, ni utilisées à des fins commerciales. + +Conformément au RGPD (règlement UE 2016/679), vous disposez d'un droit d'accès, de rectification et de suppression des données vous concernant. Pour exercer ces droits : [formulaire de contact](/contact). + +## Cookies + +Ce site utilise uniquement un **cookie de session technique**, nécessaire au fonctionnement de l'authentification. Il n'est déposé que lors d'une connexion au compte d'administration et n'est pas utilisé à des fins de suivi ou de profilage. Aucun cookie tiers n'est déposé. + +## Responsabilité + +L'éditeur s'efforce de maintenir les informations publiées à jour et exactes, mais ne peut garantir l'exhaustivité ou l'absence d'erreurs du contenu. + +Les liens vers des sites tiers sont fournis à titre informatif. L'éditeur n'est pas responsable du contenu de ces sites externes. diff --git a/data/site/licenses.json b/data/site/licenses.json new file mode 100644 index 0000000..26eab7d --- /dev/null +++ b/data/site/licenses.json @@ -0,0 +1,18 @@ +{ + "uuid": "fdff8ad3-d369-4bd7-bbb9-e14d433868d7", + "slug": "licenses", + "title": "Licences", + "author": "cedric@abonnel.fr", + "published": true, + "published_at": "2021-01-16 04:02:40", + "created_at": "2021-01-16 04:02:40", + "updated_at": "2021-01-16 04:02:40", + "revisions": [], + "cover": "", + "files_meta": [], + "external_links": [], + "seo_title": "", + "seo_description": "", + "og_image": "", + "category": "" +} diff --git a/data/site/licenses.md b/data/site/licenses.md new file mode 100644 index 0000000..e3ea570 --- /dev/null +++ b/data/site/licenses.md @@ -0,0 +1,38 @@ +# Licences + +Composants logiciels utilisés par ce site et leurs licences. + +## Ce site + +| Composant | Licence | Usage | +|-----------|---------|-------| +| **Folio** — moteur de blog PHP | MIT | Moteur de ce blog — par Cédric Abonnel ([voir la licence](/LICENSE)) | +| **Contenu éditorial** | CC BY 4.0 | Articles et textes du blog — [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) | + +## Bibliothèques (production) + +| Composant | Version | Licence | Usage | +|-----------|---------|---------|-------| +| **Bootstrap** | 5.3.3 | MIT | Framework CSS/JS — auto-hébergé ([voir la licence](/assets/css/LICENSE-Bootstrap.txt)) | +| **PHPMailer** | 6.12.0 | LGPL-2.1 | Envoi d'e-mails SMTP | +| **phpdotenv** | 5.6.2 | BSD-3-Clause | Variables d'environnement | +| **openid-connect-php** | 1.0.2 | Apache-2.0 | Authentification SSO (OIDC) | +| **Police Inter** | v20 | OFL-1.1 | Typographie — auto-hébergée ([voir la licence](/assets/fonts/LICENSE-Inter.txt)) | + +## Outils de développement + +| Composant | Version | Licence | Usage | +|-----------|---------|---------|-------| +| **PHPStan** | 1.12.32 | MIT | Analyse statique PHP | +| **PHP-CS-Fixer** | 3.89.1 | MIT | Formatage du code | +| **Claude Code CLI** | — | Commercial | Outil de développement (Anthropic) — [Conditions d'utilisation](https://www.anthropic.com/legal/aup) | + +## Infrastructure + +| Composant | Licence | Usage | +|-----------|---------|-------| +| **PHP 8.3** | PHP License v3.01 | Langage côté serveur | +| **PostgreSQL** | PostgreSQL License | Base de données relationnelle | +| **Apache HTTP Server** | Apache-2.0 | Serveur web | + + diff --git a/database/interactions_create.sql b/database/interactions_create.sql new file mode 100644 index 0000000..6287690 --- /dev/null +++ b/database/interactions_create.sql @@ -0,0 +1,30 @@ +-- Réactions visiteurs (cookie anti-doublon) +CREATE TABLE article_reactions ( + id SERIAL PRIMARY KEY, + article_uuid TEXT NOT NULL, + reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')), + visitor_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (article_uuid, reaction_type, visitor_hash) +); +CREATE INDEX ON article_reactions (article_uuid); + +-- Commentaires avec vérification par email +CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + article_uuid TEXT NOT NULL, + author_name TEXT NOT NULL, + author_email TEXT NOT NULL, + content TEXT NOT NULL CHECK (LENGTH(content) <= 2000), + verify_token TEXT, + verification_code TEXT, + verify_attempts INTEGER NOT NULL DEFAULT 0, + verified BOOLEAN NOT NULL DEFAULT FALSE, + published BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + ip_address TEXT, + user_agent TEXT +); +CREATE INDEX ON comments (article_uuid, verified, published); +CREATE INDEX ON comments (verify_token) + WHERE verified = FALSE AND verify_token IS NOT NULL; diff --git a/database/migrate-init.php b/database/migrate-init.php new file mode 100644 index 0000000..431c702 --- /dev/null +++ b/database/migrate-init.php @@ -0,0 +1,47 @@ + PDO::ERRMODE_EXCEPTION, +]); + +$pdo->exec(' + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT NOT NULL PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT NOW() + ) +'); + +// Migrations déjà appliquées avant la mise en place de ce système +$alreadyApplied = [ + 'migration_001_roles_ratings.sql', + 'migration_002_profile_url.sql', + 'migration_003_profile_slug.sql', + 'migration_004_profile_bio.sql', + 'migration_005_rss_feeds.sql', + 'migration_006_profile_links.sql', +]; + +$stmt = $pdo->prepare('INSERT INTO schema_migrations (name) VALUES (?) ON CONFLICT DO NOTHING'); +foreach ($alreadyApplied as $name) { + $stmt->execute([$name]); + echo " ✓ marquée : $name\n"; +} + +echo "\nInitialisation terminée. Vous pouvez supprimer ce fichier.\n"; diff --git a/database/migrate.php b/database/migrate.php new file mode 100644 index 0000000..6486fd4 --- /dev/null +++ b/database/migrate.php @@ -0,0 +1,83 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, +]); + +// Crée la table de suivi si elle n'existe pas encore +$pdo->exec(' + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT NOT NULL PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT NOW() + ) +'); + +// Migrations déjà appliquées +$applied = array_flip( + $pdo->query('SELECT name FROM schema_migrations ORDER BY name') + ->fetchAll(PDO::FETCH_COLUMN) +); + +// Fichiers de migration dans le même dossier, triés par nom +$files = glob(__DIR__ . '/migration_*.sql') ?: []; +sort($files); + +$count = 0; +foreach ($files as $file) { + $name = basename($file); + if (isset($applied[$name])) { + continue; + } + + $sql = file_get_contents($file); + if ($sql === false || trim($sql) === '') { + echo " ⚠ $name : fichier vide ou illisible, ignoré\n"; + continue; + } + + echo " → $name ... "; + $pdo->exec($sql); + $pdo->prepare('INSERT INTO schema_migrations (name) VALUES (?)') + ->execute([$name]); + echo "✓\n"; + $count++; +} + +if ($count === 0) { + echo " (aucune migration en attente)\n"; +} else { + echo " $count migration(s) appliquée(s).\n"; +} diff --git a/database/migration_001_roles_ratings.sql b/database/migration_001_roles_ratings.sql new file mode 100644 index 0000000..1437249 --- /dev/null +++ b/database/migration_001_roles_ratings.sql @@ -0,0 +1,39 @@ +-- Migration 001 : Système de rôles et notes d'articles +-- À exécuter une seule fois sur le serveur PostgreSQL + +-- Rôles disponibles +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + label TEXT NOT NULL +); + +INSERT INTO roles (name, label) VALUES + ('admin', 'Administrateur'), + ('editor', 'Rédacteur'), + ('reader', 'Lecteur') +ON CONFLICT (name) DO NOTHING; + +-- Association utilisateur ↔ rôle (clé : email, pour compatibilité OIDC sans FK) +CREATE TABLE IF NOT EXISTS user_roles ( + user_email TEXT NOT NULL, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + granted_at TIMESTAMP DEFAULT NOW(), + granted_by TEXT, + PRIMARY KEY (user_email, role_id) +); + +-- Seed : cedric@abonnel.fr → admin +INSERT INTO user_roles (user_email, role_id, granted_by) +SELECT 'cedric@abonnel.fr', id, 'migration' +FROM roles WHERE name = 'admin' +ON CONFLICT DO NOTHING; + +-- Notes d'articles (1-5 étoiles, une note par utilisateur par article) +CREATE TABLE IF NOT EXISTS article_ratings ( + article_uuid VARCHAR(36) NOT NULL, + user_email TEXT NOT NULL, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + rated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (article_uuid, user_email) +); diff --git a/database/migration_002_profile_url.sql b/database/migration_002_profile_url.sql new file mode 100644 index 0000000..b9b4421 --- /dev/null +++ b/database/migration_002_profile_url.sql @@ -0,0 +1 @@ +ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS profile_url TEXT NOT NULL DEFAULT ''; diff --git a/database/migration_003_profile_slug.sql b/database/migration_003_profile_slug.sql new file mode 100644 index 0000000..e8f9911 --- /dev/null +++ b/database/migration_003_profile_slug.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS profile_slug TEXT NOT NULL DEFAULT ''; +CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx ON user_profiles (profile_slug) WHERE profile_slug <> ''; diff --git a/database/migration_004_profile_bio.sql b/database/migration_004_profile_bio.sql new file mode 100644 index 0000000..0b96126 --- /dev/null +++ b/database/migration_004_profile_bio.sql @@ -0,0 +1 @@ +ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS bio TEXT NOT NULL DEFAULT ''; diff --git a/database/migration_005_rss_feeds.sql b/database/migration_005_rss_feeds.sql new file mode 100644 index 0000000..c07adaa --- /dev/null +++ b/database/migration_005_rss_feeds.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS rss_feeds ( + id SERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + feed_url TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT now(), + UNIQUE (user_email, feed_url) +); diff --git a/database/migration_006_profile_links.sql b/database/migration_006_profile_links.sql new file mode 100644 index 0000000..219837d --- /dev/null +++ b/database/migration_006_profile_links.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS profile_links ( + id SERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + url TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT now() +); diff --git a/database/migration_007_comment_verify_token.sql b/database/migration_007_comment_verify_token.sql new file mode 100644 index 0000000..d927614 --- /dev/null +++ b/database/migration_007_comment_verify_token.sql @@ -0,0 +1,6 @@ +-- Ajout du token UUID dans l'URL de vérification et du compteur de tentatives +ALTER TABLE comments ADD COLUMN IF NOT EXISTS verify_token TEXT; +ALTER TABLE comments ADD COLUMN IF NOT EXISTS verify_attempts INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_comments_verify_token ON comments (verify_token) + WHERE verified = FALSE AND verify_token IS NOT NULL; diff --git a/database/tables_create.sql b/database/tables_create.sql new file mode 100644 index 0000000..a180afb --- /dev/null +++ b/database/tables_create.sql @@ -0,0 +1,17 @@ +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + is_published BOOLEAN DEFAULT FALSE +); + +CREATE TABLE post_files ( + id SERIAL PRIMARY KEY, + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE, + file_type TEXT, + file_path TEXT, + original_name TEXT, + uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/docs/architecture-notes.md b/docs/architecture-notes.md new file mode 100644 index 0000000..7959da0 --- /dev/null +++ b/docs/architecture-notes.md @@ -0,0 +1,49 @@ +logique complète en PHP framework maison), avec un système de routing clair, base postgres et extensible pour gérer : + +* les posts (CRUD + publication + masquage (au lieu de suppression)), +* les commentaires (publier, masquer, privé), +* les pièces jointes (upload, masquage (au lieu de supprimer), inutile de réuploadé si le fichier à déjà été poussé dans un autre poste par exemple.). + +architecture MVC, avec un routeur maison et des contrôleurs structurés. +Tout sera modulaire et facile à maintenir. + +--- + +## 🏗️ Structure du projet + +``` +project/ +│ +├─ public/ +│ ├─ index.php # Point d'entrée (router) +│ └─ uploads/ # Dossier des fichiers uploadés +│ +├─ app/ +│ ├─ Core/ +│ │ ├─ Router.php # Routeur maison +| │ ├─ Model.php +| │ ├─ View.php +│ │ └─ Controller.php # Classe de base pour les contrôleurs +│ │ +│ ├─ Controllers/ +│ │ ├─ PostController.php +│ │ ├─ CommentController.php +│ │ └─ AttachmentController.php +│ │ +│ ├─ Models/ +│ │ ├─ Post.php +│ │ ├─ Comment.php +│ │ └─ Attachment.php +│ │ +│ ├── Views/ +│ │ ├── posts/ +│ │ │ ├── index.php +│ │ │ ├── show.php +│ │ │ └── form.php +│ │ ├── comments/ +│ │ └── attachments/ +│ │ +│ └─ config.php # Configuration (DB, etc.) +│ +└─ composer.json +``` diff --git a/docs/auth-magic-link.md b/docs/auth-magic-link.md new file mode 100644 index 0000000..e909cdb --- /dev/null +++ b/docs/auth-magic-link.md @@ -0,0 +1,98 @@ +# Authentification — Lien magique + +Mécanisme d'authentification par email sans mot de passe. L'utilisateur reçoit un lien à usage unique, valide un temps limité, qui ouvre une session PHP. + +## Fichiers concernés + +| Fichier | Rôle | +|---|---| +| `public/login/index.php` | Formulaire de demande + génération du token | +| `public/login/magic.php` | Consommation du token + ouverture de session | +| Table BDD `auth_magic_links` | Persistance des tokens | + +## Configuration (`.env`) + +| Variable | Défaut | Description | +|---|---|---| +| `MAGIC_LINK_TTL_MINUTES` | `30` | Durée de validité du lien | +| `MAGIC_COOLDOWN_MINUTES` | `5` | Délai minimal entre deux demandes pour le même email | +| `MAGIC_WINDOW_HOURS` | `12` | Fenêtre glissante pour le plafond | +| `MAGIC_MAX_PER_WINDOW` | `5` | Nombre maximal de liens émis par fenêtre | + +## Schéma BDD — `auth_magic_links` + +```sql +id UUID PRIMARY KEY (gen_random_uuid()) +email TEXT NOT NULL +token TEXT NOT NULL UNIQUE +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +expires_at TIMESTAMPTZ NOT NULL +consumed_at TIMESTAMPTZ NULL -- NULL = non consommé +ip TEXT +user_agent TEXT +return_to TEXT NULL -- chemin relatif de redirection post-login +``` + +## Phase 1 — Demande du lien (`index.php`, POST) + +``` +[Formulaire email] → POST /login + │ + ├─ Validation CSRF + ├─ Validation syntaxe email + │ + ├─ Purge BDD : DELETE liens expirés ou consommés pour cet email + │ + ├─ Cooldown : un lien < MAGIC_COOLDOWN_MINUTES → refus + ├─ Plafond : >= MAGIC_MAX_PER_WINDOW liens dans MAGIC_WINDOW_HOURS → refus + │ + ├─ Génération token : random_bytes(32) → base64url (43 chars, URL-safe) + ├─ INSERT auth_magic_links (expires_at = NOW() + TTL) + │ + └─ [stub] Envoi email : /login/magic.php?token= +``` + +Le token est un base64url sans padding (`+/` remplacés par `-_`, `=` supprimés) — il ne contient que des caractères `[A-Za-z0-9\-_]`. + +L'envoi de l'email est actuellement un **stub** : le code construit `$magicUrl` mais l'appel SMTP n'est pas implémenté (`index.php:142`). + +## Phase 2 — Consommation du lien (`magic.php`, GET) + +``` +[Clic sur le lien] → GET /login/magic.php?token= + │ + ├─ Validation format token (regex [A-Za-z0-9\-\_]) + │ + ├─ BEGIN TRANSACTION + │ ├─ SELECT ... FOR UPDATE (verrou anti double-consommation) + │ ├─ Token inconnu → 400 + │ ├─ consumed_at non null → "Lien déjà utilisé" + │ ├─ expires_at dépassé → "Lien expiré" + │ ├─ UPDATE consumed_at = NOW() + │ └─ COMMIT + │ + ├─ session_start() + session_regenerate_id(true) + ├─ $_SESSION['user_email'] = email + │ + └─ Redirection 303 → return_to (validé : doit commencer par /) +``` + +Le `FOR UPDATE` garantit qu'un token ne peut pas être consommé deux fois en cas de double-clic ou de requêtes concurrentes. + +## Sécurités notables + +- **CSRF** sur le formulaire de demande +- **Rate limiting** double (cooldown + plafond glissant) par email +- **Token URL-safe** généré par CSPRNG (`random_bytes`) +- **Verrou transactionnel** (`FOR UPDATE`) à la consommation +- **`session_regenerate_id(true)`** — prévient la fixation de session +- **`return_to` filtré** : seuls les chemins relatifs (commençant par `/`) sont acceptés + +## État actuel — ce qui reste à câbler + +L'envoi SMTP n'est pas implémenté. Le token est correctement généré et persisté en BDD, mais le mail n'est pas envoyé. Voir `public/login/index.php:142` : + +```php +$magicUrl = url('/login/magic.php') . '?token=' . urlencode($token); +/* envoyer_mail_smtp(...) ou mail(...) */ +``` diff --git a/docs/cache-architecture.md b/docs/cache-architecture.md new file mode 100644 index 0000000..6b5087f --- /dev/null +++ b/docs/cache-architecture.md @@ -0,0 +1,184 @@ +# Architecture du cache — Moteur Folio + +## Contexte et problème initial + +Le moteur stocke chaque article dans un sous-répertoire `data/{uuid}/` contenant deux fichiers : +- `meta.json` — métadonnées (titre, slug, catégorie, cover, dates…) +- `index.md` — contenu Markdown + +Avec 1 000+ articles, chaque vue de page déclenchait **3 appels à `getAll()`** (via `getBySlug()`, `getCategories()` et directement pour les articles liés), ce qui représentait ~6 000 lectures de fichiers par requête. La page mettait **+5 secondes** à charger. + +--- + +## Les quatre niveaux de cache + +### 1. Cache mémoire de requête — `$allCache` et `$searchIndexCache` + +**Scope** : durée de vie d'une requête PHP (in-process). + +`ArticleManager` mémoïse deux tableaux en propriétés privées : + +- `$allCache` — résultat de `loadAll()` (tous les articles avec contenu) +- `$searchIndexCache` — contenu de `search_index.json` + +```php +// Premier appel : scan disque + construction du tableau +$this->allCache = $this->loadAll(); + +// Appels suivants dans la même requête : tableau déjà en mémoire +return $this->allCache; +``` + +**Invalidation** : `writeMeta()` et `delete()` mettent les deux propriétés à `null`. + +--- + +### 2. Cache disque par article — `_cache/articles/{uuid}.json` + +**Scope** : persistant entre les requêtes, jusqu'à modification de l'article. + +`loadArticle()` vérifie si le cache est plus récent que `meta.json` avant de lire les sources : + +``` +_cache/articles/{uuid}.json <-- filemtime >= meta.json ? → utiliser le cache + → lire meta.json + index.md, écrire le cache +``` + +Le fichier cache contient toutes les données de l'article (métadonnées + contenu), ce qui réduit les lectures de **2 fichiers à 1** par article chargé. + +**Invalidation** : `writeMeta()` supprime `_cache/articles/{uuid}.json` avant d'écrire le nouveau `meta.json`. `delete()` le supprime aussi. + +--- + +### 3. Index slug → UUID — `_cache/slug_index.json` + +**Scope** : persistant, mis à jour incrémentalement. + +Permet à `getBySlug()` de trouver un article en **O(1)** (lecture de l'index + lecture du cache article) au lieu de parcourir tous les articles. + +``` +slug_index.json : {"mon-article": "uuid-xxxx", "autre-article": "uuid-yyyy", ...} +``` + +**Construction** : à la première utilisation, `buildSlugIndex()` lit le `search_index.json` (un seul fichier) pour construire la correspondance. Si le search_index n'existe pas encore, il tombe en repli sur `loadAll()`. + +**Invalidation** : `writeMeta()` supprime le fichier (reconstruction automatique à la prochaine requête). `delete()` fait de même. + +> Suppression plutôt que mise à jour incrémentale : la reconstruction depuis `search_index.json` est quasi instantanée (lecture d'un seul fichier JSON), donc il n'y a pas d'intérêt à maintenir des mises à jour partielles. + +--- + +### 4. Index de recherche — `search_index.json` + +**Scope** : persistant, reconstruit après chaque modification d'article. + +Fichier JSON plat contenant un tableau de tous les articles avec leurs champs essentiels et leur texte brut pré-calculé (`plain`, sans syntaxe Markdown). Utilisé pour : + +- La recherche plein-texte (`SearchEngine`) +- La liste des articles publiés pour les articles liés/similaires (évite `getAll()`) +- Les catégories (`getCategories()`) +- La construction du slug index + +**Champs stockés** : + +```json +{ + "uuid": "...", + "slug": "...", + "title": "...", + "category": "...", + "author": "...", + "cover": "...", + "published": true, + "published_at": "2026-01-15 10:00:00", + "created_at": "2026-01-14 09:30:00", + "updated_at": "2026-01-15 10:00:00", + "plain": "texte brut de l'article sans markdown..." +} +``` + +**Rebuild automatique** : si le fichier ne contient pas le champ `cover` (format antérieur à la v2 du cache), `getSearchIndex()` déclenche automatiquement un rebuild. + +**Invalidation** : `rebuildSearchIndex()` (appelé par `create()`, `update()`, `delete()`). + +--- + +## Chemin d'une requête de vue d'article après optimisation + +``` +GET /post/{slug} +│ +├── getBySlug(slug) +│ ├── Lire slug_index.json [1 lecture] +│ ├── → UUID trouvé +│ └── getByUuid(uuid) +│ └── Lire _cache/articles/{uuid}.json [1 lecture] +│ +├── getCategories() +│ └── getSearchIndex() [lecture de search_index.json, mise en cache mémoire] +│ +├── $_allPublished (articles liés + similaires) +│ └── getSearchIndex() [déjà en cache mémoire → 0 lecture] +│ +├── scorePool(mots_du_titre, $_allPublished) +│ └── Tokenisation unique par article, pas de re-calcul par mot +│ +└── getBacklinks(slug) + └── Lire _cache/backlinks.json [1 lecture] + +Total : ~4 lectures de fichiers, indépendamment du nombre total d'articles. +``` + +Avant optimisation (1 062 articles) : ~6 300 lectures de fichiers. + +--- + +## Performances mesurées + +| Scénario | Avant | Après | +|---|---|---| +| Cold cache (aucun cache disque) | +5 s | ~0,6 s | +| Warm cache (cache disque présent) | +5 s | ~0,4 s | + +--- + +## Scalabilité + +| Volume d'articles | Lectures de fichiers par vue (après) | +|---|---| +| 1 000 | ~4 | +| 100 000 | ~4 | +| 500 000 | ~4 | + +Le nombre de lectures est **constant** : le chemin de vue ne dépend plus du nombre total d'articles, seulement de la présence des fichiers de cache. + +La seule opération encore en O(N) est `rebuildSearchIndex()`, mais elle n'est déclenchée que sur écriture (création, modification, suppression d'article), jamais sur lecture. + +--- + +## Invalidation — résumé + +| Événement | Caches invalidés | +|---|---| +| `writeMeta()` (toute écriture d'article) | `$allCache`, `$searchIndexCache`, cache article (`{uuid}.json`), slug index | +| `delete()` | idem + suppression physique du cache article | +| `rebuildSearchIndex()` | `$searchIndexCache` (remplacé par les nouvelles données) | + +--- + +## Maintenance + +### Vider manuellement les caches disque + +En cas de besoin (migration, incohérence) : + +```bash +ssh varlog "sudo rm -rf /var/www/lan.acegrp.varlog/data/_cache/articles/" +ssh varlog "sudo rm /var/www/lan.acegrp.varlog/data/_cache/slug_index.json" +``` + +Les caches se reconstruisent automatiquement à la première requête suivante. + +### Forcer un rebuild du search_index + +Modifier et sauvegarder n'importe quel article depuis l'interface admin déclenche un rebuild complet. Il n'existe pas de commande CLI dédiée pour l'instant. diff --git a/docs/notes-dev.md b/docs/notes-dev.md new file mode 100644 index 0000000..89f5d7d --- /dev/null +++ b/docs/notes-dev.md @@ -0,0 +1,39 @@ +# Notes de développement + +## Structure du projet (serveur) + +``` +/var/www/lan.acegrp.varlog/ +├── public/ +│ ├── index.php # Point d'entrée +│ ├── route.php # Routeur (actions GET/POST) +│ └── assets/ # CSS, JS, uploads +├── templates/ # Vues PHP (incluses via extract() + include) +│ ├── layout.php +│ ├── post_form.php +│ └── post_view.php +├── src/ +│ ├── db.php # Connexion PDO PostgreSQL +│ ├── PostManager.php +│ └── FileManager.php +├── config/ +│ └── config.php # Charge .env, définit les constantes DB +└── docs/ +``` + +## Conventions templates + +Les templates reçoivent leurs variables via `extract()` depuis `route.php`. Toute variable optionnelle (non transmise dans tous les contextes) doit utiliser `??` pour éviter un `Undefined variable` warning : + +```php +// Bon +$dateValue = $published_at ?? date('Y-m-d\TH:i'); + + +// À éviter + // Warning si create (pas d'édition) +``` + +## Permissions serveur + +PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..659ec56 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Unreachable statement \\- code above always terminates\\.$#" + count: 1 + path: src/Repository/ProfileRepository.php diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..647c135 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,5 @@ + +RewriteRule ^post/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=view&slug=$1 [L,QSA] + +# Filtre par catégorie : /categorie/ +RewriteRule ^categorie/(.+?)/?$ /index.php?cat=$1 [L,QSA,B] + +# Pagination par curseur : /page/ +RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA] + +# Édition / création +RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B] +RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA] +RewriteRule ^new/?$ /index.php?action=create [L,QSA] +RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA] + +# Sources et diff +RewriteRule ^sources/([0-9a-f-]{36})/?$ /index.php?action=sources&uuid=$1 [L,QSA] +RewriteRule ^diff/([0-9a-f-]{36})/(\d+)/?$ /index.php?action=diff&uuid=$1&rev=$2 [L,QSA] + +# Fichiers / import +RewriteRule ^files/([0-9a-f-]{36})/add/?$ /index.php?action=add_files&uuid=$1 [L,QSA] +RewriteRule ^import/([0-9a-f-]{36})/?$ /index.php?action=import_image&uuid=$1 [L,QSA] + +# Admin (regen-thumbs et role/ avant la règle générique admin/) +RewriteRule ^admin/regen-thumbs/?$ /index.php?action=regen_thumbs [L,QSA] +RewriteRule ^admin/role/([a-z0-9_-]+)/?$ /index.php?action=admin_role_edit&role_name=$1 [L,QSA] +RewriteRule ^admin/([a-z0-9-]+)/?$ /index.php?action=admin&tab=$1 [L,QSA] +RewriteRule ^admin/?$ /index.php?action=admin [L,QSA] + +# Réactions et commentaires +RewriteRule ^react/?$ /index.php?action=react [L,QSA] +RewriteRule ^comment/?$ /index.php?action=comment [L,QSA] +RewriteRule ^comment-moderate/?$ /index.php?action=comment_moderate [L,QSA] +RewriteRule ^verify-comment/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/?$ /index.php?action=verify_comment&token=$1 [L,QSA] + +# Pages de gestion +RewriteRule ^categories/?$ /index.php?action=categories [L,QSA] +RewriteRule ^profile/?$ /index.php?action=profile [L,QSA] +RewriteRule ^search/?$ /index.php?action=search [L,QSA] +RewriteRule ^flux/?$ /index.php?action=flux [L,QSA] +RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA] +RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA] + +# Profil public auteur + page liens +RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/cursor/([0-9a-f-]{36})/?$ /index.php?action=author_articles&slug=$1&cursor=$2 [L,QSA] +RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/?$ /index.php?action=author_articles&slug=$1 [L,QSA] +RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA] +RewriteRule ^liens/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=liens&slug=$1 [L,QSA] +RewriteRule ^link/add/?$ /index.php?action=add_link [L,QSA] +RewriteRule ^link/delete/?$ /index.php?action=delete_link [L,QSA] +RewriteRule ^link/reorder/?$ /index.php?action=reorder_links [L,QSA] + +# Pages statiques +RewriteRule ^about/?$ /index.php?action=about [L,QSA] +RewriteRule ^legal/?$ /index.php?action=legal [L,QSA] +RewriteRule ^licenses/?$ /index.php?action=licenses [L,QSA] +RewriteRule ^contact/?$ /index.php?action=contact [L,QSA] + +# Flux RSS — /feed est canonique, /rss et /rss.xml redirigent en 301 +RewriteRule ^rss/?$ /feed [R=301,L] +RewriteRule ^rss\.xml$ /feed [R=301,L] +RewriteRule ^feed/([0-9a-f-]{36})/?$ /feed.php?after=$1 [L,QSA] +RewriteRule ^feed/?$ /feed.php [L,QSA] + +# Sitemap +RewriteRule ^sitemap\.xml$ /sitemap.php [L] + +# Ajoute .php si le fichier correspondant existe +RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI}.php -f +RewriteRule ^(.+?)/?$ /$1.php [L,QSA] + +# 404 intelligent : redirige vers l'article le plus proche +ErrorDocument 404 /index.php?action=not_found diff --git a/public/LICENSE b/public/LICENSE new file mode 100644 index 0000000..9e1275a --- /dev/null +++ b/public/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024–2026 Cédric Abonnel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/assets/css/LICENSE-Bootstrap.txt b/public/assets/css/LICENSE-Bootstrap.txt new file mode 100644 index 0000000..2a703f5 --- /dev/null +++ b/public/assets/css/LICENSE-Bootstrap.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2011-2024 The Bootstrap Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/assets/css/bootstrap.min.css b/public/assets/css/bootstrap.min.css new file mode 100644 index 0000000..3993414 --- /dev/null +++ b/public/assets/css/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..cf82aba --- /dev/null +++ b/public/assets/css/style.css @@ -0,0 +1,1809 @@ +/* varlog — style.css */ + +/* Inter — auto-hébergée */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/assets/fonts/inter-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/assets/fonts/inter-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, + U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/assets/fonts/inter-italic-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, + U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('/assets/fonts/inter-italic-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, + U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* ─── Variables ─────────────────────────── */ +:root { + /* Override Bootstrap link defaults */ + --bs-link-color: #4f46e5; + --bs-link-color-rgb: 79, 70, 229; + --bs-link-hover-color: #4338ca; + --bs-link-decoration: none; + --vl-accent: #4f46e5; + --vl-accent-dark: #4338ca; + --vl-accent-soft: #e0e7ff; + --vl-bg: #f8fafc; + --vl-surface: #ffffff; + --vl-border: #e2e8f0; + --vl-text: #1e293b; + --vl-muted: #64748b; + --vl-radius: 0.75rem; + --vl-shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); + --vl-shadow-md: 0 4px 16px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04); +} + +/* ─── Base ──────────────────────────────── */ +body { + background-color: var(--vl-bg) !important; + color: var(--vl-text); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 1rem; + line-height: 1.7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ─── Navbar ────────────────────────────── */ +.navbar { + background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #7c3aed 100%) !important; + border-bottom: none; + box-shadow: 0 2px 24px rgba(79,70,229,.35); + padding: 0.65rem 1.5rem; +} + +.navbar-brand { + font-weight: 800; + font-size: 1.25rem; + color: #fff !important; + letter-spacing: -0.5px; + transition: opacity 0.15s; + text-decoration: none !important; +} + +.navbar-brand:hover { + opacity: 0.85; +} + +/* Caret du dropdown sur fond coloré */ +.navbar-brand.dropdown-toggle::after { + border-top-color: rgba(255,255,255,.7); + vertical-align: .2em; +} + +.navbar .nav-link { + color: rgba(255,255,255,.85) !important; + font-weight: 500; + font-size: 0.9rem; + padding: 0.4rem 0.75rem !important; + border-radius: 0.5rem; + transition: color 0.15s, background 0.15s; +} + +.navbar .nav-link:hover, +.navbar .nav-link:focus { + color: #fff !important; + background-color: rgba(255,255,255,.15); +} + +.navbar-toggler { + border-color: rgba(255,255,255,.35); +} + +.navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255%2C255%2C255%2C0.85%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +/* Dropdown catégories */ +.navbar .dropdown-menu { + border: none; + border-radius: 0.75rem; + box-shadow: 0 8px 32px rgba(0,0,0,.18); + padding: 0.5rem; + min-width: 180px; +} + +.navbar .dropdown-item { + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--vl-text); + padding: 0.4rem 0.75rem; + transition: background 0.12s, color 0.12s; +} + +.navbar .dropdown-item:hover { + background-color: var(--vl-accent-soft); + color: var(--vl-accent); +} + +.navbar .dropdown-item.active { + background-color: var(--vl-accent); + color: #fff; +} + +/* ─── Category nav in navbar ─────────────── */ + +/* ─── Main ───────────────────────────────── */ +main.container, +main.container-xl, +main.container-fluid { + padding-top: 2rem; + padding-bottom: 3rem; +} + +main.container { + max-width: 980px; +} + +/* ─── Headings ───────────────────────────── */ +h1 { + font-weight: 700; + font-size: 1.75rem; + letter-spacing: -0.5px; + color: var(--vl-text); +} + +h2 { + font-weight: 600; + letter-spacing: -0.3px; +} + +/* ─── Liens globaux ─────────────────────── */ +a { + color: var(--vl-accent); + text-decoration: none !important; +} + +a:hover { + color: var(--vl-accent-dark); +} + +/* ─── Cards ─────────────────────────────── */ + +.card-cover { + height: 120px; + border-radius: var(--vl-radius) var(--vl-radius) 0 0; + flex-shrink: 0; + background-size: cover; + background-position: center; +} + +/* Couverture pleine largeur dans la vue article */ +.article-cover { + margin: 0; + overflow: hidden; + border-radius: var(--vl-radius) var(--vl-radius) 0 0; + flex-shrink: 0; +} + +.article-cover img { + width: 100%; + height: 340px; + object-fit: cover; + display: block; +} + +.article-cover--gradient { + height: 160px; + position: relative; +} + +/* Hero : titre sur l'image */ +.article-cover--hero { + position: relative; +} + +.article-cover--hero img { + height: clamp(260px, 45vw, 480px); +} + +.article-cover--hero.article-cover--gradient { + height: clamp(200px, 35vw, 340px); +} + +.article-hero-text { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.9rem 1.4rem 1.25rem; + background: linear-gradient( + to top, + rgba(0,0,0,.80) 0%, + rgba(0,0,0,.32) 50%, + transparent 100% + ); +} + +.article-hero-top { + display: flex; + justify-content: flex-end; + gap: 0.4rem; + align-items: flex-start; + min-height: 2rem; +} + +.article-hero-bottom { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; +} + +.article-hero-left { flex: 1; min-width: 0; } + +.article-hero-right { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.article-hero-text .cover-category { + position: static; + align-self: flex-start; + margin-bottom: 0.55rem; +} + +.article-hero-text .article-title { + color: #fff; + text-shadow: 0 1px 8px rgba(0,0,0,.5); + margin-bottom: 0.3rem; +} + +.article-hero-meta { + font-size: 0.82rem; + color: rgba(255,255,255,.72); + margin: 0; +} + +/* Boutons glass sur le hero */ +.hero-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.28rem 0.75rem; + font-size: 0.73rem; + font-weight: 600; + color: #fff !important; + background: rgba(0,0,0,.35); + border: 1px solid rgba(255,255,255,.22); + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + text-decoration: none !important; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; + line-height: 1.4; +} +.hero-btn:hover { background: rgba(0,0,0,.58); color: #fff !important; } +.hero-btn--danger { border-color: rgba(239,68,68,.5); } +.hero-btn--danger:hover { background: rgba(180,30,30,.65); } + +/* Score de notation dans le hero */ +.hero-reactions { + display: flex; + gap: .4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.hero-reaction-btn { + display: inline-flex; + align-items: center; + gap: .3rem; + background: rgba(255,255,255,.15); + border: 1px solid rgba(255,255,255,.35); + border-radius: 100px; + padding: .25rem .7rem; + color: #fff; + font-size: .875rem; + cursor: pointer; + transition: background .15s, border-color .15s; +} + +.hero-reaction-btn:hover, +.hero-reaction-btn--active { + background: rgba(255,255,255,.32); + border-color: rgba(255,255,255,.7); +} + +.card-cover { + position: relative; +} + +.cover-category { + position: absolute; + bottom: 0.6rem; + left: 0.75rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #fff; + background: rgba(0, 0, 0, 0.28); + padding: 0.2rem 0.55rem; + border-radius: 999px; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.card { + border: 1px solid var(--vl-border) !important; + border-radius: var(--vl-radius) !important; + box-shadow: var(--vl-shadow-sm); + background-color: var(--vl-surface); + overflow: hidden; + transition: box-shadow 0.2s ease, transform 0.2s ease; +} + +.card:hover { + box-shadow: var(--vl-shadow-md); + transform: translateY(-2px); +} + +.card-body { + padding: 1.35rem 1.5rem; +} + +.card-title { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.5rem; + color: var(--vl-text); +} + +.article-title { + font-weight: 800; + font-size: clamp(1.6rem, 4vw, 2.4rem); + line-height: 1.2; + letter-spacing: -0.5px; + color: var(--vl-text); + margin-bottom: 0.5rem; +} + +/* Override la couleur text-primary Bootstrap sur les card-title */ +.card-title.text-primary { + color: var(--vl-text) !important; +} + +.card-title a, +.card-title a:visited { + color: var(--vl-text) !important; + transition: color 0.15s; +} + +.card-title a:hover { + color: var(--vl-accent) !important; +} + +.card-text { + color: var(--vl-muted); + font-size: 0.9rem; + line-height: 1.65; +} + +/* Neutralise les borders colorées Bootstrap */ +.card[class*="border-primary"], +.card[class*="border-warning"], +.card[class*="border-success"], +.card[class*="border-danger"] { + border-color: var(--vl-border) !important; +} + +/* ─── Ribbons (brouillon / avant-première) ── */ +.draft-ribbon, +.premiere-ribbon, +.private-ribbon { + position: absolute; + top: 26px; + right: -34px; + width: 130px; + text-align: center; + color: #fff; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 5px 0; + transform: rotate(45deg); + pointer-events: none; + z-index: 2; + box-shadow: 0 1px 3px rgba(0,0,0,.18); +} + +.draft-ribbon { background: #f59e0b; } +.premiere-ribbon { background: #6366f1; } +.private-ribbon { background: #64748b; } + +/* ─── Buttons ─────────────────────────────── */ +.btn { + font-weight: 500; + font-size: 0.875rem; + border-radius: 0.5rem; + transition: all 0.15s ease; +} + +.btn-outline-primary { + --bs-btn-color: var(--vl-accent); + --bs-btn-border-color: var(--vl-accent); + --bs-btn-hover-bg: var(--vl-accent); + --bs-btn-hover-border-color: var(--vl-accent); + --bs-btn-active-bg: var(--vl-accent-dark); +} + +.btn-outline-secondary { + --bs-btn-color: var(--vl-muted); + --bs-btn-border-color: var(--vl-border); + --bs-btn-hover-bg: var(--vl-bg); + --bs-btn-hover-border-color: var(--vl-border); + --bs-btn-hover-color: var(--vl-text); +} + +.btn-success { + background-color: var(--vl-accent) !important; + border-color: var(--vl-accent) !important; +} + +.btn-success:hover, +.btn-success:focus { + background-color: var(--vl-accent-dark) !important; + border-color: var(--vl-accent-dark) !important; +} + +.btn-secondary { + background-color: #f1f5f9 !important; + border-color: var(--vl-border) !important; + color: var(--vl-text) !important; +} + +.btn-secondary:hover { + background-color: #e2e8f0 !important; + border-color: var(--vl-border) !important; +} + +.btn-danger { + --bs-btn-bg: #ef4444; + --bs-btn-border-color: #ef4444; + --bs-btn-hover-bg: #dc2626; + --bs-btn-hover-border-color: #dc2626; +} + +/* ─── Forms ──────────────────────────────── */ +.form-control, +.form-select { + border-color: var(--vl-border); + border-radius: 0.5rem; + font-size: 0.9375rem; + padding: 0.5rem 0.75rem; + transition: border-color 0.15s, box-shadow 0.15s; + background-color: var(--vl-surface); +} + +.form-control:focus, +.form-select:focus { + border-color: var(--vl-accent); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12); +} + +.form-label { + font-weight: 500; + font-size: 0.875rem; + color: var(--vl-text); + margin-bottom: 0.375rem; +} + +.form-check-input:checked { + background-color: var(--vl-accent); + border-color: var(--vl-accent); +} + +textarea.form-control { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.875rem; + line-height: 1.6; + min-height: 320px; + resize: vertical; +} + +/* ─── Alerts ─────────────────────────────── */ +.alert { + border-radius: var(--vl-radius); + border: none; + font-size: 0.9rem; +} + +.alert-danger { + background-color: #fef2f2; + color: #991b1b; +} + +/* ─── Left category sidebar ──────────────── */ +.left-sidebar { + position: sticky; + top: 1.5rem; +} + +.left-sidebar-section { + margin-bottom: 1.5rem; +} + +.left-sidebar-cat { + display: block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--vl-muted); + text-decoration: none; + margin-bottom: 0.4rem; + padding: 0.2rem 0.5rem; + border-radius: 0.4rem; + transition: background 0.15s, color 0.15s; +} + +.left-sidebar-cat:hover { + background: var(--vl-accent-soft); + color: var(--vl-accent); +} + +.left-sidebar-list { + list-style: none; + padding: 0; + margin: 0; +} + +.left-sidebar-list li { + border-left: 2px solid var(--vl-border); + padding-left: 0.6rem; + margin-bottom: 0.3rem; +} + +.left-sidebar-list li a { + font-size: 0.8rem; + line-height: 1.35; + color: var(--vl-text); + text-decoration: none; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + transition: color 0.15s; +} + +.left-sidebar-list li a:hover { + color: var(--vl-accent); +} + +/* ─── Related articles sidebar ───────────── */ +.related-sidebar { + position: sticky; + top: 1.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--vl-border) transparent; +} + +/* ─── Post layout: colonnes sidebar fixe + article flexible ── */ +@media (min-width: 992px) { + .post-sidebar-col { + flex: 0 0 260px; + width: 260px; + max-width: 260px; + align-self: stretch; + } +} + +.related-sidebar-title { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--vl-muted); + margin-bottom: 1rem; +} + +.toc-list { + list-style: none; + padding: 0; + margin: 0 0 1.5rem; + display: flex; + flex-direction: column; + gap: .1rem; +} + +.toc-list a { + display: block; + font-size: .8rem; + line-height: 1.45; + color: var(--vl-muted); + padding: .25rem .5rem; + border-left: 2px solid transparent; + transition: color .15s, border-color .15s; +} + +.toc-list a:hover, +.toc-list a.toc-active { + color: var(--vl-accent); + border-left-color: var(--vl-accent); +} + +.toc-list a.toc-active { + font-weight: 600; +} + +.toc-h3 a { + padding-left: 1.25rem; + font-size: .775rem; +} + +.toc-nav { + display: flex; + gap: .5rem; + padding-top: .75rem; + border-top: 1px solid var(--vl-border); + margin-top: .25rem; +} + +.toc-nav-btn { + flex: 1; + background: none; + border: 1px solid var(--vl-border); + border-radius: .5rem; + padding: .3rem .4rem; + font-size: .75rem; + color: var(--vl-muted); + cursor: pointer; + transition: color .15s, border-color .15s; +} + +.toc-nav-btn:hover { + color: var(--vl-accent); + border-color: var(--vl-accent); +} + +.also-read-title { + font-size: .7rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--vl-muted); + margin-bottom: .75rem; +} + +.also-read-grid { + display: flex; + flex-wrap: wrap; + gap: .75rem; +} + +.also-read-grid .related-card { + flex: 1 1 200px; + max-width: 280px; + background: var(--vl-surface); + border-radius: var(--vl-radius); + padding: .65rem; + box-shadow: var(--vl-shadow-sm); + transition: box-shadow .15s, transform .15s; +} + +.also-read-grid .related-card:hover { + box-shadow: var(--vl-shadow-md); + transform: translateY(-1px); +} + +.related-card { + display: flex; + gap: 0.75rem; + align-items: flex-start; + text-decoration: none; + color: var(--vl-text); + padding: 0.65rem; + border-radius: var(--vl-radius); + transition: background 0.15s; + margin-bottom: 0.25rem; +} + +.related-card:hover { + background: var(--vl-accent-soft); + color: var(--vl-accent); +} + +.related-card-thumb { + width: 64px; + height: 52px; + border-radius: 0.5rem; + flex-shrink: 0; + background-size: cover; + background-position: center; +} + +.related-card-body { + flex: 1; + min-width: 0; +} + +.related-card-title { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.related-card-date { + font-size: 0.75rem; + color: var(--vl-muted); + margin-top: 0.2rem; +} + +/* ─── Source / attachment cards (sidebar) ── */ +.source-card { + display: flex; + gap: 0.65rem; + align-items: flex-start; + text-decoration: none; + color: var(--vl-text); + padding: 0.5rem 0.65rem; + border-radius: var(--vl-radius); + border: 1px solid var(--vl-border); + background: var(--vl-surface); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.source-card:hover { + border-color: var(--vl-accent); + box-shadow: 0 0 0 3px rgba(79,70,229,.08); + color: var(--vl-accent); +} + +.source-card-thumb { + width: 52px; + height: 42px; + border-radius: 0.4rem; + flex-shrink: 0; + background-color: var(--vl-accent-soft); +} + +.source-card-thumb--pdf, +.source-card-thumb--link { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: var(--vl-accent); +} + +.source-card-body { + flex: 1; + min-width: 0; +} + +.source-card-title { + font-size: 0.82rem; + font-weight: 600; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.source-card-meta { + font-size: 0.72rem; + color: var(--vl-muted); + margin-top: 0.2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Post content (Markdown rendu) ──────── */ +.post-content { + font-size: 1rem; + line-height: 1.8; + color: var(--vl-text); +} + +.post-content h1, +.post-content h2, +.post-content h3, +.post-content h4, +.post-content h5, +.post-content h6 { + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.75rem; + letter-spacing: -0.3px; + line-height: 1.3; +} + +.post-content p { + margin-bottom: 1.25rem; +} + +.post-content a { + color: var(--vl-accent); + text-decoration: underline; + text-decoration-color: var(--vl-accent-soft); + text-underline-offset: 3px; + transition: text-decoration-color 0.15s; +} + +.post-content a:hover { + text-decoration-color: var(--vl-accent); +} + +.post-content code { + background: #f1f5f9; + padding: 0.2em 0.45em; + border-radius: 4px; + font-size: 0.85em; + color: #be185d; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.post-content pre { + background: #1e293b; + color: #e2e8f0; + padding: 1.25rem 1.5rem; + border-radius: var(--vl-radius); + overflow-x: auto; + font-size: 0.875rem; + line-height: 1.6; + margin: 1.5rem 0; +} + +.post-content pre code { + background: none; + color: inherit; + padding: 0; + font-size: inherit; +} + +.post-content blockquote { + border-left: 3px solid var(--vl-accent); + padding: 0.5rem 0 0.5rem 1.25rem; + margin: 1.5rem 0; + color: var(--vl-muted); + font-style: italic; +} + +.post-content ul, +.post-content ol { + padding-left: 1.5rem; + margin-bottom: 1.25rem; +} + +.post-content li { + margin-bottom: 0.375rem; +} + +.post-content img { + max-width: 100%; + border-radius: var(--vl-radius); + margin: 1rem 0; +} + +.post-content table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + font-size: 0.9rem; +} + +.post-content th, +.post-content td { + padding: 0.625rem 1rem; + border: 1px solid var(--vl-border); + text-align: left; +} + +.post-content th { + background: var(--vl-bg); + font-weight: 600; +} + +.post-content hr { + border: none; + border-top: 1px solid var(--vl-border); + margin: 2rem 0; +} + +/* ─── Post grid — auto-fill columns ─────────── */ +.post-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +/* ─── Post list — date / meta ─────────────── */ +.post-meta { + font-size: 0.8rem; + color: var(--vl-muted); +} + +/* ─── Footer ─────────────────────────────── */ +footer { + border-top: 1px solid var(--vl-border); + color: var(--vl-muted) !important; + font-size: 0.85rem; +} + +/* ─── Navbar tagline ────────────────────── */ +.navbar-tagline { + font-size: 0.68rem; + font-weight: 400; + color: rgba(255,255,255,.6); + letter-spacing: 0; + margin-top: 2px; +} + +/* ─── Liste éditoriale ───────────────────── */ +.posts-list { + max-width: 720px; + margin: 0 auto; +} + +.post-entry { + padding: 2rem 0; + border-bottom: 1px solid var(--vl-border); +} + +.post-entry:last-child { + border-bottom: none; +} + +.post-entry-title { + font-size: 1.3rem; + font-weight: 600; + margin-bottom: 0.6rem; + line-height: 1.35; + letter-spacing: -0.3px; +} + +.post-entry-title a { + color: var(--vl-text); + text-decoration: none; + transition: color 0.15s; +} + +.post-entry-title a:hover { + color: var(--vl-accent); +} + +.post-entry-excerpt { + color: var(--vl-muted); + font-size: 0.95rem; + margin-bottom: 0.85rem; + line-height: 1.65; +} + +.post-entry-meta { + display: flex; + align-items: center; + gap: 1.25rem; + font-size: 0.82rem; + color: var(--vl-muted); +} + +.post-entry-edit { + color: var(--vl-muted); + text-decoration: none; + transition: color 0.15s; +} + +.post-entry-edit:hover { + color: var(--vl-text); +} + +.post-entry-read { + color: var(--vl-accent); + text-decoration: none; + font-weight: 600; + margin-left: auto; + transition: opacity 0.15s; +} + +.post-entry-read:hover { + opacity: 0.75; +} + +/* ─── Pagination ─────────────────────────── */ +.pagination-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + flex-wrap: wrap; +} + +.pagination-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.2rem; + height: 2.2rem; + padding: 0 0.5rem; + border-radius: 0.5rem; + border: 1px solid var(--vl-border); + background: var(--vl-surface); + color: var(--vl-text); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.pagination-btn:hover:not(.disabled):not(.active) { + border-color: var(--vl-accent); + color: var(--vl-accent); + background: var(--vl-accent-soft); +} + +.pagination-btn.active { + background: var(--vl-accent); + border-color: var(--vl-accent); + color: #fff; +} + +.pagination-btn.disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; +} + +.pagination-ellipsis { + padding: 0 0.25rem; + color: var(--vl-muted); + font-size: 0.875rem; +} + +/* ─── Footer ──────────────────────────────── */ +footer { + background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #7c3aed 100%); + border-top: none; + box-shadow: 0 -2px 24px rgba(79,70,229,.25); + color: rgba(255,255,255,.85); +} + +footer.py-5 { padding-top: 2.5rem !important; padding-bottom: 2.5rem !important; } +footer.mt-5 { margin-top: 0 !important; } + +.footer-inner { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; +} + +.footer-about strong { + font-size: 1rem; + font-weight: 800; + color: #fff; + display: block; + margin-bottom: 0.4rem; + letter-spacing: -0.2px; +} + +.footer-about p { + font-size: 0.82rem; + color: rgba(255,255,255,.75); + margin: 0 0 0.5rem; + line-height: 1.55; +} + +.footer-about small { + font-size: 0.78rem; + color: rgba(255,255,255,.6); +} + +.footer-about small a { + color: rgba(255,255,255,.7); + text-decoration: underline; + text-decoration-color: rgba(255,255,255,.3); + transition: color 0.15s; +} + +.footer-about small a:hover { + color: #fff; +} + +.footer-nav { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + flex-shrink: 0; +} + +.footer-nav a { + font-size: 0.85rem; + color: rgba(255,255,255,.75); + text-decoration: none; + transition: color 0.15s; +} + +.footer-nav a:hover { + color: #fff; +} + +@media (max-width: 576px) { + .footer-inner { + flex-direction: column; + gap: 1.5rem; + } + + .footer-nav { + align-items: flex-start; + flex-direction: row; + gap: 1rem; + } +} + +/* ─── Utilitaires ─────────────────────────── */ +.text-muted { + color: var(--vl-muted) !important; +} + +.text-primary { + color: var(--vl-accent) !important; +} + +/* ─── Widget étoiles ──────────────────────── */ + +/* ─── Barre de recherche (navbar) ────────── */ +.search-form { + margin: 0 .5rem; +} + +.search-input { + width: 160px; + transition: width .2s, background .15s; + background: rgba(255,255,255,.15); + border-color: rgba(255,255,255,.25); + border-radius: 0.5rem; + color: #fff; +} + +.search-input::placeholder { + color: rgba(255,255,255,.5); +} + +.search-input:focus { + width: 220px; + background: rgba(255,255,255,.22); + border-color: rgba(255,255,255,.5); + box-shadow: 0 0 0 2px rgba(255,255,255,.15); + color: #fff; +} + +@media (max-width: 991px) { + .search-form { margin: .5rem 0; } + .search-input, .search-input:focus { width: 100%; } +} + +/* ─── Barre de recherche hero (accueil) ────── */ +.hero-search { + text-align: center; + padding: 2.25rem 1rem 1.5rem; +} +.hero-search-form { + display: flex; + max-width: 600px; + margin: 0 auto; + gap: .5rem; +} +.hero-search-input { + flex: 1; + padding: .7rem 1.1rem; + font-size: 1.05rem; + border: 1.5px solid var(--vl-border); + border-radius: .6rem; + background: var(--vl-surface); + color: var(--vl-text); + transition: border-color .2s, box-shadow .2s; +} +.hero-search-input::placeholder { color: var(--vl-muted); } +.hero-search-input:focus { + outline: none; + border-color: var(--vl-accent); + box-shadow: 0 0 0 3px rgba(79,70,229,.12); +} +.hero-search-btn { + padding: .7rem 1.3rem; + background: var(--vl-accent); + color: #fff; + border: none; + border-radius: .6rem; + font-size: .95rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background .2s; +} +.hero-search-btn:hover { background: var(--vl-accent-dark); } +.hero-search-stats { + margin-top: .8rem; + font-size: .83rem; + color: var(--vl-muted); + letter-spacing: .01em; +} + +/* ─── Page de recherche ───────────────────── */ +.search-page { max-width: 780px; margin: 0 auto; } + +.search-bar .input-group { max-width: 600px; } + +.search-results { display: flex; flex-direction: column; gap: 2rem; } + +.search-result { + border-bottom: 1px solid var(--vl-border); + padding-bottom: 1.5rem; +} + +.search-result-meta { + display: flex; + align-items: center; + gap: .75rem; + margin-bottom: .25rem; + font-size: .8rem; +} + +.search-result-cat { + background: var(--vl-surface); + border: 1px solid var(--vl-border); + border-radius: 20px; + padding: 1px 10px; + color: var(--vl-muted); + text-decoration: none; +} + +.search-result-cat:hover { color: var(--vl-accent); } + +.search-result-date { color: var(--vl-muted); } + +.search-result-title { + font-size: 1.15rem; + margin: 0 0 .4rem; +} + +.search-result-title a { + color: var(--vl-text); + text-decoration: none; +} + +.search-result-title a:hover { color: var(--vl-accent); } + +.search-result-snippet { + font-size: .9rem; + color: var(--vl-muted); + margin: 0; + line-height: 1.6; +} + +.search-result-snippet mark { + background: rgba(250, 204, 21, .35); + color: inherit; + border-radius: 2px; + padding: 0 2px; +} + +/* ── Tag cloud ────────────────────────────────────────────────── */ +.tag-cloud { + display: flex; + flex-wrap: wrap; + gap: .5rem; + align-items: center; + padding: 1.25rem 0; + border-top: 1px solid var(--vl-border, #e5e7eb); +} + +.tag-cloud-item { + display: inline-flex; + align-items: center; + gap: .3em; + padding: .25em .75em; + border-radius: 999px; + border: 1px solid var(--vl-border, #e5e7eb); + background: transparent; + color: var(--vl-muted); + text-decoration: none; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; + font-size: .85rem !important; +} + +.tag-cloud-item:hover { + background: var(--vl-accent); + border-color: var(--vl-accent); + color: #fff; +} + +.tag-cloud-item.active { + background: var(--vl-accent); + border-color: var(--vl-accent); + color: #fff; + font-weight: 600; +} + +.tag-count { + font-size: .8em; + opacity: .7; +} + +.tag-cloud-reset { + margin-left: auto; + font-size: .85rem; + color: var(--vl-muted); + text-decoration: none; +} + +.tag-cloud-reset:hover { color: var(--vl-accent); } + +/* ─── Profil public auteur ───────────────── */ + +.author-profile-hero { + display: flex; + align-items: flex-start; + gap: 1.5rem; +} + +.author-avatar { + flex-shrink: 0; + width: 4rem; + height: 4rem; + border-radius: 50%; + background: var(--vl-accent); + color: #fff; + font-size: 1.75rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.author-profile-name { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 .25rem; +} + +.author-profile-link { + font-size: .875rem; + color: var(--vl-muted); +} + +.author-profile-link:hover { color: var(--vl-accent); } + +.liens-cta { + display: inline-block; + margin-top: .625rem; + padding: .5rem 1.25rem; + border-radius: 100px; + background: var(--vl-accent); + color: #fff; + font-size: .875rem; + font-weight: 700; + text-decoration: none; + transition: background .2s, transform .15s; +} +.liens-cta:hover { + background: var(--vl-accent-dark); + color: #fff; + transform: translateY(-1px); +} + +.author-bio-wrap { + flex: 1; +} + +.author-profile-bio { + margin: 0 0 .25rem; + color: var(--vl-muted); + line-height: 1.7; + font-size: .9375rem; +} + +.author-profile-bio.bio-clamped { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.bio-toggle { + background: none; + border: none; + padding: 0; + color: var(--vl-accent); + font-size: .875rem; + cursor: pointer; + text-decoration: underline; +} + +/* ─── Page "Mes liens" ───────────────────── */ + +.liens-page { + max-width: 520px; + margin: 0 auto; + padding: 3rem 1.25rem 5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 2.5rem; +} + +.liens-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + text-align: center; +} + +.liens-avatar { + width: 6rem; + height: 6rem; + border-radius: 50%; + background: var(--vl-accent); + color: #fff; + font-size: 2.5rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.liens-name { + font-size: 1.5rem; + font-weight: 700; + margin: 0; +} + +.liens-bio { + font-size: .9375rem; + color: var(--vl-muted); + line-height: 1.6; + margin: 0; +} + +.liens-sep { + width: 100%; + border: none; + border-top: 1.5px solid var(--vl-border); + margin: 0; +} + +.liens-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.liens-item { + display: flex; + flex-direction: column; + align-items: center; + gap: .3rem; + width: 100%; + padding: 1.125rem 1.75rem; + border-radius: 100px; + border: none; + background: var(--btn-bg, var(--vl-accent)); + text-align: center; + text-decoration: none; + color: #fff; + font-weight: 700; + transition: filter .2s, transform .15s, box-shadow .2s; + box-shadow: 0 2px 8px rgba(0,0,0,.12); +} + +.liens-item:hover { + color: #fff; + filter: brightness(1.12); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,.18); +} + +.liens-item-title { font-size: 1.125rem; } + +.liens-item-desc { + font-size: .85rem; + font-weight: 400; + color: rgba(255,255,255,.8); +} + +.liens-footer { + font-size: .8rem; + color: var(--vl-muted); +} +.liens-footer a { color: inherit; } +.liens-footer a:hover { color: var(--vl-accent); } + +.liens-bg { background: #f1f5f9; } + +/* ─── Agrégateur de flux ─────────────────── */ + +.flux-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 52rem; +} + +.flux-item { + border-left: 3px solid var(--vl-border); + padding-left: 1rem; +} + +.flux-item-meta { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; + font-size: .8rem; + color: var(--vl-muted); + margin-bottom: .25rem; +} + +.flux-author { + font-weight: 600; + color: var(--vl-accent); + text-decoration: none; +} +.flux-author:hover { text-decoration: underline; } + +.flux-feed-name::before { content: '·'; margin-right: .5rem; } + +.flux-date::before { content: '·'; margin-right: .5rem; } + +.flux-item-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 .25rem; +} + +.flux-item-title a { + color: var(--vl-text); + text-decoration: none; +} +.flux-item-title a:hover { color: var(--vl-accent); } + +.flux-item-summary { + font-size: .875rem; + color: var(--vl-muted); + margin: 0; + line-height: 1.6; +} + +/* Bouton flottant "+" — nouvel article */ +.fab-new { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 1050; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: var(--vl-accent); + color: #fff; + font-size: 1.75rem; + line-height: 3.25rem; + text-align: center; + text-decoration: none; + box-shadow: 0 4px 16px rgba(79,70,229,.45); + transition: background 0.15s, box-shadow 0.15s, transform 0.15s; + user-select: none; +} +.fab-new:hover, +.fab-new:focus { + background: var(--vl-accent-dark); + color: #fff; + box-shadow: 0 6px 20px rgba(79,70,229,.55); + transform: scale(1.08); +} + +/* ─── Homepage sections ─────────────────────────────────────────── */ +.home-section { margin-top: 3rem; } +.home-section--first { margin-top: 0; } + +.home-section-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--vl-muted); + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1.25rem; +} +.home-section-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vl-border); +} +.home-section-title-sub { + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} +.home-section-badge { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--vl-accent); + color: #fff; + padding: 0.15rem 0.55rem; + border-radius: 999px; +} + +/* ─── Hero card homepage ────────────────────────────────────────── */ +.home-hero-card { + display: block; + text-decoration: none; + color: inherit; + border-radius: var(--vl-radius); + overflow: hidden; + margin-bottom: 1.5rem; + box-shadow: var(--vl-shadow-md); + transition: box-shadow 0.2s, transform 0.2s; +} +.home-hero-card:hover { box-shadow: 0 8px 32px rgba(0,0,0,.14); transform: translateY(-2px); } + +.home-hero-card-cover { + position: relative; + height: 340px; + background-size: cover; + background-position: center; +} +.home-hero-card-cover::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(10,15,30,.75) 0%, rgba(10,15,30,.15) 55%, transparent 100%); +} +.home-hero-badge { + position: absolute; + top: 1rem; + left: 1rem; + z-index: 2; + background: var(--vl-accent); + color: #fff; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + padding: 0.2rem 0.65rem; + border-radius: 999px; +} +.home-hero-card-meta { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1.25rem 1.5rem; + z-index: 1; + color: #fff; +} +.home-hero-card-cat { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + opacity: 0.8; + margin-bottom: 0.35rem; +} +.home-hero-card-title { + font-size: clamp(1.3rem, 3vw, 1.9rem); + font-weight: 700; + line-height: 1.2; + margin: 0 0 0.45rem; +} +.home-hero-card-excerpt { + font-size: 0.9rem; + opacity: 0.78; + margin: 0 0 0.6rem; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.home-hero-card-date { + font-size: 0.8rem; + opacity: 0.65; +} + +@media (max-width: 640px) { + .home-hero-card-cover { height: 240px; } +} + +/* ─── More link ─────────────────────────────────────────────────── */ +.home-more-link { margin-top: 1.25rem; text-align: center; } + +/* ─── Compact list (récemment mis à jour, redécouvertes) ────────── */ +.home-compact-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.6rem; +} +.home-compact-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.875rem; + background: var(--vl-surface); + border: 1px solid var(--vl-border); + border-radius: var(--vl-radius); + text-decoration: none; + color: var(--vl-text); + transition: border-color 0.15s, box-shadow 0.15s; +} +.home-compact-item:hover { + border-color: var(--vl-accent); + box-shadow: var(--vl-shadow-sm); + color: var(--vl-text); +} +.home-compact-thumb { + width: 44px; + height: 44px; + border-radius: 6px; + flex-shrink: 0; + background-size: cover; + background-position: center; +} +.home-compact-meta { flex: 1; min-width: 0; } +.home-compact-title { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.home-compact-date { + font-size: 0.75rem; + color: var(--vl-muted); + margin-top: 0.15rem; +} diff --git a/public/assets/favicon.svg b/public/assets/favicon.svg new file mode 100644 index 0000000..80913eb --- /dev/null +++ b/public/assets/favicon.svg @@ -0,0 +1,4 @@ + + + v + diff --git a/public/assets/fonts/LICENSE-Inter.txt b/public/assets/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/public/assets/fonts/LICENSE-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/assets/fonts/inter-italic-latin-ext.woff2 b/public/assets/fonts/inter-italic-latin-ext.woff2 new file mode 100644 index 0000000..9de5e3e Binary files /dev/null and b/public/assets/fonts/inter-italic-latin-ext.woff2 differ diff --git a/public/assets/fonts/inter-italic-latin.woff2 b/public/assets/fonts/inter-italic-latin.woff2 new file mode 100644 index 0000000..9e98286 Binary files /dev/null and b/public/assets/fonts/inter-italic-latin.woff2 differ diff --git a/public/assets/fonts/inter-normal-latin-ext.woff2 b/public/assets/fonts/inter-normal-latin-ext.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/public/assets/fonts/inter-normal-latin-ext.woff2 differ diff --git a/public/assets/fonts/inter-normal-latin.woff2 b/public/assets/fonts/inter-normal-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/public/assets/fonts/inter-normal-latin.woff2 differ diff --git a/public/assets/js/add_files.js b/public/assets/js/add_files.js new file mode 100644 index 0000000..165b7cc --- /dev/null +++ b/public/assets/js/add_files.js @@ -0,0 +1,118 @@ +document.addEventListener('DOMContentLoaded', function () { + var panel = document.getElementById('sf-panel'); + if (!panel) return; + + var input = document.getElementById('sf-input'); + var btn = document.getElementById('sf-btn'); + var box = document.getElementById('sf-results'); + var toUuid = panel.dataset.uuid; + + function fileIcon(mime) { + if (mime.startsWith('video/')) return '🎬'; + if (mime.startsWith('audio/')) return '🎵'; + if (mime === 'application/pdf') return '📑'; + return '📄'; + } + + async function doSearch() { + var q = input.value.trim(); + if (!q) return; + btn.disabled = true; + box.innerHTML = '

Recherche…

'; + try { + var res = await fetch('/?action=search_files&q=' + encodeURIComponent(q) + '&exclude=' + encodeURIComponent(toUuid)); + var data = await res.json(); + box.innerHTML = ''; + if (!data.length) { + box.innerHTML = '

Aucun fichier trouvé.

'; + return; + } + data.forEach(function (group) { + var section = document.createElement('div'); + section.className = 'mb-4'; + + var header = document.createElement('p'); + header.className = 'fw-semibold small mb-2'; + header.textContent = group.article.title; + section.appendChild(header); + + var grid = document.createElement('div'); + grid.className = 'd-flex flex-wrap gap-2'; + + group.files.forEach(function (f) { + var wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;cursor:pointer'; + wrap.title = f.name + ' (' + (f.size / 1024).toFixed(1) + ' Ko)'; + + if (f.is_image) { + var img = document.createElement('img'); + img.src = f.url; + img.alt = f.name; + img.style.cssText = 'width:72px;height:72px;object-fit:cover;border-radius:6px;border:2px solid transparent;transition:border-color .15s,opacity .15s;display:block'; + wrap.appendChild(img); + } else { + var icon = document.createElement('div'); + icon.style.cssText = 'width:72px;height:72px;border-radius:6px;border:2px solid #dee2e6;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:1.6rem;background:#f8f9fa;transition:border-color .15s'; + icon.innerHTML = fileIcon(f.mime) + '' + f.name.split('.').pop().toUpperCase() + ''; + wrap.appendChild(icon); + } + + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;inset:0;border-radius:6px;display:none;align-items:center;justify-content:center;background:rgba(25,135,84,.8);color:#fff;font-size:1.4rem'; + overlay.textContent = '✓'; + wrap.appendChild(overlay); + + wrap.addEventListener('mouseenter', function () { + if (!wrap._copied) wrap.firstChild.style.borderColor = '#0d6efd'; + }); + wrap.addEventListener('mouseleave', function () { + if (!wrap._copied) wrap.firstChild.style.borderColor = 'transparent'; + }); + + wrap.addEventListener('click', async function () { + if (wrap._copying || wrap._copied) return; + wrap._copying = true; + wrap.firstChild.style.opacity = '.5'; + try { + var fd = new FormData(); + fd.append('from_uuid', group.article.uuid); + fd.append('name', f.name); + fd.append('to_uuid', toUuid); + var r = await fetch('/?action=copy_file&uuid=' + encodeURIComponent(toUuid), {method: 'POST', body: fd}); + var d = await r.json(); + if (d.ok) { + wrap._copied = true; + wrap.firstChild.style.opacity = '1'; + wrap.firstChild.style.borderColor = '#198754'; + overlay.style.display = 'flex'; + } else { + wrap.firstChild.style.opacity = '1'; + wrap.firstChild.style.borderColor = '#dc3545'; + wrap.title = d.error || 'Erreur'; + } + } catch (e) { + wrap.firstChild.style.opacity = '1'; + } finally { + wrap._copying = false; + } + }); + + grid.appendChild(wrap); + }); + + section.appendChild(grid); + box.appendChild(section); + }); + } catch (e) { + box.innerHTML = '

Erreur de recherche.

'; + } finally { + btn.disabled = false; + } + } + + btn.addEventListener('click', doSearch); + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); doSearch(); } + }); + doSearch(); +}); diff --git a/public/assets/js/admin.js b/public/assets/js/admin.js new file mode 100644 index 0000000..12d6f90 --- /dev/null +++ b/public/assets/js/admin.js @@ -0,0 +1,48 @@ +document.addEventListener('DOMContentLoaded', function () { + // Confirmation data-confirm sur les formulaires (evite confirm() inline bloqué par CSP) + document.querySelectorAll('form[data-confirm]').forEach(function (form) { + form.addEventListener('submit', function (e) { + var msg = form.getAttribute('data-confirm') || 'Confirmer ?'; + if (!window.confirm(msg)) { + e.preventDefault(); + } + }); + }); + + // Sélection globale articles + var checkAll = document.getElementById('check-all'); + if (checkAll) { + checkAll.addEventListener('change', function () { + document.querySelectorAll('.bulk-check').forEach(function (cb) { + cb.checked = checkAll.checked; + }); + }); + } + + // Indicateurs de traitement formulaire SMTP (config + tester connexion) + var smtpForm = document.getElementById('smtp-config-form'); + if (smtpForm) { + smtpForm.addEventListener('submit', function (e) { + var clicked = e.submitter; + if (!clicked) return; + smtpForm.querySelectorAll('button[type="submit"]').forEach(function (btn) { + btn.disabled = true; + }); + var isSave = clicked.id === 'smtp-save-btn'; + clicked.innerHTML = '' + + (isSave ? 'Enregistrement…' : 'En cours…'); + }); + } + + // Indicateur de traitement envoi email de test + var smtpTestForm = document.getElementById('smtp-test-form'); + if (smtpTestForm) { + smtpTestForm.addEventListener('submit', function () { + var btn = document.getElementById('smtp-send-btn'); + if (btn) { + btn.disabled = true; + btn.innerHTML = 'En cours…'; + } + }); + } +}); diff --git a/public/assets/js/app.js b/public/assets/js/app.js new file mode 100644 index 0000000..9f85bfa --- /dev/null +++ b/public/assets/js/app.js @@ -0,0 +1,462 @@ +// varlog — app.js + +document.addEventListener('DOMContentLoaded', function () { + + // ─── Auto-resize textareas ─────────────────────────────────────────────── + document.querySelectorAll('textarea.form-control').forEach(function (ta) { + function resize() { + ta.style.height = 'auto'; + ta.style.height = ta.scrollHeight + 'px'; + } + ta.addEventListener('input', resize); + resize(); + }); + + // ─── Ctrl+Enter : soumettre le formulaire ──────────────────────────────── + var form = document.querySelector('form[method="POST"]'); + if (form) { + form.addEventListener('keydown', function (e) { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + form.submit(); + } + }); + } + + // ─── Slug auto-génération ──────────────────────────────────────────────── + const titleInput = document.getElementById('title'); + const slugField = document.getElementById('slug'); + const slugPreview = document.getElementById('slug-preview'); + + if (titleInput && slugField) { + if (slugField.value !== '') slugField._auto = false; + + titleInput.addEventListener('input', function () { + if (slugField._auto !== false) { + const generated = slugify(this.value); + slugField.value = generated; + if (slugPreview) slugPreview.textContent = generated; + } + }); + + slugField.addEventListener('input', function () { + this._auto = (this.value === ''); + if (slugPreview) slugPreview.textContent = this.value; + }); + } + + function slugify(s) { + const map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'}; + return s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, c => map[c] || c) + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + } + + // ─── Rôle : nom technique auto depuis le label ─────────────────────────── + var roleLabelInput = document.getElementById('role-label'); + var roleNameInput = document.getElementById('role-name'); + if (roleLabelInput && roleNameInput) { + roleLabelInput.addEventListener('input', function () { + if (roleNameInput._manual) return; + roleNameInput.value = slugify(this.value); + }); + roleNameInput.addEventListener('input', function () { + this._manual = (this.value !== ''); + }); + roleNameInput.addEventListener('blur', function () { + if (this.value === '') { + this._manual = false; + this.value = slugify(roleLabelInput.value); + } + }); + } + + // ─── Aperçu couleur catégorie ──────────────────────────────────────────── + const KNOWN_CATS = { + 'actualité': 10, 'travaux': 35, 'scolaire': 55, + 'linux': 120, 'domotique': 160, 'télécom': 190, + 'blog': 220, 'informatique': 255, 'réflexion': 285, + 'loisirs': 320, 'perso': 345, + }; + const FREE_HUES = [87, 140, 205, 237, 302]; + + function gradient(hue) { + return `linear-gradient(135deg,hsl(${hue},70%,88%) 0%,hsl(${hue},60%,28%) 100%)`; + } + + function hashHue(str) { + let h = 5381; + for (let i = 0; i < str.length; i++) h = (((h << 5) + h) + str.charCodeAt(i)) | 0; + return ((Math.abs(h) * 0.6180339887) * 360 | 0) % 360; + } + + function nearestKnown(hue) { + let best = null, bestDist = Infinity; + for (const [name, h] of Object.entries(KNOWN_CATS)) { + const d = Math.min(Math.abs(hue - h), 360 - Math.abs(hue - h)); + if (d < bestDist) { bestDist = d; best = name; } + } + return { name: best, dist: bestDist }; + } + + function updateCatPreview(val) { + const key = val.trim().toLowerCase(); + const swatch = document.getElementById('cat-swatch'); + const hint = document.getElementById('cat-hint'); + const freeEl = document.getElementById('cat-free-swatches'); + if (!swatch) return; + freeEl.innerHTML = ''; + + if (!key) { + swatch.style.background = '#e5e7eb'; + swatch.title = ''; + hint.textContent = ''; + return; + } + + if (KNOWN_CATS[key] !== undefined) { + const hue = KNOWN_CATS[key]; + swatch.style.background = gradient(hue); + swatch.title = `${hue}°`; + hint.textContent = `Catégorie existante · teinte fixe (${hue}°)`; + hint.className = 'text-muted d-block mt-1'; + return; + } + + const hue = hashHue(key); + const { name, dist } = nearestKnown(hue); + + swatch.style.background = gradient(hue); + swatch.title = `${hue}°`; + + if (dist < 20) { + hint.innerHTML = `⚠ Teinte proche de ${name} (${dist}° d'écart) · couleurs disponibles :`; + hint.className = 'text-warning d-block mt-1'; + FREE_HUES.forEach(h => { + const el = document.createElement('span'); + el.title = `${h}°`; + el.style.cssText = `display:inline-block;width:28px;height:20px;border-radius:4px;cursor:help;background:${gradient(h)}`; + freeEl.appendChild(el); + }); + } else { + hint.textContent = `Nouvelle catégorie · teinte libre (${hue}°)`; + hint.className = 'text-muted d-block mt-1'; + } + } + + const catInput = document.getElementById('category'); + if (catInput) { + catInput.addEventListener('input', function () { updateCatPreview(this.value); }); + updateCatPreview(catInput.value); + } + + // ─── Copier la référence Markdown ──────────────────────────────────────── + document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) { + btn.addEventListener('click', function () { + const name = this.dataset.copyMdName; + const isImage = this.dataset.copyMdIsImage === '1'; + const ref = isImage ? `![](${name})` : `[${name}](${name})`; + navigator.clipboard.writeText(ref).then(() => { + const orig = this.textContent; + this.textContent = 'Copié !'; + setTimeout(() => { this.textContent = orig; }, 1500); + }); + }); + }); + + // ─── Boîtes de confirmation (suppression) ─────────────────────────────── + document.querySelectorAll('button[data-confirm], a[data-confirm]').forEach(function (el) { + el.addEventListener('click', function (e) { + if (!confirm(this.dataset.confirm)) e.preventDefault(); + }); + }); + document.querySelectorAll('form[data-confirm]').forEach(function (form) { + form.addEventListener('submit', function (e) { + if (!confirm(this.dataset.confirm)) e.preventDefault(); + }); + }); + + // ─── Insérer une référence Markdown au curseur ─────────────────────────── + const ta = document.getElementById('content'); + if (ta) { + ta._savedStart = null; + ta._savedEnd = null; + + function saveCursor() { + if (document.activeElement === ta) { + ta._savedStart = ta.selectionStart; + ta._savedEnd = ta.selectionEnd; + } + } + document.addEventListener('mousedown', saveCursor); + ta.addEventListener('keyup', saveCursor); + ta.addEventListener('mouseup', saveCursor); + + document.querySelectorAll('[data-insert-ref]').forEach(function (el) { + el.addEventListener('click', function () { + insertRef(this.dataset.insertRef); + }); + if (el.tagName === 'IMG') { + el.addEventListener('mouseenter', function () { this.style.borderColor = '#0d6efd'; }); + el.addEventListener('mouseleave', function () { this.style.borderColor = 'transparent'; }); + } + }); + } + + function insertRef(url) { + if (!ta) return; + const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(url); + const label = url.startsWith('http') + ? (decodeURIComponent(url.split('/').pop().split('?')[0]) || url) + : url; + const ref = isImage ? `![](${url})` : `[${label}](${url})`; + const len = ta.value.length; + const start = ta._savedStart !== null ? ta._savedStart : len; + const end = ta._savedEnd !== null ? ta._savedEnd : len; + ta.focus(); + ta.setRangeText(ref, start, end, 'end'); + ta._savedStart = ta._savedEnd = start + ref.length; + ta.dispatchEvent(new Event('input')); + } + + // ─── Compteurs SEO ─────────────────────────────────────────────────────── + function initCounter(inputId, counterId, max) { + const input = document.getElementById(inputId); + const counter = document.getElementById(counterId); + if (!input || !counter) return; + function update() { + const len = input.value.length; + counter.textContent = `${len} / ${max}`; + counter.className = len > max ? 'text-danger' : 'text-muted'; + } + input.addEventListener('input', update); + update(); + } + initCounter('seo_title', 'seo_title_counter', 60); + initCounter('seo_description', 'seo_desc_counter', 155); + + // ─── Page catégories ───────────────────────────────────────────────────── + function catComputeGradient(val) { + const key = val.trim().toLowerCase(); + if (!key) return null; + if (KNOWN_CATS[key] !== undefined) return { hue: KNOWN_CATS[key], known: true }; + const hue = hashHue(key); + const { name, dist } = nearestKnown(hue); + return { hue, known: false, conflict: dist < 20 ? name : null }; + } + + document.querySelectorAll('form[action="/?action=rename_category"] input[name="new"]').forEach(function (input) { + input.addEventListener('input', function () { + const swatch = input.closest('form').querySelector('.rename-swatch'); + const result = catComputeGradient(input.value); + if (swatch) swatch.style.background = result ? gradient(result.hue) : '#e5e7eb'; + }); + }); + + const newCatInput = document.getElementById('new-cat-input'); + if (newCatInput) { + newCatInput.addEventListener('input', function () { + const swatch = document.getElementById('new-cat-swatch'); + const hint = document.getElementById('new-cat-hint'); + const result = catComputeGradient(this.value); + + if (!result) { + swatch.style.background = '#e5e7eb'; + hint.textContent = ''; + return; + } + + swatch.style.background = gradient(result.hue); + + if (result.known) { + hint.textContent = `Catégorie existante · teinte fixe (${result.hue}°)`; + hint.className = 'text-muted d-block mb-3'; + } else if (result.conflict) { + hint.textContent = `⚠ Teinte proche de « ${result.conflict} » — choisissez un autre nom ou une couleur disponible ci-dessous`; + hint.className = 'text-warning d-block mb-3'; + } else { + hint.textContent = `Couleur libre · teinte ${result.hue}°`; + hint.className = 'text-success d-block mb-3'; + } + }); + } + + // ─── Import image : récupérer les métadonnées ──────────────────────────── + const fetchMetaBtn = document.getElementById('fetch-meta-btn'); + if (fetchMetaBtn) { + fetchMetaBtn.addEventListener('click', async function () { + const urlInput = document.getElementById('import-url'); + const resultDiv = document.getElementById('meta-result'); + const url = urlInput ? urlInput.value.trim() : ''; + + if (!url) { + resultDiv.innerHTML = 'Saisissez une URL d\'abord.'; + return; + } + + fetchMetaBtn.disabled = true; + fetchMetaBtn.textContent = 'Chargement…'; + resultDiv.innerHTML = ''; + + try { + const res = await fetch(`/?action=fetch_file_meta&url=${encodeURIComponent(url)}`); + const data = await res.json(); + + if (!data.ok) { + resultDiv.innerHTML = `${data.error || 'Erreur lors de la récupération.'}`; + return; + } + + // Auto-remplissage dynamique des champs (si vides) + const AUTOFILL = { + img_author: { keys: ['author', 'credit'], label: 'Auteur / crédit' }, + img_source: { keys: ['canonical', 'source'], label: 'URL source' }, + }; + const autofillKeys = new Set(); + const autofillNotice = []; + for (const [fieldName, cfg] of Object.entries(AUTOFILL)) { + const f = document.querySelector(`input[name="${fieldName}"]`); + if (!f || f.value) continue; + for (const key of cfg.keys) { + if (data[key]) { + f.value = data[key]; + autofillKeys.add(key); + autofillNotice.push(`${cfg.label} : ${data[key]}`); + break; + } + } + } + + // Affichage dynamique de tous les champs retournés + const isPdf = (data.mime === 'application/pdf'); + const isHtml = (data.mime || '').startsWith('text/html'); + + const META_ORDER = ['mime','size','pages','page_size','pdf_version', + 'width','site_name','og_type','language', + 'title','description','author','subject','keywords', + 'credit','source','creator','producer','date','camera','copyright', + 'canonical','og_image']; + const META_LABELS = { + mime: 'Type', size: 'Taille', width: 'Dimensions', + pages: 'Pages', page_size: 'Format', pdf_version: 'Version PDF', + site_name: 'Site', og_type: 'Type OG', language: 'Langue', + title: isPdf || isHtml ? 'Titre' : 'Titre EXIF/IPTC', + author: isPdf || isHtml ? 'Auteur' : 'Auteur EXIF/IPTC', + date: isPdf ? 'Créé le' : isHtml ? 'Publié le' : 'Prise de vue', + description: 'Description', subject: 'Sujet', keywords: 'Mots-clés', + credit: 'Crédit', source: 'Source IPTC', + creator: 'Créé avec', producer: 'Produit par', + camera: 'Appareil', copyright: 'Copyright', + canonical: 'URL canonique', og_image: 'Image OG', + }; + + function fmtVal(key, val) { + if (key === 'size') return (val/1024).toFixed(0) + ' Ko' + (val >= 1048576 ? ` (${(val/1048576).toFixed(1)} Mo)` : ''); + if (key === 'width') return `${data.width} × ${data.height} px`; + if (key === 'og_image') return ``; + if (key === 'canonical') return `${val}`; + return String(val); + } + + const SKIP = new Set(['ok', 'height']); + const seen = new Set(); + const rows = []; + + for (const key of META_ORDER) { + const val = data[key]; + if (val == null || val === '' || key === 'height') continue; + seen.add(key); + const badge = autofillKeys.has(key) + ? ' ↓ pré-rempli' + : ''; + rows.push([META_LABELS[key] ?? key, fmtVal(key, val) + badge]); + } + for (const [key, val] of Object.entries(data)) { + if (seen.has(key) || SKIP.has(key) || val == null || val === '') continue; + rows.push([key, fmtVal(key, val)]); + } + + let html = ''; + if (rows.length > 0) { + const trs = rows.map(([k, v]) => + `${k}${v}` + ).join(''); + html = `${trs}
`; + } else { + html = 'Aucune métadonnée disponible pour ce fichier.'; + } + if (autofillNotice.length > 0) { + html += `
✓ Pré-rempli — ${autofillNotice.join(' · ')}
`; + } + resultDiv.innerHTML = html; + } catch { + resultDiv.innerHTML = 'Erreur de connexion.'; + } finally { + fetchMetaBtn.disabled = false; + fetchMetaBtn.textContent = 'Métadonnées'; + } + }); + } + + // ─── Import image : toggle mode download ──────────────────────────────── + document.querySelectorAll('input[name="mode"]').forEach(function (r) { + r.addEventListener('change', function () { + const dl = this.value === 'download'; + const ss = this.value === 'screenshot'; + const warn = document.getElementById('copyright-warning'); + const fields = document.getElementById('download-fields'); + if (warn) warn.style.display = dl ? 'block' : 'none'; + if (fields) fields.style.display = (dl || ss) ? 'block' : 'none'; + }); + }); + + // ─── Données page (mode édition uniquement) ────────────────────────────── + const pageEl = document.getElementById('vl-page'); + if (!pageEl) return; + + const uuid = pageEl.dataset.uuid; + const insertUrl = pageEl.dataset.insertUrl; + + // Auto-insertion après import d'image + if (insertUrl && ta) { + const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl); + const name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier'; + const ref = isImage ? `![](${insertUrl})` : `[${name}](${insertUrl})`; + const sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : ''; + ta.value += sep + ref; + ta.focus(); + ta.selectionStart = ta.selectionEnd = ta.value.length; + ta.dispatchEvent(new Event('input')); + } + + // ─── Autosave ──────────────────────────────────────────────────────────── + const indicator = document.getElementById('autosave-indicator'); + if (!indicator || !uuid) return; + + let timer = null; + + function scheduleAutosave() { + clearTimeout(timer); + timer = setTimeout(doAutosave, 3000); + } + + async function doAutosave() { + const title = document.getElementById('title').value; + const slug = document.getElementById('slug').value; + const content = document.getElementById('content').value; + indicator.textContent = 'Sauvegarde…'; + try { + const res = await fetch(`/?action=autosave&uuid=${encodeURIComponent(uuid)}`, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({title, slug, content}), + }); + const data = await res.json(); + indicator.textContent = data.ok ? `Brouillon sauvegardé à ${data.time}` : 'Erreur de sauvegarde'; + } catch { + indicator.textContent = 'Erreur de sauvegarde'; + } + } + + ['title', 'slug', 'content'].forEach(id => { + document.getElementById(id)?.addEventListener('input', scheduleAutosave); + }); +}); diff --git a/public/assets/js/bio-toggle.js b/public/assets/js/bio-toggle.js new file mode 100644 index 0000000..549bcad --- /dev/null +++ b/public/assets/js/bio-toggle.js @@ -0,0 +1,14 @@ +(function(){ + var bio = document.getElementById('author-bio'); + var btn = document.getElementById('bio-toggle'); + if (!bio || !btn) return; + requestAnimationFrame(function() { + if (bio.scrollHeight > bio.clientHeight + 2) { btn.hidden = false; } + }); + btn.addEventListener('click', function() { + var exp = btn.getAttribute('aria-expanded') === 'true'; + bio.classList.toggle('bio-clamped', exp); + btn.textContent = exp ? 'plus' : 'moins'; + btn.setAttribute('aria-expanded', exp ? 'false' : 'true'); + }); +})(); diff --git a/public/assets/js/bootstrap.bundle.min.js b/public/assets/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..bbd9b5e --- /dev/null +++ b/public/assets/js/bootstrap.bundle.min.js @@ -0,0 +1,6 @@ +/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); diff --git a/public/assets/js/links-sortable.js b/public/assets/js/links-sortable.js new file mode 100644 index 0000000..7a02393 --- /dev/null +++ b/public/assets/js/links-sortable.js @@ -0,0 +1,29 @@ +(function() { + const list = document.getElementById('links-sortable'); + if (!list) return; + let dragged = null; + list.querySelectorAll('li').forEach(li => { + li.setAttribute('draggable', true); + li.addEventListener('dragstart', () => { dragged = li; li.style.opacity = '.4'; }); + li.addEventListener('dragend', () => { dragged = null; li.style.opacity = ''; saveOrder(); }); + li.addEventListener('dragover', e => { e.preventDefault(); const after = getDragAfter(list, e.clientY); after ? list.insertBefore(dragged, after) : list.appendChild(dragged); }); + }); + function getDragAfter(container, y) { + return [...container.querySelectorAll('li:not([style*="opacity"])')].reduce((closest, el) => { + const box = el.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + return offset < 0 && offset > closest.offset ? { offset, element: el } : closest; + }, { offset: Number.NEGATIVE_INFINITY }).element; + } + function saveOrder() { + const form = document.getElementById('reorder-form'); + if (!form) return; + form.querySelectorAll('input').forEach(i => i.remove()); + list.querySelectorAll('li[data-id]').forEach(li => { + const inp = document.createElement('input'); + inp.type = 'hidden'; inp.name = 'order[]'; inp.value = li.dataset.id; + form.appendChild(inp); + }); + form.submit(); + } +})(); diff --git a/public/assets/js/post_confirm.js b/public/assets/js/post_confirm.js new file mode 100644 index 0000000..2c0834f --- /dev/null +++ b/public/assets/js/post_confirm.js @@ -0,0 +1,62 @@ +document.addEventListener('DOMContentLoaded', function () { + var data = document.getElementById('pc-data'); + if (!data) return; + + var defaultTitle = data.dataset.defaultTitle; + var defaultDesc = data.dataset.defaultDesc; + var baseUrl = data.dataset.baseUrl; + + function initCounter(inputId, counterId, max) { + var el = document.getElementById(inputId); + var ct = document.getElementById(counterId); + if (!el || !ct) return; + function upd() { + var n = el.value.length; + ct.textContent = n + ' / ' + max; + ct.className = n > max ? 'text-danger' : 'text-muted'; + } + el.addEventListener('input', upd); + upd(); + } + initCounter('seo_title', 'seo_title_counter', 60); + initCounter('seo_description', 'seo_desc_counter', 155); + + function updatePreview() { + var seoTitle = document.getElementById('seo_title').value.trim(); + var seoDesc = document.getElementById('seo_description').value.trim(); + var slug = document.getElementById('confirm-slug').value.trim(); + document.getElementById('preview-title').textContent = seoTitle || defaultTitle; + document.getElementById('preview-desc').textContent = seoDesc || defaultDesc; + document.getElementById('preview-url').textContent = baseUrl + slug; + } + + ['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener('input', updatePreview); + }); + + var slugInput = document.getElementById('confirm-slug'); + var slugDisplay = document.getElementById('slug-display'); + + var btnSuggest = document.getElementById('slug-btn-suggest'); + if (btnSuggest) { + btnSuggest.addEventListener('click', function () { + var val = btnSuggest.dataset.slugSuggest; + slugInput.value = val; + slugDisplay.textContent = val; + updatePreview(); + }); + } + + var btnKeep = document.getElementById('slug-btn-keep'); + if (btnKeep) { + btnKeep.addEventListener('click', function () { + var val = btnKeep.dataset.slugKeep; + slugInput.value = val; + slugDisplay.textContent = val; + updatePreview(); + }); + } + + updatePreview(); +}); diff --git a/public/assets/js/reactions.js b/public/assets/js/reactions.js new file mode 100644 index 0000000..d5d4fa1 --- /dev/null +++ b/public/assets/js/reactions.js @@ -0,0 +1,45 @@ +// reactions.js — toggle réactions via fetch, fallback formulaire natif + +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.reaction-form').forEach(function (form) { + form.addEventListener('submit', function (e) { + e.preventDefault(); + + var btn = form.querySelector('.reaction-btn'); + var type = form.querySelector('[name="type"]').value; + var uuid = form.querySelector('[name="uuid"]').value; + var badge = form.querySelector('.reaction-count'); + var active = btn.classList.contains('btn-primary'); + + var data = new URLSearchParams(); + data.append('uuid', uuid); + data.append('type', type); + data.append('_ajax', '1'); + + fetch('/react', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: data.toString(), + }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (!json.ok) { form.submit(); return; } + + var nowActive = json.active; + if (btn.classList.contains('hero-reaction-btn')) { + btn.classList.toggle('hero-reaction-btn--active', nowActive); + } else { + btn.classList.toggle('btn-primary', nowActive); + btn.classList.toggle('btn-outline-secondary', !nowActive); + if (badge) { + badge.classList.toggle('bg-light', nowActive); + badge.classList.toggle('text-primary', nowActive); + badge.classList.toggle('bg-secondary', !nowActive); + } + } + if (badge) { badge.textContent = json.count; } + }) + .catch(function () { form.submit(); }); + }); + }); +}); diff --git a/public/assets/js/toc.js b/public/assets/js/toc.js new file mode 100644 index 0000000..ea52b50 --- /dev/null +++ b/public/assets/js/toc.js @@ -0,0 +1,37 @@ +(function () { + var headings = document.querySelectorAll('.post-content h2, .post-content h3'); + var links = document.querySelectorAll('.toc-list a'); + + if (headings.length && links.length) { + var map = {}; + links.forEach(function (a) { + map[decodeURIComponent(a.getAttribute('href').slice(1))] = a; + }); + + var active = null; + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + if (active) active.classList.remove('toc-active'); + active = map[entry.target.id] || null; + if (active) active.classList.add('toc-active'); + } + }); + }, { rootMargin: '-8% 0px -82% 0px', threshold: 0 }); + + headings.forEach(function (h) { observer.observe(h); }); + } + + var btnTop = document.getElementById('toc-go-top'); + var btnBot = document.getElementById('toc-go-bottom'); + if (btnTop) { + btnTop.addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + if (btnBot) { + btnBot.addEventListener('click', function () { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }); + } +})(); diff --git a/public/feed.php b/public/feed.php new file mode 100644 index 0000000..302820e --- /dev/null +++ b/public/feed.php @@ -0,0 +1,110 @@ +getPrivateCategories(); +$Parsedown = new Parsedown(); + +$now = time(); +$base = rtrim(APP_URL, '/'); + +$all = array_values(array_filter( + $articles->getAll(publishedOnly: true), + static function (array $a) use ($now, $privateCats): bool { + if (strtotime((string)($a['published_at'] ?? '')) > $now) { + return false; + } + $cat = trim($a['category'] ?? ''); + return $cat === '' || !in_array($cat, $privateCats, true); + } +)); + +// ─── Pagination curseur ────────────────────────────────────────────────────── +$after = trim($_GET['after'] ?? ''); +$offset = 0; +if ($after !== '') { + foreach ($all as $i => $a) { + if ($a['uuid'] === $after) { + $offset = $i + 1; + break; + } + } +} + +$items = array_slice($all, $offset, FEED_PAGE_SIZE); +$nextCursor = (count($all) > $offset + FEED_PAGE_SIZE) + ? ($all[$offset + FEED_PAGE_SIZE - 1]['uuid'] ?? null) + : null; + +$feedUrl = $base . '/feed'; +$feedNextUrl = $nextCursor !== null ? $base . '/feed/' . $nextCursor : null; + +// ─── lastBuildDate ─────────────────────────────────────────────────────────── +$lastBuild = ''; +foreach ($all as $a) { + $ts = (int)strtotime((string)($a['updated_at'] ?? $a['published_at'] ?? '')); + if ($ts > (int)strtotime($lastBuild ?: '1970-01-01')) { + $lastBuild = date(DATE_RSS, $ts); + } +} +if ($lastBuild === '') { + $lastBuild = date(DATE_RSS); +} + +header('Content-Type: application/rss+xml; charset=UTF-8'); +header('X-Content-Type-Options: nosniff'); + +echo '' . "\n"; +?> + + + <?= htmlspecialchars(siteTitle()) ?> + + + + + + + + 0): ?> + + + + + diff --git a/public/file.php b/public/file.php new file mode 100644 index 0000000..7dbb4ff --- /dev/null +++ b/public/file.php @@ -0,0 +1,35 @@ + 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); + session_start(); +} + +require_once BASE_PATH . '/src/helpers.php'; +require_once BASE_PATH . '/src/auth.php'; +require_once BASE_PATH . '/src/SiteSettings.php'; +require_once BASE_PATH . '/config/config.php'; +require_once BASE_PATH . '/src/ArticleManager.php'; + +$articles = new ArticleManager(BASE_PATH . '/data'); + +$action = $_GET['action'] ?? 'list'; +$uuid = $_GET['uuid'] ?? ''; +$slug = $_GET['slug'] ?? ''; + +$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links', 'react', 'comment', 'verify_comment', 'comment_moderate', 'comment_delete', 'comment_resend', 'create_tag_type', 'delete_tag_type', 'edit_tags']; +$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null; +unset($_noindexActions); + +// ─── Recherche de l'article le plus proche et redirection 301 ──────────────── +function searchAndRedirect(string $rawPath, ArticleManager $articles): void +{ + require_once BASE_PATH . '/src/SearchEngine.php'; + $query = (string)preg_replace('/\s{2,}/', ' ', trim( + (string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath)) + )); + if ($query === '') { + return; + } + $privateCats = $articles->getPrivateCategories(); + $pool = array_values(array_filter( + $articles->getAll(true), + static function (array $a) use ($privateCats): bool { + if (strtotime((string)($a['published_at'] ?? '')) > time()) { + return false; + } + $cat = trim($a['category'] ?? ''); + return $cat === '' || !in_array($cat, $privateCats, true); + } + )); + $results = (new SearchEngine())->search($query, $pool); + if (!empty($results)) { + header('Location: /post/' . rawurlencode($results[0]['article']['slug'] ?? ''), true, 301); + exit; + } +} + +// ─── Pages statiques depuis data/site/ ────────────────────────────────────── +function loadSitePageData(string $slug): array +{ + $base = BASE_PATH . '/data/site'; + $meta = []; + $raw = @file_get_contents($base . '/' . $slug . '.json'); + if ($raw !== false) { + $meta = json_decode($raw, true) ?? []; + } + $html = ''; + $mdRaw = @file_get_contents($base . '/' . $slug . '.md'); + if ($mdRaw !== false) { + require_once BASE_PATH . '/src/Parsedown.php'; + $html = (new Parsedown())->text($mdRaw); + } + return ['meta' => $meta, 'html' => $html]; +} + +// ─── Extraction de métadonnées depuis une URL ──────────────────────────────── +function fetchUrlMeta(string $url): array +{ + if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) { + return ['ok' => false, 'error' => 'URL invalide']; + } + $tmpFile = tempnam(sys_get_temp_dir(), 'vl_meta_'); + $fp = fopen($tmpFile, 'wb'); + $downloaded = 0; + $limit = 4 * 1024 * 1024; + $contentLength = null; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_TIMEOUT => 20, + CURLOPT_USERAGENT => 'Mozilla/5.0 varlog-meta/1.0', + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_ENCODING => '', // accepte gzip/deflate/br, décompresse automatiquement + CURLOPT_HEADERFUNCTION => static function ($curl, $header) use (&$contentLength): int { + if (preg_match('/^content-length:\s*(\d+)/i', $header, $m)) { + $contentLength = (int) $m[1]; + } + return strlen($header); + }, + CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use ($fp, &$downloaded, $limit): int { + $downloaded += strlen($chunk); + if ($downloaded > $limit) { + return -1; + } + fwrite($fp, $chunk); + return strlen($chunk); + }, + ]); + curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $mimeRaw = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $errno = curl_errno($ch); + curl_close($ch); + fclose($fp); + // 403 = site accessible mais protégé (Cloudflare bot-check, etc.) : on laisse passer + // avec métadonnées vides pour que l'utilisateur saisisse manuellement le titre. + if ($httpCode === 403) { + @unlink($tmpFile); + return ['ok' => true, 'mime' => 'text/html', 'blocked' => true]; + } + if ($httpCode < 200 || $httpCode >= 400 || ($errno !== 0 && $errno !== 23)) { + @unlink($tmpFile); + return ['ok' => false, 'error' => "Téléchargement impossible (HTTP $httpCode)"]; + } + $mime = strtok($mimeRaw ?: 'application/octet-stream', '; '); + $result = ['ok' => true, 'mime' => $mime, 'size' => $contentLength ?? $downloaded]; + + if (!str_starts_with($mime, 'text/html')) { + $etJson = @shell_exec('exiftool -json -charset utf8 -struct ' . escapeshellarg($tmpFile) . ' 2>/dev/null'); + if ($etJson) { + $et = json_decode($etJson, true)[0] ?? []; + $etVal = static function (string ...$keys) use ($et): ?string { + foreach ($keys as $key) { + $v = $et[$key] ?? null; + if (is_array($v)) { + $v = implode(', ', array_filter(array_map('trim', $v))); + } + if (is_string($v) && trim($v) !== '') { + return trim($v); + } + } + return null; + }; + if ($v = $etVal('Title', 'Headline', 'ObjectName')) { + $result['title'] = $v; + } + if ($v = $etVal('Description', 'Caption-Abstract', 'ImageDescription')) { + $result['description'] = $v; + } + if ($v = $etVal('Keywords')) { + $result['keywords'] = $v; + } + if ($v = $etVal('Copyright', 'CopyrightNotice', 'Rights')) { + $result['copyright'] = $v; + } + if ($v = $etVal('DateTimeOriginal', 'CreateDate', 'ModifyDate')) { + $result['date'] = preg_replace('/^(\d{4}):(\d{2}):(\d{2})/', '$1-$2-$3', $v); + } + if (str_starts_with($mime, 'image/')) { + if ($v = $etVal('Artist', 'Creator', 'By-line')) { + $result['author'] = $v; + } + $w = $et['ImageWidth'] ?? $et['ExifImageWidth'] ?? null; + $h = $et['ImageHeight'] ?? $et['ExifImageHeight'] ?? null; + if ($w !== null && $h !== null) { + $result['width'] = (int)$w; + $result['height'] = (int)$h; + } + $camera = trim(($et['Make'] ?? '') . ' ' . ($et['Model'] ?? '')); + if ($camera !== '') { + $result['camera'] = $camera; + } + if ($v = $etVal('Credit')) { + $result['credit'] = $v; + } + if ($v = $etVal('Source')) { + $result['source'] = $v; + } + } + if ($mime === 'application/pdf') { + if ($v = $etVal('Author')) { + $result['author'] = $v; + } + if ($v = $etVal('Subject')) { + $result['subject'] = $v; + } + if ($v = $etVal('Creator', 'CreatorTool')) { + $result['creator'] = $v; + } + if ($v = $etVal('Producer')) { + $result['producer'] = $v; + } + if (isset($et['PageCount'])) { + $result['pages'] = (int) $et['PageCount']; + } + if (isset($et['PDFVersion'])) { + $result['pdf_version'] = 'PDF ' . $et['PDFVersion']; + } + $fhPdf = fopen($tmpFile, 'rb'); + $pdfHead = fread($fhPdf, min(filesize($tmpFile), 65536)); + fclose($fhPdf); + if (preg_match('/\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/', $pdfHead, $mb)) { + $wPt = (float)$mb[3] - (float)$mb[1]; + $hPt = (float)$mb[4] - (float)$mb[2]; + $wMm = (int) round($wPt * 25.4 / 72); + $hMm = (int) round($hPt * 25.4 / 72); + $landscape = $wMm > $hMm; + if ($landscape) { + [$wMm, $hMm] = [$hMm, $wMm]; + } + $paperSizes = ['A0' => [841,1189],'A1' => [594,841],'A2' => [420,594], + 'A3' => [297,420],'A4' => [210,297],'A5' => [148,210], + 'Letter' => [216,279],'Legal' => [216,356]]; + $paperName = null; + foreach ($paperSizes as $pname => [$pw, $ph]) { + if (abs($wMm - $pw) <= 2 && abs($hMm - $ph) <= 2) { + $paperName = $pname; + break; + } + } + $label = $paperName ? $paperName . ($landscape ? ' paysage' : '') : ($landscape ? 'Paysage' : 'Portrait'); + $result['page_size'] = "$label ({$wMm}×{$hMm} mm)"; + } + } + } + } + + if (str_starts_with($mime, 'text/html')) { + try { + $fhHtml = fopen($tmpFile, 'rb'); + $html = fread($fhHtml, min(filesize($tmpFile), 65536)); + fclose($fhHtml); + + // Détection du charset : 1) en-tête HTTP, 2) , 3) + $charset = null; + if (preg_match('/charset=([^\s;]+)/i', $mimeRaw, $cm)) { + $charset = trim($cm[1], '"\''); + } + if (!$charset && preg_match('/]+charset=["\']?\s*([^"\'\s;>]+)/i', $html, $cm)) { + $charset = trim($cm[1]); + } + if (!$charset && preg_match('/]+content=["\'][^"\']*charset=([^"\'\s;]+)/i', $html, $cm)) { + $charset = trim($cm[1]); + } + if ($charset && strtolower(str_replace(['-','_'], '', $charset)) !== 'utf8') { + $converted = @mb_convert_encoding($html, 'UTF-8', $charset); + if ($converted !== false && $converted !== '') { + $html = $converted; + } + } + + preg_match('/]*>(.*?)<\/head>/si', $html, $headMatch); + $headHtml = $headMatch[1] ?? $html; + if (preg_match('/]*>\s*([^<]+)\s*<\/title>/i', $headHtml, $m)) { + $result['title'] = html_entity_decode(trim($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + $metaMap = []; + preg_match_all('/]+)>/i', $headHtml, $metaTags); + foreach ($metaTags[1] as $attrs) { + $key = $val = null; + if (preg_match('/(?:name|property)\s*=\s*["\']([^"\']+)["\']/', $attrs, $m)) { + $key = strtolower($m[1]); + } + if (preg_match('/content\s*=\s*["\']([^"\']*)["\']/', $attrs, $m)) { + $val = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + if ($key !== null && $val !== null && $val !== '') { + $metaMap[$key] = $val; + } + } + $result['title'] ??= $metaMap['og:title'] ?? $metaMap['twitter:title'] ?? $metaMap['title'] ?? null; + $result['description'] ??= $metaMap['og:description'] ?? $metaMap['twitter:description'] ?? $metaMap['description'] ?? null; + $result['author'] ??= $metaMap['author'] ?? $metaMap['article:author'] ?? $metaMap['dc.creator'] ?? null; + $result['keywords'] ??= $metaMap['keywords'] ?? $metaMap['news_keywords'] ?? null; + $result['og_image'] ??= $metaMap['og:image'] ?? $metaMap['twitter:image'] ?? null; + $result['site_name'] ??= $metaMap['og:site_name'] ?? null; + $result['og_type'] ??= $metaMap['og:type'] ?? null; + $result['language'] ??= $metaMap['og:locale'] ?? $metaMap['dc.language'] ?? null; + $result['date'] ??= $metaMap['article:published_time'] ?? $metaMap['dc.date'] ?? null; + if (preg_match('/]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $headHtml, $m) + || preg_match('/]+href=["\']([^"\']+)["\'][^>]+rel=["\']canonical["\'][^>]*>/i', $headHtml, $m)) { + $result['canonical'] = $m[1]; + } + preg_match_all('/]+type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/si', $headHtml, $ldTags); + foreach ($ldTags[1] as $jsonStr) { + $ld = @json_decode(trim($jsonStr), true); + if (!is_array($ld)) { + continue; + } + foreach (isset($ld[0]) ? $ld : [$ld] as $item) { + if (!is_array($item)) { + continue; + } + if (empty($result['title']) && !empty($item['headline'])) { + $result['title'] = $item['headline']; + } + if (empty($result['description']) && !empty($item['description'])) { + $result['description'] = $item['description']; + } + if (empty($result['date'])) { + $d = $item['datePublished'] ?? $item['dateCreated'] ?? null; + if ($d) { + $result['date'] = $d; + } + } + if (empty($result['author'])) { + $au = $item['author'] ?? null; + if (is_array($au)) { + $au = $au['name'] ?? ($au[0]['name'] ?? null); + } + if (is_string($au) && $au !== '') { + $result['author'] = $au; + } + } + } + break; + } + $result = array_filter($result, static fn ($v) => $v !== null && $v !== ''); + $result['ok'] = true; + } catch (\Throwable) { + } + } + + @unlink($tmpFile); + return $result; +} + +// ─── Télécharge une image distante → _thumb_ local, retourne le nom du fichier ─ +function downloadImageToThumb(string $imageUrl, string $filesDir): ?string +{ + if (!filter_var($imageUrl, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $imageUrl)) { + return null; + } + $ch = curl_init($imageUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0', + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($body === false || $code !== 200 || strlen($body) < 512) { + return null; + } + $urlPath = parse_url($imageUrl, PHP_URL_PATH) ?? ''; + $ext = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)); + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif'], true)) { + $ext = 'jpg'; + } + if ($ext === 'jpeg') { + $ext = 'jpg'; + } + if (!is_dir($filesDir)) { + mkdir($filesDir, 0755, true); + } + $hash = substr(hash('sha256', $body), 0, 16); + $size = strlen($body); + $name = '_thumb_' . $hash . '-' . $size . '.' . $ext; + file_put_contents($filesDir . '/' . $name, $body); + return $name; +} + +// ─── Trouve l'URL de la plus grande image d'une page HTML ──────────────────── +function findLargestPageImage(string $pageUrl): ?string +{ + if (!filter_var($pageUrl, FILTER_VALIDATE_URL)) { + return null; + } + $ch = curl_init($pageUrl); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0', + CURLOPT_ENCODING => '', + CURLOPT_WRITEFUNCTION => static function ($curl, $chunk) use (&$htmlBuf, &$htmlLen): int { + $htmlLen = ($htmlLen ?? 0) + strlen($chunk); + if ($htmlLen <= 131072) { + $htmlBuf = ($htmlBuf ?? '') . $chunk; + } + return strlen($chunk); + }, + ]); + $htmlBuf = ''; + $htmlLen = 0; + curl_exec($ch); + curl_close($ch); + if (!$htmlBuf) { + return null; + } + + $scheme = parse_url($pageUrl, PHP_URL_SCHEME) ?? 'https'; + $host = $scheme . '://' . (parse_url($pageUrl, PHP_URL_HOST) ?? ''); + + preg_match_all('/]+src=["\']([^"\']+)["\'][^>]*>/i', $htmlBuf, $m); + $candidates = []; + foreach ($m[1] as $src) { + if (preg_match('#^https?://#i', $src)) { + $candidates[] = $src; + } elseif (str_starts_with($src, '//')) { + $candidates[] = $scheme . ':' . $src; + } elseif (str_starts_with($src, '/')) { + $candidates[] = $host . $src; + } + } + // Filtre les icônes/avatars courants par leur chemin + $candidates = array_filter( + $candidates, + fn ($u) => + !preg_match('#/(icon|logo|avatar|favicon|sprite|pixel|spacer|blank|1x1|tracking)#i', $u) + && preg_match('#\.(jpe?g|png|webp|gif|avif)(\?.*)?$#i', $u) + ); + $candidates = array_slice(array_values($candidates), 0, 10); + if (empty($candidates)) { + return null; + } + + // HEAD requests pour comparer Content-Length + $best = null; + $bestSize = 0; + foreach ($candidates as $imgUrl) { + $ch = curl_init($imgUrl); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 5, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_USERAGENT => 'Mozilla/5.0 varlog/1.0', + ]); + curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $len = (int) curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); + curl_close($ch); + if ($code === 200 && $len > $bestSize) { + $bestSize = $len; + $best = $imgUrl; + } + } + // Si aucun Content-Length retourné, prend le premier candidat + return $best ?? $candidates[0]; +} + +// ─── Capture d'écran via Chromium headless ────────────────────────────────── +function takeScreenshot(string $url, string $outputPath): bool +{ + $bin = ''; + foreach (['chromium-headless-shell', 'chromium', 'chromium-browser', 'google-chrome'] as $name) { + $found = trim((string) shell_exec('which ' . escapeshellarg($name) . ' 2>/dev/null')); + if ($found !== '') { + $bin = $found; + break; + } + } + if ($bin === '') { + return false; + } + + $cmd = 'timeout 20 ' . escapeshellarg($bin) + . ' --headless=new' + . ' --disable-gpu' + . ' --no-sandbox' + . ' --disable-setuid-sandbox' + . ' --hide-scrollbars' + . ' --window-size=1200,630' + . ' --screenshot=' . escapeshellarg($outputPath) + . ' --virtual-time-budget=6000' + . ' ' . escapeshellarg($url) + . ' 2>/dev/null'; + shell_exec($cmd); + return file_exists($outputPath) && filesize($outputPath) > 0; +} + +switch ($action) { + + case 'create': + requireAuth(); + + $title = $_POST['title'] ?? ''; + $content = $_POST['content'] ?? ''; + $postSlug = $_POST['slug'] ?? ''; + $published = isset($_POST['published']); + $published_at = str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')); + $seoTitle = $_POST['seo_title'] ?? ''; + $seoDescription = $_POST['seo_description'] ?? ''; + $ogImage = $_POST['og_image'] ?? ''; + $category = $_POST['category'] ?? ''; + $errors = []; + + $postTags = []; + foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { + $_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== '')); + if ($_vals !== []) { + $postTags[trim((string)$_tk)] = $_vals; + } + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (trim($title) === '') { + $errors[] = 'Le titre est obligatoire.'; + } + if (empty($errors)) { + $newUuid = $articles->create($title, $content, $published, $postSlug, $published_at, currentUserEmail() ?? '', $seoTitle, $seoDescription, $ogImage, $category, $postTags); + + foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) { + if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) { + $articles->addFile($newUuid, [ + 'name' => $_FILES['files']['name'][$i], + 'tmp_name' => $tmpName, + 'error' => $_FILES['files']['error'][$i], + ]); + } + } + + header('Location: /'); + exit; + } + } + + $formAction = '/new'; + $action = 'create'; + $tagTypes = $articles->getTagTypes(); + $articleTags = $postTags; + $allTagValues = []; + foreach ($tagTypes as $_tk => $_) { + $allTagValues[$_tk] = $articles->getAllTagValues($_tk); + } + include BASE_PATH . '/templates/post_form.php'; + break; + + case 'view': + $article = $slug !== '' ? $articles->getBySlug($slug) : null; + if (!$article) { + searchAndRedirect($slug, $articles); + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + + if (!$article['published']) { + if (!canDoOnArticle('view_drafts', $article)) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + } + + // Avant-première : publié mais date future → réservé aux utilisateurs avec view_previews + if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) { + if (!hasCapability('view_previews')) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + } + + // Catégorie privée → réservé aux connectés + $allCats = $articles->getCategories(); + $privateCats = $articles->getPrivateCategories(); + $articleCat = trim($article['category'] ?? ''); + $isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true); + if ($isPrivateCat && !isLoggedIn()) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + + $files = $articles->getFiles($article['uuid']); + + // Résout les chemins de fichiers relatifs dans le contenu + $rawContent = $articles->resolveFileUrls($article['uuid'], $article['content']); + + // Ratings + $ratingStats = ['avg' => null, 'count' => 0]; + $userRating = null; + $pdo = dbPdo(); + if ($pdo) { + require_once BASE_PATH . '/src/RatingManager.php'; + $ratingMgr = new RatingManager($pdo); + $ratingStats = $ratingMgr->statsForArticle($article['uuid']); + if (isLoggedIn()) { + $userRating = $ratingMgr->userRating($article['uuid'], currentUserEmail() ?? ''); + } + } + + // Tous les articles publiés — depuis le search_index (1 fichier) si dispo, sinon getAll() + $_si = $articles->getSearchIndex(); + $_allPublished = $_si !== null + ? array_values(array_filter($_si, fn ($a) => (bool)($a['published'] ?? false))) + : $articles->getAll(true); + unset($_si); + + // Articles liés (même catégorie) + $relatedArticles = []; + if ($articleCat !== '') { + foreach ($_allPublished as $a) { + if ($a['uuid'] === $article['uuid']) { + continue; + } + if (trim($a['category'] ?? '') !== $articleCat) { + continue; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + continue; + } + $relatedArticles[] = $a; + if (count($relatedArticles) >= 5) { + break; + } + } + } + + // Articles proches par titre → OR implicite, cumul des scores + require_once BASE_PATH . '/src/SearchEngine.php'; + $_simEngine = new SearchEngine(); + $_stopWords = ['avec', 'dans', 'pour', 'une', 'les', 'des', 'sur', 'par', 'qui', 'que', + 'tout', 'mais', 'donc', 'comment', 'quand', 'plus', 'cette', 'cet', 'ces', + 'mon', 'ton', 'son', 'notre', 'votre', 'leur', 'tres', 'bien', 'fait', + 'aussi', 'comme', 'sans', 'sous', 'entre', 'vers', 'chez']; + $_simPool = array_values(array_filter( + $_allPublished, + static function (array $a) use ($article, $privateCats): bool { + if ($a['uuid'] === $article['uuid']) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + return false; + } + $cat = trim($a['category'] ?? ''); + return $cat === '' || !in_array($cat, $privateCats, true) || isLoggedIn(); + } + )); + $_titleWords = array_unique(array_values(array_filter( + preg_split('/\W+/u', mb_strtolower($article['title']), -1, PREG_SPLIT_NO_EMPTY) ?: [], + fn ($w) => mb_strlen($w) >= 4 && !in_array($w, $_stopWords, true) + ))); + [$_scoreMap, $_articleMap] = $_simEngine->scorePool($_titleWords, $_simPool); + arsort($_scoreMap); + $_similarArticles = array_map( + fn ($uuid) => $_articleMap[$uuid], + array_slice(array_keys($_scoreMap), 0, 5) + ); + unset($_simEngine, $_simPool, $_titleWords, $_stopWords, $_scoreMap, $_articleMap); + + // "À lire aussi" : similaires en premier, même catégorie pour compléter jusqu'à 5 + $alsoReadArticles = $_similarArticles; + if (count($alsoReadArticles) < 5) { + $_used = array_column($alsoReadArticles, 'uuid'); + foreach ($relatedArticles as $_ra) { + if (count($alsoReadArticles) >= 5) { + break; + } + if (!in_array($_ra['uuid'], $_used, true)) { + $alsoReadArticles[] = $_ra; + } + } + unset($_used); + } + unset($_similarArticles); + + unset($_allPublished); + + $backlinks = $articles->getBacklinks($article['slug'] ?? '', $article['uuid']); + + // Réactions et commentaires + require_once BASE_PATH . '/src/ReactionManager.php'; + require_once BASE_PATH . '/src/CommentManager.php'; + $reactionStats = array_fill_keys(ReactionManager::TYPES, 0); + $visitorReactions = []; + $comments = []; + $commentFlash = isset($_GET['commented']); + $commentVerified = isset($_GET['verified']); + $commentError = null; + if ($pdo) { + $reactionMgr = new ReactionManager($pdo); + $commentMgr = new CommentManager($pdo); + + // Cookie visiteur (fingerprint anti-doublon) + if (empty($_COOKIE['vl_vid'])) { + $vid = bin2hex(random_bytes(16)); + setcookie('vl_vid', $vid, [ + 'expires' => time() + 365 * 86400, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } else { + $vid = $_COOKIE['vl_vid']; + } + + $reactionStats = $reactionMgr->statsForArticle($article['uuid']); + $visitorReactions = $reactionMgr->visitorReactions($article['uuid'], $vid); + $comments = $commentMgr->forArticle($article['uuid']); + } + + include BASE_PATH . '/templates/post_view.php'; + break; + + case 'edit': + requireAuth(); + + $article = $articles->getByUuid($uuid); + if (!$article) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + + if (!canDoOnArticle('edit_articles', $article)) { + http_response_code(403); + echo 'Accès refusé.'; + exit; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $articles->setFeatured($uuid, !((bool)($article['featured'] ?? false))); + header('Location: /edit/' . rawurlencode($uuid)); + exit; + } + + $title = $_POST['title'] ?? $article['title']; + $content = $_POST['content'] ?? $article['content']; + $postSlug = $_POST['slug'] ?? $article['slug']; + $published = isset($_POST['published']) ? true : $article['published']; + $published_at = $_POST['published_at'] + ?? date('Y-m-d\TH:i', strtotime((string)($article['published_at'] ?? 'now'))); + $seoTitle = $_POST['seo_title'] ?? ($article['seo_title'] ?? ''); + $seoDescription = $_POST['seo_description'] ?? ($article['seo_description'] ?? ''); + $ogImage = $_POST['og_image'] ?? ($article['og_image'] ?? ''); + $category = $_POST['category'] ?? ($article['category'] ?? ''); + $errors = []; + + // Tags : lire depuis POST si soumis, sinon depuis l'article + $pendingTags = []; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { + $_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== '')); + if ($_vals !== []) { + $pendingTags[trim((string)$_tk)] = $_vals; + } + } + } else { + $pendingTags = $article['tags'] ?? []; + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (trim($title) === '') { + $errors[] = 'Le titre est obligatoire.'; + } + if (empty($errors)) { + if (!empty($_POST['_confirm'])) { + $coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? ''); + $ogImageFromCover = $coverFile !== '' + ? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile) + : ''; + + $articles->update( + $uuid, + $title, + $content, + $published, + $_POST['slug'] ?? '', + str_replace('T', ' ', $_POST['published_at'] ?? ''), + $_POST['revision_comment'] ?? '', + $_POST['seo_title'] ?? '', + $_POST['seo_description'] ?? '', + $ogImageFromCover, + $_POST['category'] ?? '', + $pendingTags + ); + + $fmetaNames = $_POST['fmeta_name'] ?? []; + $fmetaAuthors = $_POST['fmeta_author'] ?? []; + $fmetaSources = $_POST['fmeta_source'] ?? []; + foreach ($fmetaNames as $fi => $fname) { + $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); + } + + $coverFile = trim($_POST['cover_file'] ?? ''); + if ($coverFile !== '') { + $articles->setCover($uuid, $coverFile); + } + + $updated = $articles->getByUuid($uuid); + header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); + exit; + } + + // ─── Page de confirmation ──────────────────────────────────── + $diffLines = lineDiff((string)($article['content'] ?? ''), $content); + $titleChanged = ($title !== ($article['title'] ?? '')); + $autoSlug = slugify($title); + + $changes = []; + if ($titleChanged) { + $changes[] = 'titre modifié'; + } + if (($category ?? '') !== ($article['category'] ?? '')) { + $changes[] = 'catégorie modifiée'; + } + if ($pendingTags !== ($article['tags'] ?? [])) { + $changes[] = 'tags modifiés'; + } + if ($content !== ($article['content'] ?? '')) { + $changes[] = 'contenu modifié'; + } + $oldPublished = (bool)($article['published'] ?? false); + if ($published !== $oldPublished) { + $changes[] = $published ? 'article publié' : 'article dépublié'; + } + $newCover = trim($_POST['cover_file'] ?? ''); + if ($newCover !== '' && $newCover !== ($article['cover'] ?? '')) { + $changes[] = 'couverture modifiée'; + } + $fmetaNames = $_POST['fmeta_name'] ?? []; + $fmetaAuthors = $_POST['fmeta_author'] ?? []; + $fmetaSources = $_POST['fmeta_source'] ?? []; + foreach ($fmetaNames as $fi => $fname) { + $savedMeta = ($article['files_meta'][$fname] ?? []); + if (trim($fmetaAuthors[$fi] ?? '') !== ($savedMeta['author'] ?? '') + || trim($fmetaSources[$fi] ?? '') !== ($savedMeta['source_url'] ?? '')) { + $changes[] = 'métadonnées fichiers modifiées'; + break; + } + } + $autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : ''; + + require_once BASE_PATH . '/src/Parsedown.php'; + $_pd = new Parsedown(); + $autoSeoDesc = mb_strimwidth( + trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text($content)))), + 0, + 155, + '…' + ); + unset($_pd); + + // Tags sous forme de chaînes CSV pour les champs hidden du formulaire de confirmation + $confirmTags = []; + foreach ($pendingTags as $_tk => $_vals) { + $confirmTags[$_tk] = implode(', ', $_vals); + } + + include BASE_PATH . '/templates/post_confirm.php'; + exit; + } + } + + $formAction = '/edit/' . rawurlencode($uuid); + $action = 'edit'; + $existingFiles = $articles->getFiles($uuid); + $insertUrl = ''; + if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) { + $insertUrl = $_GET['insert_url']; + } + $tagTypes = $articles->getTagTypes(); + $articleTags = $pendingTags; + $allTagValues = []; + foreach ($tagTypes as $_tk => $_) { + $allTagValues[$_tk] = $articles->getAllTagValues($_tk); + } + include BASE_PATH . '/templates/post_form.php'; + break; + + case 'edit_tags': + requireAuth(); + $article = $uuid !== '' ? $articles->getByUuid($uuid) : null; + if (!$article) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + if (!canDoOnArticle('edit_articles', $article)) { + http_response_code(403); + echo 'Accès refusé.'; + exit; + } + + $tagType = urldecode(trim($_GET['tag_type'] ?? '')); + $isCatField = ($tagType === 'categorie' || $tagType === 'catégorie'); + $tagTypes = $articles->getTagTypes(); + + require_once BASE_PATH . '/src/TagSuggester.php'; + $suggester = new TagSuggester(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($isCatField) { + $newCat = trim($_POST['category'] ?? ''); + // Met à jour uniquement la catégorie via update() en préservant tout le reste + $articles->update( + $uuid, + $article['title'], + $article['content'], + $article['published'], + $article['slug'], + $article['published_at'] ?? '', + '', + $article['seo_title'] ?? '', + $article['seo_description'] ?? '', + $article['og_image'] ?? '', + $newCat, + $article['tags'] ?? [] + ); + } else { + // Tags : les valeurs cochées remplacent les tags du type concerné + $selected = array_values(array_filter(array_map('trim', $_POST['selected'] ?? []), fn ($v) => $v !== '')); + $allTags = $article['tags'] ?? []; + if (empty($selected)) { + unset($allTags[$tagType]); + } else { + $allTags[$tagType] = $selected; + } + $articles->setTags($uuid, $allTags); + } + header('Location: /edit/' . rawurlencode($uuid)); + exit; + } + + // GET — calculer les suggestions + if ($isCatField) { + $allCats = $articles->getCategories(); + $currentCat = $article['category'] ?? ''; + $suggestions = $suggester->suggestCategory($article['content'], $allCats, $currentCat); + $currentTags = []; + } else { + $existingValues = $articles->getAllTagValues($tagType); + $currentTags = $article['tags'][$tagType] ?? []; + $suggestions = $suggester->suggest($article['content'], $existingValues, $currentTags); + $allCats = null; + $currentCat = null; + } + + include BASE_PATH . '/templates/edit_tags.php'; + break; + + case 'delete_file': + requireAuth(); + $fileName = basename($_POST['name'] ?? ''); + if ($uuid !== '' && $fileName !== '' && $fileName[0] !== '.') { + $articles->deleteFile($uuid, $fileName); + } + header('Location: /edit/' . rawurlencode($uuid)); + exit; + + case 'delete': + requireAuth(); + if ($uuid !== '') { + $articles->delete($uuid); + } + header('Location: /'); + exit; + + case 'delete_revision': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + if ($uuid !== '' && isset($_POST['rev_n'])) { + $articles->deleteRevision($uuid, (int)$_POST['rev_n']); + } + header('Location: /edit/' . rawurlencode($uuid) . '#historyPanel'); + exit; + + case 'delete_all_revisions': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + if ($uuid !== '') { + $articles->deleteAllRevisions($uuid); + } + header('Location: /edit/' . rawurlencode($uuid)); + exit; + + case 'categories': + header('Location: /admin/categories'); + exit; + + case 'create_tag_type': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $typeKey = strtolower((string)preg_replace('/[^a-z0-9_]/i', '', trim($_POST['type_key'] ?? ''))); + $typeLabel = trim($_POST['type_label'] ?? ''); + if ($typeKey !== '' && $typeLabel !== '') { + $types = $articles->getTagTypes(); + if (!isset($types[$typeKey])) { + $types[$typeKey] = $typeLabel; + $articles->saveTagTypes($types); + } + } + } + header('Location: /admin/categories'); + exit; + + case 'delete_tag_type': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $typeKey = trim($_POST['type_key'] ?? ''); + if ($typeKey !== '') { + $types = $articles->getTagTypes(); + unset($types[$typeKey]); + $articles->saveTagTypes($types); + } + } + header('Location: /admin/categories'); + exit; + + case 'rename_category': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $old = trim($_POST['old'] ?? ''); + $new = trim($_POST['new'] ?? ''); + if ($old !== '' && $new !== '' && $old !== $new) { + $articles->renameCategory($old, $new); + } + } + header('Location: /admin/categories'); + exit; + + case 'delete_category': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $cat = trim($_POST['category'] ?? ''); + if ($cat !== '') { + $articles->deleteCategory($cat); + } + } + header('Location: /admin/categories'); + exit; + + case 'toggle_private_category': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $cat = trim($_POST['category'] ?? ''); + if ($cat !== '') { + $articles->togglePrivateCategory($cat); + } + } + header('Location: /admin/categories'); + exit; + + case 'author': + $authorSlug = trim($_GET['slug'] ?? ''); + $authorRow = profileBySlug($authorSlug); + if (!$authorRow) { + http_response_code(404); + $content = '

Profil introuvable.

'; + $title = 'Profil introuvable'; + include BASE_PATH . '/templates/layout.php'; + break; + } + $privateCats = $articles->getPrivateCategories(); + $authorArticles = array_values(array_filter( + $articles->getAll(publishedOnly: true), + static function (array $a) use ($authorRow, $privateCats): bool { + if (($a['author'] ?? '') !== $authorRow['email']) { + return false; + } + $cat = trim($a['category'] ?? ''); + if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + return false; + } + return true; + } + )); + include BASE_PATH . '/templates/author_profile.php'; + break; + + case 'author_articles': + $authorSlug = trim($_GET['slug'] ?? ''); + $authorRow = profileBySlug($authorSlug); + if (!$authorRow) { + http_response_code(404); + $content = '

Profil introuvable.

'; + $title = 'Profil introuvable'; + include BASE_PATH . '/templates/layout.php'; + break; + } + $privateCats = $articles->getPrivateCategories(); + $allCats = $articles->getCategories(); + $authorArticles = array_values(array_filter( + $articles->getAll(publishedOnly: true), + static function (array $a) use ($authorRow, $privateCats): bool { + if (($a['author'] ?? '') !== $authorRow['email']) { + return false; + } + $cat = trim($a['category'] ?? ''); + if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + return false; + } + return true; + } + )); + $perPage = postsPerPage(); + $cursor = trim($_GET['cursor'] ?? ''); + $offset = 0; + if ($cursor !== '') { + foreach ($authorArticles as $i => $a) { + if ($a['uuid'] === $cursor) { + $offset = $i + 1; + break; + } + } + } + $posts = array_slice($authorArticles, $offset, $perPage); + $nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null; + $prevCursor = null; + if ($offset > 0) { + $prevOffset = max(0, $offset - $perPage); + $prevCursor = $prevOffset > 0 ? $authorArticles[$prevOffset - 1]['uuid'] : ''; + } + include BASE_PATH . '/templates/author_articles.php'; + break; + + case 'liens': + $liensSlug = trim($_GET['slug'] ?? ''); + $liensRow = profileBySlug($liensSlug); + if (!$liensRow) { + http_response_code(404); + $content = '

Page introuvable.

'; + $title = 'Page introuvable'; + include BASE_PATH . '/templates/layout.php'; + break; + } + $_lName = $liensRow['display_name'] ?? ''; + $_lBio = $liensRow['bio'] ?? ''; + $_lSlug = $liensRow['profile_slug'] ?? ''; + $_lInitials = mb_strtoupper(mb_substr($_lName, 0, 1, 'UTF-8'), 'UTF-8'); + $profileLinks = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare( + 'SELECT id, url, title, description FROM profile_links + WHERE user_email = :e ORDER BY position, id' + ); + $st->execute([':e' => $liensRow['email']]); + $profileLinks = $st->fetchAll(PDO::FETCH_ASSOC); + } catch (\Throwable) { + } + } + include BASE_PATH . '/templates/liens.php'; + break; + + case 'add_link': + requireAuth(); + $linkUrl = filter_var(trim($_POST['link_url'] ?? ''), FILTER_VALIDATE_URL) ?: ''; + $linkTitle = trim($_POST['link_title'] ?? ''); + $linkDesc = trim($_POST['link_desc'] ?? ''); + if ($linkUrl !== '') { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare( + 'INSERT INTO profile_links (user_email, url, title, description, position) + VALUES (:e, :u, :t, :d, + COALESCE((SELECT MAX(position)+1 FROM profile_links WHERE user_email = :e), 0))' + ); + $st->execute([':e' => currentUserEmail(), ':u' => $linkUrl, ':t' => $linkTitle, ':d' => $linkDesc]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#links'); + exit; + + case 'delete_link': + requireAuth(); + $linkId = (int)($_POST['link_id'] ?? 0); + if ($linkId > 0) { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('DELETE FROM profile_links WHERE id = :id AND user_email = :e'); + $st->execute([':id' => $linkId, ':e' => currentUserEmail()]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#links'); + exit; + + case 'reorder_links': + requireAuth(); + $order = $_POST['order'] ?? []; + if (is_array($order)) { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('UPDATE profile_links SET position = :p WHERE id = :id AND user_email = :e'); + foreach (array_values($order) as $pos => $id) { + $st->execute([':p' => $pos, ':id' => (int)$id, ':e' => currentUserEmail()]); + } + } catch (\Throwable) { + } + } + } + header('Location: /profile#links'); + exit; + + case 'flux': + require_once BASE_PATH . '/src/FeedFetcher.php'; + $fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds'); + $fluxItems = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->query( + 'SELECT f.user_email, f.feed_url, f.label, + p.display_name, p.profile_slug + FROM rss_feeds f + LEFT JOIN user_profiles p ON p.email = f.user_email + ORDER BY f.created_at' + ); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) { + $data = $fetcher->get($_row['feed_url']); + if (!$data) { + continue; + } + $feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title']; + $authorName = $_row['display_name'] ?? ''; + $authorSlug = $_row['profile_slug'] ?? ''; + foreach ($data['items'] as $_item) { + $fluxItems[] = array_merge($_item, [ + 'feed_title' => $feedTitle, + 'feed_url' => $_row['feed_url'], + 'author_name' => $authorName, + 'author_slug' => $authorSlug, + ]); + } + } + } catch (\Throwable) { + } + } + usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']); + include BASE_PATH . '/templates/flux.php'; + break; + + case 'about': + ['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('about'); + include BASE_PATH . '/templates/about.php'; + break; + + case 'legal': + ['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('legal'); + include BASE_PATH . '/templates/legal.php'; + break; + + case 'contact': + include BASE_PATH . '/templates/contact.php'; + break; + + case 'licenses': + ['meta' => $siteMeta, 'html' => $siteContent] = loadSitePageData('licenses'); + include BASE_PATH . '/templates/licenses.php'; + break; + + case 'diff': + requireAuth(); + $article = $articles->getByUuid($uuid); + if (!$article) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + $revisions = $article['revisions'] ?? []; + $revN = (int)($_GET['rev'] ?? 0); + // Trouver l'index dans le tableau par numéro de révision + $revIndex = null; + foreach ($revisions as $ri => $r) { + if ((int)($r['n'] ?? 0) === $revN) { + $revIndex = $ri; + break; + } + } + if ($revIndex === null || $revN < 1) { + header('Location: /edit/' . rawurlencode($uuid)); + exit; + } + $oldContent = $articles->getRevisionContent($uuid, $revN); + if ($oldContent === null) { + http_response_code(404); + echo 'Révision introuvable.'; + exit; + } + $diffLines = lineDiff($oldContent, $article['content']); + include BASE_PATH . '/templates/diff.php'; + break; + + case 'autosave': + requireAuth(); + header('Content-Type: application/json'); + if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') { + echo json_encode(['ok' => false]); + exit; + } + $asTitle = trim($_POST['title'] ?? ''); + $asContent = $_POST['content'] ?? ''; + $asSlug = trim($_POST['slug'] ?? ''); + if ($asTitle === '') { + echo json_encode(['ok' => false]); + exit; + } + $ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug); + echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); + exit; + + case 'copy_file': + requireAuth(); + header('Content-Type: application/json'); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + echo json_encode(['ok' => false]); + exit; + } + $cfFrom = trim($_POST['from_uuid'] ?? ''); + $cfTo = $uuid !== '' ? $uuid : trim($_POST['to_uuid'] ?? ''); + $cfName = basename($_POST['name'] ?? ''); + if (!preg_match('/^[0-9a-f-]{36}$/', $cfFrom) + || !preg_match('/^[0-9a-f-]{36}$/', $cfTo) + || $cfName === '' + || str_starts_with($cfName, '.')) { + echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']); + exit; + } + $cfSrc = BASE_PATH . '/data/' . $cfFrom . '/files/' . $cfName; + $cfDstDir = BASE_PATH . '/data/' . $cfTo . '/files'; + $cfDst = $cfDstDir . '/' . $cfName; + if (!file_exists($cfSrc)) { + echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']); + exit; + } + if (!is_dir($cfDstDir)) { + mkdir($cfDstDir, 0775, true); + } + echo json_encode(['ok' => copy($cfSrc, $cfDst)]); + exit; + + case 'add_files': + requireAuth(); + $addFilesArticle = $articles->getByUuid($uuid); + if (!$addFilesArticle) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + $addFilesError = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Quand post_max_size est dépassé, PHP vide $_FILES et $_POST silencieusement + if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0 && empty($_FILES)) { + $addFilesError = sprintf('Fichier trop lourd — limite serveur : %s.', ini_get('post_max_size')); + } else { + foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) { + if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) { + $articles->addFile($uuid, [ + 'name' => $_FILES['files']['name'][$i], + 'tmp_name' => $tmpName, + 'error' => $_FILES['files']['error'][$i], + ]); + } + } + header('Location: /edit/' . rawurlencode($uuid)); + exit; + } + } + include BASE_PATH . '/templates/add_files.php'; + break; + + case 'import_image': + requireAuth(); + $importArticle = $articles->getByUuid($uuid); + if (!$importArticle) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + $importError = $_GET['error'] ?? ''; + include BASE_PATH . '/templates/import_image.php'; + break; + + case 'fetch_file_meta': + requireAuth(); + header('Content-Type: application/json'); + echo json_encode(fetchUrlMeta(trim($_GET['url'] ?? ''))); + exit; + + case 'import_image_step2': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /import/' . rawurlencode($uuid)); + exit; + } + $step2Article = $articles->getByUuid($uuid); + if (!$step2Article) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + $step2Url = trim($_POST['image_url'] ?? ''); + if (!filter_var($step2Url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $step2Url)) { + header('Location: /import/' . rawurlencode($uuid) . '?error=1'); + exit; + } + // Détection URL interne (même hostname que APP_URL → lecture directe sans cURL) + $step2Meta = null; + $step2IsInternal = false; + $_iHost = parse_url(APP_URL, PHP_URL_HOST) ?? ''; + $_uHost = parse_url($step2Url, PHP_URL_HOST) ?? ''; + $_uPath = parse_url($step2Url, PHP_URL_PATH) ?? ''; + if ($_iHost !== '' && $_uHost === $_iHost && preg_match('#^/post/([a-z0-9][a-z0-9-]*)/?$#', $_uPath, $_sm)) { + $_ia = $articles->getBySlug($_sm[1]); + if ($_ia) { + $step2IsInternal = true; + $step2Meta = ['ok' => true, 'title' => $_ia['title'] ?? '', 'mime' => 'text/html']; + if (!empty($_ia['seo_description'])) { + $step2Meta['description'] = $_ia['seo_description']; + } elseif (!empty($_ia['content'])) { + require_once BASE_PATH . '/src/Parsedown.php'; + $_plain = strip_tags((new Parsedown())->text($_ia['content'])); + $step2Meta['description'] = mb_strimwidth(trim(preg_replace('/\s+/', ' ', $_plain)), 0, 155, '…'); + unset($_plain); + } + if (!empty($_ia['cover'])) { + $step2Meta['og_image'] = '/file?uuid=' . rawurlencode($_ia['uuid']) . '&name=' . rawurlencode($_ia['cover']); + } + } + unset($_ia); + } + unset($_iHost, $_uHost, $_uPath, $_sm); + if ($step2Meta === null) { + $step2Meta = fetchUrlMeta($step2Url); + } + if (!($step2Meta['ok'] ?? false)) { + header('Location: /import/' . rawurlencode($uuid) . '?error=1'); + exit; + } + // Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement) + $step2Screenshot = null; + if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) { + $filesDir = BASE_PATH . '/data/' . $uuid . '/files'; + if (!is_dir($filesDir)) { + mkdir($filesDir, 0755, true); + } + $previewPath = $filesDir . '/_preview.png'; + @unlink($previewPath); // supprime le résidu d'une analyse précédente + if (takeScreenshot($step2Url, $previewPath)) { + $step2Screenshot = '_preview.png'; + } + } + include BASE_PATH . '/templates/import_image_step2.php'; + break; + + case 'copyright_ack': + requireAuth(); + $ackArticle = $articles->getByUuid($uuid); + if (!$ackArticle) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + $ackUrl = filter_var($_GET['image_url'] ?? '', FILTER_VALIDATE_URL) + ? $_GET['image_url'] : ''; + if ($ackUrl === '') { + header('Location: /import/' . rawurlencode($uuid)); + exit; + } + $ackTitle = $_GET['img_title'] ?? ''; + $ackAuthor = $_GET['img_author'] ?? ''; + $ackSource = $_GET['img_source'] ?? ''; + $ackIsCover = !empty($_GET['is_cover']); + $ackMetaJson = $_GET['meta_json'] ?? ''; + include BASE_PATH . '/templates/copyright_ack.php'; + break; + + case 'add_file_from_url': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /'); + exit; + } + $urlUuid = $_GET['uuid'] ?? $_POST['uuid'] ?? ''; + $imageUrl = trim($_POST['image_url'] ?? ''); + $mode = $_POST['mode'] ?? 'link'; + $isCover = isset($_POST['is_cover']); + $imgTitle = trim($_POST['img_title'] ?? ''); + $imgAuthor = trim($_POST['img_author'] ?? ''); + $imgSource = trim($_POST['img_source'] ?? '') ?: $imageUrl; + + // Métadonnées supplémentaires (passées depuis l'étape 2) + $importedMeta = []; + $rawMetaJson = $_POST['meta_json'] ?? ''; + if ($rawMetaJson !== '') { + $dec = @json_decode($rawMetaJson, true); + if (is_array($dec)) { + foreach ($dec as $k => $v) { + if (is_string($k) && strlen($k) <= 60 && is_scalar($v)) { + $importedMeta[$k] = $v; + } + } + } + } + + $urlArticle = $articles->getByUuid($urlUuid); + if (!$urlArticle || $imageUrl === '' || !filter_var($imageUrl, FILTER_VALIDATE_URL)) { + header('Location: /import/' . rawurlencode($urlUuid)); + exit; + } + + $screenshotFile = basename(trim($_POST['screenshot_file'] ?? '')); + + if ($mode === 'screenshot') { + if ($screenshotFile === '' || $screenshotFile !== '_preview.png') { + header('Location: /import/' . rawurlencode($urlUuid) . '?error=1'); + exit; + } + $filesDir = BASE_PATH . '/data/' . $urlUuid . '/files'; + $previewPath = $filesDir . '/' . $screenshotFile; + if (!file_exists($previewPath)) { + header('Location: /import/' . rawurlencode($urlUuid) . '?error=1'); + exit; + } + $hash = substr(hash_file('sha256', $previewPath), 0, 16); + $size = filesize($previewPath); + $destName = $hash . '-' . $size . '.png'; + rename($previewPath, $filesDir . '/' . $destName); + $articles->addFileMeta($urlUuid, $destName, $imgAuthor, $imgSource, $imgTitle, $importedMeta); + if ($isCover) { + $articles->setCover($urlUuid, $destName); + } + header('Location: /edit/' . rawurlencode($urlUuid)); + exit; + } + + if ($mode === 'link') { + $filesDir = BASE_PATH . '/data/' . $urlUuid . '/files'; + if (!is_dir($filesDir)) { + mkdir($filesDir, 0755, true); + } + $linkMime = $importedMeta['mime'] ?? ''; + $isHtmlLink = str_starts_with($linkMime, 'text/html') || $linkMime === ''; + if ($isHtmlLink) { + $thumbSet = false; + // 1. Télécharge l'og:image distante + $extOg = $importedMeta['og_image'] ?? ''; + if (!$thumbSet && $extOg !== '' && !str_starts_with($extOg, '/')) { + $thumbName = downloadImageToThumb($extOg, $filesDir); + if ($thumbName !== null) { + $importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName); + $thumbSet = true; + } + } + // 2. Plus grande image trouvée sur la page + if (!$thumbSet) { + $bigImg = findLargestPageImage($imageUrl); + if ($bigImg !== null) { + $thumbName = downloadImageToThumb($bigImg, $filesDir); + if ($thumbName !== null) { + $importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName); + $thumbSet = true; + } + } + } + // 3. Screenshot pré-généré depuis step2 + if (!$thumbSet && $screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) { + $previewPath = $filesDir . '/' . $screenshotFile; + $hash = substr(hash_file('sha256', $previewPath), 0, 16); + $size = filesize($previewPath); + $thumbName = '_thumb_' . $hash . '-' . $size . '.png'; + rename($previewPath, $filesDir . '/' . $thumbName); + $importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName); + $thumbSet = true; + } + // 4. Screenshot à la volée en dernier recours + if (!$thumbSet) { + $screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png'; + if (takeScreenshot($imageUrl, $screenshotTmp)) { + $hash = substr(hash_file('sha256', $screenshotTmp), 0, 16); + $size = filesize($screenshotTmp); + $thumbName = '_thumb_' . $hash . '-' . $size . '.png'; + rename($screenshotTmp, $filesDir . '/' . $thumbName); + $importedMeta['og_image'] = '/file?uuid=' . rawurlencode($urlUuid) . '&name=' . rawurlencode($thumbName); + } else { + @unlink($screenshotTmp); + } + } + // Supprime le preview inutilisé si toujours présent + if ($screenshotFile !== '' && file_exists($filesDir . '/' . $screenshotFile)) { + @unlink($filesDir . '/' . $screenshotFile); + } + } elseif ($screenshotFile !== '') { + // Non-HTML : supprime le preview inutilisé + @unlink($filesDir . '/' . $screenshotFile); + } + $articles->addExternalLink($urlUuid, $imageUrl, $imgTitle, $imgAuthor, $importedMeta); + header('Location: /edit/' . rawurlencode($urlUuid)); + exit; + } + + // Mode téléchargement : accusé de réception obligatoire + if (empty($_POST['copyright_acked'])) { + header('Location: /?action=copyright_ack&' . http_build_query([ + 'uuid' => $urlUuid, + 'image_url' => $imageUrl, + 'img_title' => $imgTitle, + 'img_author' => $imgAuthor, + 'img_source' => trim($_POST['img_source'] ?? ''), + 'is_cover' => $isCover ? '1' : '', + 'meta_json' => $rawMetaJson, + ])); + exit; + } + + $imported = $articles->addFileFromUrl($urlUuid, $imageUrl, $isCover, $imgAuthor, $imgSource, $imgTitle, $importedMeta); + if ($imported) { + header('Location: /edit/' . rawurlencode($urlUuid)); + } else { + header('Location: /import/' . rawurlencode($urlUuid) . '?error=1&mode=download'); + } + exit; + + case 'sources': + $article = $articles->getByUuid($uuid); + if (!$article) { + http_response_code(404); + echo 'Article introuvable.'; + exit; + } + requireAuth(); + if (!canDoOnArticle('view_sources', $article)) { + http_response_code(403); + echo 'Accès refusé.'; + exit; + } + $sourcesFiles = $articles->getFiles($uuid); + include BASE_PATH . '/templates/sources.php'; + break; + + case 'regen_thumbs': + requireAuth(); + + // Page de confirmation si pas encore lancé + if (!isset($_GET['run'])) { + ob_start(); + ?> +

Génération des aperçus de liens

+
+ +
+
+ + +
+ Supprime et régénère les miniatures locales déjà enregistrées. +
+
+ +
+
+ ← Retour + + Génération des aperçus + + '; + $heading = $force ? 'Régénération de tous les aperçus' : 'Génération des aperçus manquants'; + echo '

' . $heading . '

    '; + @ob_flush(); + flush(); + + $done = $fail = $skip = 0; + foreach ($articles->getAll() as $article) { + $artUuid = $article['uuid']; + $filesDir = BASE_PATH . '/data/' . $artUuid . '/files'; + foreach ($article['external_links'] ?? [] as $link) { + $lMeta = $link['meta'] ?? []; + $lMime = $lMeta['mime'] ?? 'text/html'; + $lUrl = $link['url'] ?? ''; + + // Ignore si ce n'est pas du HTML + if ($lMime !== '' && !str_starts_with($lMime, 'text/html')) { + $skip++; + continue; + } + + $hasLocal = !empty($lMeta['og_image']) && str_starts_with($lMeta['og_image'], '/'); + if ($hasLocal && !$force) { + $skip++; + continue; + } + + echo '
  • '; + echo '' . htmlspecialchars($article['title']) . ''; + echo htmlspecialchars($lUrl) . ' … '; + @ob_flush(); + flush(); + + // Supprime l'ancienne miniature locale si on force + if ($force && $hasLocal) { + $oldMeta = $lMeta['og_image']; + $oldName = rawurldecode(parse_url($oldMeta, PHP_URL_QUERY) ? (explode('name=', $oldMeta)[1] ?? '') : ''); + // Extrait le paramètre name= de l'URL /file?uuid=...&name=... + parse_str(parse_url($oldMeta, PHP_URL_QUERY) ?? '', $oldQs); + $oldFile = $oldQs['name'] ?? ''; + if ($oldFile !== '' && file_exists($filesDir . '/' . $oldFile)) { + @unlink($filesDir . '/' . $oldFile); + } + } + + $thumbName = null; + $method = ''; + + // 1. og_image → téléchargement direct + // Non-force : utilise l'og_image stockée si externe + // Force : refetch la page pour récupérer l'URL d'origine + $ogToDownload = ''; + if (!$force) { + $stored = $lMeta['og_image'] ?? ''; + if ($stored !== '' && filter_var($stored, FILTER_VALIDATE_URL)) { + $ogToDownload = $stored; + } + } else { + $freshMeta = fetchUrlMeta($lUrl); + $ogToDownload = $freshMeta['og_image'] ?? ''; + if (!filter_var($ogToDownload, FILTER_VALIDATE_URL)) { + $ogToDownload = ''; + } + } + if ($ogToDownload !== '') { + $thumbName = downloadImageToThumb($ogToDownload, $filesDir); + if ($thumbName) { + $method = '✓ og:image'; + } + } + + // 2. Plus grande image de la page + if ($thumbName === null) { + $largestUrl = findLargestPageImage($lUrl); + if ($largestUrl) { + $thumbName = downloadImageToThumb($largestUrl, $filesDir); + if ($thumbName) { + $method = '✓ plus grande image'; + } + } + } + + // 3. Screenshot Chromium en dernier recours + if ($thumbName === null) { + $screenshotTmp = tempnam(sys_get_temp_dir(), 'vl_ss_') . '.png'; + if (takeScreenshot($lUrl, $screenshotTmp)) { + if (!is_dir($filesDir)) { + mkdir($filesDir, 0755, true); + } + $hash = substr(hash_file('sha256', $screenshotTmp), 0, 16); + $size = filesize($screenshotTmp); + $thumbName = '_thumb_' . $hash . '-' . $size . '.png'; + rename($screenshotTmp, $filesDir . '/' . $thumbName); + $method = '✓ screenshot'; + } else { + @unlink($screenshotTmp); + } + } + + if ($thumbName !== null) { + $ogUrl = '/file?uuid=' . rawurlencode($artUuid) . '&name=' . rawurlencode($thumbName); + $articles->updateExternalLinkMeta($artUuid, $lUrl, ['og_image' => $ogUrl]); + echo '' . $method . ''; + $done++; + } else { + echo '✗ échec'; + $fail++; + } + echo '
  • '; + @ob_flush(); + flush(); + } + } + + echo '
'; + echo '

Terminé — '; + echo $done . ' capturé' . ($done > 1 ? 's' : '') . ', '; + echo $fail . ' échec' . ($fail > 1 ? 's' : '') . ', '; + echo $skip . ' ignoré' . ($skip > 1 ? 's' : '') . '.

'; + echo '← Retour'; + echo ''; + exit; + + case 'delete_external_link': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uuid !== '') { + $linkUrl = $_POST['url'] ?? ''; + if ($linkUrl !== '' && filter_var($linkUrl, FILTER_VALIDATE_URL)) { + $articles->removeExternalLink($uuid, $linkUrl); + } + } + header('Location: /edit/' . rawurlencode($uuid)); + exit; + + case 'react': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /'); + exit; + } + $reactUuid = trim($_POST['uuid'] ?? ''); + $reactType = trim($_POST['type'] ?? ''); + $isAjax = ($_POST['_ajax'] ?? '') === '1'; + + // Cookie visiteur + if (empty($_COOKIE['vl_vid'])) { + $vid = bin2hex(random_bytes(16)); + setcookie('vl_vid', $vid, [ + 'expires' => time() + 365 * 86400, + 'path' => '/', + 'secure' => !empty($_SERVER['HTTPS']), + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } else { + $vid = $_COOKIE['vl_vid']; + } + + $pdo = dbPdo(); + if ($pdo && $reactUuid !== '') { + require_once BASE_PATH . '/src/ReactionManager.php'; + $rm = new ReactionManager($pdo); + $added = $rm->toggle($reactUuid, $reactType, $vid); + $count = $rm->statsForArticle($reactUuid)[$reactType] ?? 0; + + if ($isAjax) { + header('Content-Type: application/json'); + echo json_encode(['ok' => true, 'active' => $added, 'count' => $count]); + exit; + } + } + + $reactBack = $_POST['_back'] ?? '/'; + header('Location: ' . $reactBack); + exit; + + case 'comment': + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /'); + exit; + } + + // Honeypot + if (($_POST['website'] ?? '') !== '') { + header('Location: /'); + exit; + } + + // CSRF + $csrfOk = isset($_POST['_token'], $_SESSION['comment_csrf']) + && hash_equals($_SESSION['comment_csrf'], $_POST['_token']); + unset($_SESSION['comment_csrf']); + if (!$csrfOk) { + header('Location: /'); + exit; + } + + $cmtUuid = trim($_POST['uuid'] ?? ''); + $cmtName = trim($_POST['author_name'] ?? ''); + $cmtEmail = trim($_POST['author_email'] ?? ''); + $cmtContent = trim($_POST['content'] ?? ''); + + $cmtArticle = $cmtUuid !== '' ? $articles->getByUuid($cmtUuid) : null; + $cmtBack = $cmtArticle ? '/post/' . rawurlencode($cmtArticle['slug'] ?? $cmtUuid) : '/'; + + $pdo = dbPdo(); + if (!$pdo || !$cmtArticle || $cmtName === '' || !filter_var($cmtEmail, FILTER_VALIDATE_EMAIL) || $cmtContent === '') { + header('Location: ' . $cmtBack . '#comment-form-card'); + exit; + } + + if (mb_strlen($cmtContent) > 2000) { + header('Location: ' . $cmtBack . '#comment-form-card'); + exit; + } + + require_once BASE_PATH . '/src/CommentManager.php'; + require_once BASE_PATH . '/src/mailer.php'; + + $cm = new CommentManager($pdo); + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''; + $ua = $_SERVER['HTTP_USER_AGENT'] ?? ''; + ['token' => $cmtToken, 'code' => $cmtCode] = $cm->submit($cmtUuid, $cmtName, $cmtEmail, $cmtContent, $ip, $ua); + + $verifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $cmtToken; + $subject = '[' . siteTitle() . '] Confirmez votre commentaire'; + $html = '' + . '

Bonjour ' . htmlspecialchars($cmtName) . ',

' + . '

Votre commentaire sur ' . htmlspecialchars($cmtArticle['title']) . ' a bien été reçu.

' + . '

Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :

' + . '

Confirmer mon commentaire

' + . '

Votre code : ' . htmlspecialchars($cmtCode) . '

' + . '

Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.

' + . ''; + + try { + envoyer_mail_smtp($cmtEmail, $subject, $html); + } catch (\RuntimeException) { + // Taux limité ou erreur SMTP : on continue sans planter le visiteur + } + + header('Location: ' . $cmtBack . '?commented=1#comments'); + exit; + + case 'verify_comment': + $vcToken = trim($_GET['token'] ?? ''); + $vcCode = trim($_POST['code'] ?? ''); + $vcError = false; + $vcDeleted = false; + $vcAttemptsLeft = null; + + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $vcToken)) { + header('Location: /'); + exit; + } + + if ($vcCode !== '' && $_SERVER['REQUEST_METHOD'] === 'POST') { + $pdo = dbPdo(); + if ($pdo && preg_match('/^[0-9]{6}$/', $vcCode)) { + require_once BASE_PATH . '/src/CommentManager.php'; + $cm = new CommentManager($pdo); + $result = $cm->verify($vcToken, $vcCode); + if (is_string($result)) { + $vcArticle = $articles->getByUuid($result); + $vcSlug = $vcArticle ? ($vcArticle['slug'] ?? $result) : $result; + header('Location: /post/' . rawurlencode($vcSlug) . '?verified=1#comments'); + exit; + } + if ($result === 0) { + $vcDeleted = true; + } else { + $vcError = true; + $vcAttemptsLeft = (int)$result; + } + } else { + $vcError = true; + $vcAttemptsLeft = null; + } + } + + ob_start(); + ?> +
+

Confirmer mon commentaire

+ +
+ Trop de tentatives incorrectes. Votre commentaire a été annulé. +
+ ← Retour à l'accueil + + +
+ Code incorrect. + + Il vous reste essai 1 ? 's' : '' ?>. + +
+ +

Saisissez le code à 6 chiffres reçu par email.

+ +
+
+ + +
+ +
+ +
+ 0) { + require_once BASE_PATH . '/src/CommentManager.php'; + (new CommentManager($pdo))->setPublished($modId, $modPub === 1); + } + header('Location: /admin/comments'); + exit; + + case 'comment_delete': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $delId = (int)($_POST['id'] ?? 0); + $pdo = dbPdo(); + if ($pdo && $delId > 0) { + require_once BASE_PATH . '/src/CommentManager.php'; + (new CommentManager($pdo))->delete($delId); + } + header('Location: /admin/comments'); + exit; + + case 'comment_resend': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $resendId = (int)($_POST['id'] ?? 0); + $pdo = dbPdo(); + if ($pdo && $resendId > 0) { + require_once BASE_PATH . '/src/CommentManager.php'; + require_once BASE_PATH . '/src/mailer.php'; + $cm = new CommentManager($pdo); + $resendRow = $cm->getById($resendId); + if ($resendRow && !$resendRow['verified'] && !empty($resendRow['verification_code']) && !empty($resendRow['verify_token'])) { + $resendArticle = $articles->getByUuid((string)$resendRow['article_uuid']); + if ($resendArticle) { + $resendCode = (string)$resendRow['verification_code']; + $resendToken = (string)$resendRow['verify_token']; + $resendName = (string)$resendRow['author_name']; + $resendEmail = (string)$resendRow['author_email']; + $resendBack = '/post/' . rawurlencode($resendArticle['slug'] ?? $resendRow['article_uuid']); + $resendVerifyUrl = rtrim(APP_URL, '/') . '/verify-comment/' . $resendToken; + $resendSubject = '[' . siteTitle() . '] Confirmez votre commentaire'; + $resendHtml = '' + . '

Bonjour ' . htmlspecialchars($resendName) . ',

' + . '

Votre commentaire sur ' . htmlspecialchars($resendArticle['title']) . ' a bien été reçu.

' + . '

Cliquez sur le lien ci-dessous, puis saisissez le code à 6 chiffres :

' + . '

Confirmer mon commentaire

' + . '

Votre code : ' . htmlspecialchars($resendCode) . '

' + . '

Ce lien et ce code expirent dans 24 heures. Si vous n\'êtes pas à l\'origine de ce message, ignorez-le.

' + . ''; + try { + envoyer_mail_smtp($resendEmail, $resendSubject, $resendHtml, null, ['bypass_rate_limit' => true]); + } catch (\RuntimeException) { + // Erreur SMTP : on continue sans planter + } + } + } + } + header('Location: /admin/comments'); + exit; + + case 'rate': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: /'); + exit; + } + $rateUuid = $_POST['uuid'] ?? ''; + $rateValue = (int)($_POST['rating'] ?? 0); + $rateArticle = $articles->getByUuid($rateUuid); + if ($rateArticle && $rateValue >= 1 && $rateValue <= 5) { + $pdo = dbPdo(); + if ($pdo) { + require_once BASE_PATH . '/src/RatingManager.php'; + (new RatingManager($pdo))->rate($rateUuid, currentUserEmail() ?? '', $rateValue); + } + header('Location: /post/' . rawurlencode($rateArticle['slug'] ?? $rateUuid)); + } else { + header('Location: /'); + } + exit; + + case 'admin': + requireAuth(); + $tab = $_GET['tab'] ?? (isAdmin() ? 'dashboard' : 'articles'); + $adminData = []; + $siteSettingsSaved = isset($_GET['saved']); + + if ($tab === 'dashboard') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $allArticles = $articles->getAll(); + $now = time(); + $adminData['total'] = count($allArticles); + $adminData['published'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $now)); + $adminData['drafts'] = count(array_filter($allArticles, fn ($a) => !$a['published'])); + $adminData['previews'] = count(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $now)); + $adminData['recent'] = array_slice( + usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')) ? $allArticles : $allArticles, + 0, + 5 + ); + // Trier par updated_at desc + usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')); + $adminData['recent'] = array_slice($allArticles, 0, 5); + } + + if ($tab === 'articles') { + $allArticles = $articles->getAll(); + if (!isAdmin()) { + $me = currentUserEmail() ?? ''; + $allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me)); + } + usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')); + + $adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author')))); + $adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category')))); + sort($adminData['filter_authors']); + sort($adminData['filter_categories']); + + $filterAuthor = trim($_GET['filter_author'] ?? ''); + $filterCategory = trim($_GET['filter_category'] ?? ''); + $filterStatus = trim($_GET['filter_status'] ?? ''); + $adminData['filter_author'] = $filterAuthor; + $adminData['filter_category'] = $filterCategory; + $adminData['filter_status'] = $filterStatus; + + $nowTs = time(); + if ($filterAuthor !== '') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $filterAuthor)); + } + if ($filterCategory !== '') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => trim($a['category'] ?? '') === $filterCategory)); + } + if ($filterStatus === 'published') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $nowTs)); + } elseif ($filterStatus === 'draft') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => !$a['published'])); + } elseif ($filterStatus === 'preview') { + $allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs)); + } + + $adminData['articles'] = $allArticles; + } + + if ($tab === 'roles') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $pdo = dbPdo(); + if ($pdo) { + $st = $pdo->query( + 'SELECT r.id, r.name, r.label, COUNT(ur.user_email) AS user_count + FROM roles r + LEFT JOIN user_roles ur ON ur.role_id = r.id + GROUP BY r.id, r.name, r.label + ORDER BY r.name' + ); + $roles = $st->fetchAll(PDO::FETCH_ASSOC); + try { + $capRows = $pdo->query('SELECT role_id, capability FROM role_capabilities')->fetchAll(PDO::FETCH_ASSOC); + $capsMap = []; + foreach ($capRows as $cr) { + $capsMap[(int)$cr['role_id']][] = $cr['capability']; + } + } catch (\Throwable) { + $capsMap = []; + } + foreach ($roles as &$r) { + $r['capabilities'] = $capsMap[(int)$r['id']] ?? []; + } + unset($r); + $adminData['roles'] = $roles; + } else { + $adminData['roles'] = []; + } + } + + if ($tab === 'users') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $pdo = dbPdo(); + if ($pdo) { + // users table may not exist yet — degrade gracefully + $usersFromDb = []; + try { + $st = $pdo->query('SELECT email, is_active FROM users ORDER BY email'); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + $v = $row['is_active']; + $usersFromDb[$row['email']] = is_bool($v) ? $v : in_array(strtolower((string)$v), ['t', '1', 'true', 'yes'], true); + } + } catch (\PDOException) { + // table absente, on continue avec la liste user_roles seulement + } + + $st = $pdo->query('SELECT ur.user_email, r.name, r.label FROM user_roles ur JOIN roles r ON r.id = ur.role_id ORDER BY ur.user_email'); + $rolesMap = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rolesMap[$row['user_email']][] = ['name' => $row['name'], 'label' => $row['label']]; + } + + $merged = []; + foreach (array_unique(array_merge(array_keys($usersFromDb), array_keys($rolesMap))) as $email) { + $merged[$email] = [ + 'email' => $email, + 'is_active' => $usersFromDb[$email] ?? null, + 'roles' => $rolesMap[$email] ?? [], // [['name'=>..., 'label'=>...], ...] + ]; + } + ksort($merged); + $adminData['users'] = array_values($merged); + + $st = $pdo->query('SELECT id, name, label FROM roles ORDER BY name'); + $adminData['roles'] = $st->fetchAll(PDO::FETCH_ASSOC); + } else { + $adminData['users'] = []; + $adminData['roles'] = []; + } + } + + if ($tab === 'comments') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $pdo = dbPdo(); + $cmtFilterStatus = trim($_GET['filter_status'] ?? ''); + $adminData['cmt_filter_status'] = $cmtFilterStatus; + if ($pdo) { + require_once BASE_PATH . '/src/CommentManager.php'; + $cm = new CommentManager($pdo); + $adminData['comments'] = $cm->allForAdmin($cmtFilterStatus); + $adminData['cmt_counts'] = $cm->countsByStatus(); + // Enrichit avec le slug de chaque article pour les liens + $adminData['articleSlugs'] = []; + foreach ($adminData['comments'] as $cmtRow) { + $uuid = $cmtRow['article_uuid']; + if (!isset($adminData['articleSlugs'][$uuid])) { + $a = $articles->getByUuid($uuid); + $adminData['articleSlugs'][$uuid] = $a ? ($a['slug'] ?? null) : null; + } + } + } else { + $adminData['comments'] = []; + $adminData['articleSlugs'] = []; + $adminData['cmt_counts'] = ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0]; + } + } + + if ($tab === 'emails') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + $pdo = dbPdo(); + if ($pdo) { + $emlFilter = in_array($_GET['filter'] ?? '', ['sent', 'error', 'queued'], true) ? $_GET['filter'] : ''; + $emlPage = max(0, (int)($_GET['page'] ?? 0)); + $emlLimit = 50; + $emlOffset = $emlPage * $emlLimit; + + $whereEml = $emlFilter !== '' ? 'WHERE status = ' . $pdo->quote($emlFilter) : ''; + + $row = $pdo->query("SELECT + COUNT(*) AS all, + COUNT(*) FILTER (WHERE status = 'sent') AS sent, + COUNT(*) FILTER (WHERE status = 'error') AS error, + COUNT(*) FILTER (WHERE status = 'queued') AS queued + FROM journal_smtp")->fetch(PDO::FETCH_ASSOC); + $adminData['eml_counts'] = [ + 'all' => (int)($row['all'] ?? 0), + 'sent' => (int)($row['sent'] ?? 0), + 'error' => (int)($row['error'] ?? 0), + 'queued' => (int)($row['queued'] ?? 0), + ]; + $adminData['emails'] = $pdo->query( + "SELECT id, created_at, to_email, subject, status, error_message, content_text, sent_at + FROM journal_smtp $whereEml + ORDER BY created_at DESC + LIMIT $emlLimit OFFSET $emlOffset" + )->fetchAll(PDO::FETCH_ASSOC); + $adminData['eml_filter'] = $emlFilter; + $adminData['eml_page'] = $emlPage; + } else { + $adminData['emails'] = []; + $adminData['eml_counts'] = ['all' => 0, 'sent' => 0, 'error' => 0, 'queued' => 0]; + $adminData['eml_filter'] = ''; + $adminData['eml_page'] = 0; + } + } + + if ($tab === 'smtp') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + require_once BASE_PATH . '/src/SmtpSettings.php'; + $adminData['smtp_config'] = [ + 'host' => smtpCfg('host', 'SMTP_HOST'), + 'port' => smtpCfg('port', 'SMTP_PORT'), + 'secure' => smtpCfg('secure', 'SMTP_SECURE'), + 'user' => smtpCfg('user', 'SMTP_USER'), + 'has_pass' => smtpCfg('pass', 'SMTP_PASS') !== '', + 'from' => smtpCfg('from', 'SMTP_FROM'), + 'from_name' => smtpCfg('from_name', 'SMTP_FROM_NAME'), + ]; + $adminData['smtp_test'] = $_SESSION['smtp_test_result'] ?? null; + unset($_SESSION['smtp_test_result']); + } + + if ($tab === 'searches') { + if (!isAdmin()) { + http_response_code(403); + exit; + } + require_once BASE_PATH . '/src/SearchLogParser.php'; + $parser = new SearchLogParser(); + $adminData['search_terms'] = $parser->topTerms(100); + $adminData['search_log_readable'] = $parser->isReadable(); + } + + if ($tab === 'categories') { + $adminData['cats'] = $articles->getCategories(); + $adminData['privateCats'] = $articles->getPrivateCategories(); + $adminData['tagTypes'] = $articles->getTagTypes(); + } + + include BASE_PATH . '/templates/admin.php'; + break; + + case 'admin_smtp_save': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + require_once BASE_PATH . '/src/SmtpSettings.php'; + + saveSmtpSettings([ + 'host' => $_POST['smtp_host'] ?? '', + 'port' => $_POST['smtp_port'] ?? '', + 'secure' => $_POST['smtp_secure'] ?? '', + 'user' => $_POST['smtp_user'] ?? '', + 'pass' => $_POST['smtp_pass'] ?? '', + 'from' => $_POST['smtp_from'] ?? '', + 'from_name' => $_POST['smtp_from_name'] ?? '', + ]); + header('Location: /admin/smtp?saved=1'); + exit; + + case 'admin_smtp_test': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + require_once BASE_PATH . '/src/SmtpSettings.php'; + require_once BASE_PATH . '/src/mailer.php'; + + $mode = in_array($_POST['mode'] ?? '', ['connect', 'send'], true) ? $_POST['mode'] : 'connect'; + $testEmail = trim($_POST['test_email'] ?? ''); + if ($testEmail !== '' && !filter_var($testEmail, FILTER_VALIDATE_EMAIL)) { + $testEmail = ''; + } + + $smtpLogs = []; + $smtpOk = false; + $smtpErrMsg = ''; + + try { + $mail = new \PHPMailer\PHPMailer\PHPMailer(true); + $mail->isSMTP(); + $mail->Host = smtpCfg('host', 'SMTP_HOST', 'localhost'); + $mail->Port = (int)smtpCfg('port', 'SMTP_PORT', '587'); + $_stUser = smtpCfg('user', 'SMTP_USER'); + $_stPass = smtpCfg('pass', 'SMTP_PASS'); + $mail->SMTPAuth = ($_stUser !== '' || $_stPass !== ''); + $mail->Username = $_stUser; + $mail->Password = $_stPass; + $smtpSecure = strtolower(smtpCfg('secure', 'SMTP_SECURE', 'tls')); + if ($smtpSecure === 'ssl') { + $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + } elseif ($smtpSecure === 'tls') { + $mail->SMTPSecure = \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + } + $mail->Timeout = 15; + $mail->SMTPOptions = ['ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false]]; + $mail->SMTPDebug = \PHPMailer\PHPMailer\SMTP::DEBUG_SERVER; + $mail->Debugoutput = static function (string $str) use (&$smtpLogs): void { + $smtpLogs[] = rtrim($str); + }; + + if ($mode === 'send' && $testEmail !== '') { + $mail->CharSet = 'UTF-8'; + $mail->isHTML(true); + $_smtpFrom = smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr'); + $_smtpFromName = smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog'); + $mail->setFrom($_smtpFrom, $_smtpFromName); + $mail->addAddress($testEmail); + $_siteName = siteTitle(); + $_siteUrl = rtrim(APP_URL, '/'); + $_sentAt = date('d/m/Y à H\hi', time()); + $mail->Subject = 'Vérification de la configuration email — ' . $_siteName; + $mail->Body = '' + . '' + . '' + . '

Bonjour,

' + . '

Cet email confirme que la configuration SMTP de ' . htmlspecialchars($_siteName) . ' fonctionne correctement.

' + . '

Envoyé le ' . $_sentAt . ' depuis ' . htmlspecialchars($_siteUrl) . '.

' + . '
' + . '

Vous recevez cet email car un administrateur a effectué un test de configuration depuis l\'interface d\'administration de ' . htmlspecialchars($_siteName) . '.' + . ' Si vous n\'attendiez pas cet email, vous pouvez l\'ignorer.

' + . ''; + $mail->AltBody = "Bonjour,\r\n\r\n" + . "Cet email confirme que la configuration SMTP de {$_siteName} fonctionne correctement.\r\n\r\n" + . "Envoyé le {$_sentAt} depuis {$_siteUrl}.\r\n\r\n" + . "--\r\n" + . "Vous recevez cet email car un administrateur a effectué un test de configuration depuis l'interface d'administration de {$_siteName}." + . " Si vous n'attendiez pas cet email, vous pouvez l'ignorer."; + $mail->send(); + } else { + $mail->smtpConnect(); + $mail->smtpClose(); + } + $smtpOk = true; + } catch (\Exception $e) { + $smtpErrMsg = $e->getMessage(); + } + + $_SESSION['smtp_test_result'] = [ + 'success' => $smtpOk, + 'error' => $smtpErrMsg, + 'logs' => $smtpLogs, + 'mode' => $mode, + 'email' => $testEmail, + 'ts' => date('d/m/Y H:i:s'), + ]; + header('Location: /admin/smtp'); + exit; + + case 'admin_bulk_delete': + requireAuth(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $uuids = $_POST['uuids'] ?? []; + if (is_array($uuids)) { + $me = currentUserEmail() ?? ''; + foreach ($uuids as $uid) { + $uid = trim((string)$uid); + if ($uid === '') { + continue; + } + $art = $articles->getByUuid($uid); + if (!$art) { + continue; + } + if (isAdmin() || ($art['author'] ?? '') === $me) { + $articles->delete($uid); + } + } + } + } + header('Location: /admin/articles'); + exit; + + case 'admin_grant_role': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $targetEmail = strtolower(trim($_POST['email'] ?? '')); + $roleName = trim($_POST['role'] ?? ''); + if ($targetEmail && $roleName && filter_var($targetEmail, FILTER_VALIDATE_EMAIL)) { + $pdo = dbPdo(); + if ($pdo) { + $st = $pdo->prepare( + 'INSERT INTO user_roles (user_email, role_id, granted_by) + SELECT :email, id, :by FROM roles WHERE name = :role + ON CONFLICT DO NOTHING' + ); + $st->execute([':email' => $targetEmail, ':role' => $roleName, ':by' => currentUserEmail()]); + } + } + header('Location: /admin/users'); + exit; + + case 'admin_revoke_role': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $targetEmail = strtolower(trim($_POST['email'] ?? '')); + $roleName = trim($_POST['role'] ?? ''); + if ($targetEmail && $roleName) { + $pdo = dbPdo(); + if ($pdo) { + // Bloquer si c'est le dernier admin (en DB — hors ADMIN_EMAIL env) + if ($roleName === 'admin') { + $st = $pdo->prepare( + 'SELECT COUNT(*) FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE r.name = :role AND ur.user_email != :email' + ); + $st->execute([':role' => 'admin', ':email' => $targetEmail]); + if ((int)$st->fetchColumn() === 0) { + header('Location: /admin/users?error=last_admin'); + exit; + } + } + $st = $pdo->prepare( + 'DELETE FROM user_roles + WHERE user_email = :email + AND role_id = (SELECT id FROM roles WHERE name = :role)' + ); + $st->execute([':email' => $targetEmail, ':role' => $roleName]); + } + } + header('Location: /admin/users'); + exit; + + case 'admin_save_site': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + saveSiteSettings([ + 'site_title' => $_POST['site_title'] ?? '', + 'site_claim' => $_POST['site_claim'] ?? '', + 'site_lang' => $_POST['site_lang'] ?? '', + 'posts_per_page' => $_POST['posts_per_page'] ?? '', + 'site_license_label' => $_POST['site_license_label'] ?? '', + 'site_license_url' => $_POST['site_license_url'] ?? '', + ]); + header('Location: /admin/site?saved=1'); + exit; + + case 'admin_create_role': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $roleLabel = trim($_POST['label'] ?? ''); + $roleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_POST['name'] ?? ''))); + if ($roleName === '' && $roleLabel !== '') { + $roleName = slugify($roleLabel); + } + if ($roleName && $roleLabel) { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('INSERT INTO roles (name, label) VALUES (:n, :l) ON CONFLICT (name) DO NOTHING'); + $st->execute([':n' => $roleName, ':l' => $roleLabel]); + } catch (\PDOException) { + } + } + } + header('Location: /admin/roles'); + exit; + + case 'admin_update_role': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $roleId = (int)($_POST['id'] ?? 0); + $roleLabel = trim($_POST['label'] ?? ''); + if ($roleId > 0 && $roleLabel) { + $pdo = dbPdo(); + if ($pdo) { + $st = $pdo->prepare('UPDATE roles SET label = :l WHERE id = :id'); + $st->execute([':l' => $roleLabel, ':id' => $roleId]); + } + } + header('Location: /admin/roles'); + exit; + + case 'admin_delete_role': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $roleId = (int)($_POST['id'] ?? 0); + if ($roleId > 0) { + $pdo = dbPdo(); + if ($pdo) { + $st = $pdo->prepare('DELETE FROM roles WHERE id = :id'); + $st->execute([':id' => $roleId]); + } + } + header('Location: /admin/roles'); + exit; + + case 'admin_update_role_caps': + requireAuth(); + if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(403); + exit; + } + $roleId = (int)($_POST['role_id'] ?? 0); + $newCaps = array_filter((array)($_POST['caps'] ?? []), fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES)); + if ($roleId > 0) { + $pdo = dbPdo(); + if ($pdo) { + $pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id')->execute([':id' => $roleId]); + $ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)'); + foreach ($newCaps as $cap) { + $ins->execute([':id' => $roleId, ':cap' => $cap]); + } + // Invalide le cache de capacités en session (affecte l'utilisateur courant) + unset($_SESSION['user_capabilities']); + } + } + header('Location: /admin/roles'); + exit; + + case 'admin_role_edit': + requireAuth(); + if (!isAdmin()) { + http_response_code(403); + exit; + } + $editRoleName = preg_replace('/[^a-z0-9_-]/', '', strtolower(trim($_GET['role_name'] ?? ''))); + if (!$editRoleName) { + header('Location: /admin/roles'); + exit; + } + $pdo = dbPdo(); + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($pdo) { + $newLabel = trim($_POST['label'] ?? ''); + $newCaps = array_filter( + (array)($_POST['caps'] ?? []), + fn ($c) => array_key_exists($c, KNOWN_CAPABILITIES) + ); + if ($newLabel) { + $pdo->prepare('UPDATE roles SET label = :l WHERE name = :n') + ->execute([':l' => $newLabel, ':n' => $editRoleName]); + } + $st = $pdo->prepare('SELECT id FROM roles WHERE name = :n'); + $st->execute([':n' => $editRoleName]); + $editRoleId = $st->fetchColumn(); + if ($editRoleId) { + $pdo->prepare('DELETE FROM role_capabilities WHERE role_id = :id') + ->execute([':id' => $editRoleId]); + $ins = $pdo->prepare('INSERT INTO role_capabilities (role_id, capability) VALUES (:id, :cap)'); + foreach ($newCaps as $cap) { + $ins->execute([':id' => $editRoleId, ':cap' => $cap]); + } + } + unset($_SESSION['user_capabilities']); + } + header('Location: /admin/roles'); + exit; + } + // GET — charge le rôle et ses capacités + $editRole = null; + $editRoleCaps = []; + if ($pdo) { + try { + $st = $pdo->prepare('SELECT id, name, label FROM roles WHERE name = :n'); + $st->execute([':n' => $editRoleName]); + $editRole = $st->fetch(PDO::FETCH_ASSOC) ?: null; + } catch (\Throwable) { + } + if ($editRole) { + try { + $st = $pdo->prepare('SELECT capability FROM role_capabilities WHERE role_id = :id'); + $st->execute([':id' => $editRole['id']]); + $editRoleCaps = $st->fetchAll(PDO::FETCH_COLUMN) ?: []; + } catch (\Throwable) { + } + } + } + if (!$editRole) { + header('Location: /admin/roles'); + exit; + } + include BASE_PATH . '/templates/admin_role_edit.php'; + exit; + + case 'profile': + requireAuth(); + $profileError = ''; + $profileSuccess = false; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $newName = trim($_POST['display_name'] ?? ''); + $newUrl = trim($_POST['profile_url'] ?? ''); + if ($newUrl !== '' && !filter_var($newUrl, FILTER_VALIDATE_URL)) { + $newUrl = ''; + } + $newBio = trim($_POST['bio'] ?? ''); + if ($newName === '') { + $profileError = 'Le nom ne peut pas être vide.'; + } else { + $pdo = dbPdo(); + if ($pdo) { + try { + $newSlug = slugify($newName); + $st = $pdo->prepare( + 'INSERT INTO user_profiles (email, display_name, profile_url, profile_slug, bio, updated_at) + VALUES (:e, :n, :u, :s, :b, now()) + ON CONFLICT (email) DO UPDATE SET display_name = :n, profile_url = :u, profile_slug = :s, bio = :b, updated_at = now()' + ); + $st->execute([':e' => currentUserEmail(), ':n' => $newName, ':u' => $newUrl, ':s' => $newSlug, ':b' => $newBio]); + $_SESSION['user_display_name'] = $newName; + $profileSuccess = true; + } catch (\Throwable $ex) { + $profileError = 'Erreur lors de la sauvegarde.'; + } + } + } + } + $profileCurrentName = currentUserName(); + $_profileData = authorProfile(currentUserEmail() ?? ''); + $profileCurrentUrl = $_profileData['url']; + $profileCurrentBio = $_profileData['bio']; + $profileCurrentSlug = $_profileData['slug']; + // Pré-remplir l'URL avec l'URL de profil public si vide + if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') { + $profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug); + } + // Liens de la page "Mes liens" + $profileLinks = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('SELECT id, url, title, description FROM profile_links WHERE user_email = :e ORDER BY position, id'); + $st->execute([':e' => currentUserEmail()]); + $profileLinks = $st->fetchAll(PDO::FETCH_ASSOC); + } catch (\Throwable) { + } + } + // Feeds RSS de l'utilisateur + $profileFeeds = []; + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at'); + $st->execute([':e' => currentUserEmail()]); + $profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC); + } catch (\Throwable) { + } + } + include BASE_PATH . '/templates/profile.php'; + break; + + case 'add_feed': + requireAuth(); + $feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: ''; + $feedLabel = trim($_POST['feed_label'] ?? ''); + if ($feedUrl !== '') { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare( + 'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l) + ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l' + ); + $st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#feeds'); + exit; + + case 'delete_feed': + requireAuth(); + $feedId = (int)($_POST['feed_id'] ?? 0); + if ($feedId > 0) { + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e'); + $st->execute([':id' => $feedId, ':e' => currentUserEmail()]); + } catch (\Throwable) { + } + } + } + header('Location: /profile#feeds'); + exit; + + case 'search_files': + requireAuth(); + header('Content-Type: application/json'); + $q = trim($_GET['q'] ?? ''); + $sfExclude = trim($_GET['exclude'] ?? ''); + if ($q === '') { + echo json_encode([]); + exit; + } + require_once BASE_PATH . '/src/SearchEngine.php'; + $sfPool = $articles->getSearchIndex() ?? $articles->getAll(); + $sfResults = (new SearchEngine())->search($q, $sfPool); + $sfOut = []; + foreach ($sfResults as $r) { + $a = $r['article']; + $aId = $a['uuid'] ?? ''; + if ($aId === '' || $aId === $sfExclude) { + continue; + } + $aFiles = $articles->getFiles($aId); + if (empty($aFiles)) { + continue; + } + $sfFiles = []; + foreach ($aFiles as $f) { + if (str_starts_with($f['name'], '_thumb_')) { + continue; + } + $sfFiles[] = [ + 'url' => '/file?uuid=' . rawurlencode($aId) . '&name=' . rawurlencode($f['name']), + 'name' => $f['name'], + 'mime' => $f['mime'], + 'is_image' => $f['is_image'], + 'size' => $f['size'], + ]; + } + if (empty($sfFiles)) { + continue; + } + $sfOut[] = [ + 'article' => ['uuid' => $aId, 'title' => $a['title'] ?? '', 'slug' => $a['slug'] ?? ''], + 'files' => $sfFiles, + ]; + if (count($sfOut) >= 20) { + break; + } + } + echo json_encode($sfOut); + exit; + + case 'search': + require_once BASE_PATH . '/src/SearchEngine.php'; + $searchQuery = trim($_GET['q'] ?? ''); + $searchResults = []; + if ($searchQuery !== '') { + $isAnonSearch = !isLoggedIn(); + // Lecture du cache pour les visiteurs anonymes + if ($isAnonSearch) { + $searchResults = $articles->getSearchCache($searchQuery) ?? []; + } + if (empty($searchResults)) { + $privateCats = $articles->getPrivateCategories(); + $rawPool = $articles->getSearchIndex() ?? $articles->getAll(true); + $searchPool = array_values(array_filter($rawPool, static function (array $a) use ($privateCats): bool { + if (!($a['published'] ?? false)) { + return false; + } + $cat = trim($a['category'] ?? ''); + if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + return false; + } + return true; + })); + $searchResults = (new SearchEngine())->search($searchQuery, $searchPool); + if ($isAnonSearch && !empty($searchResults)) { + $articles->setSearchCache($searchQuery, $searchResults); + } + } + } + include BASE_PATH . '/templates/search.php'; + break; + + case 'not_found': + $notFoundPath = trim( + (string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''), + '/' + ); + if ($notFoundPath !== '') { + searchAndRedirect(basename($notFoundPath), $articles); + } + http_response_code(404); + ob_start(); + ?> +
+

Page introuvable

+

Cette adresse ne correspond à aucun article.
Vous avez peut-être suivi un ancien lien.

+ ← Retour à l'accueil +
+ getPrivateCategories(); + $allCats = $articles->getCategories(); + + $filterCat = array_key_exists('cat', $_GET) ? trim($_GET['cat']) : ''; + $allPosts = array_values(array_filter($articles->getAll(), static function (array $a) use ($privateCats, $filterCat): bool { + if (!$a['published']) { + return canDoOnArticle('view_drafts', $a); + } + $cat = trim($a['category'] ?? ''); + if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) { + return false; + } + if ($filterCat !== '' && $cat !== $filterCat) { + return false; + } + return true; + })); + $perPage = postsPerPage(); + $cursor = trim($_GET['cursor'] ?? ''); + + // Trouve la position du curseur dans la liste triée + $offset = 0; + if ($cursor !== '') { + foreach ($allPosts as $i => $a) { + if ($a['uuid'] === $cursor) { + $offset = $i + 1; + break; + } + } + } + + $posts = array_slice($allPosts, $offset, $perPage); + $nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null; + $prevCursor = null; + if ($offset > 0) { + $prevOffset = max(0, $offset - $perPage); + $prevCursor = $prevOffset > 0 ? $allPosts[$prevOffset - 1]['uuid'] : ''; + } + + // Compteurs pour le hero de la page d'accueil + $totalPublished = 0; + $totalUpcoming = 0; + $_now = time(); + foreach ($articles->getSearchIndex() ?? [] as $_a) { + if (!($_a['published'] ?? false)) { + continue; + } + $_cat = trim($_a['category'] ?? ''); + if ($_cat !== '' && in_array($_cat, $privateCats, true) && !isLoggedIn()) { + continue; + } + if (strtotime((string)($_a['published_at'] ?? '')) > $_now) { + $totalUpcoming++; + } else { + $totalPublished++; + } + } + unset($_now, $_a, $_cat); + + // ─── Sections spécifiques à la page d'accueil ───────────────────── + $isHomepage = ($cursor === '' && $filterCat === ''); + $heroPost = null; + $latestPosts = []; + $popularPosts = []; + $recentlyUpdated = []; + $redecouvertes = []; + $featuredArticle = null; + + if ($isHomepage) { + // Article mis en avant + foreach ($allPosts as $_hp) { + if (!empty($_hp['featured'])) { + $featuredArticle = $_hp; + break; + } + } + $heroPost = $featuredArticle ?? ($allPosts[0] ?? null); + + // 5 articles les plus récents (hors hero) + $_heroUuid = $heroPost['uuid'] ?? ''; + $_count = 0; + foreach ($allPosts as $_hp) { + if ($_hp['uuid'] === $_heroUuid) { + continue; + } + $latestPosts[] = $_hp; + if (++$_count >= 5) { + break; + } + } + unset($_heroUuid, $_count, $_hp); + + $allPostsMap = array_column($allPosts, null, 'uuid'); + + // Articles populaires (10 derniers jours) — score pondéré + $_pdo = dbPdo(); + if ($_pdo) { + try { + $_stmt = $_pdo->query(" + SELECT article_uuid, SUM(score) AS total + FROM ( + SELECT article_uuid, 1 AS score FROM article_reactions + WHERE created_at >= NOW() - INTERVAL '10 days' + UNION ALL + SELECT article_uuid, 2 AS score FROM article_ratings + WHERE rated_at >= NOW() - INTERVAL '10 days' + UNION ALL + SELECT article_uuid, 3 AS score FROM comments + WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE + ) ev + GROUP BY article_uuid + ORDER BY total DESC + LIMIT 20 + "); + foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) { + if (count($popularPosts) >= 6) { + break; + } + $_uuid = $_row['article_uuid']; + if (!isset($allPostsMap[$_uuid])) { + continue; + } + $popularPosts[] = $allPostsMap[$_uuid]; + } + } catch (Throwable) { + } + + // Redécouvertes : anciens articles (> 30 j) avec activité récente + try { + $_stmt = $_pdo->query(" + SELECT DISTINCT article_uuid FROM ( + SELECT article_uuid FROM article_reactions + WHERE created_at >= NOW() - INTERVAL '10 days' + UNION + SELECT article_uuid FROM comments + WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE + ) ev + "); + $_thirtyDaysAgo = date('Y-m-d H:i:s', time() - 30 * 86400); + $_latestUuids = array_column($latestPosts, 'uuid'); + $_popularUuids = array_column($popularPosts, 'uuid'); + $_heroUuid = $heroPost['uuid'] ?? ''; + foreach ($_stmt->fetchAll(PDO::FETCH_COLUMN, 0) as $_uuid) { + if (count($redecouvertes) >= 4) { + break; + } + if (!isset($allPostsMap[$_uuid])) { + continue; + } + $_a = $allPostsMap[$_uuid]; + if (($_a['published_at'] ?? '') >= $_thirtyDaysAgo) { + continue; + } + if ($_a['uuid'] === $_heroUuid) { + continue; + } + if (in_array($_uuid, $_latestUuids, true)) { + continue; + } + if (in_array($_uuid, $_popularUuids, true)) { + continue; + } + $redecouvertes[] = $_a; + } + unset($_thirtyDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_uuid, $_a); + } catch (Throwable) { + } + } + unset($_pdo, $_stmt, $_row); + + // Récemment mis à jour (7 derniers jours, pas dans les autres sections) + $_sevenDaysAgo = date('Y-m-d H:i:s', time() - 7 * 86400); + $_latestUuids = array_column($latestPosts, 'uuid'); + $_popularUuids = array_column($popularPosts, 'uuid'); + $_heroUuid = $heroPost['uuid'] ?? ''; + foreach ($allPosts as $_a) { + if (count($recentlyUpdated) >= 4) { + break; + } + if ($_a['uuid'] === $_heroUuid) { + continue; + } + if (in_array($_a['uuid'], $_latestUuids, true)) { + continue; + } + if (in_array($_a['uuid'], $_popularUuids, true)) { + continue; + } + if (($_a['updated_at'] ?? '') < $_sevenDaysAgo) { + continue; + } + if (($_a['published_at'] ?? '') >= $_sevenDaysAgo) { + continue; + } // déjà récent dans latest + $recentlyUpdated[] = $_a; + } + unset($_sevenDaysAgo, $_latestUuids, $_popularUuids, $_heroUuid, $_a, $allPostsMap); + } + // ────────────────────────────────────────────────────────────────── + + include BASE_PATH . '/templates/post_list.php'; + break; +} diff --git a/public/login/config.php b/public/login/config.php new file mode 100644 index 0000000..923af8c --- /dev/null +++ b/public/login/config.php @@ -0,0 +1,136 @@ + trim((string)($_POST['oidc_issuer'] ?? '')), + 'oidc_name' => trim((string)($_POST['oidc_name'] ?? '')), + 'oidc_client_id' => trim((string)($_POST['oidc_client_id'] ?? '')), + 'oidc_client_secret' => trim((string)($_POST['oidc_client_secret'] ?? '')), + 'oidc_redirect_uri' => trim((string)($_POST['oidc_redirect_uri'] ?? '')), + ]; + + // validations simples + if ($in['allow_oidc']) { + if ($in['oidc_issuer'] === '' || $in['oidc_client_id'] === '' || $in['oidc_client_secret'] === '' || $in['oidc_redirect_uri'] === '') { + $err = 'OIDC activé mais champs incomplets.'; + } + } + + if (!$err) { + config_repo_save($in); + + // Mise à jour du .env + $envPairs = [ + 'OIDC_ISSUER' => $in['oidc_issuer'] !== '' ? $in['oidc_issuer'] : null, + 'OIDC_NAME' => $in['oidc_name'] !== '' ? $in['oidc_name'] : null, + 'OIDC_CLIENT_ID' => $in['oidc_client_id'] !== '' ? $in['oidc_client_id'] : null, + 'OIDC_CLIENT_SECRET' => $in['oidc_client_secret'] !== '' ? $in['oidc_client_secret'] : null, + 'OIDC_REDIRECT_URI' => $in['oidc_redirect_uri'] !== '' ? $in['oidc_redirect_uri'] : null, + ]; + env_set_pairs(BASE_PATH.'/.env', $envPairs); + + $cfg = config_repo_get(); + $msg = 'Configuration enregistrée.'; + } +} +?> + + + + + Configuration authentification + + + + +
+

Configuration authentification

+ +
+
+ +
+ +
+ Modes de connexion +
+ > + +
+
+ > + +
+
+ +
+ Inscriptions +
+ > + +
+
+ > + +
+
+ +
+ Paramètres OIDC +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Ces champs alimentent le fichier .env.

+
+ +
+ +
+
+
+ + + diff --git a/public/login/index.php b/public/login/index.php new file mode 100644 index 0000000..3c84d1f --- /dev/null +++ b/public/login/index.php @@ -0,0 +1,216 @@ +beginTransaction(); + try { + // purge expirés / consommés + $pdo->prepare('DELETE FROM auth_magic_links WHERE email = :e AND (expires_at < NOW() OR consumed_at IS NOT NULL)') + ->execute([':e' => $email]); + + // 1) cooldown: refuser si un envoi récent < coolMin + $sql = sprintf( + "SELECT 1 FROM auth_magic_links + WHERE email = :e AND created_at >= NOW() - INTERVAL '%d minutes' + LIMIT 1", + max(0, $coolMin) + ); + $stmt = $pdo->prepare($sql); + $stmt->execute([':e' => $email]); + if ($stmt->fetchColumn()) { + throw new RuntimeException(sprintf('Un lien vient d’être envoyé. Réessayez dans %d min. + Si vous ne recevez toujours rien, envisagez d\'utiliser un fournisseur de messagerie respectueux de la vie privée, + comme Proton Mail, Tuta, Posteo, Mailfence ou Infomaniak, qui garantissent un hébergement européen + et ne revendent pas vos données. -- Cédrix, le 11/10/2025', $coolMin)); + } + + // 2) plafond: maxPerWin liens sur winHours + $sql = sprintf( + "SELECT COUNT(*) FROM auth_magic_links + WHERE email = :e AND created_at >= NOW() - INTERVAL '%d hours'", + max(0, $winHours) + ); + $stmt = $pdo->prepare($sql); + $stmt->execute([':e' => $email]); + if ((int)$stmt->fetchColumn() >= $maxPerWin) { + throw new RuntimeException('Quota atteint. Réessayez plus tard.'); + } + + // Génère et enregistre le lien avec TTL ttlMin + $raw = random_bytes(32); + $token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + $sql = sprintf( + "INSERT INTO auth_magic_links (id,email,token,created_at,expires_at,ip,user_agent,return_to) + VALUES (gen_random_uuid(), :email, :token, NOW(), NOW() + INTERVAL '%d minutes', :ip, :ua, :rt) + RETURNING token", + max(1, $ttlMin) + ); + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':email' => $email, + ':token' => $token, + ':ip' => $ip, + ':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512), + ':rt' => ($returnTo !== '/' ? $returnTo : null), + ]); + $pdo->commit(); + + // construit l'URL et envoie le mail + $magicUrl = url('/login/magic.php') . '?token=' . urlencode($token); + $siteName = htmlspecialchars(env('SMTP_FROM_NAME', 'varlog'), ENT_QUOTES); + $html = <<Bonjour,

+

Cliquez sur le lien ci-dessous pour vous connecter à {$siteName} :

+

{$magicUrl}

+

Ce lien est valable {$ttlMin} minutes et ne peut être utilisé qu'une seule fois.

+

Si vous n'avez pas demandé ce lien, ignorez cet email.

+ HTML; + envoyer_mail_smtp( + $email, + "Votre lien de connexion — {$siteName}", + $html, + null, + ['bypass_rate_limit' => true] + ); + + // message utilisateur + $okMsg = 'Un lien vient d’être envoyé. Vérifiez votre boîte de réception et le dossier spam/indésirables.'; + + } catch (\Throwable $ex) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $errors[] = $ex->getMessage(); + } + } + } +} + +$csrf = Csrf::token(); + +ob_start(); +?> + + +
+
+

Connexion

+

Vous n'êtes pas connecté.

+ + +
+ + +
+ + + + +
ou
+ +
A5L indisponible : configurez OIDC_ISSUER et OIDC_CLIENT_ID dans .env.
+
ou
+ + +
+ +
+ + +
+ +
+
+
+ +beginTransaction(); +try { + // récupère lien non consommé et non expiré + $sql = 'SELECT id, email, token, created_at, expires_at, consumed_at, return_to + FROM auth_magic_links + WHERE token = :t + FOR UPDATE'; + $stmt = $pdo->prepare($sql); + $stmt->execute([':t' => $token]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + throw new RuntimeException('Lien inconnu.'); + } + if ($row['consumed_at'] !== null) { + throw new RuntimeException('Lien déjà utilisé.'); + } + if (strtotime((string)$row['expires_at']) < time()) { + throw new RuntimeException('Lien expiré.'); + } + + // consomme le lien + $pdo->prepare('UPDATE auth_magic_links SET consumed_at = NOW() WHERE id = :id')->execute([':id' => $row['id']]); + $pdo->commit(); + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + session_regenerate_id(true); + $_SESSION['user_email'] = strtolower(trim((string)$row['email'])); + + $dest = $row['return_to'] ?? '/'; + // sécurité: ne renvoyer que des chemins relatifs + if (!is_string($dest) || !str_starts_with($dest, '/')) { + $dest = '/'; + } + header('Location: ' . $dest, true, 303); + exit; +} catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + http_response_code(400); + echo htmlspecialchars($e->getMessage(), ENT_QUOTES); +} diff --git a/public/login/oidc.php b/public/login/oidc.php new file mode 100644 index 0000000..61b7d1d --- /dev/null +++ b/public/login/oidc.php @@ -0,0 +1,6 @@ + 'authorization_code', + 'code' => $code, + 'redirect_uri' => $OIDC_REDIRECT_URI, + 'client_id' => $OIDC_CLIENT_ID, + 'code_verifier' => $codeVerifier, +]; +if ($OIDC_CLIENT_SECRET !== '') { + $post['client_secret'] = $OIDC_CLIENT_SECRET; +} + +$ch = curl_init($tokenEndpoint); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986), + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, +]); +$tokenResponse = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$curlErr = curl_error($ch); +curl_close($ch); + +if ($tokenResponse === false || $httpCode !== 200) { + http_response_code(500); + echo $debug ? 'Échec échange token : ' . htmlspecialchars($curlErr ?: (string)$tokenResponse) : 'Erreur d\'authentification.'; + exit; +} + +$tokens = json_decode((string)$tokenResponse, true) ?: []; +$accessToken = $tokens['access_token'] ?? null; +$idToken = $tokens['id_token'] ?? null; + +if (!$accessToken) { + http_response_code(500); + echo $debug ? 'Access token manquant.' : 'Erreur d\'authentification.'; + exit; +} + +// UserInfo +$ch = curl_init($userInfoEndpoint); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken], + CURLOPT_TIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, +]); +$userInfoResponse = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($userInfoResponse === false || $httpCode !== 200) { + http_response_code(500); + echo $debug ? 'Échec UserInfo.' : 'Erreur d\'authentification.'; + exit; +} + +$claims = json_decode((string)$userInfoResponse, true) ?: []; +$email = $claims['email'] ?? null; + +// Fallback : lire l'email depuis le payload du id_token +if (!$email && $idToken && substr_count($idToken, '.') === 2) { + [, $p, ] = explode('.', $idToken, 3); + $payload = json_decode((string)base64_decode(strtr($p, '-_', '+/'), true), true); + if (is_array($payload) && !empty($payload['email'])) { + $email = $payload['email']; + } +} + +if (!$email) { + http_response_code(400); + echo $debug ? 'Email non fourni par l\'IdP.' : 'Impossible de récupérer votre email.'; + exit; +} + +// Nom d'affichage depuis les claims SSO +$ssoName = ''; +if (!empty($claims['given_name']) || !empty($claims['family_name'])) { + $ssoName = trim(($claims['given_name'] ?? '') . ' ' . ($claims['family_name'] ?? '')); +} elseif (!empty($claims['name'])) { + $ssoName = trim($claims['name']); +} elseif (!empty($claims['preferred_username'])) { + $ssoName = trim($claims['preferred_username']); +} + +// Charge le nom personnalisé depuis la base (prioritaire sur le SSO) +require_once dirname(__DIR__, 2) . '/src/auth.php'; +$pdo = dbPdo(); +$dbName = ''; +if ($pdo) { + try { + $st = $pdo->prepare('SELECT display_name FROM user_profiles WHERE email = :e'); + $st->execute([':e' => strtolower(trim($email))]); + $dbName = (string)($st->fetchColumn() ?: ''); + } catch (\Throwable) { + } +} + +if ($dbName !== '') { + // Nom personnalisé existant → on le conserve, le SSO ne l'écrase pas + $sessionName = $dbName; +} else { + // Première connexion → on persiste le nom SSO + $sessionName = $ssoName; + if ($ssoName !== '' && $pdo) { + try { + $pdo->prepare( + 'INSERT INTO user_profiles (email, display_name, updated_at) + VALUES (:e, :n, now()) + ON CONFLICT (email) DO NOTHING' + )->execute([':e' => strtolower(trim($email)), ':n' => $ssoName]); + } catch (\Throwable) { + } + } +} + +// Ouvre la session authentifiée +session_regenerate_id(true); +$_SESSION['user_email'] = strtolower(trim($email)); +$_SESSION['user_display_name'] = $sessionName; +$_SESSION['oidc'] = [ + 'issuer' => $OIDC_ISSUER, + 'sub' => $claims['sub'] ?? null, + 'access_token' => $accessToken, + 'id_token' => $idToken, + 'expires_at' => time() + (int)($tokens['expires_in'] ?? 3600), +]; + +$target = $_SESSION['oidc_return_to'] ?? '/'; +unset($_SESSION['oidc_return_to'], $_SESSION['oidc_flow']); +if (!is_string($target) || $target === '' || $target[0] !== '/') { + $target = '/'; +} +header('Location: ' . $target, true, 303); +exit; diff --git a/public/oidc/me.php b/public/oidc/me.php new file mode 100644 index 0000000..91cf8f5 --- /dev/null +++ b/public/oidc/me.php @@ -0,0 +1,194 @@ + true, + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accTok], + CURLOPT_TIMEOUT => 6, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($resp !== false && $code === 200) { + $tmp = json_decode((string)$resp, true); + if (is_array($tmp)) { + $claims = $tmp; + } + } +} + +// Extraire rôles groupés (Keycloak) +$roles = []; +if (isset($claims['realm_access']['roles']) && is_array($claims['realm_access']['roles'])) { + $roles = array_merge($roles, $claims['realm_access']['roles']); +} +if (isset($claims['resource_access']) && is_array($claims['resource_access'])) { + foreach ($claims['resource_access'] as $clientId => $data) { + if (!empty($data['roles']) && is_array($data['roles'])) { + foreach ($data['roles'] as $r) { + $roles[] = $clientId . ':' . $r; + } + } + } +} +$roles = array_values(array_unique($roles)); +?> + + + + +OIDC • Profil + + + + + +

Profil A5L

+ + +
Aucune session A5L. Connecte-toi via A5L d'abord.
+ +
+
Session / Jetons
+
+
+
Issuer
+
Subject (sub)
+
ID Token
+
Access Token
+
Expire à
+
Temps restant
+
+ +
+ Voir jetons non masqués (danger) +
+
ID Token
+
+
Access Token
+
+
+
+ +
+
+ +
+
Claims
+
+
+
Email
+
Preferred username
+
Given name
+
Family name
+
Name
+
Locale
+
Rôles
+
+ +
    + +
  • + +
+ + — + +
+
+ + +
Claims (JSON complet)
+
+ + + +
+ Aucun claim reçu. Vérifie que ton callback remplit bien $_SESSION['oidc_userinfo'] ou que l’ID Token contient les champs. +
+ +
+
+ + + Retour + + diff --git a/public/oidc/start.php b/public/oidc/start.php new file mode 100644 index 0000000..5461ec8 --- /dev/null +++ b/public/oidc/start.php @@ -0,0 +1,72 @@ + 'code', + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'scope' => 'openid email profile', + 'state' => $state, + 'nonce' => $nonce, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + 'ui_locales' => 'fr', +]; + +header('Location: ' . $authEndpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986), true, 302); +exit; diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..f3362e1 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,16 @@ +User-agent: * +Disallow: /?action=edit +Disallow: /?action=create +Disallow: /?action=admin +Disallow: /?action=delete +Disallow: /?action=diff +Disallow: /?action=categories +Disallow: /?action=add_files +Disallow: /?action=import_image +Disallow: /?action=sources +Disallow: /?action=profile +Disallow: /login +Disallow: /logout.php +Disallow: /oidc/ + +Sitemap: https://varlog.a5l.fr/sitemap.xml diff --git a/public/route.php b/public/route.php new file mode 100644 index 0000000..9919296 --- /dev/null +++ b/public/route.php @@ -0,0 +1,7 @@ +getPrivateCategories(); + +$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool { + $cat = trim($a['category'] ?? ''); + if ($cat !== '' && in_array($cat, $privateCats, true)) { + return false; + } + if (strtotime((string)($a['published_at'] ?? '')) > time()) { + return false; + } + return true; +}); + +header('Content-Type: application/xml; charset=UTF-8'); +header('X-Robots-Tag: noindex'); + +echo '' . "\n"; +echo '' . "\n"; + +// Homepage +echo ' ' . "\n"; +echo ' ' . htmlspecialchars(rtrim(APP_URL, '/') . '/') . '' . "\n"; +echo ' daily' . "\n"; +echo ' 1.0' . "\n"; +echo ' ' . "\n"; + +foreach ($published as $article) { + $loc = htmlspecialchars(rtrim(APP_URL, '/') . '/post/' . rawurlencode($article['slug'] ?? '')); + $lastmod = date('Y-m-d', strtotime((string)($article['updated_at'] ?? $article['published_at'] ?? 'now'))); + echo ' ' . "\n"; + echo ' ' . $loc . '' . "\n"; + echo ' ' . $lastmod . '' . "\n"; + echo ' monthly' . "\n"; + echo ' 0.8' . "\n"; + echo ' ' . "\n"; +} + +echo '' . "\n"; diff --git a/scripts/fetch-network-info.sh b/scripts/fetch-network-info.sh new file mode 100644 index 0000000..8dda75e --- /dev/null +++ b/scripts/fetch-network-info.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Récupère l'IP publique et les infos AS du FAI courant. +# Résultat dans config/network-info.json — lu par templates/legal.php +set -euo pipefail + +OUT="/var/www/lan.acegrp.varlog/config/network-info.json" + +json=$(curl -sf --max-time 10 "https://ipinfo.io/json") + +if [ -z "$json" ]; then + echo "Erreur : réponse vide de ip-api.com" >&2 + exit 1 +fi + +echo "$json" > "$OUT" +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) — network-info mis à jour : $json" diff --git a/src/ArticleManager.php b/src/ArticleManager.php new file mode 100644 index 0000000..b7c2711 --- /dev/null +++ b/src/ArticleManager.php @@ -0,0 +1,1213 @@ +allCache === null) { + $this->allCache = $this->loadAll(); + } + if ($publishedOnly) { + return array_values(array_filter($this->allCache, fn ($a) => $a['published'])); + } + return $this->allCache; + } + + private function loadAll(): array + { + $articles = []; + if (!is_dir($this->dataDir)) { + return $articles; + } + + foreach (scandir($this->dataDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $dir = $this->dataDir . '/' . $entry; + if (!is_dir($dir) || !file_exists($dir . '/meta.json')) { + continue; + } + + $article = $this->loadArticle($dir); + if (!$article) { + continue; + } + $articles[] = $article; + } + + usort($articles, static fn ($a, $b) => strcmp($b['published_at'] ?? '', $a['published_at'] ?? '')); + + return $articles; + } + + public function getBySlug(string $slug): ?array + { + $path = $this->slugIndexPath(); + if (!file_exists($path)) { + $this->buildSlugIndex(); + } + $index = json_decode((string) file_get_contents($path), true) ?? []; + $uuid = $index[$slug] ?? null; + return $uuid !== null ? $this->getByUuid($uuid) : null; + } + + public function getByUuid(string $uuid): ?array + { + if (!$this->isValidUuid($uuid)) { + return null; + } + $dir = $this->dataDir . '/' . $uuid; + if (!is_dir($dir) || !file_exists($dir . '/meta.json')) { + return null; + } + return $this->loadArticle($dir); + } + + public function getRevisionContent(string $uuid, int $n): ?string + { + if (!$this->isValidUuid($uuid) || $n < 1) { + return null; + } + $path = sprintf('%s/%s/revisions/%04d.md', $this->dataDir, $uuid, $n); + if (!file_exists($path)) { + return null; + } + $c = file_get_contents($path); + return $c !== false ? $c : null; + } + + // ------------------------------------------------------------------ // + // Écriture + // ------------------------------------------------------------------ // + + public function create(string $title, string $content, bool $published, string $slug = '', string $publishedAt = '', string $author = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', array $tags = []): string + { + $uuid = $this->generateUuid(); + $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); + $slug = $this->uniqueSlug($slug, $uuid); + $now = date('Y-m-d H:i:s'); + $publishedAt = $publishedAt !== '' ? $publishedAt : $now; + + $dir = $this->dataDir . '/' . $uuid; + mkdir($dir, 0755, true); + mkdir($dir . '/files', 0755, true); + + $meta = [ + 'uuid' => $uuid, + 'slug' => $slug, + 'title' => $title, + 'author' => $author, + 'published' => $published, + 'featured' => false, + 'published_at' => $publishedAt, + 'created_at' => $now, + 'updated_at' => $now, + 'revisions' => [], + 'cover' => '', + 'files_meta' => [], + 'external_links' => [], + 'seo_title' => $seoTitle, + 'seo_description' => $seoDescription, + 'og_image' => $ogImage, + 'category' => $category, + 'tags' => $this->normalizeTags($tags), + ]; + $this->writeMeta($dir, $meta); + file_put_contents($dir . '/index.md', ltrim($content)); + $this->rebuildSearchIndex(); + $this->rebuildBacklinksCache(); + + return $uuid; + } + + public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null): void + { + $article = $this->getByUuid($uuid); + if (!$article) { + return; + } + + $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); + $slug = $this->uniqueSlug($slug, $uuid); + + // Snapshot de l'état courant avant écrasement — uniquement si le contenu ou le titre a changé + $revisions = $article['revisions'] ?? []; + $contentChanged = ltrim($content) !== ($article['content'] ?? ''); + $titleChanged = $title !== ($article['title'] ?? ''); + + if ($contentChanged || $titleChanged) { + $revDir = $this->dataDir . '/' . $uuid . '/revisions'; + if (!is_dir($revDir)) { + mkdir($revDir, 0755, true); + } + $n = count($revisions) + 1; + $revFile = sprintf('%s/%04d.md', $revDir, $n); + file_put_contents($revFile, $article['content']); + + $revisions[] = [ + 'n' => $n, + 'date' => date('Y-m-d H:i:s'), + 'comment' => $revisionComment, + 'title' => $article['title'], + ]; + + // Limite à MAX_REVISIONS + if (count($revisions) > self::MAX_REVISIONS) { + $oldest = array_shift($revisions); + @unlink(sprintf('%s/%04d.md', $revDir, (int)($oldest['n'] ?? 0))); + } + } // fin if ($contentChanged || $titleChanged) + + $meta = [ + 'uuid' => $uuid, + 'slug' => $slug, + 'title' => $title, + 'author' => $article['author'] ?? '', + 'published' => $published, + 'featured' => (bool)($article['featured'] ?? false), + 'published_at' => $publishedAt !== '' ? $publishedAt : ($article['published_at'] ?? date('Y-m-d H:i:s')), + 'created_at' => $article['created_at'] ?? date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + 'revisions' => $revisions, + 'cover' => $article['cover'] ?? '', + 'files_meta' => $article['files_meta'] ?? [], + 'external_links' => $article['external_links'] ?? [], + 'seo_title' => $seoTitle, + 'seo_description' => $seoDescription, + 'og_image' => $ogImage, + 'category' => $category, + 'tags' => $tags !== null ? $this->normalizeTags($tags) : ($article['tags'] ?? []), + ]; + $dir = $this->dataDir . '/' . $uuid; + $this->writeMeta($dir, $meta); + file_put_contents($dir . '/index.md', ltrim($content)); + $this->rebuildSearchIndex(); + $this->rebuildBacklinksCache(); + } + + public function autosave(string $uuid, string $title, string $content, string $slug): bool + { + if (!$this->isValidUuid($uuid)) { + return false; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = @file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return false; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return false; + } + + $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); + $slug = $this->uniqueSlug($slug, $uuid); + + $meta['title'] = $title; + $meta['slug'] = $slug; + $meta['updated_at'] = date('Y-m-d H:i:s'); + + $this->writeMeta($dir, $meta); + @file_put_contents($dir . '/index.md', ltrim($content)); + return true; + } + + public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $filename = basename($filename); + + $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); + if ($raw === false) { + return; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return; + } + if (!isset($meta['files_meta']) || !is_array($meta['files_meta'])) { + $meta['files_meta'] = []; + } + $entry = ['author' => $author, 'source_url' => $sourceUrl]; + if ($title !== '') { + $entry['title'] = $title; + } + if (!empty($extraMeta)) { + $clean = $extraMeta; + unset($clean['title'], $clean['author'], $clean['credit'], $clean['source']); + $entry['meta'] = $clean; + } + $meta['files_meta'][$filename] = $entry; + $this->writeMeta($this->dataDir . '/' . $uuid, $meta); + } + + public function setCover(string $uuid, string $filename): void + { + $article = $this->getByUuid($uuid); + if (!$article) { + return; + } + $filename = basename($filename); + $filesDir = $this->dataDir . '/' . $uuid . '/files'; + $targetPath = $filesDir . '/' . $filename; + if (!file_exists($targetPath)) { + return; + } + + $mime = mime_content_type($targetPath) ?: ''; + $ext = $this->extFromMime($mime) ?? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) ?: 'jpg'; + $coverName = 'cover.' . $ext; + + // Rename old cover back to hash name + $oldCover = $article['cover'] ?? ''; + if ($oldCover && $oldCover !== $filename && $oldCover !== $coverName) { + $oldPath = $filesDir . '/' . basename($oldCover); + if (file_exists($oldPath)) { + $hash = substr(hash_file('sha256', $oldPath), 0, 16); + $size = filesize($oldPath); + $oldExt = strtolower(pathinfo($oldCover, PATHINFO_EXTENSION)); + rename($oldPath, $filesDir . '/' . "{$hash}-{$size}.{$oldExt}"); + } + } + + // Rename target to cover.{ext} + $newPath = $filesDir . '/' . $coverName; + if ($targetPath !== $newPath) { + rename($targetPath, $newPath); + } + + $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); + if ($raw === false) { + return; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return; + } + $meta['cover'] = $coverName; + $this->writeMeta($this->dataDir . '/' . $uuid, $meta); + } + + public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string + { + if (!$this->isValidUuid($uuid)) { + return null; + } + if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) { + return null; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 3, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_USERAGENT => 'varlog/1.0', + ]); + $body = curl_exec($ch); + $info = curl_getinfo($ch); + curl_close($ch); + + if ($body === false || (int)$info['http_code'] !== 200) { + return null; + } + + $tmp = tempnam(sys_get_temp_dir(), 'vl_'); + file_put_contents($tmp, $body); + + $mime = mime_content_type($tmp) ?: 'application/octet-stream'; + $isImage = str_starts_with($mime, 'image/'); + $filesDir = $this->dataDir . '/' . $uuid . '/files'; + if (!is_dir($filesDir)) { + mkdir($filesDir, 0755, true); + } + + if ($isImage) { + $ext = $this->extFromMime($mime) ?? strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION)) ?: 'jpg'; + + if ($isCover) { + // Gérer l'ancienne cover + $article = $this->getByUuid($uuid); + $oldCover = $article['cover'] ?? ''; + if ($oldCover) { + $oldPath = $filesDir . '/' . basename($oldCover); + if (file_exists($oldPath)) { + $hash = substr(hash_file('sha256', $oldPath), 0, 16); + $size = filesize($oldPath); + $oldExt = strtolower(pathinfo($oldCover, PATHINFO_EXTENSION)); + rename($oldPath, $filesDir . '/' . "{$hash}-{$size}.{$oldExt}"); + } + } + $filename = 'cover.' . $ext; + } else { + $hash = substr(hash_file('sha256', $tmp), 0, 16); + $size = strlen($body); + $filename = "{$hash}-{$size}.{$ext}"; + } + } else { + // Non-image : nom extrait de l'URL, sanitisé, dédupliqué + $urlPath = parse_url($url, PHP_URL_PATH) ?? ''; + $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($urlPath)) ?: 'file'; + $i = 1; + $info = pathinfo($filename); + while (file_exists($filesDir . '/' . $filename)) { + $filename = $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : ''); + $i++; + } + } + + rename($tmp, $filesDir . '/' . $filename); + + if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) { + $this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta); + } + + if ($isCover && $isImage) { + $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); + if ($raw !== false) { + $meta = json_decode($raw, true); + if (is_array($meta)) { + $meta['cover'] = $filename; + $this->writeMeta($this->dataDir . '/' . $uuid, $meta); + } + } + } + + return $filename; + } + + public function addExternalLink(string $uuid, string $url, string $title = '', string $author = '', array $extraMeta = []): bool + { + if (!$this->isValidUuid($uuid) || !filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return false; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return false; + } + if (!isset($meta['external_links']) || !is_array($meta['external_links'])) { + $meta['external_links'] = []; + } + foreach ($meta['external_links'] as $link) { + if ($link['url'] === $url) { + return true; // already exists + } + } + $urlPath = parse_url($url, PHP_URL_PATH) ?? ''; + $name = $title !== '' ? $title : (rawurldecode(basename($urlPath)) ?: $url); + $entry = ['url' => $url, 'name' => $name, 'added_at' => date('Y-m-d H:i:s')]; + $resolvedAuthor = $author !== '' ? $author : ($extraMeta['author'] ?? ''); + if ($resolvedAuthor !== '') { + $entry['author'] = $resolvedAuthor; + } + if (!empty($extraMeta)) { + $clean = $extraMeta; + unset($clean['title'], $clean['author'], $clean['credit']); + $entry['meta'] = $clean; + } + $meta['external_links'][] = $entry; + $this->writeMeta($dir, $meta); + $this->rebuildBacklinksCache(); + return true; + } + + public function updateExternalLinkMeta(string $uuid, string $url, array $metaUpdates): bool + { + if (!$this->isValidUuid($uuid)) { + return false; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return false; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return false; + } + $found = false; + foreach ($meta['external_links'] as &$link) { + if ($link['url'] === $url) { + $link['meta'] = array_merge($link['meta'] ?? [], $metaUpdates); + $found = true; + break; + } + } + unset($link); + if (!$found) { + return false; + } + $this->writeMeta($dir, $meta); + return true; + } + + public function removeExternalLink(string $uuid, string $url): bool + { + if (!$this->isValidUuid($uuid)) { + return false; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return false; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return false; + } + $meta['external_links'] = array_values(array_filter( + $meta['external_links'] ?? [], + static fn ($l) => $l['url'] !== $url + )); + $this->writeMeta($dir, $meta); + $this->rebuildBacklinksCache(); + return true; + } + + public function getCategories(): array + { + $cats = []; + $source = $this->getSearchIndex() ?? $this->getAll(); + foreach ($source as $article) { + $cat = trim($article['category'] ?? ''); + if ($cat !== '') { + $cats[$cat] = ($cats[$cat] ?? 0) + 1; + } + } + ksort($cats); + return $cats; + } + + public function renameCategory(string $old, string $new): void + { + if (!is_dir($this->dataDir)) { + return; + } + foreach (scandir($this->dataDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $metaPath = $this->dataDir . '/' . $entry . '/meta.json'; + if (!file_exists($metaPath)) { + continue; + } + $raw = file_get_contents($metaPath); + if ($raw === false) { + continue; + } + $meta = json_decode($raw, true); + if (!is_array($meta) || trim($meta['category'] ?? '') !== $old) { + continue; + } + $meta['category'] = $new; + $this->writeMeta($this->dataDir . '/' . $entry, $meta); + } + } + + public function deleteCategory(string $name): void + { + $this->renameCategory($name, ''); + } + + public function getPrivateCategories(): array + { + $path = $this->dataDir . '/private_cats.json'; + if (!file_exists($path)) { + return []; + } + $data = json_decode((string)file_get_contents($path), true); + return is_array($data) ? $data : []; + } + + public function togglePrivateCategory(string $cat): void + { + $cats = $this->getPrivateCategories(); + if (in_array($cat, $cats, true)) { + $cats = array_values(array_filter($cats, fn ($c) => $c !== $cat)); + } else { + $cats[] = $cat; + } + file_put_contents( + $this->dataDir . '/private_cats.json', + json_encode(array_values($cats), JSON_UNESCAPED_UNICODE) + ); + } + + // ─── Tag types ────────────────────────────────────────────────────────────── + + private function tagTypesPath(): string + { + return $this->dataDir . '/tag_types.json'; + } + + public function getTagTypes(): array + { + $p = $this->tagTypesPath(); + if (!file_exists($p)) { + return []; + } + $data = json_decode((string) file_get_contents($p), true); + return is_array($data) ? $data : []; + } + + public function saveTagTypes(array $types): void + { + file_put_contents( + $this->tagTypesPath(), + json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n" + ); + } + + /** Enregistre les tags d'un article directement (utile pour les scripts de migration). */ + public function setTags(string $uuid, array $tags): void + { + $dir = $this->dataDir . '/' . $uuid; + $metaPath = $dir . '/meta.json'; + if (!file_exists($metaPath)) { + return; + } + + $meta = json_decode((string) file_get_contents($metaPath), true); + if (!is_array($meta)) { + return; + } + + $meta['tags'] = $this->normalizeTags($tags); + $this->writeMeta($dir, $meta); + $this->rebuildSearchIndex(); + } + + /** @return list Toutes les valeurs distinctes d'un type de tag, triées. */ + public function getAllTagValues(string $type): array + { + $values = []; + foreach ($this->getSearchIndex() ?? $this->getAll() as $a) { + foreach (($a['tags'][$type] ?? []) as $v) { + $values[$v] = true; + } + } + ksort($values); + return array_keys($values); + } + + private function normalizeTags(array $raw): array + { + $out = []; + foreach ($raw as $type => $values) { + $type = trim((string)$type); + if ($type === '') { + continue; + } + $vals = array_values(array_filter(array_map('trim', (array)$values), fn ($v) => $v !== '')); + if ($vals !== []) { + $out[$type] = $vals; + } + } + return $out; + } + + public function setFeatured(string $uuid, bool $featured): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = @file_get_contents($dir . '/meta.json'); + if ($raw === false) { + return; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + return; + } + $meta['featured'] = $featured; + $this->writeMeta($dir, $meta); + $this->allCache = null; + @unlink($this->articleCachePath($uuid)); + } + + public function delete(string $uuid): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + if (is_dir($dir)) { + $this->allCache = null; + @unlink($this->articleCachePath($uuid)); + @unlink($this->slugIndexPath()); + $this->removeDir($dir); + } + $this->rebuildSearchIndex(); + $this->rebuildBacklinksCache(); + } + + // ------------------------------------------------------------------ // + // Cache des rétroliens + // ------------------------------------------------------------------ // + + private function backlinksPath(): string + { + return $this->dataDir . '/_cache/backlinks.json'; + } + + private function articleCachePath(string $uuid): string + { + return $this->dataDir . '/_cache/articles/' . $uuid . '.json'; + } + + private function slugIndexPath(): string + { + return $this->dataDir . '/_cache/slug_index.json'; + } + + private function buildSlugIndex(): void + { + $cacheDir = $this->dataDir . '/_cache'; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0755, true); + } + $index = []; + // Préférer le search_index (lecture unique) plutôt que loadAll() (N lectures) + $source = $this->getSearchIndex() ?? $this->loadAll(); + foreach ($source as $article) { + $slug = $article['slug'] ?? ''; + $uuid = $article['uuid'] ?? ''; + if ($slug !== '' && $uuid !== '') { + $index[$slug] = $uuid; + } + } + file_put_contents( + $this->slugIndexPath(), + json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + /** + * Reconstruit le cache des rétroliens. + * Produit un index slug → [article minimal, ...] pour tous les articles publiés + * qui pointent vers un autre article interne via leurs external_links. + */ + public function rebuildBacklinksCache(): void + { + $cacheDir = $this->dataDir . '/_cache'; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0755, true); + } + + $index = []; + foreach ($this->getAll(publishedOnly: true) as $article) { + foreach ($article['external_links'] ?? [] as $link) { + $path = rtrim(parse_url($link['url'] ?? '', PHP_URL_PATH) ?? '', '/'); + if (!preg_match('#^/post/([a-z0-9][a-z0-9-]*)$#', $path, $m)) { + continue; + } + $target = $m[1]; + $index[$target][] = [ + 'uuid' => $article['uuid'], + 'slug' => $article['slug'] ?? '', + 'title' => $article['title'] ?? '', + 'cover' => $article['cover'] ?? '', + 'category' => $article['category'] ?? '', + 'published_at' => $article['published_at'] ?? '', + 'created_at' => $article['created_at'] ?? '', + ]; + } + } + + file_put_contents( + $this->backlinksPath(), + json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + /** + * Retourne les articles qui pointent vers /post/, depuis le cache. + * Reconstruit le cache si absent. + */ + public function getBacklinks(string $slug, string $excludeUuid = ''): array + { + $path = $this->backlinksPath(); + if (!file_exists($path)) { + $this->rebuildBacklinksCache(); + } + $index = json_decode((string) file_get_contents($path), true); + if (!is_array($index)) { + return []; + } + $result = $index[$slug] ?? []; + if ($excludeUuid !== '') { + $result = array_values(array_filter($result, static fn ($a) => $a['uuid'] !== $excludeUuid)); + } + return $result; + } + + // ------------------------------------------------------------------ // + // Index de recherche (fichier plat) + // ------------------------------------------------------------------ // + + /** + * Reconstruit search_index.json à partir de tous les articles. + * Appelé automatiquement après chaque create/update/delete. + */ + public function rebuildSearchIndex(): void + { + $index = []; + foreach ($this->getAll() as $article) { + $index[] = [ + 'uuid' => $article['uuid'], + 'slug' => $article['slug'] ?? '', + 'title' => $article['title'] ?? '', + 'category' => $article['category'] ?? '', + 'author' => $article['author'] ?? '', + 'cover' => $article['cover'] ?? '', + 'published' => $article['published'], + 'published_at' => $article['published_at'] ?? '', + 'created_at' => $article['created_at'] ?? '', + 'updated_at' => $article['updated_at'] ?? '', + 'tags' => $article['tags'] ?? [], + 'plain' => $this->stripForIndex($article['content'] ?? ''), + ]; + } + file_put_contents( + $this->dataDir . '/search_index.json', + json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $this->searchIndexCache = $index; + $this->clearSearchResultsCache(); + } + + /** Vide le cache des résultats de recherche (appelé après chaque modification). */ + public function clearSearchResultsCache(): void + { + $dir = $this->dataDir . '/_cache/search'; + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $f) { + if (str_ends_with($f, '.json')) { + @unlink($dir . '/' . $f); + } + } + } + + /** + * Retourne les résultats mis en cache pour une requête anonyme, ou null si absent. + * @return array|null + */ + public function getSearchCache(string $query): ?array + { + $path = $this->searchCachePath($query); + if (!file_exists($path)) { + return null; + } + $data = json_decode((string) file_get_contents($path), true); + return is_array($data) ? $data : null; + } + + /** Persiste les résultats d'une requête anonyme dans le cache. */ + public function setSearchCache(string $query, array $results): void + { + $dir = $this->dataDir . '/_cache/search'; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + file_put_contents( + $this->searchCachePath($query), + json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + private function searchCachePath(string $query): string + { + return $this->dataDir . '/_cache/search/' . md5(mb_strtolower(trim($query))) . '.json'; + } + + /** Retourne l'index pré-construit, ou null s'il n'existe pas encore. */ + public function getSearchIndex(): ?array + { + if ($this->searchIndexCache !== null) { + return $this->searchIndexCache; + } + $path = $this->dataDir . '/search_index.json'; + if (!file_exists($path)) { + return null; + } + $data = json_decode((string) file_get_contents($path), true); + if (!is_array($data) || empty($data)) { + return null; + } + // Rebuild automatique si le format est obsolète (champs cover/created_at absents) + if (!array_key_exists('cover', $data[0])) { + $this->rebuildSearchIndex(); + return $this->searchIndexCache; + } + $this->searchIndexCache = $data; + return $this->searchIndexCache; + } + + /** Retire la syntaxe Markdown pour stocker du texte brut dans l'index. */ + private function stripForIndex(string $md): string + { + $t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; + $t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; + $t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; + $t = preg_replace('/`[^`]+`/', '', $t) ?? $t; + $t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; + $t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; + $t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; + $t = preg_replace('/\n{2,}/', ' ', $t) ?? $t; + return trim($t); + } + + // ------------------------------------------------------------------ // + // Fichiers associés + // ------------------------------------------------------------------ // + + public function getFiles(string $uuid): array + { + $dir = $this->dataDir . '/' . $uuid . '/files'; + if (!is_dir($dir)) { + return []; + } + + $files = []; + foreach (scandir($dir) as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $path = $dir . '/' . $name; + if (!is_file($path)) { + continue; + } + $mime = mime_content_type($path) ?: 'application/octet-stream'; + $files[] = [ + 'name' => $name, + 'size' => filesize($path), + 'mime' => $mime, + 'is_image' => str_starts_with($mime, 'image/'), + 'is_video' => str_starts_with($mime, 'video/'), + 'is_audio' => str_starts_with($mime, 'audio/'), + 'uploaded_at' => date('Y-m-d H:i:s', (int)filemtime($path)), + ]; + } + return $files; + } + + public function deleteFile(string $uuid, string $name): bool + { + if (!$this->isValidUuid($uuid)) { + return false; + } + $name = basename($name); + if ($name === '' || $name[0] === '.') { + return false; + } + $path = $this->dataDir . '/' . $uuid . '/files/' . $name; + if (!is_file($path)) { + return false; + } + return unlink($path); + } + + public function addFile(string $uuid, array $uploadedFile): ?string + { + if (!$this->isValidUuid($uuid)) { + return null; + } + $dir = $this->dataDir . '/' . $uuid . '/files'; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream'; + + if (str_starts_with($mime, 'image/')) { + // HEIC/HEIF : converti en JPEG (non supporté nativement par les navigateurs) + if (in_array($mime, ['image/heic', 'image/heif'], true) && extension_loaded('imagick')) { + try { + $img = new \Imagick($uploadedFile['tmp_name']); + $img->setImageFormat('jpeg'); + $img->setImageCompressionQuality(88); + $converted = tempnam(sys_get_temp_dir(), 'vl_heic_') . '.jpg'; + $img->writeImage($converted); + $img->destroy(); + $uploadedFile['tmp_name'] = $converted; + $mime = 'image/jpeg'; + } catch (\Exception $e) { + // Échec conversion → stocke tel quel + } + } + + $ext = $this->extFromMime($mime) + ?? strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION)) + ?: 'jpg'; + $hash = substr(hash_file('sha256', $uploadedFile['tmp_name']), 0, 16); + $size = filesize($uploadedFile['tmp_name']); + $name = "{$hash}-{$size}.{$ext}"; + $dest = $dir . '/' . $name; + if (!rename($uploadedFile['tmp_name'], $dest) && !move_uploaded_file($uploadedFile['tmp_name'], $dest)) { + return null; + } + return $name; + } + + // Non-image : nom sanitisé + déduplication + $name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($uploadedFile['name'])); + $dest = $dir . '/' . $name; + $i = 1; + $info = pathinfo($name); + while (file_exists($dest)) { + $dest = $dir . '/' . $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : ''); + $i++; + } + if (!move_uploaded_file($uploadedFile['tmp_name'], $dest)) { + return null; + } + return basename($dest); + } + + // ------------------------------------------------------------------ // + // Rendu + // ------------------------------------------------------------------ // + + public function resolveFileUrls(string $uuid, string $markdown): string + { + $base = '/file?uuid=' . rawurlencode($uuid) . '&name='; + + return preg_replace_callback( + '/(!?\[([^\]]*)\])\((?!https?:\/\/)(?!\/)([^)]+)\)/', + static function (array $m) use ($base): string { + return $m[1] . '(' . $base . rawurlencode($m[3]) . ')'; + }, + $markdown + ) ?? $markdown; + } + + // ------------------------------------------------------------------ // + // Helpers privés + // ------------------------------------------------------------------ // + + private function extFromMime(string $mime): ?string + { + return match($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/avif' => 'avif', + 'image/svg+xml' => 'svg', + 'image/heic' => 'jpg', + 'image/heif' => 'jpg', + default => null, + }; + } + + private function loadArticle(string $dir): ?array + { + $metaPath = $dir . '/meta.json'; + if (!file_exists($metaPath)) { + return null; + } + $uuid = basename($dir); + $cachePath = $this->articleCachePath($uuid); + + // Utiliser le cache si plus récent que meta.json + if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath)) { + $cached = json_decode((string) file_get_contents($cachePath), true); + if (is_array($cached) && !empty($cached['uuid'])) { + return $cached; + } + } + + $raw = file_get_contents($metaPath); + if ($raw === false) { + return null; + } + $meta = json_decode($raw, true); + if (!is_array($meta) || empty($meta['uuid'])) { + return null; + } + + $contentPath = $dir . '/index.md'; + $meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : ''; + $meta['published'] = (bool)($meta['published'] ?? false); + $meta['featured'] = (bool)($meta['featured'] ?? false); + $meta['files_meta'] = $meta['files_meta'] ?? []; + $meta['external_links'] = $meta['external_links'] ?? []; + $meta['tags'] = $meta['tags'] ?? []; + + if (!empty($meta['cover'])) { + $coverPath = $dir . '/files/' . basename((string)$meta['cover']); + if (!file_exists($coverPath)) { + $meta['cover'] = ''; + } + } + + // Écrire le cache + $cacheDir = dirname($cachePath); + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0755, true); + } + file_put_contents($cachePath, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return $meta; + } + + public function deleteRevision(string $uuid, int $revN): void + { + if (!$this->isValidUuid($uuid) || $revN < 1) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + $raw = @file_get_contents($dir . '/meta.json'); + $meta = $raw !== false ? json_decode($raw, true) : null; + if (!is_array($meta)) { + return; + } + $revisions = $meta['revisions'] ?? []; + $newRevisions = []; + foreach ($revisions as $rev) { + if ((int)($rev['n'] ?? 0) === $revN) { + @unlink(sprintf('%s/revisions/%04d.md', $dir, $revN)); + } else { + $newRevisions[] = $rev; + } + } + $meta['revisions'] = array_values($newRevisions); + $this->writeMeta($dir, $meta); + } + + public function deleteAllRevisions(string $uuid): void + { + if (!$this->isValidUuid($uuid)) { + return; + } + $dir = $this->dataDir . '/' . $uuid; + $revDir = $dir . '/revisions'; + if (is_dir($revDir)) { + foreach (glob($revDir . '/*.md') ?: [] as $f) { + @unlink($f); + } + } + $raw = @file_get_contents($dir . '/meta.json'); + $meta = $raw !== false ? json_decode($raw, true) : null; + if (is_array($meta)) { + $meta['revisions'] = []; + $this->writeMeta($dir, $meta); + } + } + + private function writeMeta(string $dir, array $meta): void + { + $this->allCache = null; + $this->searchIndexCache = null; + $uuid = $meta['uuid'] ?? basename($dir); + + // Invalider le cache article et le slug index + @unlink($this->articleCachePath($uuid)); + @unlink($this->slugIndexPath()); + + file_put_contents( + $dir . '/meta.json', + json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" + ); + } + + private function generateSlug(string $title): string + { + $map = [ + 'à' => 'a', 'â' => 'a', 'ä' => 'a', + 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'î' => 'i', 'ï' => 'i', + 'ô' => 'o', 'ö' => 'o', + 'ù' => 'u', 'û' => 'u', 'ü' => 'u', + 'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', + ]; + $slug = mb_strtolower($title, 'UTF-8'); + $slug = strtr($slug, $map); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); + return trim((string)$slug, '-') ?: 'article'; + } + + private function sanitizeSlug(string $slug): string + { + $slug = mb_strtolower(trim($slug), 'UTF-8'); + $slug = preg_replace('/[^a-z0-9-]/', '-', $slug); + $slug = preg_replace('/-+/', '-', $slug); + return trim((string)$slug, '-') ?: 'article'; + } + + private function uniqueSlug(string $slug, string $excludeUuid): string + { + $taken = array_column( + array_filter($this->getAll(), static fn ($a) => $a['uuid'] !== $excludeUuid), + 'slug' + ); + + if (!in_array($slug, $taken, true)) { + return $slug; + } + $i = 2; + while (in_array($slug . '-' . $i, $taken, true)) { + $i++; + } + return $slug . '-' . $i; + } + + private function generateUuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); + $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + + private function isValidUuid(string $uuid): bool + { + return (bool)preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $uuid + ); + } + + /** + * Retourne les articles publiés qui contiennent un lien vers /post/. + */ + private function removeDir(string $dir): void + { + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/src/CommentManager.php b/src/CommentManager.php new file mode 100644 index 0000000..87588ca --- /dev/null +++ b/src/CommentManager.php @@ -0,0 +1,202 @@ + UUID (dans l'URL), 'code' => 6 chiffres (saisi par le visiteur)]. + */ + public function submit( + string $articleUuid, + string $name, + string $email, + string $content, + string $ip, + string $ua + ): array { + $bytes = random_bytes(16); + $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); + $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); + $token = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + $code = sprintf('%06d', random_int(100000, 999999)); + $this->pdo->prepare( + 'INSERT INTO comments + (article_uuid, author_name, author_email, content, verify_token, verification_code, ip_address, user_agent) + VALUES (:uuid, :name, :email, :content, :token, :code, :ip, :ua)' + )->execute([ + ':uuid' => $articleUuid, + ':name' => $name, + ':email' => $email, + ':content' => $content, + ':token' => $token, + ':code' => $code, + ':ip' => $ip, + ':ua' => substr($ua, 0, 512), + ]); + return ['token' => $token, 'code' => $code]; + } + + /** + * Vérifie le code PIN pour un token donné. + * Retourne l'article_uuid en cas de succès. + * Retourne int > 0 : tentatives restantes (code incorrect). + * Retourne 0 : commentaire supprimé après 3 tentatives échouées. + * Retourne null : token introuvable ou expiré. + */ + public function verify(string $token, string $code): string|int|null + { + $st = $this->pdo->prepare( + "SELECT id, verification_code, verify_attempts, article_uuid + FROM comments + WHERE verify_token = :token + AND verified = FALSE + AND created_at >= NOW() - INTERVAL '24 hours' + LIMIT 1" + ); + $st->execute([':token' => $token]); + $row = $st->fetch(PDO::FETCH_ASSOC); + + if (!$row) { + return null; + } + + if ($row['verification_code'] !== $code) { + $newAttempts = (int)$row['verify_attempts'] + 1; + if ($newAttempts >= 3) { + $this->pdo->prepare('DELETE FROM comments WHERE id = :id') + ->execute([':id' => $row['id']]); + return 0; + } + $this->pdo->prepare('UPDATE comments SET verify_attempts = :a WHERE id = :id') + ->execute([':a' => $newAttempts, ':id' => $row['id']]); + return 3 - $newAttempts; + } + + $this->pdo->prepare( + 'UPDATE comments + SET verified = TRUE, published = TRUE, verification_code = NULL, verify_token = NULL + WHERE id = :id' + )->execute([':id' => $row['id']]); + + return (string)$row['article_uuid']; + } + + /** @return array> */ + public function forArticle(string $uuid): array + { + $st = $this->pdo->prepare( + 'SELECT id, author_name, content, created_at + FROM comments + WHERE article_uuid = :uuid AND verified = TRUE AND published = TRUE + ORDER BY created_at ASC' + ); + $st->execute([':uuid' => $uuid]); + return $st->fetchAll(PDO::FETCH_ASSOC); + } + + public function setPublished(int $id, bool $published): void + { + $this->pdo->prepare('UPDATE comments SET published = :pub WHERE id = :id') + ->execute([':pub' => $published, ':id' => $id]); + } + + public function delete(int $id): void + { + $this->pdo->prepare('DELETE FROM comments WHERE id = :id') + ->execute([':id' => $id]); + } + + /** @return array|null */ + public function getById(int $id): ?array + { + $st = $this->pdo->prepare( + 'SELECT id, article_uuid, author_name, author_email, content, + verify_token, verification_code, verify_attempts, verified, published, created_at, ip_address + FROM comments WHERE id = :id LIMIT 1' + ); + $st->execute([':id' => $id]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** @return array{all:int,pending:int,verified:int,hidden:int} */ + public function countsByStatus(): array + { + try { + $row = $this->pdo->query( + 'SELECT + COUNT(*) AS all, + COUNT(*) FILTER (WHERE verified = FALSE) AS pending, + COUNT(*) FILTER (WHERE verified = TRUE AND published = TRUE) AS verified, + COUNT(*) FILTER (WHERE verified = TRUE AND published = FALSE) AS hidden + FROM comments' + )->fetch(PDO::FETCH_ASSOC); + return [ + 'all' => (int)($row['all'] ?? 0), + 'pending' => (int)($row['pending'] ?? 0), + 'verified' => (int)($row['verified'] ?? 0), + 'hidden' => (int)($row['hidden'] ?? 0), + ]; + } catch (\Throwable) { + return ['all' => 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0]; + } + } + + /** + * Retourne tous les commentaires pour l'admin, avec statut email depuis journal_smtp. + * + * @param string $filterStatus '' = tous, 'pending' = non vérifié, + * 'verified' = vérifié+publié, 'hidden' = vérifié+non publié + * @return array> + */ + public function allForAdmin(string $filterStatus = ''): array + { + $where = match($filterStatus) { + 'pending' => 'WHERE c.verified = FALSE', + 'verified' => 'WHERE c.verified = TRUE AND c.published = TRUE', + 'hidden' => 'WHERE c.verified = TRUE AND c.published = FALSE', + default => '', + }; + + $sqlWithJoin = " + SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content, + c.verification_code, c.verified, c.published, c.created_at, c.ip_address, + j.status AS mail_status, + j.error_message AS mail_error, + j.sent_at AS mail_sent_at + FROM comments c + LEFT JOIN LATERAL ( + SELECT status, error_message, sent_at + FROM journal_smtp + WHERE to_email = c.author_email + AND created_at BETWEEN c.created_at - INTERVAL '1 minute' + AND c.created_at + INTERVAL '10 minutes' + ORDER BY created_at ASC + LIMIT 1 + ) j ON TRUE + $where + ORDER BY c.created_at DESC + "; + + try { + return $this->pdo->query($sqlWithJoin)->fetchAll(PDO::FETCH_ASSOC); + } catch (\Throwable) { + // journal_smtp absent ou jointure échouée : requête de secours sans jointure + $sqlFallback = " + SELECT c.id, c.article_uuid, c.author_name, c.author_email, c.content, + c.verification_code, c.verified, c.published, c.created_at, c.ip_address, + NULL AS mail_status, NULL AS mail_error, NULL AS mail_sent_at + FROM comments c + $where + ORDER BY c.created_at DESC + "; + return $this->pdo->query($sqlFallback)->fetchAll(PDO::FETCH_ASSOC); + } + } +} diff --git a/src/ConfigRepo.php b/src/ConfigRepo.php new file mode 100644 index 0000000..bace310 --- /dev/null +++ b/src/ConfigRepo.php @@ -0,0 +1,114 @@ +query('SELECT * FROM app_config WHERE id=1')->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return [ + 'allow_password' => true,'allow_oidc' => false,'registrations_open' => true, + 'oidc_issuer' => null,'oidc_name' => null,'oidc_client_id' => null,'oidc_client_secret' => null,'oidc_redirect_uri' => null + ]; + } + return $row; +} + +function config_repo_save(array $in): void +{ + $pdo = db(); + $sql = 'INSERT INTO app_config + (id, allow_password, allow_oidc, registrations_open, oidc_issuer, oidc_name, oidc_client_id, oidc_client_secret, oidc_redirect_uri, updated_at) + VALUES (1,:pw,:oidc,:open,:iss,:name,:cid,:sec,:redir, now()) + ON CONFLICT (id) DO UPDATE SET + allow_password=:pw, allow_oidc=:oidc, registrations_open=:open, + oidc_issuer=:iss, oidc_name=:name, oidc_client_id=:cid, oidc_client_secret=:sec, oidc_redirect_uri=:redir, + updated_at=now()'; + $stmt = $pdo->prepare($sql); + $stmt->execute([ + ':pw' => (bool)$in['allow_password'], + ':oidc' => (bool)$in['allow_oidc'], + ':open' => (bool)$in['registrations_open'], + ':iss' => trim((string)($in['oidc_issuer'] ?? '')) ?: null, + ':name' => trim((string)($in['oidc_name'] ?? '')) ?: null, + ':cid' => trim((string)($in['oidc_client_id'] ?? '')) ?: null, + ':sec' => trim((string)($in['oidc_client_secret'] ?? '')) ?: null, + ':redir' => trim((string)($in['oidc_redirect_uri'] ?? '')) ?: null, + ]); +} + +/** + * Met à jour le fichier .env en conservant les autres lignes. + * $pairs = ['KEY'=>'value', ...] ; value null => supprime la clé. + */ +function env_set_pairs(string $envPath, array $pairs): void +{ + if (!is_file($envPath)) { + file_put_contents($envPath, ''); + } + $lines = file($envPath, FILE_IGNORE_NEW_LINES); + $map = []; + foreach ($lines as $i => $line) { + if (preg_match('/^\s*#/', $line) || trim($line) === '') { + $map[$i] = $line; + continue; + } + if (!str_contains($line, '=')) { + $map[$i] = $line; + continue; + } + [$k,$v] = explode('=', $line, 2); + $k = trim($k); + if ($k === '') { + $map[$i] = $line; + continue; + } + if (array_key_exists($k, $pairs)) { + if ($pairs[$k] === null) { + $map[$i] = null; + } // supprimé + else { + $map[$i] = $k.'='.env_quote((string)$pairs[$k]); + } + unset($pairs[$k]); + } else { + $map[$i] = $line; + } + } + // append keys restantes + foreach ($pairs as $k => $v) { + if ($v === null) { + continue; + } + $map[] = $k.'='.env_quote((string)$v); + } + // re-écriture + $out = []; + foreach ($map as $line) { + if ($line === null) { + continue; + } $out[] = $line; + } + file_put_contents($envPath, implode(PHP_EOL, $out).PHP_EOL); +} + +function env_quote(string $v): string +{ + if ($v === '' || preg_match('/\s|[#"\'=]/', $v)) { + // met entre guillemets et échappe + $v = str_replace(['\\','"'], ['\\\\','\\"'], $v); + return "\"$v\""; + } + return $v; +} + +function ensure_admin(): void +{ + // adapte à ton système + if (empty($_SESSION['user']['is_admin'])) { + http_response_code(403); + exit('Forbidden'); + } +} diff --git a/src/Domain/User.php b/src/Domain/User.php new file mode 100644 index 0000000..da5e745 --- /dev/null +++ b/src/Domain/User.php @@ -0,0 +1,16 @@ +cacheRead($url); + if ($cached !== null && time() < (int)$cached['fetched_at'] + (int)$cached['ttl']) { + return $cached; + } + return $this->fetch($url); + } + + /** Force le refetch et met le cache à jour. */ + public function fetch(string $url): ?array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_USERAGENT => 'varlog/1.0 FeedFetcher (+' . (defined('APP_URL') ? APP_URL : '') . ')', + CURLOPT_HEADER => true, + ]); + $raw = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $hSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + if ($raw === false || !is_int($httpCode) || $httpCode < 200 || $httpCode >= 400) { + return null; + } + + $headers = substr((string)$raw, 0, $hSize); + $body = substr((string)$raw, $hSize); + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($body); + libxml_clear_errors(); + if ($xml === false) { + return null; + } + + $isAtom = ($xml->getName() === 'feed'); + $items = $isAtom ? $this->parseAtom($xml) : $this->parseRss($xml); + $feedTitle = $isAtom + ? (string)($xml->title ?? '') + : (string)($xml->channel->title ?? ''); + + $ttl = $this->resolveTtl($xml, $isAtom, $headers); + + $data = [ + 'feed_title' => $feedTitle, + 'fetched_at' => time(), + 'ttl' => $ttl, + 'items' => $items, + ]; + $this->cacheWrite($url, $data); + return $data; + } + + // ------------------------------------------------------------------ // + + private function parseRss(\SimpleXMLElement $xml): array + { + $items = []; + foreach ($xml->channel->item ?? [] as $item) { + $date = (string)($item->pubDate ?? ''); + $items[] = [ + 'title' => trim((string)($item->title ?? '')), + 'url' => trim((string)($item->link ?? '')), + 'summary' => $this->cleanSummary((string)($item->description ?? '')), + 'date' => $date !== '' ? (int)strtotime($date) : 0, + 'author' => trim((string)($item->author ?? '')), + ]; + } + return $this->sortItems($items); + } + + private function parseAtom(\SimpleXMLElement $xml): array + { + $ns = $xml->getNamespaces(true); + $items = []; + foreach ($xml->entry ?? [] as $entry) { + $url = ''; + foreach ($entry->link ?? [] as $link) { + $rel = (string)($link['rel'] ?? 'alternate'); + if ($rel === 'alternate' || $rel === '') { + $url = (string)($link['href'] ?? ''); + break; + } + } + $date = (string)($entry->published ?? $entry->updated ?? ''); + $author = (string)($entry->author->name ?? ''); + $summary = (string)($entry->summary ?? $entry->content ?? ''); + $items[] = [ + 'title' => trim((string)($entry->title ?? '')), + 'url' => trim($url), + 'summary' => $this->cleanSummary($summary), + 'date' => $date !== '' ? (int)strtotime($date) : 0, + 'author' => trim($author), + ]; + } + return $this->sortItems($items); + } + + private function cleanSummary(string $html): string + { + $text = strip_tags($html); + $text = preg_replace('/\s+/', ' ', $text) ?? $text; + return mb_strimwidth(trim($text), 0, 200, '…'); + } + + private function sortItems(array $items): array + { + usort($items, static fn ($a, $b) => $b['date'] <=> $a['date']); + return $items; + } + + private function resolveTtl(\SimpleXMLElement $xml, bool $isAtom, string $headers): int + { + // 1. TTL déclaré dans le flux RSS ( en minutes) + if (!$isAtom) { + $rssttl = (int)($xml->channel->ttl ?? 0); + if ($rssttl > 0) { + return $this->clampTtl($rssttl * 60); + } + } + + // 2. Cache-Control: max-age depuis les headers HTTP + if (preg_match('/max-age=(\d+)/i', $headers, $m)) { + return $this->clampTtl((int)$m[1]); + } + + // 3. Valeur par défaut : 1 heure + return 3600; + } + + private function clampTtl(int $seconds): int + { + return max(self::MIN_TTL, min(self::MAX_TTL, $seconds)); + } + + // ------------------------------------------------------------------ // + + private function cachePath(string $url): string + { + return $this->cacheDir . '/' . md5($url) . '.json'; + } + + private function cacheRead(string $url): ?array + { + $path = $this->cachePath($url); + if (!file_exists($path)) { + return null; + } + $data = json_decode((string)file_get_contents($path), true); + return is_array($data) ? $data : null; + } + + private function cacheWrite(string $url, array $data): void + { + if (!is_dir($this->cacheDir)) { + mkdir($this->cacheDir, 0755, true); + } + file_put_contents( + $this->cachePath($url), + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } +} diff --git a/src/FileManager.php b/src/FileManager.php new file mode 100644 index 0000000..990cc8c --- /dev/null +++ b/src/FileManager.php @@ -0,0 +1,85 @@ +db = $db; + $this->uploadDir = rtrim($uploadDir, '/'); + } + + public function upload(int $postId, array $file): ?int + { + if ($file['error'] !== UPLOAD_ERR_OK) { + return null; + } + + $type = $this->guessType($file['type']); + $originalName = basename($file['name']); + $ext = pathinfo($originalName, PATHINFO_EXTENSION); + $filename = uniqid('file_') . '.' . $ext; + $destination = $this->uploadDir . '/' . $filename; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + return null; + } + + $stmt = $this->db->prepare(' + INSERT INTO post_files (post_id, file_type, file_path, original_name) + VALUES (:post_id, :file_type, :file_path, :original_name) + '); + $stmt->execute([ + 'post_id' => $postId, + 'file_type' => $type, + 'file_path' => $filename, + 'original_name' => $originalName + ]); + + return (int)$this->db->lastInsertId(); + } + + public function getFilesForPost(int $postId): array + { + $stmt = $this->db->prepare('SELECT * FROM post_files WHERE post_id = :post_id ORDER BY uploaded_at'); + $stmt->execute(['post_id' => $postId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function delete(int $fileId): bool + { + $stmt = $this->db->prepare('SELECT file_path FROM post_files WHERE id = :id'); + $stmt->execute(['id' => $fileId]); + $file = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$file) { + return false; + } + + $fullPath = $this->uploadDir . '/' . $file['file_path']; + if (file_exists($fullPath)) { + unlink($fullPath); + } + + $stmt = $this->db->prepare('DELETE FROM post_files WHERE id = :id'); + return $stmt->execute(['id' => $fileId]); + } + + private function guessType(string $mime): string + { + if (str_starts_with($mime, 'image/')) { + return 'image'; + } + if (str_starts_with($mime, 'video/')) { + return 'video'; + } + if (str_starts_with($mime, 'audio/')) { + return 'audio'; + } + return 'file'; + } +} diff --git a/src/Http/Csrf.php b/src/Http/Csrf.php new file mode 100644 index 0000000..ecf3fbf --- /dev/null +++ b/src/Http/Csrf.php @@ -0,0 +1,24 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return self::$pdo = $pdo; + } catch (PDOException $e) { + throw new RuntimeException('Connexion BDD échouée.', previous: $e); + } + } + + /** @deprecated Utiliser Database::get() */ + public static function pdo(): PDO + { + @trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED); + return self::get(); + } + + /** @deprecated Utiliser Database::get() */ + public static function getPdo(): PDO + { + @trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED); + return self::get(); + } + + /** @deprecated Utiliser Database::get() */ + public static function getInstance(): PDO + { + @trigger_error(__METHOD__.' est déprécié. Utiliser Database::get()', E_USER_DEPRECATED); + return self::get(); + } + + public static function transactional(callable $fn) + { + $pdo = self::get(); + try { + $pdo->beginTransaction(); + $ret = $fn($pdo); + $pdo->commit(); + return $ret; + } catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + } +} diff --git a/src/Infrastructure/DbAdapter.php b/src/Infrastructure/DbAdapter.php new file mode 100644 index 0000000..4230c86 --- /dev/null +++ b/src/Infrastructure/DbAdapter.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } +} diff --git a/src/Infrastructure/Session.php b/src/Infrastructure/Session.php new file mode 100644 index 0000000..86a6443 --- /dev/null +++ b/src/Infrastructure/Session.php @@ -0,0 +1,42 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_start(); + + // Verrouillage basique contre session hijacking + $key = '_sess_fingerprint'; + $fp = hash('xxh3', ($_SERVER['REMOTE_ADDR'] ?? '') . '|' . ($_SERVER['HTTP_USER_AGENT'] ?? '')); + if (!isset($_SESSION[$key])) { + $_SESSION[$key] = $fp; + } elseif ($_SESSION[$key] !== $fp) { + session_regenerate_id(true); + $_SESSION = []; + $_SESSION[$key] = $fp; + } + } + + public static function regenerate(): void + { + session_regenerate_id(true); + } +} diff --git a/src/Parsedown.php b/src/Parsedown.php new file mode 100644 index 0000000..b7ca513 --- /dev/null +++ b/src/Parsedown.php @@ -0,0 +1,1828 @@ +textElements($text); + + # convert to markup + $markup = $this->elements($Elements); + + # trim line breaks + $markup = trim($markup, "\n"); + + return $markup; + } + + protected function textElements($text) + { + # make sure no definitions are set + $this->DefinitionData = array(); + + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + # remove surrounding line breaks + $text = trim($text, "\n"); + + # split text into lines + $lines = explode("\n", $text); + + # iterate through lines to identify blocks + return $this->linesElements($lines); + } + + # + # Setters + # + + public function setBreaksEnabled($breaksEnabled) + { + $this->breaksEnabled = $breaksEnabled; + + return $this; + } + + protected $breaksEnabled; + + public function setMarkupEscaped($markupEscaped) + { + $this->markupEscaped = $markupEscaped; + + return $this; + } + + protected $markupEscaped; + + public function setUrlsLinked($urlsLinked) + { + $this->urlsLinked = $urlsLinked; + + return $this; + } + + protected $urlsLinked = true; + + public function setSafeMode($safeMode) + { + $this->safeMode = (bool) $safeMode; + + return $this; + } + + protected $safeMode; + + public function setStrictMode($strictMode) + { + $this->strictMode = (bool) $strictMode; + + return $this; + } + + protected $strictMode; + + protected $safeLinksWhitelist = array( + 'http://', + 'https://', + 'ftp://', + 'ftps://', + 'mailto:', + 'tel:', + 'data:image/png;base64,', + 'data:image/gif;base64,', + 'data:image/jpeg;base64,', + 'irc:', + 'ircs:', + 'git:', + 'ssh:', + 'news:', + 'steam:', + ); + + # + # Lines + # + + protected $BlockTypes = array( + '#' => array('Header'), + '*' => array('Rule', 'List'), + '+' => array('List'), + '-' => array('SetextHeader', 'Table', 'Rule', 'List'), + '0' => array('List'), + '1' => array('List'), + '2' => array('List'), + '3' => array('List'), + '4' => array('List'), + '5' => array('List'), + '6' => array('List'), + '7' => array('List'), + '8' => array('List'), + '9' => array('List'), + ':' => array('Table'), + '<' => array('Comment', 'Markup'), + '=' => array('SetextHeader'), + '>' => array('Quote'), + '[' => array('Reference'), + '_' => array('Rule'), + '`' => array('FencedCode'), + '|' => array('Table'), + '~' => array('FencedCode'), + ); + + # ~ + + protected $unmarkedBlockTypes = array( + 'Code', + ); + + # + # Blocks + # + + protected function lines(array $lines) + { + return $this->elements($this->linesElements($lines)); + } + + protected function linesElements(array $lines) + { + $Elements = array(); + $CurrentBlock = null; + + foreach ($lines as $line) { + if (chop($line) === '') { + if (isset($CurrentBlock)) { + $CurrentBlock['interrupted'] = ( + isset($CurrentBlock['interrupted']) + ? $CurrentBlock['interrupted'] + 1 : 1 + ); + } + + continue; + } + + while (($beforeTab = strstr($line, "\t", true)) !== false) { + $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; + + $line = $beforeTab + . str_repeat(' ', $shortage) + . substr($line, strlen($beforeTab) + 1) + ; + } + + $indent = strspn($line, ' '); + + $text = $indent > 0 ? substr($line, $indent) : $line; + + # ~ + + $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); + + # ~ + + if (isset($CurrentBlock['continuable'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; + $Block = $this->$methodName($Line, $CurrentBlock); + + if (isset($Block)) { + $CurrentBlock = $Block; + + continue; + } else { + if ($this->isBlockCompletable($CurrentBlock['type'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + } + } + + # ~ + + $marker = $text[0]; + + # ~ + + $blockTypes = $this->unmarkedBlockTypes; + + if (isset($this->BlockTypes[$marker])) { + foreach ($this->BlockTypes[$marker] as $blockType) { + $blockTypes [] = $blockType; + } + } + + # + # ~ + + foreach ($blockTypes as $blockType) { + $Block = $this->{"block$blockType"}($Line, $CurrentBlock); + + if (isset($Block)) { + $Block['type'] = $blockType; + + if (! isset($Block['identified'])) { + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $Block['identified'] = true; + } + + if ($this->isBlockContinuable($blockType)) { + $Block['continuable'] = true; + } + + $CurrentBlock = $Block; + + continue 2; + } + } + + # ~ + + if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { + $Block = $this->paragraphContinue($Line, $CurrentBlock); + } + + if (isset($Block)) { + $CurrentBlock = $Block; + } else { + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + $CurrentBlock = $this->paragraph($Line); + + $CurrentBlock['identified'] = true; + } + } + + # ~ + + if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { + $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; + $CurrentBlock = $this->$methodName($CurrentBlock); + } + + # ~ + + if (isset($CurrentBlock)) { + $Elements[] = $this->extractElement($CurrentBlock); + } + + # ~ + + return $Elements; + } + + protected function extractElement(array $Component) + { + if (! isset($Component['element'])) { + if (isset($Component['markup'])) { + $Component['element'] = array('rawHtml' => $Component['markup']); + } elseif (isset($Component['hidden'])) { + $Component['element'] = array(); + } + } + + return $Component['element']; + } + + protected function isBlockContinuable($Type) + { + return method_exists($this, 'block' . $Type . 'Continue'); + } + + protected function isBlockCompletable($Type) + { + return method_exists($this, 'block' . $Type . 'Complete'); + } + + # + # Code + + protected function blockCode($Line, $Block = null) + { + if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] >= 4) { + $text = substr($Line['body'], 4); + + $Block = array( + 'element' => array( + 'name' => 'pre', + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ), + ); + + return $Block; + } + } + + protected function blockCodeContinue($Line, $Block) + { + if ($Line['indent'] >= 4) { + if (isset($Block['interrupted'])) { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + $Block['element']['element']['text'] .= "\n"; + + $text = substr($Line['body'], 4); + + $Block['element']['element']['text'] .= $text; + + return $Block; + } + } + + protected function blockCodeComplete($Block) + { + return $Block; + } + + # + # Comment + + protected function blockComment($Line) + { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (strpos($Line['text'], '') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + } + + protected function blockCommentContinue($Line, array $Block) + { + if (isset($Block['closed'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + if (strpos($Line['text'], '-->') !== false) { + $Block['closed'] = true; + } + + return $Block; + } + + # + # Fenced Code + + protected function blockFencedCode($Line) + { + $marker = $Line['text'][0]; + + $openerLength = strspn($Line['text'], $marker); + + if ($openerLength < 3) { + return; + } + + $infostring = trim(substr($Line['text'], $openerLength), "\t "); + + if (strpos($infostring, '`') !== false) { + return; + } + + $Element = array( + 'name' => 'code', + 'text' => '', + ); + + if ($infostring !== '') { + /** + * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes + * Every HTML element may have a class attribute specified. + * The attribute, if specified, must have a value that is a set + * of space-separated tokens representing the various classes + * that the element belongs to. + * [...] + * The space characters, for the purposes of this specification, + * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), + * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and + * U+000D CARRIAGE RETURN (CR). + */ + $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); + + $Element['attributes'] = array('class' => "language-$language"); + } + + $Block = array( + 'char' => $marker, + 'openerLength' => $openerLength, + 'element' => array( + 'name' => 'pre', + 'element' => $Element, + ), + ); + + return $Block; + } + + protected function blockFencedCodeContinue($Line, $Block) + { + if (isset($Block['complete'])) { + return; + } + + if (isset($Block['interrupted'])) { + $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); + + unset($Block['interrupted']); + } + + if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] + and chop(substr($Line['text'], $len), ' ') === '' + ) { + $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); + + $Block['complete'] = true; + + return $Block; + } + + $Block['element']['element']['text'] .= "\n" . $Line['body']; + + return $Block; + } + + protected function blockFencedCodeComplete($Block) + { + return $Block; + } + + # + # Header + + protected function blockHeader($Line) + { + $level = strspn($Line['text'], '#'); + + if ($level > 6) { + return; + } + + $text = trim($Line['text'], '#'); + + if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') { + return; + } + + $text = trim($text, ' '); + + $Block = array( + 'element' => array( + 'name' => 'h' . $level, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $text, + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + + # + # List + + protected function blockList($Line, ?array $CurrentBlock = null) + { + list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); + + if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) { + $contentIndent = strlen($matches[2]); + + if ($contentIndent >= 5) { + $contentIndent -= 1; + $matches[1] = substr($matches[1], 0, -$contentIndent); + $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; + } elseif ($contentIndent === 0) { + $matches[1] .= ' '; + } + + $markerWithoutWhitespace = strstr($matches[1], ' ', true); + + $Block = array( + 'indent' => $Line['indent'], + 'pattern' => $pattern, + 'data' => array( + 'type' => $name, + 'marker' => $matches[1], + 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), + ), + 'element' => array( + 'name' => $name, + 'elements' => array(), + ), + ); + $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); + + if ($name === 'ol') { + $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; + + if ($listStart !== '1') { + if ( + isset($CurrentBlock) + and $CurrentBlock['type'] === 'Paragraph' + and ! isset($CurrentBlock['interrupted']) + ) { + return; + } + + $Block['element']['attributes'] = array('start' => $listStart); + } + } + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] [] = & $Block['li']; + + return $Block; + } + } + + protected function blockListContinue($Line, array $Block) + { + if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) { + return null; + } + + $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); + + if ($Line['indent'] < $requiredIndent + and ( + ( + $Block['data']['type'] === 'ol' + and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) or ( + $Block['data']['type'] === 'ul' + and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) + ) + ) + ) { + if (isset($Block['interrupted'])) { + $Block['li']['handler']['argument'] [] = ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + unset($Block['li']); + + $text = isset($matches[1]) ? $matches[1] : ''; + + $Block['indent'] = $Line['indent']; + + $Block['li'] = array( + 'name' => 'li', + 'handler' => array( + 'function' => 'li', + 'argument' => array($text), + 'destination' => 'elements' + ) + ); + + $Block['element']['elements'] [] = & $Block['li']; + + return $Block; + } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) { + return null; + } + + if ($Line['text'][0] === '[' and $this->blockReference($Line)) { + return $Block; + } + + if ($Line['indent'] >= $requiredIndent) { + if (isset($Block['interrupted'])) { + $Block['li']['handler']['argument'] [] = ''; + + $Block['loose'] = true; + + unset($Block['interrupted']); + } + + $text = substr($Line['body'], $requiredIndent); + + $Block['li']['handler']['argument'] [] = $text; + + return $Block; + } + + if (! isset($Block['interrupted'])) { + $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']); + + $Block['li']['handler']['argument'] [] = $text; + + return $Block; + } + } + + protected function blockListComplete(array $Block) + { + if (isset($Block['loose'])) { + foreach ($Block['element']['elements'] as &$li) { + if (end($li['handler']['argument']) !== '') { + $li['handler']['argument'] [] = ''; + } + } + } + + return $Block; + } + + # + # Quote + + protected function blockQuote($Line) + { + if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { + $Block = array( + 'element' => array( + 'name' => 'blockquote', + 'handler' => array( + 'function' => 'linesElements', + 'argument' => (array) $matches[1], + 'destination' => 'elements', + ) + ), + ); + + return $Block; + } + } + + protected function blockQuoteContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { + $Block['element']['handler']['argument'] [] = $matches[1]; + + return $Block; + } + + if (! isset($Block['interrupted'])) { + $Block['element']['handler']['argument'] [] = $Line['text']; + + return $Block; + } + } + + # + # Rule + + protected function blockRule($Line) + { + $marker = $Line['text'][0]; + + if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') { + $Block = array( + 'element' => array( + 'name' => 'hr', + ), + ); + + return $Block; + } + } + + # + # Setext + + protected function blockSetextHeader($Line, ?array $Block = null) + { + if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') { + $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; + + return $Block; + } + } + + # + # Markup + + protected function blockMarkup($Line) + { + if ($this->markupEscaped or $this->safeMode) { + return; + } + + if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) { + $element = strtolower($matches[1]); + + if (in_array($element, $this->textLevelElements, true)) { + return; + } + + $Block = array( + 'name' => $matches[1], + 'element' => array( + 'rawHtml' => $Line['text'], + 'autobreak' => true, + ), + ); + + return $Block; + } + } + + protected function blockMarkupContinue($Line, array $Block) + { + if (isset($Block['closed']) or isset($Block['interrupted'])) { + return; + } + + $Block['element']['rawHtml'] .= "\n" . $Line['body']; + + return $Block; + } + + # + # Reference + + protected function blockReference($Line) + { + if (strpos($Line['text'], ']') !== false + and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) + ) { + $id = strtolower($matches[1]); + + $Data = array( + 'url' => $matches[2], + 'title' => isset($matches[3]) ? $matches[3] : null, + ); + + $this->DefinitionData['Reference'][$id] = $Data; + + $Block = array( + 'element' => array(), + ); + + return $Block; + } + } + + # + # Table + + protected function blockTable($Line, ?array $Block = null) + { + if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { + return; + } + + if ( + strpos($Block['element']['handler']['argument'], '|') === false + and strpos($Line['text'], '|') === false + and strpos($Line['text'], ':') === false + or strpos($Block['element']['handler']['argument'], "\n") !== false + ) { + return; + } + + if (chop($Line['text'], ' -:|') !== '') { + return; + } + + $alignments = array(); + + $divider = $Line['text']; + + $divider = trim($divider); + $divider = trim($divider, '|'); + + $dividerCells = explode('|', $divider); + + foreach ($dividerCells as $dividerCell) { + $dividerCell = trim($dividerCell); + + if ($dividerCell === '') { + return; + } + + $alignment = null; + + if ($dividerCell[0] === ':') { + $alignment = 'left'; + } + + if (substr($dividerCell, - 1) === ':') { + $alignment = $alignment === 'left' ? 'center' : 'right'; + } + + $alignments [] = $alignment; + } + + # ~ + + $HeaderElements = array(); + + $header = $Block['element']['handler']['argument']; + + $header = trim($header); + $header = trim($header, '|'); + + $headerCells = explode('|', $header); + + if (count($headerCells) !== count($alignments)) { + return; + } + + foreach ($headerCells as $index => $headerCell) { + $headerCell = trim($headerCell); + + $HeaderElement = array( + 'name' => 'th', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $headerCell, + 'destination' => 'elements', + ) + ); + + if (isset($alignments[$index])) { + $alignment = $alignments[$index]; + + $HeaderElement['attributes'] = array( + 'style' => "text-align: $alignment;", + ); + } + + $HeaderElements [] = $HeaderElement; + } + + # ~ + + $Block = array( + 'alignments' => $alignments, + 'identified' => true, + 'element' => array( + 'name' => 'table', + 'elements' => array(), + ), + ); + + $Block['element']['elements'] [] = array( + 'name' => 'thead', + ); + + $Block['element']['elements'] [] = array( + 'name' => 'tbody', + 'elements' => array(), + ); + + $Block['element']['elements'][0]['elements'] [] = array( + 'name' => 'tr', + 'elements' => $HeaderElements, + ); + + return $Block; + } + + protected function blockTableContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) { + $Elements = array(); + + $row = $Line['text']; + + $row = trim($row); + $row = trim($row, '|'); + + preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); + + $cells = array_slice($matches[0], 0, count($Block['alignments'])); + + foreach ($cells as $index => $cell) { + $cell = trim($cell); + + $Element = array( + 'name' => 'td', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $cell, + 'destination' => 'elements', + ) + ); + + if (isset($Block['alignments'][$index])) { + $Element['attributes'] = array( + 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', + ); + } + + $Elements [] = $Element; + } + + $Element = array( + 'name' => 'tr', + 'elements' => $Elements, + ); + + $Block['element']['elements'][1]['elements'] [] = $Element; + + return $Block; + } + } + + # + # ~ + # + + protected function paragraph($Line) + { + return array( + 'type' => 'Paragraph', + 'element' => array( + 'name' => 'p', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $Line['text'], + 'destination' => 'elements', + ), + ), + ); + } + + protected function paragraphContinue($Line, array $Block) + { + if (isset($Block['interrupted'])) { + return; + } + + $Block['element']['handler']['argument'] .= "\n".$Line['text']; + + return $Block; + } + + # + # Inline Elements + # + + protected $InlineTypes = array( + '!' => array('Image'), + '&' => array('SpecialCharacter'), + '*' => array('Emphasis'), + ':' => array('Url'), + '<' => array('UrlTag', 'EmailTag', 'Markup'), + '[' => array('Link'), + '_' => array('Emphasis'), + '`' => array('Code'), + '~' => array('Strikethrough'), + '\\' => array('EscapeSequence'), + ); + + # ~ + + protected $inlineMarkerList = '!*_&[:<`~\\'; + + # + # ~ + # + + public function line($text, $nonNestables = array()) + { + return $this->elements($this->lineElements($text, $nonNestables)); + } + + protected function lineElements($text, $nonNestables = array()) + { + # standardize line breaks + $text = str_replace(array("\r\n", "\r"), "\n", $text); + + $Elements = array(); + + $nonNestables = ( + empty($nonNestables) + ? array() + : array_combine($nonNestables, $nonNestables) + ); + + # $excerpt is based on the first occurrence of a marker + + while ($excerpt = strpbrk($text, $this->inlineMarkerList)) { + $marker = $excerpt[0]; + + $markerPosition = strlen($text) - strlen($excerpt); + + $Excerpt = array('text' => $excerpt, 'context' => $text); + + foreach ($this->InlineTypes[$marker] as $inlineType) { + # check to see if the current inline type is nestable in the current context + + if (isset($nonNestables[$inlineType])) { + continue; + } + + $Inline = $this->{"inline$inlineType"}($Excerpt); + + if (! isset($Inline)) { + continue; + } + + # makes sure that the inline belongs to "our" marker + + if (isset($Inline['position']) and $Inline['position'] > $markerPosition) { + continue; + } + + # sets a default inline position + + if (! isset($Inline['position'])) { + $Inline['position'] = $markerPosition; + } + + # cause the new element to 'inherit' our non nestables + + + $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) + ? array_merge($Inline['element']['nonNestables'], $nonNestables) + : $nonNestables + ; + + # the text that comes before the inline + $unmarkedText = substr($text, 0, $Inline['position']); + + # compile the unmarked text + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + # compile the inline + $Elements[] = $this->extractElement($Inline); + + # remove the examined text + $text = substr($text, $Inline['position'] + $Inline['extent']); + + continue 2; + } + + # the marker does not belong to an inline + + $unmarkedText = substr($text, 0, $markerPosition + 1); + + $InlineText = $this->inlineText($unmarkedText); + $Elements[] = $InlineText['element']; + + $text = substr($text, $markerPosition + 1); + } + + $InlineText = $this->inlineText($text); + $Elements[] = $InlineText['element']; + + foreach ($Elements as &$Element) { + if (! isset($Element['autobreak'])) { + $Element['autobreak'] = false; + } + } + + return $Elements; + } + + # + # ~ + # + + protected function inlineText($text) + { + $Inline = array( + 'extent' => strlen($text), + 'element' => array(), + ); + + $Inline['element']['elements'] = self::pregReplaceElements( + $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', + array( + array('name' => 'br'), + array('text' => "\n"), + ), + $text + ); + + return $Inline; + } + + protected function inlineCode($Excerpt) + { + $marker = $Excerpt['text'][0]; + + if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), + 'element' => array( + 'name' => 'code', + 'text' => $text, + ), + ); + } + } + + protected function inlineEmailTag($Excerpt) + { + $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; + + $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' + . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; + + if (strpos($Excerpt['text'], '>') !== false + and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) + ) { + $url = $matches[1]; + + if (! isset($matches[2])) { + $url = "mailto:$url"; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $matches[1], + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + protected function inlineEmphasis($Excerpt) + { + if (! isset($Excerpt['text'][1])) { + return; + } + + $marker = $Excerpt['text'][0]; + + if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'strong'; + } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { + $emphasis = 'em'; + } else { + return; + } + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => $emphasis, + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + + protected function inlineEscapeSequence($Excerpt) + { + if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters, true)) { + return array( + 'element' => array('rawHtml' => $Excerpt['text'][1]), + 'extent' => 2, + ); + } + } + + protected function inlineImage($Excerpt) + { + if (! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') { + return; + } + + $Excerpt['text'] = substr($Excerpt['text'], 1); + + $Link = $this->inlineLink($Excerpt); + + if ($Link === null) { + return; + } + + $Inline = array( + 'extent' => $Link['extent'] + 1, + 'element' => array( + 'name' => 'img', + 'attributes' => array( + 'src' => $Link['element']['attributes']['href'], + 'alt' => $Link['element']['handler']['argument'], + ), + 'autobreak' => true, + ), + ); + + $Inline['element']['attributes'] += $Link['element']['attributes']; + + unset($Inline['element']['attributes']['href']); + + return $Inline; + } + + protected function inlineLink($Excerpt) + { + $Element = array( + 'name' => 'a', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => null, + 'destination' => 'elements', + ), + 'nonNestables' => array('Url', 'Link'), + 'attributes' => array( + 'href' => null, + 'title' => null, + ), + ); + + $extent = 0; + + $remainder = $Excerpt['text']; + + if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) { + $Element['handler']['argument'] = $matches[1]; + + $extent += strlen($matches[0]); + + $remainder = substr($remainder, $extent); + } else { + return; + } + + if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) { + $Element['attributes']['href'] = $matches[1]; + + if (isset($matches[2])) { + $Element['attributes']['title'] = substr($matches[2], 1, - 1); + } + + $extent += strlen($matches[0]); + } else { + if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) { + $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; + $definition = strtolower($definition); + + $extent += strlen($matches[0]); + } else { + $definition = strtolower($Element['handler']['argument']); + } + + if (! isset($this->DefinitionData['Reference'][$definition])) { + return; + } + + $Definition = $this->DefinitionData['Reference'][$definition]; + + $Element['attributes']['href'] = $Definition['url']; + $Element['attributes']['title'] = $Definition['title']; + } + + return array( + 'extent' => $extent, + 'element' => $Element, + ); + } + + protected function inlineMarkup($Excerpt) + { + if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { + return; + } + + if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + + if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) { + return array( + 'element' => array('rawHtml' => $matches[0]), + 'extent' => strlen($matches[0]), + ); + } + } + + protected function inlineSpecialCharacter($Excerpt) + { + if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false + and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) + ) { + return array( + 'element' => array('rawHtml' => '&' . $matches[1] . ';'), + 'extent' => strlen($matches[0]), + ); + } + + return; + } + + protected function inlineStrikethrough($Excerpt) + { + if (! isset($Excerpt['text'][1])) { + return; + } + + if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) { + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'del', + 'handler' => array( + 'function' => 'lineElements', + 'argument' => $matches[1], + 'destination' => 'elements', + ) + ), + ); + } + } + + protected function inlineUrl($Excerpt) + { + if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') { + return; + } + + if (strpos($Excerpt['context'], 'http') !== false + and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) + ) { + $url = $matches[0][0]; + + $Inline = array( + 'extent' => strlen($matches[0][0]), + 'position' => $matches[0][1], + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + + return $Inline; + } + } + + protected function inlineUrlTag($Excerpt) + { + if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) { + $url = $matches[1]; + + return array( + 'extent' => strlen($matches[0]), + 'element' => array( + 'name' => 'a', + 'text' => $url, + 'attributes' => array( + 'href' => $url, + ), + ), + ); + } + } + + # ~ + + protected function unmarkedText($text) + { + $Inline = $this->inlineText($text); + return $this->element($Inline['element']); + } + + # + # Handlers + # + + protected function handle(array $Element) + { + if (isset($Element['handler'])) { + if (!isset($Element['nonNestables'])) { + $Element['nonNestables'] = array(); + } + + if (is_string($Element['handler'])) { + $function = $Element['handler']; + $argument = $Element['text']; + unset($Element['text']); + $destination = 'rawHtml'; + } else { + $function = $Element['handler']['function']; + $argument = $Element['handler']['argument']; + $destination = $Element['handler']['destination']; + } + + $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); + + if ($destination === 'handler') { + $Element = $this->handle($Element); + } + + unset($Element['handler']); + } + + return $Element; + } + + protected function handleElementRecursive(array $Element) + { + return $this->elementApplyRecursive(array($this, 'handle'), $Element); + } + + protected function handleElementsRecursive(array $Elements) + { + return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); + } + + protected function elementApplyRecursive($closure, array $Element) + { + $Element = call_user_func($closure, $Element); + + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); + } + + return $Element; + } + + protected function elementApplyRecursiveDepthFirst($closure, array $Element) + { + if (isset($Element['elements'])) { + $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); + } elseif (isset($Element['element'])) { + $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); + } + + $Element = call_user_func($closure, $Element); + + return $Element; + } + + protected function elementsApplyRecursive($closure, array $Elements) + { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursive($closure, $Element); + } + + return $Elements; + } + + protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) + { + foreach ($Elements as &$Element) { + $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); + } + + return $Elements; + } + + protected function element(array $Element) + { + if ($this->safeMode) { + $Element = $this->sanitiseElement($Element); + } + + # identity map if element has no handler + $Element = $this->handle($Element); + + $hasName = isset($Element['name']); + + $markup = ''; + + if ($hasName) { + $markup .= '<' . $Element['name']; + + if (isset($Element['attributes'])) { + foreach ($Element['attributes'] as $name => $value) { + if ($value === null) { + continue; + } + + $markup .= " $name=\"".self::escape($value).'"'; + } + } + } + + $permitRawHtml = false; + + if (isset($Element['text'])) { + $text = $Element['text']; + } + // very strongly consider an alternative if you're writing an + // extension + elseif (isset($Element['rawHtml'])) { + $text = $Element['rawHtml']; + + $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; + $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; + } + + $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); + + if ($hasContent) { + $markup .= $hasName ? '>' : ''; + + if (isset($Element['elements'])) { + $markup .= $this->elements($Element['elements']); + } elseif (isset($Element['element'])) { + $markup .= $this->element($Element['element']); + } else { + if (!$permitRawHtml) { + $markup .= self::escape($text, true); + } else { + $markup .= $text; + } + } + + $markup .= $hasName ? '' : ''; + } elseif ($hasName) { + $markup .= ' />'; + } + + return $markup; + } + + protected function elements(array $Elements) + { + $markup = ''; + + $autoBreak = true; + + foreach ($Elements as $Element) { + if (empty($Element)) { + continue; + } + + $autoBreakNext = ( + isset($Element['autobreak']) + ? $Element['autobreak'] : isset($Element['name']) + ); + // (autobreak === false) covers both sides of an element + $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; + + $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); + $autoBreak = $autoBreakNext; + } + + $markup .= $autoBreak ? "\n" : ''; + + return $markup; + } + + # ~ + + protected function li($lines) + { + $Elements = $this->linesElements($lines); + + if (! in_array('', $lines, true) + and isset($Elements[0]) and isset($Elements[0]['name']) + and $Elements[0]['name'] === 'p' + ) { + unset($Elements[0]['name']); + } + + return $Elements; + } + + # + # AST Convenience + # + + /** + * Replace occurrences $regexp with $Elements in $text. Return an array of + * elements representing the replacement. + */ + protected static function pregReplaceElements($regexp, $Elements, $text) + { + $newElements = array(); + + while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { + $offset = $matches[0][1]; + $before = substr($text, 0, $offset); + $after = substr($text, $offset + strlen($matches[0][0])); + + $newElements[] = array('text' => $before); + + foreach ($Elements as $Element) { + $newElements[] = $Element; + } + + $text = $after; + } + + $newElements[] = array('text' => $text); + + return $newElements; + } + + # + # Deprecated Methods + # + + public function parse($text) + { + $markup = $this->text($text); + + return $markup; + } + + protected function sanitiseElement(array $Element) + { + static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; + static $safeUrlNameToAtt = array( + 'a' => 'href', + 'img' => 'src', + ); + + if (! isset($Element['name'])) { + unset($Element['attributes']); + return $Element; + } + + if (isset($safeUrlNameToAtt[$Element['name']])) { + $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); + } + + if (! empty($Element['attributes'])) { + foreach ($Element['attributes'] as $att => $val) { + # filter out badly parsed attribute + if (! preg_match($goodAttribute, $att)) { + unset($Element['attributes'][$att]); + } + # dump onevent attribute + elseif (self::striAtStart($att, 'on')) { + unset($Element['attributes'][$att]); + } + } + } + + return $Element; + } + + protected function filterUnsafeUrlInAttribute(array $Element, $attribute) + { + foreach ($this->safeLinksWhitelist as $scheme) { + if (self::striAtStart($Element['attributes'][$attribute], $scheme)) { + return $Element; + } + } + + $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); + + return $Element; + } + + # + # Static Methods + # + + protected static function escape($text, $allowQuotes = false) + { + return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); + } + + protected static function striAtStart($string, $needle) + { + $len = strlen($needle); + + if ($len > strlen($string)) { + return false; + } else { + return strtolower(substr($string, 0, $len)) === strtolower($needle); + } + } + + public static function instance($name = 'default') + { + if (isset(self::$instances[$name])) { + return self::$instances[$name]; + } + + $instance = new static(); + + self::$instances[$name] = $instance; + + return $instance; + } + + private static $instances = array(); + + # + # Fields + # + + protected $DefinitionData; + + # + # Read-Only + + protected $specialCharacters = array( + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' + ); + + protected $StrongRegex = array( + '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', + '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', + ); + + protected $EmRegex = array( + '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', + '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', + ); + + protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; + + protected $voidElements = array( + 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + ); + + protected $textLevelElements = array( + 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', + 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', + 'i', 'rp', 'del', 'code', 'strike', 'marquee', + 'q', 'rt', 'ins', 'font', 'strong', + 's', 'tt', 'kbd', 'mark', + 'u', 'xm', 'sub', 'nobr', + 'sup', 'ruby', + 'var', 'span', + 'wbr', 'time', + ); +} diff --git a/src/PostManager.php b/src/PostManager.php new file mode 100644 index 0000000..4050dfd --- /dev/null +++ b/src/PostManager.php @@ -0,0 +1,69 @@ +db = $db; + } + + public function getAll(): array + { + $stmt = $this->db->query('SELECT * FROM posts ORDER BY created_at DESC'); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function get(int $id): ?array + { + $stmt = $this->db->prepare('SELECT * FROM posts WHERE id = :id'); + $stmt->execute(['id' => $id]); + $post = $stmt->fetch(PDO::FETCH_ASSOC); + return $post ?: null; + } + + public function create(string $title, string $content, string $published_at): int + { + $stmt = $this->db->prepare(' + INSERT INTO posts (title, content, created_at, is_published) + VALUES (:title, :content, :published_at, true) + '); + $stmt->execute([ + 'title' => $title, + 'content' => $content, + 'published_at' => $published_at, + ]); + return (int)$this->db->lastInsertId(); + } + + + public function update(int $id, string $title, string $content, string $published_at, bool $published): bool + { + $stmt = $this->db->prepare(' + UPDATE posts + SET title = :title, + content = :content, + created_at = :published_at, + is_published = :published, + updated_at = NOW() + WHERE id = :id + '); + return $stmt->execute([ + 'id' => $id, + 'title' => $title, + 'content' => $content, + 'published_at' => $published_at, + 'published' => $published, + ]); + } + + + public function delete(int $id): bool + { + $stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id'); + return $stmt->execute(['id' => $id]); + } +} diff --git a/src/RatingManager.php b/src/RatingManager.php new file mode 100644 index 0000000..b73eb99 --- /dev/null +++ b/src/RatingManager.php @@ -0,0 +1,46 @@ +pdo->prepare( + 'INSERT INTO article_ratings (article_uuid, user_email, rating) + VALUES (:uuid, :email, :r) + ON CONFLICT (article_uuid, user_email) + DO UPDATE SET rating = :r, rated_at = NOW()' + ); + $st->execute([':uuid' => $uuid, ':email' => strtolower($email), ':r' => $rating]); + } + + /** @return array{avg: float|null, count: int} */ + public function statsForArticle(string $uuid): array + { + $st = $this->pdo->prepare( + 'SELECT ROUND(AVG(rating)::numeric, 1) as avg, COUNT(*) as count + FROM article_ratings WHERE article_uuid = :uuid' + ); + $st->execute([':uuid' => $uuid]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return [ + 'avg' => $row && $row['avg'] !== null ? (float)$row['avg'] : null, + 'count' => $row ? (int)$row['count'] : 0, + ]; + } + + public function userRating(string $uuid, string $email): ?int + { + $st = $this->pdo->prepare( + 'SELECT rating FROM article_ratings WHERE article_uuid = :uuid AND user_email = :email' + ); + $st->execute([':uuid' => $uuid, ':email' => strtolower($email)]); + $v = $st->fetchColumn(); + return $v !== false ? (int)$v : null; + } +} diff --git a/src/ReactionManager.php b/src/ReactionManager.php new file mode 100644 index 0000000..842b344 --- /dev/null +++ b/src/ReactionManager.php @@ -0,0 +1,63 @@ +pdo->prepare( + 'SELECT id FROM article_reactions + WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash' + ); + $st->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]); + if ($st->fetchColumn() !== false) { + $this->pdo->prepare( + 'DELETE FROM article_reactions + WHERE article_uuid = :uuid AND reaction_type = :type AND visitor_hash = :hash' + )->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]); + return false; + } + $this->pdo->prepare( + 'INSERT INTO article_reactions (article_uuid, reaction_type, visitor_hash) + VALUES (:uuid, :type, :hash) ON CONFLICT DO NOTHING' + )->execute([':uuid' => $uuid, ':type' => $type, ':hash' => $visitorHash]); + return true; + } + + /** @return array */ + public function statsForArticle(string $uuid): array + { + $st = $this->pdo->prepare( + 'SELECT reaction_type, COUNT(*) AS cnt + FROM article_reactions WHERE article_uuid = :uuid GROUP BY reaction_type' + ); + $st->execute([':uuid' => $uuid]); + $stats = array_fill_keys(self::TYPES, 0); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { + $stats[$row['reaction_type']] = (int) $row['cnt']; + } + return $stats; + } + + /** @return string[] */ + public function visitorReactions(string $uuid, string $visitorHash): array + { + $st = $this->pdo->prepare( + 'SELECT reaction_type FROM article_reactions + WHERE article_uuid = :uuid AND visitor_hash = :hash' + ); + $st->execute([':uuid' => $uuid, ':hash' => $visitorHash]); + return array_column($st->fetchAll(PDO::FETCH_ASSOC), 'reaction_type'); + } +} diff --git a/src/Repository/DictionnaryRepository.php b/src/Repository/DictionnaryRepository.php new file mode 100644 index 0000000..262aa69 --- /dev/null +++ b/src/Repository/DictionnaryRepository.php @@ -0,0 +1,54 @@ +pdo->prepare('SELECT * FROM dd_entities WHERE code = :c AND is_active IS TRUE'); + $st->execute([':c' => $code]); + $e = $st->fetch(PDO::FETCH_ASSOC); + if (!$e) { + return null; + } + + $e['fields'] = $this->getFields((int)$e['id']); + $e['rules'] = $this->getRules((int)$e['id']); + return $e; + } + + public function getFields(int $entityId): array + { + $st = $this->pdo->prepare('SELECT * FROM dd_fields WHERE entity_id = :id ORDER BY ui_order NULLS LAST, id'); + $st->execute([':id' => $entityId]); + return $st->fetchAll(PDO::FETCH_ASSOC); + } + + public function getRules(int $entityId): array + { + $st = $this->pdo->prepare('SELECT * FROM dd_rules WHERE entity_id = :id AND active IS TRUE'); + $st->execute([':id' => $entityId]); + return $st->fetchAll(PDO::FETCH_ASSOC); + } + + public function getEnum(string $name): array + { + $st = $this->pdo->prepare(' + SELECT ev.code, ev.label + FROM dd_enums e JOIN dd_enum_values ev ON ev.enum_id = e.id + WHERE e.name = :n AND ev.active IS TRUE + ORDER BY ev.sort_order, ev.id + '); + $st->execute([':n' => $name]); + return $st->fetchAll(PDO::FETCH_ASSOC); + } +} diff --git a/src/Repository/ProfileRepository.php b/src/Repository/ProfileRepository.php new file mode 100644 index 0000000..94c3abb --- /dev/null +++ b/src/Repository/ProfileRepository.php @@ -0,0 +1,158 @@ +pdo = $pdo; + return; + } + + // 1) App\Infrastructure\Database (si elle expose quelque chose) + if (class_exists(Database::class)) { + if (method_exists(Database::class, 'pdo')) { + $try = Database::pdo(); + if ($try instanceof PDO) { + $this->pdo = $try; + return; + } + } + if (method_exists(Database::class, 'getPdo')) { + $try = Database::getPdo(); + if ($try instanceof PDO) { + $this->pdo = $try; + return; + } + } + if (method_exists(Database::class, 'getInstance')) { + $db = Database::getInstance(); + if ($db instanceof PDO) { + $this->pdo = $db; + return; + } + if (is_object($db) && method_exists($db, 'pdo')) { + $try = $db->pdo(); + if ($try instanceof PDO) { + $this->pdo = $try; + return; + } + } + } + } + + // 2) Fonction globale éventuelle + if (function_exists('db')) { + $try = db(); + if ($try instanceof PDO) { + $this->pdo = $try; + return; + } + } + + // 3) Variable globale éventuelle + if (!empty($GLOBALS['pdo']) && $GLOBALS['pdo'] instanceof PDO) { + $this->pdo = $GLOBALS['pdo']; + return; + } + + // 4) Fallback env/const : compose un DSN pgsql si nécessaire + $dsn = getenv('DB_DSN') ?: ($_ENV['DB_DSN'] ?? null); + $user = getenv('DB_USER') ?: ($_ENV['DB_USER'] ?? null); + $pass = getenv('DB_PASS') ?: ($_ENV['DB_PASS'] ?? null); + if (!$dsn) { + $host = getenv('DB_HOST') ?: ($_ENV['DB_HOST'] ?? 'localhost'); + $port = getenv('DB_PORT') ?: ($_ENV['DB_PORT'] ?? '5432'); + $name = getenv('DB_NAME') ?: ($_ENV['DB_NAME'] ?? null); + if ($name) { + $dsn = sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $name); + } + } + if ($dsn) { + $pdo = new PDO($dsn, (string)$user, (string)$pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + $this->pdo = $pdo; + return; + } + + throw new \RuntimeException('Impossible d’obtenir un PDO (aucune source valide trouvée).'); + } + + public function all(?bool $onlyActive = null): array + { + $sql = 'SELECT * FROM profiles'; + if ($onlyActive !== null) { + $sql .= ' WHERE is_active = :act'; + } + $sql .= ' ORDER BY slug'; + $stmt = $this->pdo->prepare($sql); + if ($onlyActive !== null) { + $stmt->bindValue(':act', $onlyActive, PDO::PARAM_BOOL); + } + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + public function findById(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function findBySlug(string $slug): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM profiles WHERE slug = :slug'); + $stmt->execute([':slug' => $slug]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function create(string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): int + { + $stmt = $this->pdo->prepare('INSERT INTO profiles(slug,label,description,permissions,is_system,is_active) VALUES(:slug,:label,:desc,CAST(:perms AS jsonb),:sys,:act) RETURNING id'); + $stmt->execute([ + ':slug' => $slug, + ':label' => $label, + ':desc' => $description, + ':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':sys' => $isSystem, + ':act' => $isActive, + ]); + return (int)$stmt->fetchColumn(); + } + + public function update(int $id, string $slug, string $label, ?string $description, array $permissions, bool $isSystem, bool $isActive): void + { + $stmt = $this->pdo->prepare('UPDATE profiles SET slug=:slug,label=:label,description=:desc,permissions=CAST(:perms AS jsonb),is_system=:sys,is_active=:act WHERE id=:id'); + $stmt->execute([ + ':id' => $id, + ':slug' => $slug, + ':label' => $label, + ':desc' => $description, + ':perms' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ':sys' => $isSystem, + ':act' => $isActive, + ]); + } + + public function delete(int $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM profiles WHERE id=:id AND is_system = FALSE'); + $stmt->execute([':id' => $id]); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..9c74b7f --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,129 @@ +pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $st->execute([':email' => $email]); + $id = $st->fetchColumn(); + if ($id !== false && $id !== null) { + return (string)$id; + } + + // 2) Création + // Génère un hash robuste sur une valeur aléatoire (aucune chance de connexion par mot de passe). + $randomSecret = bin2hex(random_bytes(32)); + $randomHash = password_hash($randomSecret, PASSWORD_DEFAULT); + + $sql = <<pdo->prepare($sql); + $st->execute([ + ':email' => $email, + ':hash' => $randomHash, + ]); + return (string)$st->fetchColumn(); + } catch (\PDOException $e) { + // Unique violation sur email (23505) → on relit l’id (race condition) + if ($e->getCode() === '23505') { + $st = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $st->execute([':email' => $email]); + $id = $st->fetchColumn(); + if ($id !== false && $id !== null) { + return (string)$id; + } + } + throw $e; + } + } + + public function findByEmail(string $email): ?User + { + $sql = 'SELECT id, email, password_hash, is_active FROM users WHERE email = :email LIMIT 1'; + $st = $this->pdo->prepare($sql); + $st->execute([':email' => $email]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + $isActive = $this->toBool($row['is_active']); + + return new User( + (string)$row['id'], + (string)$row['email'], + (string)$row['password_hash'], + $isActive + ); + } + + public function create(string $email, string $passwordHash): string + { + // PostgreSQL + $sql = 'INSERT INTO users (email, password_hash) VALUES (:email, :hash) RETURNING id'; + $st = $this->pdo->prepare($sql); + $st->execute([':email' => $email, ':hash' => $passwordHash]); + return (string)$st->fetchColumn(); + } + + public function updatePassword(string $userId, string $newHash): void + { + $sql = <<pdo->prepare($sql); + $st->execute([':h' => $newHash, ':id' => $userId]); + } + + /** + * Normalise un bool venant de PDO/pgsql ('t','f',1,0,true,false,'1','0','true','false') + */ + private function toBool(mixed $v): bool + { + if (is_bool($v)) { + return $v; + } + if (is_int($v)) { + return $v === 1; + } + if (is_string($v)) { + $v = strtolower($v); + return in_array($v, ['t', '1', 'true', 'on', 'yes'], true); + } + return (bool)$v; + } +} diff --git a/src/SearchEngine.php b/src/SearchEngine.php new file mode 100644 index 0000000..01f8f06 --- /dev/null +++ b/src/SearchEngine.php @@ -0,0 +1,276 @@ + $articles Liste brute d'articles (depuis ArticleManager) + * @return array + */ + public function search(string $query, array $articles): array + { + $tokens = $this->tokenize($query); + if (empty($tokens)) { + return []; + } + + $results = []; + foreach ($articles as $article) { + // 'plain' est pré-calculé dans search_index.json, sinon on stripe à la volée + $plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? ''); + $tWords = $this->tokenize($article['title'] ?? ''); + $cWords = $this->tokenize($article['category'] ?? ''); + $pWords = $this->tokenize($plain); + + $score = $this->scoreArticle($tokens, $tWords, $cWords, $pWords); + if ($score > 0.0) { + $results[] = [ + 'article' => $article, + 'score' => $score, + 'snippet' => $this->buildSnippet($plain, $tokens), + 'tier' => $this->determineTier($tokens, $tWords, $cWords, $pWords), + ]; + } + } + + usort($results, static function (array $a, array $b): int { + if ($a['tier'] !== $b['tier']) { + return $a['tier'] <=> $b['tier']; + } + return $b['score'] <=> $a['score']; + }); + return $results; + } + + // ─── Scoring ───────────────────────────────────────────────────────────── + + private function scoreArticle(array $tokens, array $tWords, array $cWords, array $pWords): float + { + $total = 0.0; + foreach ($tokens as $token) { + $ts = $this->tokenScore($token, $tWords) * self::TITLE_WEIGHT + + $this->tokenScore($token, $cWords) * self::CAT_WEIGHT + + $this->tokenScore($token, $pWords) * self::CONTENT_WEIGHT; + + if ($ts <= 0.0) { + return 0.0; // AND strict : token introuvable → article exclu + } + $total += $ts; + } + return $total; + } + + /** + * Classe un résultat en tier : + * 1 → tous les tokens trouvés exactement dans le titre + * 2 → tous les tokens trouvés exactement dans titre, catégorie ou contenu + * 3 → au moins un token uniquement en correspondance floue + */ + private function determineTier(array $tokens, array $tWords, array $cWords, array $pWords): int + { + $inTitle = true; + foreach ($tokens as $token) { + if ($this->tokenScore($token, $tWords, false) < 0.75) { + $inTitle = false; + break; + } + } + if ($inTitle) { + return 1; + } + + $allWords = array_merge($tWords, $cWords, $pWords); + foreach ($tokens as $token) { + if ($this->tokenScore($token, $allWords, false) < 0.75) { + return 3; + } + } + return 2; + } + + /** + * Retourne un score 0–1 mesurant à quel point $token correspond + * au meilleur mot de la liste $words. + */ + private function tokenScore(string $token, array $words, bool $fuzzy = true): float + { + $best = 0.0; + $tLen = mb_strlen($token); + foreach ($words as $w) { + if ($w === $token) { + return 1.0; // exact + } + if ($tLen >= 3 && (str_contains($w, $token) || str_contains($token, $w))) { + $best = max($best, 0.75); // sous-chaîne (pluriels, conjugaisons) + } + if ($fuzzy && $tLen >= 4) { + $sim = $this->trigramSimilarity($token, $w); + if ($sim >= self::FUZZY_FLOOR) { + $best = max($best, $sim * 0.55); // fuzzy (fautes de frappe) + } + } + } + return $best; + } + + /** + * Calcule un score cumulé (OR) pour plusieurs tokens sur un ensemble d'articles. + * Tokenise chaque article une seule fois — évite N tokenisations avec N appels à search(). + * Le fuzzy (trigramme) est désactivé sur le contenu (poids 1.0) pour des raisons de perf. + * + * @param string[] $tokens Mots normalisés (lowercase, sans accents) + * @param array[] $articles Articles (doivent avoir uuid, title, category, plain|content) + * @return array{0: array, 1: array} + */ + public function scorePool(array $tokens, array $articles): array + { + if (empty($tokens) || empty($articles)) { + return [[], []]; + } + + $scoreMap = []; + $articleMap = []; + + foreach ($articles as $article) { + $plain = $article['plain'] ?? $this->stripMarkdown($article['content'] ?? ''); + $tWords = $this->tokenize($article['title'] ?? ''); + $cWords = $this->tokenize($article['category'] ?? ''); + $pWords = $this->tokenize($plain); + + $total = 0.0; + foreach ($tokens as $token) { + $ts = $this->tokenScore($token, $tWords, true) * self::TITLE_WEIGHT + + $this->tokenScore($token, $cWords, true) * self::CAT_WEIGHT + + $this->tokenScore($token, $pWords, false) * self::CONTENT_WEIGHT; + $total += $ts; + } + + if ($total > 0.0) { + $uuid = $article['uuid']; + $scoreMap[$uuid] = $total; + $articleMap[$uuid] = $article; + } + } + + return [$scoreMap, $articleMap]; + } + + // ─── Trigramme ─────────────────────────────────────────────────────────── + + private function trigramSimilarity(string $a, string $b): float + { + $tA = $this->trigrams($a); + $tB = $this->trigrams($b); + if (empty($tA) || empty($tB)) { + return 0.0; + } + $common = count(array_intersect($tA, $tB)); + return $common / max(count($tA), count($tB)); + } + + /** @return string[] */ + private function trigrams(string $s): array + { + $out = []; + $len = mb_strlen($s); + for ($i = 0; $i + 2 < $len; $i++) { + $out[] = mb_substr($s, $i, 3); + } + return array_unique($out); + } + + // ─── Snippet avec surbrillance ──────────────────────────────────────────── + + private function buildSnippet(string $text, array $tokens): string + { + $norm = $this->normalize($text); + $pos = 0; + foreach ($tokens as $token) { + $p = mb_strpos($norm, $token); + if ($p !== false) { + $pos = max(0, $p - 60); + break; + } + } + + $raw = mb_substr($text, $pos, self::SNIPPET_LEN); + if ($pos > 0) { + $raw = '…' . ltrim($raw); + } + if ($pos + self::SNIPPET_LEN < mb_strlen($text)) { + $raw .= '…'; + } + + $escaped = htmlspecialchars($raw, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + // Surbrillance : on cherche les tokens dans le texte HTML-échappé + foreach ($tokens as $token) { + $escaped = (string) preg_replace( + '/(' . preg_quote(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), '/') . ')/iu', + '$1', + $escaped + ); + } + + return $escaped; + } + + // ─── Helpers texte ──────────────────────────────────────────────────────── + + /** Découpe en mots normalisés (min. 2 caractères). */ + private function tokenize(string $text): array + { + $norm = $this->normalize($text); + $words = preg_split('/\W+/u', $norm, -1, PREG_SPLIT_NO_EMPTY) ?: []; + return array_values(array_filter($words, fn ($w) => mb_strlen($w) >= 2)); + } + + /** Minuscule + translittération des accents français. */ + private function normalize(string $text): string + { + $text = mb_strtolower($text, 'UTF-8'); + return strtr($text, [ + 'à' => 'a', 'â' => 'a', 'ä' => 'a', + 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'î' => 'i', 'ï' => 'i', + 'ô' => 'o', 'ö' => 'o', + 'ù' => 'u', 'û' => 'u', 'ü' => 'u', + 'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', 'ñ' => 'n', + ]); + } + + /** Retire la syntaxe Markdown pour extraire le texte brut. */ + private function stripMarkdown(string $md): string + { + $t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; // images + $t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; // liens + $t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; // blocs code + $t = preg_replace('/`[^`]+`/', '', $t) ?? $t; // code inline + $t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; // titres + $t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; // gras/italique + $t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; // listes, citations, tableaux + $t = preg_replace('/\n{2,}/', ' ', $t) ?? $t; + return trim($t); + } +} diff --git a/src/SearchLogParser.php b/src/SearchLogParser.php new file mode 100644 index 0000000..cb7c3b5 --- /dev/null +++ b/src/SearchLogParser.php @@ -0,0 +1,127 @@ +logDir = rtrim($logDir, '/'); + $this->vhostBase = $vhostBase; + $this->cacheFile = $cacheFile !== '' + ? $cacheFile + : dirname(__DIR__) . '/_cache/search_terms.json'; + $this->cacheTtl = $cacheTtl; + } + + /** @return array terme => nombre d'occurrences, trié desc */ + public function topTerms(int $limit = 100): array + { + if ($this->cacheValid()) { + $data = json_decode((string) file_get_contents($this->cacheFile), true); + if (is_array($data)) { + return array_slice($data, 0, $limit, true); + } + } + + $counts = []; + foreach ($this->logFiles() as $file) { + $this->parseFile($file, $counts); + } + arsort($counts); + + @mkdir(dirname($this->cacheFile), 0755, true); + file_put_contents($this->cacheFile, json_encode($counts, JSON_UNESCAPED_UNICODE)); + + return array_slice($counts, 0, $limit, true); + } + + public function isReadable(): bool + { + $f = $this->logDir . '/' . $this->vhostBase; + return file_exists($f) && is_readable($f); + } + + private function cacheValid(): bool + { + return file_exists($this->cacheFile) + && (time() - filemtime($this->cacheFile)) < $this->cacheTtl; + } + + /** @return list */ + private function logFiles(): array + { + $base = $this->logDir . '/' . $this->vhostBase; + $files = []; + + if (file_exists($base) && is_readable($base)) { + $files[] = ['path' => $base, 'gz' => false]; + } + + for ($i = 1; $i <= 14; $i++) { + $plain = $base . '.' . $i; + $gz = $plain . '.gz'; + if (file_exists($plain) && is_readable($plain)) { + $files[] = ['path' => $plain, 'gz' => false]; + } elseif (file_exists($gz) && is_readable($gz)) { + $files[] = ['path' => $gz, 'gz' => true]; + } + } + + return $files; + } + + private function parseFile(array $file, array &$counts): void + { + if ($file['gz']) { + $h = @gzopen($file['path'], 'rb'); + if (!$h) { + return; + } + while (!gzeof($h)) { + $line = gzgets($h, 8192); + if ($line !== false) { + $this->parseLine($line, $counts); + } + } + gzclose($h); + } else { + $h = @fopen($file['path'], 'rb'); + if (!$h) { + return; + } + while (($line = fgets($h)) !== false) { + $this->parseLine($line, $counts); + } + fclose($h); + } + } + + private function parseLine(string $line, array &$counts): void + { + if (!str_contains($line, 'GET /search?')) { + return; + } + if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) { + return; + } + + parse_str($m[1], $params); + $q = trim(urldecode($params['q'] ?? '')); + + if ($q === '' || mb_strlen($q) > 200) { + return; + } + $q = mb_strtolower($q); + $counts[$q] = ($counts[$q] ?? 0) + 1; + } +} diff --git a/src/Service/AuthService.php b/src/Service/AuthService.php new file mode 100644 index 0000000..4169fe7 --- /dev/null +++ b/src/Service/AuthService.php @@ -0,0 +1,105 @@ + now() - interval '5 minutes' + and success = false"; + $st = \App\Infrastructure\Database::pdo()->prepare($sql); + $st->execute([':ip' => $ip]); + $fails = (int)$st->fetchColumn(); + return $fails < 10; // à ajuster + } + + + + public function login(string $email, string $password, string $ip): bool + { + $user = $this->users->findByEmail($email); + $ok = $user && $user->isActive && password_verify($password, $user->passwordHash); + + $pdo = \App\Infrastructure\Database::pdo(); + $st = $pdo->prepare('insert into login_attempts(email, ip, success) values(:e, :ip, :s)'); + $st->bindValue(':e', $email, \PDO::PARAM_STR); + $st->bindValue(':ip', $ip, \PDO::PARAM_STR); + $st->bindValue(':s', $ok, \PDO::PARAM_BOOL); + $st->execute(); + + if ($ok) { + \App\Infrastructure\Session::regenerate(); + $_SESSION['uid'] = $user->id; + $_SESSION['email'] = $user->email; + } + return $ok; + } + + + public function changePassword(string $userId, string $currentPassword, string $newPassword): bool + { + // Récupération de l’utilisateur (rapide : requête directe ; tu peux créer findById() si tu préfères) + $pdo = \App\Infrastructure\Database::pdo(); + $st = $pdo->prepare('select id, email, password_hash, is_active from users where id = :id'); + $st->execute([':id' => $userId]); + $row = $st->fetch(\PDO::FETCH_ASSOC); + if (!$row || !(bool)$row['is_active']) { + return false; + } + + // Vérifier l’ancien mot de passe + if (!password_verify($currentPassword, (string)$row['password_hash'])) { + return false; + } + + // Politique minimale : longueur uniquement (espaces autorisés) + if (mb_strlen($newPassword) < 7) { + return false; + } + // (optionnel) interdire seulement le caractère NUL + if (strpos($newPassword, "\0") !== false) { + return false; + } + + // Mettre à jour le hash + $newHash = password_hash($newPassword, PASSWORD_ARGON2ID); + (new \App\Repository\UserRepository(\App\Infrastructure\Database::get()))->updatePassword($row['id'], $newHash); + + // (Optionnel) rotation session + \App\Infrastructure\Session::regenerate(); + return true; + } + + public function register(string $email, string $password): string + { + $hash = password_hash($password, PASSWORD_ARGON2ID); + return $this->users->create($email, $hash); + } + + public static function requireAuth(): void + { + if (!isset($_SESSION['uid'])) { + header('Location: /login'); + exit; + } + } + + public static function logout(): void + { + $_SESSION = []; + session_destroy(); + } +} diff --git a/src/Service/MailQueue.php b/src/Service/MailQueue.php new file mode 100644 index 0000000..42fdc58 --- /dev/null +++ b/src/Service/MailQueue.php @@ -0,0 +1,218 @@ +pdo = $pdo; + $this->mailService = $mailService; + } + + /** + * Ajoute un email dans la file. + * + * @param array{delay?:int} $options delay en secondes avant éligibilité (facultatif) + */ + public function enqueue(string $to, string $subject, string $body, array $options = []): int + { + $delay = max(0, (int)($options['delay'] ?? 0)); + + $sql = <<pdo->prepare($sql); + $stmt->execute([ + ':to' => trim($to), + ':subject' => $subject, + ':body' => $body, + ':delay' => $delay, + ]); + + /** @var int $id */ + $id = (int) $stmt->fetchColumn(); + return $id; + } + + /** + * Traite la file en batch. + * - Récupère jusqu'à $max jobs "disponibles" + * - Pose un lease (locked_at) pour éviter les doublons de traitement + * - Envoie via MailService + * - Met à jour le statut (sent/failed) + planifie un retry si besoin + * + * @return array{processed:int, sent:int, failed:int, retried:int} + */ + public function process(int $max = 100): array + { + $max = max(1, min(1000, $max)); + + $jobs = $this->reserveBatch($max); + $processed = $sent = $failed = $retried = 0; + + foreach ($jobs as $job) { + $processed++; + $ok = false; + try { + // Anti-abus est appliqué par MailService::send() + $ok = $this->mailService->send($job['to_email'], $job['subject'], $job['body']); + } catch (Throwable $e) { + // On traitera en "retry" sous ce catch + $this->appendError((int)$job['id'], 'unexpected: ' . $e->getMessage()); + } + + if ($ok) { + $this->markAsSent((int)$job['id']); + $sent++; + } else { + // incrémente attempts et programme retry/backoff + $didRetry = $this->scheduleRetry((int)$job['id'], (int)$job['attempts'] + 1); + if ($didRetry) { + $retried++; + } else { + $this->markAsFailed((int)$job['id']); + $failed++; + } + } + } + + return compact('processed', 'sent', 'failed', 'retried'); + } + + /** + * Réserve jusqu'à $max jobs prêts (pending) en posant locked_at (lease). + * Utilise FOR UPDATE SKIP LOCKED pour le parallélisme côté SQL. + * + * @return array + */ + private function reserveBatch(int $max): array + { + // 1) Sélection des candidats + $this->pdo->beginTransaction(); + try { + $sql = <<pdo->prepare($sql); + $stmt->bindValue(':max', $max, PDO::PARAM_INT); + $stmt->execute(); + + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + if ($rows) { + // 2) Marquer locked_at + sending + $ids = array_map(static fn ($r) => (int)$r['id'], $rows); + $in = implode(',', array_fill(0, count($ids), '?')); + + $up = $this->pdo->prepare( + "UPDATE mail_queue + SET locked_at = (NOW() AT TIME ZONE 'UTC'), + status = 'sending' + WHERE id IN ($in)" + ); + $up->execute($ids); + } + + $this->pdo->commit(); + return $rows; + } catch (Throwable $e) { + $this->pdo->rollBack(); + return []; + } + } + + private function markAsSent(int $id): void + { + $stmt = $this->pdo->prepare("UPDATE mail_queue SET status='sent', locked_at=NULL WHERE id=:id"); + $stmt->execute([':id' => $id]); + } + + private function markAsFailed(int $id): void + { + $stmt = $this->pdo->prepare("UPDATE mail_queue SET status='failed', locked_at=NULL WHERE id=:id"); + $stmt->execute([':id' => $id]); + } + + /** + * Définit la prochaine tentative avec backoff exponentiel plafonné. + * Retourne false si on considère que c'est "trop" et qu'il faut passer en failed. + */ + private function scheduleRetry(int $id, int $nextAttempt): bool + { + // Politique simple : jusqu'à 8 tentatives (≈ ~4h de backoff cumulé, puis plafond 24h) + if ($nextAttempt > 8) { + return false; + } + + $delay = min(self::BASE_BACKOFF_SECONDS * (2 ** ($nextAttempt - 1)), self::MAX_BACKOFF_SECONDS); + + $sql = <<pdo->prepare($sql); + $stmt->execute([ + ':attempts' => $nextAttempt, + ':delay' => $delay, + ':id' => $id, + ]); + + return true; + } + + private function appendError(int $id, string $message): void + { + $sql = "UPDATE mail_queue SET last_error = COALESCE(last_error,'') || :e || E'\n' WHERE id = :id"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute([ + ':e' => '[' . (new DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('c') . '] ' . $message, + ':id' => $id, + ]); + } +} diff --git a/src/Service/MailService.php b/src/Service/MailService.php new file mode 100644 index 0000000..e554280 --- /dev/null +++ b/src/Service/MailService.php @@ -0,0 +1,287 @@ + */ + private array $smtpConfig; + + /** + * @param \PDO $pdo PDO connecté (PostgreSQL recommandé) + * @param array $smtpConfig [ + * 'host' => 'smtp.example.tld', + * 'port' => 587, + * 'username' => 'user', + * 'password' => 'pass', + * 'encryption' => 'tls'|'ssl'|null, + * 'from' => 'no-reply@example.tld', + * 'from_name' => 'Mon appli', + * 'reply_to' => 'contact@example.tld' (optionnel), + * 'reply_to_name' => 'Support' (optionnel), + * 'smtp_options' => [...] (optionnel, cf. PHPMailer::SMTPOptions) + * ] + */ + public function __construct(\PDO $pdo, array $smtpConfig) + { + $this->pdo = $pdo; + $this->smtpConfig = $smtpConfig; + + $this->mailer = new PHPMailer(true); + $this->configureMailer($this->mailer, $smtpConfig); + } + + /** + * Envoie un mail et journalise l'opération dans journal_smtp + */ + public function send(string $to, string $subject, string $body): bool + { + $to = trim($to); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + $this->log('blocked', $to, $subject, $body, 'invalid_email'); + return false; + } + + if (!$this->passesRateLimits($to)) { + $this->log('blocked', $to, $subject, $body, 'rate_limited'); + return false; + } + + try { + $this->mailer->clearAddresses(); + $this->mailer->clearReplyTos(); + + $this->mailer->addAddress($to); + if (!empty($this->smtpConfig['reply_to'])) { + $this->mailer->addReplyTo( + (string) $this->smtpConfig['reply_to'], + (string)($this->smtpConfig['reply_to_name'] ?? '') + ); + } + + $this->mailer->Subject = $subject; + $this->mailer->Body = $body; + $this->mailer->AltBody = $this->buildAltBody($body); + $this->mailer->isHTML($this->looksLikeHtml($body)); + + $sent = $this->mailer->send(); + + $this->log( + $sent ? 'sent' : 'error', + $to, + $subject, + $body, + $sent ? null : 'phpmailer_send_returned_false', + $this->mailer->getLastMessageID() ?: null + ); + + return $sent; + } catch (MailException $e) { + $this->log('error', $to, $subject, $body, 'phpmailer_exception: ' . $e->getMessage()); + return false; + } catch (\Throwable $e) { + $this->log('error', $to, $subject, $body, 'unexpected: ' . $e->getMessage()); + return false; + } + } + + /** + * Retourne les derniers envois (journal) + * @return array> + */ + public function list(int $limit = 50): array + { + $limit = max(1, min(500, $limit)); + $sql = <<pdo->prepare($sql); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + /** @var array> $rows */ + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return $rows ?: []; + } + + // ---------- Internals ---------- + + /** + * @param array $cfg + */ + private function configureMailer(PHPMailer $m, array $cfg): void + { + $m->isSMTP(); + $m->Host = (string) $cfg['host']; + $m->Port = (int) ($cfg['port'] ?? 587); + $m->SMTPAuth = true; + $m->Username = (string) $cfg['username']; + $m->Password = (string) $cfg['password']; + $m->SMTPSecure = $cfg['encryption'] ?? PHPMailer::ENCRYPTION_STARTTLS; + if (!empty($cfg['smtp_options']) && is_array($cfg['smtp_options'])) { + $m->SMTPOptions = $cfg['smtp_options']; + } + + $from = (string) ($cfg['from'] ?? $cfg['username']); + $fromName = (string) ($cfg['from_name'] ?? ''); + $m->setFrom($from, $fromName); + + // Hygiène SMTP + $m->CharSet = 'UTF-8'; + $m->Encoding = 'base64'; + $m->Timeout = 15; // secondes + } + + /** Règles anti-abus : per-recipient + garde-fous globaux */ + private function passesRateLimits(string $recipient): bool + { + // 1) Min interval per destinataire (5 min) + $sql1 = <<pdo->prepare($sql1); + $stmt1->execute([':r' => $recipient]); + $last = $stmt1->fetchColumn(); + if ($last) { + $lastTs = (new DateTimeImmutable((string) $last))->getTimestamp(); + $delta = time() - $lastTs; + if ($delta < self::MIN_INTERVAL_BETWEEN_SENDS_SECONDS) { + return false; + } + } + + // 2) Max 5 en 12h par destinataire + $sql2 = <<= (NOW() AT TIME ZONE 'UTC') - INTERVAL '12 hours' + AND status = 'sent' + SQL; + $stmt2 = $this->pdo->prepare($sql2); + $stmt2->execute([':r' => $recipient]); + $count12h = (int) $stmt2->fetchColumn(); + if ($count12h >= self::MAX_SENDS_PER_12H_PER_RECIPIENT) { + return false; + } + + // 3) Garde-fou global / heure + $sql3 = <<= (NOW() AT TIME ZONE 'UTC') - INTERVAL '1 hour' + AND status = 'sent' + SQL; + $stmt3 = $this->pdo->query($sql3); + $global1h = (int) $stmt3->fetchColumn(); + if ($global1h >= self::MAX_GLOBAL_PER_HOUR) { + return false; + } + + return true; + } + + /** + * Journalise un envoi / tentative + * + * @param 'sent'|'error'|'blocked' $status + */ + private function log( + string $status, + string $recipient, + string $subject, + string $body, + ?string $error = null, + ?string $messageId = null + ): void { + $script = $this->detectScript(); + $host = (string) ($this->smtpConfig['host'] ?? ''); + $user = (string) ($this->smtpConfig['username'] ?? ''); + $subjectDb = mb_strimwidth($subject, 0, self::MAX_SUBJECT_LEN, '…', 'UTF-8'); + $bodyDb = mb_strimwidth($body, 0, self::MAX_BODY_LEN_FOR_LOG, '…', 'UTF-8'); + + $sql = <<pdo->prepare($sql); + $stmt->execute([ + ':script' => $script, + ':recipient' => $recipient, + ':subject' => $subjectDb, + ':body' => $bodyDb, + ':status' => $status, + ':error' => $error, + ':smtp_host' => $host, + ':smtp_user' => $user, + ':message_id' => $messageId, + ]); + } + + private function detectScript(): string + { + // Exemple : /public/pages/notifications/send.php + $script = $_SERVER['SCRIPT_NAME'] ?? ($_SERVER['PHP_SELF'] ?? ''); + if ($script === '' && \PHP_SAPI === 'cli') { + $script = $_SERVER['argv'][0] ?? 'cli'; + } + return (string) $script; + } + + private function looksLikeHtml(string $body): bool + { + return (bool) preg_match('~<(?:html|body|div|p|span|table|br|h[1-6]|a)\b~i', $body); + } + + private function buildAltBody(string $body): string + { + if (!$this->looksLikeHtml($body)) { + // Déjà texte brut + return $body; + } + // Version simplifiée : strip tags (on peut faire mieux selon besoins) + $text = trim((string) @html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + return $text !== '' ? $text : '[Voir version HTML]'; + } +} diff --git a/src/Service/UiFormRenderer.php b/src/Service/UiFormRenderer.php new file mode 100644 index 0000000..566405a --- /dev/null +++ b/src/Service/UiFormRenderer.php @@ -0,0 +1,76 @@ +dict->getEntityByCode($entityCode); + if (!$e) { + return '
Entité inconnue
'; + } + + $html = ''; + foreach ($e['fields'] as $f) { + if (!$f['form_visible']) { + continue; + } + if ($f['read_only']) { + continue; + } + + $name = $f['code']; + $label = $f['label']; + $help = $f['help_text'] ?? ''; + $widget = $f['ui_widget'] ?? 'text'; + $val = $values[$name] ?? ''; + + $html .= '
'; + $html .= ''; + + if ($widget === 'select' && $f['enum_domain']) { + $opts = $this->dict->getEnum($f['enum_domain']); + $html .= ''; + } else { + $type = match ($widget) { + 'email' => 'email', + 'number' => 'number', + 'date' => 'date', + 'checkbox' => 'checkbox', + default => 'text', + }; + if ($type === 'checkbox') { + $chk = $val ? ' checked' : ''; + $html .= ''; + } else { + $placeholder = $f['placeholder'] ?? ''; + $html .= ''; + } + } + + if ($help) { + $html .= '
'.htmlspecialchars($help).'
'; + } + $html .= '
'; + } + return $html; + } +} diff --git a/src/Service/Validator.php b/src/Service/Validator.php new file mode 100644 index 0000000..5214a2a --- /dev/null +++ b/src/Service/Validator.php @@ -0,0 +1,79 @@ +dict->getEntityByCode($entityCode); + if (!$e) { + return ['_global' => ['Entité inconnue']]; + } + + // Index les champs + $fields = []; + foreach ($e['fields'] as $f) { + $fields[$f['code']] = $f; + } + + foreach ($e['rules'] as $r) { + $code = $r['field_code']; + $type = $r['rule_type']; + $val = $r['rule_value']; + $msg = $r['message']; + + $v = $code ? ($payload[$code] ?? null) : null; + + switch ($type) { + case 'required': + if ($code && ($v === null || $v === '')) { + $errors[$code][] = $msg; + } + break; + case 'regex': + if ($code && $v !== null && $v !== '' && !preg_match('#'.$val.'#u', (string)$v)) { + $errors[$code][] = $msg; + } + break; + case 'min': + if ($code && is_numeric($v) && (float)$v < (float)$val) { + $errors[$code][] = $msg; + } + break; + case 'max': + if ($code && is_numeric($v) && (float)$v > (float)$val) { + $errors[$code][] = $msg; + } + break; + case 'between': + if ($code && is_numeric($v)) { + [$a,$b] = array_map('floatval', explode(',', $val)); + $fv = (float)$v; + if ($fv < $a || $fv > $b) { + $errors[$code][] = $msg; + } + } + break; + case 'unique': + // à implémenter côté repo (SELECT COUNT(*) FROM table WHERE col=:v AND id<>:id) + // Laisse un hook ici. + break; + case 'custom': + // point d’extension si tu veux appeler une callable par nom + break; + } + } + + return $errors; + } +} diff --git a/src/SiteSettings.php b/src/SiteSettings.php new file mode 100644 index 0000000..6c4bbc1 --- /dev/null +++ b/src/SiteSettings.php @@ -0,0 +1,84 @@ + 0) { + $current['posts_per_page'] = $val; + } + } + file_put_contents( + siteSettingsPath(), + json_encode($current, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); +} diff --git a/src/SmtpSettings.php b/src/SmtpSettings.php new file mode 100644 index 0000000..a054959 --- /dev/null +++ b/src/SmtpSettings.php @@ -0,0 +1,53 @@ +}> + */ + public function suggest( + string $markdown, + array $existingValues = [], + array $currentTags = [] + ): array { + $plain = $this->stripMarkdown($markdown); + + $candidates = []; + + // ── 1. Valeurs connues dans le système ────────────────────────────── + foreach ($existingValues as $val) { + $cnt = $this->countOccurrences($plain, $val); + if ($cnt > 0) { + $this->add($candidates, $val, $cnt, true, in_array($val, $currentTags, true), 'known'); + } + } + + // ── 2. Abréviations : 2-7 majuscules consécutives (avec chiffres/tirets) ── + preg_match_all('/\b([A-Z][A-Z0-9]{1,6}(?:-[A-Z0-9]+)?)\b/', $plain, $m); + foreach (array_count_values($m[1]) as $abbr => $cnt) { + if (!isset($candidates[$abbr])) { + $this->add($candidates, $abbr, $cnt, false, in_array($abbr, $currentTags, true), 'abbrev'); + } + } + + // ── 3. CamelCase / PascalCase (ex: Zigbee2MQTT, OpenWrt, HomeAssistant) ── + preg_match_all('/\b([A-Z][a-z]+(?:[A-Z0-9][a-z0-9]*)+)\b/', $plain, $m); + foreach (array_count_values($m[1]) as $word => $cnt) { + if (!isset($candidates[$word])) { + $this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'camel'); + } + } + + // ── 4. Noms propres multi-mots (ex: "Home Assistant", "Raspberry Pi") ── + preg_match_all( + '/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,}(?:\s+[A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{1,})+)\b/u', + $plain, + $m + ); + foreach (array_count_values($m[1]) as $phrase => $cnt) { + if (!isset($candidates[$phrase]) && !$this->isStop($phrase)) { + $this->add($candidates, $phrase, $cnt, false, in_array($phrase, $currentTags, true), 'proper'); + } + } + + // ── 5. Mots capitalisés simples présents ≥ 2 fois ─────────────────── + preg_match_all('/\b([A-ZÀÂÄÉÈÊËÎÏÔÙÛÜŸÇ][a-zàâäéèêëîïôùûüÿç]{2,})\b/u', $plain, $m); + foreach (array_count_values($m[1]) as $word => $cnt) { + if ($cnt >= 2 && !isset($candidates[$word]) && !in_array($word, self::STOP, true)) { + $this->add($candidates, $word, $cnt, false, in_array($word, $currentTags, true), 'proper'); + } + } + + // ── Filtrage ───────────────────────────────────────────────────────── + foreach (array_keys($candidates) as $key) { + if (mb_strlen($key) < 2 || is_numeric($key)) { + unset($candidates[$key]); + } + } + + // ── Tri : actuels > connus > fréquence ─────────────────────────────── + uasort( + $candidates, + fn ($a, $b) => + ((int)$b['current'] <=> (int)$a['current']) + ?: ((int)$b['known'] <=> (int)$a['known']) + ?: ($b['count'] <=> $a['count']) + ); + + return $candidates; + } + + /** + * Suggestions spéciales pour le champ « catégorie » : + * retourne les catégories existantes classées par fréquence dans le texte. + * + * @param string[] $allCats Clés = nom de catégorie, valeurs = nb articles + * @return array catégorie => nb occurrences dans le texte + */ + public function suggestCategory(string $markdown, array $allCats, string $currentCat = ''): array + { + $plain = $this->stripMarkdown($markdown); + $result = []; + foreach (array_keys($allCats) as $cat) { + $cnt = $this->countOccurrences($plain, $cat); + $result[$cat] = $cnt; + } + // Trier : catégorie courante en premier, puis par occurrence décroissante, puis alphabétique + uksort($result, function ($a, $b) use ($result, $currentCat) { + if ($a === $currentCat) { + return -1; + } + if ($b === $currentCat) { + return 1; + } + return ($result[$b] <=> $result[$a]) ?: strcmp($a, $b); + }); + return $result; + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private function add(array &$c, string $key, int $cnt, bool $known, bool $current, string $group): void + { + $c[$key] = [ + 'count' => $cnt, + 'known' => $known, + 'current' => $current, + 'group' => $group, + ]; + } + + private function countOccurrences(string $haystack, string $needle): int + { + if ($needle === '') { + return 0; + } + return substr_count(mb_strtolower($haystack), mb_strtolower($needle)); + } + + private function isStop(string $phrase): bool + { + $words = explode(' ', $phrase); + // Phrase stop si tous les mots sont des stop words + foreach ($words as $w) { + if (!in_array($w, self::STOP, true)) { + return false; + } + } + return true; + } + + public function stripMarkdown(string $md): string + { + // Blocs de code → on retire pour éviter les faux positifs de variables/commandes + $md = preg_replace('/```[\s\S]*?```/m', ' ', $md); + $md = preg_replace('/`[^`\n]+`/', ' ', $md); + // En-têtes + $md = preg_replace('/^#{1,6}\s+/m', '', $md); + // Gras/italique + $md = preg_replace('/\*{1,3}([^*]+)\*{1,3}/', '$1', $md); + $md = preg_replace('/_{1,3}([^_]+)_{1,3}/', '$1', $md); + // Liens et images + $md = preg_replace('/!\[[^\]]*\]\([^\)]*\)/', ' ', $md); + $md = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $md); + // URLs brutes + $md = preg_replace('/https?:\/\/\S+/', ' ', $md); + // Balises HTML + $md = strip_tags($md); + // Marqueurs de liste + $md = preg_replace('/^[\*\-\+]\s+/m', '', $md); + // Lignes horizontales + $md = preg_replace('/^[-*_]{3,}\s*$/m', '', $md); + // Espaces multiples + return trim((string)preg_replace('/\s+/', ' ', $md)); + } +} diff --git a/src/auth.php b/src/auth.php new file mode 100644 index 0000000..a4efb25 --- /dev/null +++ b/src/auth.php @@ -0,0 +1,285 @@ + '', 'url' => '', 'slug' => '']; + } + if (array_key_exists($key, $cache)) { + return $cache[$key]; + } + $pdo = dbPdo(); + if ($pdo) { + try { + $st = $pdo->prepare('SELECT display_name, profile_url, profile_slug, bio FROM user_profiles WHERE email = :e'); + $st->execute([':e' => $key]); + $row = $st->fetch(PDO::FETCH_ASSOC); + if ($row) { + $cache[$key] = [ + 'name' => ($row['display_name'] !== '') ? $row['display_name'] : explode('@', $key)[0], + 'url' => $row['profile_url'] ?? '', + 'slug' => $row['profile_slug'] ?? '', + 'bio' => $row['bio'] ?? '', + ]; + return $cache[$key]; + } + } catch (\Throwable) { + } + } + $cache[$key] = ['name' => explode('@', $key)[0], 'url' => '', 'slug' => '']; + return $cache[$key]; +} + +function authorSlug(string $email): string +{ + return authorProfile($email)['slug']; +} + +function profileBySlug(string $slug): ?array +{ + if ($slug === '') { + return null; + } + $pdo = dbPdo(); + if (!$pdo) { + return null; + } + try { + $st = $pdo->prepare('SELECT email, display_name, profile_url, profile_slug, bio FROM user_profiles WHERE profile_slug = :s'); + $st->execute([':s' => $slug]); + $row = $st->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } catch (\Throwable) { + return null; + } +} + + +function dbPdo(): ?PDO +{ + static $pdo = null; + static $failed = false; + if ($failed) { + return null; + } + if ($pdo !== null) { + return $pdo; + } + $dsn = $_ENV['DB_DSN'] ?? (getenv('DB_DSN') ?: ''); + $user = $_ENV['DB_USER'] ?? (getenv('DB_USER') ?: ''); + $pass = $_ENV['DB_PASS'] ?? (getenv('DB_PASS') ?: ''); + if (!$dsn) { + $failed = true; + return null; + } + try { + $pdo = new PDO($dsn, $user ?: null, $pass ?: null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + } catch (\Throwable) { + $failed = true; + return null; + } + return $pdo; +} + +function currentUserRoles(): array +{ + if (!isLoggedIn()) { + return []; + } + if (isset($_SESSION['user_roles'])) { + return $_SESSION['user_roles']; + } + $pdo = dbPdo(); + if (!$pdo) { + $_SESSION['user_roles'] = []; + return []; + } + try { + $st = $pdo->prepare( + 'SELECT r.name FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_email = :e' + ); + $st->execute([':e' => strtolower(currentUserEmail() ?? '')]); + $_SESSION['user_roles'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: []; + } catch (\Throwable) { + $_SESSION['user_roles'] = []; + } + return $_SESSION['user_roles']; +} + +function hasRole(string $role): bool +{ + return in_array($role, currentUserRoles(), true); +} + +// Capacités connues — clé => label affiché dans l'admin +const KNOWN_CAPABILITIES = [ + 'propose_articles' => 'Proposer des articles', + 'validate_articles_all' => 'Valider des articles', + 'validate_articles_own' => 'Valider ses articles uniquement', + 'publish_articles_all' => 'Publier des articles', + 'publish_articles_own' => 'Publier ses articles uniquement', + 'edit_articles_all' => 'Modifier des articles', + 'edit_articles_own' => 'Modifier ses articles uniquement', + 'rate_articles' => 'Noter des articles', + 'view_previews' => 'Lire des avant-premières', + 'view_drafts_all' => 'Voir tous les brouillons', + 'view_drafts_own' => 'Voir ses brouillons', + 'view_sources_all' => 'Voir les sources (tous les articles)', + 'view_sources_own' => 'Voir les sources de ses articles', +]; + +// Groupes pour l'interface d'administration +const CAPABILITY_GROUPS = [ + 'Articles' => [ + 'propose_articles', + 'validate_articles_all', + 'validate_articles_own', + 'publish_articles_all', + 'publish_articles_own', + 'edit_articles_all', + 'edit_articles_own', + ], + 'Accès & lecture' => [ + 'rate_articles', + 'view_previews', + 'view_drafts_all', + 'view_drafts_own', + 'view_sources_all', + 'view_sources_own', + ], +]; + +function currentUserCapabilities(): array +{ + if (!isLoggedIn()) { + return []; + } + if (isset($_SESSION['user_capabilities'])) { + return $_SESSION['user_capabilities']; + } + $pdo = dbPdo(); + if (!$pdo) { + $_SESSION['user_capabilities'] = []; + return []; + } + try { + $st = $pdo->prepare( + 'SELECT DISTINCT rc.capability + FROM role_capabilities rc + JOIN user_roles ur ON ur.role_id = rc.role_id + WHERE ur.user_email = :e' + ); + $st->execute([':e' => strtolower(currentUserEmail() ?? '')]); + $_SESSION['user_capabilities'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: []; + } catch (\Throwable) { + $_SESSION['user_capabilities'] = []; + } + return $_SESSION['user_capabilities']; +} + +function hasCapability(string $cap): bool +{ + if (isAdmin()) { + return true; + } + return in_array($cap, currentUserCapabilities(), true); +} + +function canDoOnArticle(string $baseCap, array $article): bool +{ + if (isAdmin()) { + return true; + } + if (hasCapability($baseCap . '_all')) { + return true; + } + if (hasCapability($baseCap . '_own')) { + $owner = strtolower($article['author'] ?? ''); + return $owner !== '' && $owner === strtolower(currentUserEmail() ?? ''); + } + return false; +} + +function isAdmin(): bool +{ + $email = currentUserEmail(); + if (!$email) { + return false; + } + // Fallback bootstrap : var d'env + $rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: ''); + $allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin))); + if (in_array(strtolower($email), array_map('strtolower', $allowed), true)) { + return true; + } + return hasRole('admin'); +} + +function ssoLogoutUrl(): string +{ + $issuer = rtrim((string)($_ENV['OIDC_ISSUER'] ?? (getenv('OIDC_ISSUER') ?: '')), '/'); + $clientId = (string)($_ENV['OIDC_CLIENT_ID'] ?? (getenv('OIDC_CLIENT_ID') ?: '')); + $baseUrl = rtrim((string)($_ENV['APP_URL'] ?? (getenv('APP_URL') ?: '/')), '/'); + + $params = [ + 'client_id' => $clientId, + 'post_logout_redirect_uri' => $baseUrl . '/', + ]; + if (!empty($_SESSION['oidc']['id_token'])) { + $params['id_token_hint'] = $_SESSION['oidc']['id_token']; + } + + if (!$issuer) { + return $baseUrl . '/'; + } + + return $issuer . '/protocol/openid-connect/logout?' . http_build_query($params); +} diff --git a/src/db.php b/src/db.php new file mode 100644 index 0000000..16a2030 --- /dev/null +++ b/src/db.php @@ -0,0 +1,18 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +} catch (PDOException $e) { + die('Connexion échouée : ' . $e->getMessage()); +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..fc22b88 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,135 @@ +$output"; +} + +function slugify(string $s): string +{ + $map = ['à' => 'a','â' => 'a','ä' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e','î' => 'i','ï' => 'i','ô' => 'o','ö' => 'o','ù' => 'u','û' => 'u','ü' => 'u','ç' => 'c','æ' => 'ae','œ' => 'oe']; + $s = mb_strtolower($s); + $s = strtr($s, $map); + $s = (string)preg_replace('/[^a-z0-9]+/', '-', $s); + return trim($s, '-'); +} + +/** + * Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où + * op est '=' (inchangé), '-' (supprimé), '+' (ajouté). + */ +function lineDiff(string $old, string $new): array +{ + $a = explode("\n", $old); + $b = explode("\n", $new); + $n = count($a); + $m = count($b); + + if ($n * $m > 300000) { + return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]]; + } + + $dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0)); + for ($i = $n - 1; $i >= 0; $i--) { + for ($j = $m - 1; $j >= 0; $j--) { + $dp[$i][$j] = $a[$i] === $b[$j] + ? 1 + $dp[$i + 1][$j + 1] + : max($dp[$i + 1][$j], $dp[$i][$j + 1]); + } + } + + $diff = []; + $i = 0; + $j = 0; + while ($i < $n || $j < $m) { + if ($i < $n && $j < $m && $a[$i] === $b[$j]) { + $diff[] = ['=', $a[$i]]; + $i++; + $j++; + } elseif ($j < $m && ($i >= $n || $dp[$i][$j + 1] >= $dp[$i + 1][$j])) { + $diff[] = ['+', $b[$j++]]; + } else { + $diff[] = ['-', $a[$i++]]; + } + } + return $diff; +} + +// 16 couleurs RGB de base — distribuées sur le spectre, visuellement distinctes +const COLOR_PALETTE_16 = [ + [220, 38, 38], // rouge + [234, 88, 12], // orange + [217, 119, 6], // ambre + [161, 142, 14], // jaune-olive + [77, 124, 15], // citron + [22, 163, 74], // vert + [4, 120, 87], // émeraude + [15, 118, 110], // sarcelle + [8, 145, 178], // cyan + [3, 105, 161], // ciel + [37, 99, 235], // bleu + [79, 70, 229], // indigo + [109, 40, 217], // violet + [147, 51, 234], // pourpre + [192, 38, 211], // fuchsia + [219, 39, 119], // rose +]; + +/** + * Génère un dégradé CSS pour une catégorie. + * Avec $allCats, l'assignation est séquentielle (par ordre alpha) ; + * au-delà de 16, un décalage de teinte et d'angle différencie les palettes. + * Sans $allCats, fallback par hachage sur la palette. + */ +function coverGradient(string $seed, array $allCats = []): string +{ + $key = strtolower(trim($seed)); + + if (!empty($allCats)) { + $keys = array_map(fn ($k) => strtolower(trim((string)$k)), array_keys($allCats)); + $pos = array_search($key, $keys, true); + if ($pos !== false) { + $idx = (int) $pos; + $tier = (int) floor($idx / 16); + $ci = $idx % 16; + return _paletteGradient(COLOR_PALETTE_16[$ci], $tier); + } + } + + // Hachage déterministe en l'absence de liste + $ci = abs(crc32($key)) % 16; + return _paletteGradient(COLOR_PALETTE_16[$ci], 0); +} + +function _paletteGradient(array $rgb, int $tier): string +{ + [$r, $g, $b] = $rgb; + + // Tier 0 : dégradé standard clair → foncé, 135° + // Tier 1 : plus saturé, angle inversé, 315° + // Tier 2+ : plus sombre encore, 225° + $tintMix = match ($tier) { + 0 => 0.65, 1 => 0.48, default => 0.35 + }; + $shadeK = match ($tier) { + 0 => 0.35, 1 => 0.25, default => 0.18 + }; + $angle = match ($tier) { + 0 => 135, 1 => 315, default => 225 + }; + + $tr = (int) round($r * (1 - $tintMix) + 255 * $tintMix); + $tg = (int) round($g * (1 - $tintMix) + 255 * $tintMix); + $tb = (int) round($b * (1 - $tintMix) + 255 * $tintMix); + + $sr = (int) round($r * $shadeK); + $sg = (int) round($g * $shadeK); + $sb = (int) round($b * $shadeK); + + return "linear-gradient({$angle}deg,rgb($tr,$tg,$tb) 0%,rgb($sr,$sg,$sb) 100%)"; +} diff --git a/src/mailer.php b/src/mailer.php new file mode 100644 index 0000000..ed75539 --- /dev/null +++ b/src/mailer.php @@ -0,0 +1,181 @@ +0) + if ($coolMin > 0) { + $q1 = "SELECT 1 FROM journal_smtp + WHERE to_email = :e AND created_at >= NOW() - INTERVAL :cool + AND status IN ('sent','queued') + LIMIT 1"; + $stmt = $pdo->prepare($q1); + $stmt->execute([':e' => $email, ':cool' => sprintf('%d minutes', $coolMin)]); + if ($stmt->fetchColumn()) { + return [false, "Un email vient d’être envoyé. Réessayez dans {$coolMin} min."]; + } + } + + // Plafond 12h (actif seulement si >0) + if ($maxPer12h > 0) { + $q2 = "SELECT COUNT(*) FROM journal_smtp + WHERE to_email = :e AND created_at >= NOW() - INTERVAL '12 hours' + AND status IN ('sent','queued')"; + $stmt = $pdo->prepare($q2); + $stmt->execute([':e' => $email]); + if ((int)$stmt->fetchColumn() >= $maxPer12h) { + return [false, 'Quota atteint. Réessayez plus tard.']; + } + } + + return [true, '']; +} + + +/** + * Envoi immédiat SMTP avec PHPMailer + journalisation. + * @param string $to destinataire + * @param string $subject objet + * @param string $html corps HTML + * @param string|null $text corps texte brut (optionnel, auto-généré si null) + * @param array $opts ['reply_to'=>['email','name']] + */ +function envoyer_mail_smtp(string $to, string $subject, string $html, ?string $text = null, array $opts = []): bool +{ + if (!($opts['bypass_rate_limit'] ?? false)) { + [$ok, $msg] = mailer_can_send($to, (int)env('SMTP_COOLDOWN_MINUTES', '5'), (int)env('SMTP_MAX_PER_12H', '5')); + if (!$ok) { + throw new RuntimeException($msg); + } + } + + $pdo = db(); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare("INSERT INTO journal_smtp + (created_at, script_path, to_email, subject, content_html, content_text, status, ip, user_agent) + VALUES (NOW(), :script, :to, :subj, :html, :text, 'queued', :ip, :ua) + RETURNING id"); + $stmt->execute([ + ':script' => ($_SERVER['SCRIPT_NAME'] ?? ''), + ':to' => $to, + ':subj' => $subject, + ':html' => $html, + ':text' => $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES)), + ':ip' => ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''), + ':ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512), + ]); + $rowId = (int)$stmt->fetchColumn(); + $pdo->commit(); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + $mail = new PHPMailer(true); + try { + $mail->isSMTP(); + $_smtpRead = function_exists('smtpCfg'); + $mail->Host = $_smtpRead ? smtpCfg('host', 'SMTP_HOST', 'localhost') : (string)env('SMTP_HOST', 'localhost'); + $mail->Port = (int)($_smtpRead ? smtpCfg('port', 'SMTP_PORT', '587') : env('SMTP_PORT', '587')); + $_smtpUser = $_smtpRead ? smtpCfg('user', 'SMTP_USER') : (string)env('SMTP_USER', ''); + $_smtpPass = $_smtpRead ? smtpCfg('pass', 'SMTP_PASS') : (string)env('SMTP_PASS', ''); + $mail->SMTPAuth = ($_smtpUser !== '' || $_smtpPass !== ''); + $mail->Username = $_smtpUser; + $mail->Password = $_smtpPass; + $secure = strtolower($_smtpRead ? smtpCfg('secure', 'SMTP_SECURE', 'tls') : (string)env('SMTP_SECURE', 'tls')); + if ($secure === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } elseif ($secure === 'tls') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPKeepAlive = true; // réutilise la connexion + $mail->Timeout = 30; // évite les blocages longs + $mail->SMTPOptions = ['ssl' => ['verify_peer' => true,'verify_peer_name' => true,'allow_self_signed' => false]]; + + $mail->CharSet = 'UTF-8'; + $mail->isHTML(true); + + // Expéditeur + $from = $_smtpRead ? smtpCfg('from', 'SMTP_FROM', 'no-reply@varlog.a5l.fr') : (string)env('SMTP_FROM', 'no-reply@varlog.a5l.fr'); + $fromName = $_smtpRead ? smtpCfg('from_name', 'SMTP_FROM_NAME', 'varlog') : (string)env('SMTP_FROM_NAME', 'varlog'); + $mail->setFrom($from, $fromName); + + // Reply-To + if (!empty($opts['reply_to']) && is_array($opts['reply_to']) && filter_var($opts['reply_to'][0] ?? '', FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($opts['reply_to'][0], $opts['reply_to'][1] ?? ''); + } elseif ($rt = env('SMTP_REPLY_TO')) { + $mail->addReplyTo($rt, (string)env('SMTP_REPLY_TO_NAME', 'Support')); + } + + // DKIM optionnel + if ($d = env('DKIM_DOMAIN')) { + $mail->DKIM_domain = $d; + $mail->DKIM_selector = (string)env('DKIM_SELECTOR', 'default'); + $mail->DKIM_private = (string)env('DKIM_PRIVATE_KEY_PATH', ''); + $mail->DKIM_passphrase = (string)env('DKIM_PASSPHRASE', ''); + $mail->DKIM_identity = $from; + } + + $mail->addAddress($to); + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = $text ?? trim(html_entity_decode(strip_tags($html), ENT_QUOTES)); + + $mail->send(); + + $pdo->prepare("UPDATE journal_smtp SET status='sent', sent_at=NOW() WHERE id=:id")->execute([':id' => $rowId]); + return true; + } catch (Exception $e) { + $pdo->prepare("UPDATE journal_smtp SET status='error', error_message=:err, sent_at=NOW() WHERE id=:id") + ->execute([':id' => $rowId, ':err' => substr($e->getMessage(), 0, 1000)]); + throw new RuntimeException('Envoi email impossible: '.$e->getMessage()); + } +} diff --git a/templates/about.php b/templates/about.php new file mode 100644 index 0000000..13d8065 --- /dev/null +++ b/templates/about.php @@ -0,0 +1,10 @@ + +
+ +
+getFiles($addFilesArticle['uuid']); +$articleUuid = $addFilesArticle['uuid']; +$articleTitle = $addFilesArticle['title']; + +// Extraire 1-3 mots significatifs du titre pour l'auto-recherche +$_sfStop = ['ou','et','un','une','le','la','les','de','du','des','en','au','aux','ce','cet', + 'cette','ces','que','qui','par','sur','dans','son','sa','ses','mon','ton','nos', + 'vos','leur','leurs','voir','comment','quoi','dont','votre','notre','selon','car', + 'mais','donc','puis','plus','très','avec','pour','pas','est','sont','était', + 'être','avoir','faire','tout','tous','toute','toutes']; +$_sfWords = preg_split('/[^a-zA-ZÀ-ÿ0-9]+/u', $articleTitle) ?: []; +$_sfKw = []; +foreach ($_sfWords as $_w) { + if (mb_strlen($_w) >= 3 && !in_array(mb_strtolower($_w), $_sfStop, true)) { + $_sfKw[] = $_w; + if (count($_sfKw) >= 3) { + break; + } + } +} +$autoSearchQuery = !empty($_sfKw) ? implode(' ', $_sfKw) : $articleTitle; +unset($_sfStop, $_sfWords, $_sfKw, $_w); +?> + + +
+ + +
+ ← Retour +

Ajouter des fichiers

+
+ +

+ Article : +

+ +
+ + +
+
+
+
Uploader
+
+
+ +
+ Images → sha256-taille.ext
+ Vidéos, PDF, autres → nom sanitisé +
+
+
+ + Annuler +
+
+
+
+ + + +
+
+
Fichiers existants
+
+ +
+ + + + + '🎬', + str_starts_with($f['mime'], 'audio/') => '🎵', + $f['mime'] === 'application/pdf' => '📑', + default => '📄', + } ?> + + +
+ + Ko +
+ + cover + +
+ +
+
+
+ +
+ + +
+
+
+
Fichiers d'autres articles
+
+ + +
+
+
+
+
+ +
+ + + +Brouillon'; + } + if (strtotime((string)($a['published_at'] ?? '')) > $now) { + return 'Avant-première'; + } + return 'Publié'; +} +?> + +
+

Administration

+ + Nouvel article +
+ + + + + + + +
+ 'Publiés', 'value' => $adminData['published'], 'color' => 'success'], + ['label' => 'Avant-premières', 'value' => $adminData['previews'], 'color' => 'warning'], + ['label' => 'Brouillons', 'value' => $adminData['drafts'], 'color' => 'secondary'], + ['label' => 'Total', 'value' => $adminData['total'], 'color' => 'primary'], + ]; + foreach ($stats as $s): ?> +
+
+
+
+
+
+
+
+ +
+ +
Activité récente
+ + + + + + + + + + + + + + + + + + + +
TitreAuteurStatutModifié le
+ + + + + +
+ + + + + +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ + + + Réinitialiser + +
+ +
+ résultat(s) +
+ +
+ + +

Aucun article.

+ +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TitreAuteurCatégorieStatutDate
+ + + + + + + + + Modifier +
+
+ + + + + + + +
+ Impossible de retirer le rôle Administrateur : il doit rester au moins un administrateur. +
+ + + +
+
Attribuer un rôle
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + +

Aucun utilisateur.

+ + + + + + + + + + + + + + + + + + + + +
EmailStatutRôles
+ + Pré-inscrit + + Actif + + Inactif + + + + +
+ + + +
+ + + + +
+ + !in_array($r['name'], $currentRoleNames, true)); + ?> + +
+ + + +
+ +
+ + + + + +
+ + +
+ +

Aucun rôle défini.

+ + + + + + + + + + + + + + + + + + +
RôleUtilisateurs
+ + +
+ KNOWN_CAPABILITIES[$c] ?? $c, + $r['capabilities'] + ); + echo htmlspecialchars(implode(', ', $capLabels) ?: '–'); + ?> +
+ +
Toutes les permissions
+ +
+ + + Éditer + +
+ + +
+ +
+ +
+ + +
+
+
Nouveau rôle
+
+
+
+ + +
+ + +
+
+
+
+ +
+ + + + +
Paramètres enregistrés.
+ + +
+
Paramètres du site
+
+
+
+ + +
Affiché dans la barre de navigation et les onglets.
+
+
+ + +
Affiché sous le titre dans la navbar et dans le pied de page.
+
+
+ + +
Format BCP 47 (ex : fr, fr-FR). Utilisé dans <html lang>, og:locale, RSS et JSON-LD.
+
+
+ + +
+
+ + +
+
+ + +
Affiché dans le footer.
+
+ +
+
+
+ + + + + + + + +
+ + +
+
Catégories existantes
+ +

Aucune catégorie définie.

+ +
+ $_count): + $_gradient = coverGradient($_cat, $_cats); ?> +
+
+ +
+ +
+ + article 1 ? 's' : '' ?> +
+ +
+ + + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ +
+ +
+ + +
+
Prochaine couleur
+
+
+

+ La prochaine catégorie reçoit la couleur n°. +

+
+ $_rgb): + $_g = _paletteGradient($_rgb, 0); + $_active = $_i === $_nextIdx; + ?> +
+
+ +
+
+
+
+ +
+ + +
+ +
+ +
+
Types de tags
+ +

Aucun type de tag défini.

+ +
+ $_label): ?> +
+
+ + +
+ + +
+
+
+ +
+ +
+ +
+
Nouveau type
+
+
+
+
+ + +
Minuscules, chiffres, _
+
+
+ + +
+ +
+
+
+
+ +
+ + + + + + + + + + + + +
Paramètres SMTP enregistrés.
+ + +
+ + +
+
+
Configuration SMTP
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
Laisser vide pour conserver le mot de passe actuel.
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+ + +
+ + +
+
+
+
+ + + +
+
+ + + ✓ Succès + + ✗ Échec + + — + + + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ + + + + + + + 0, 'sent' => 0, 'error' => 0, 'queued' => 0]; + $emlFilters = [ + '' => ['label' => 'Tous', 'count' => $emlCounts['all']], + 'sent' => ['label' => 'Envoyés', 'count' => $emlCounts['sent']], + 'error' => ['label' => 'Erreurs', 'count' => $emlCounts['error']], + 'queued' => ['label' => 'En file', 'count' => $emlCounts['queued']], + ]; + ?> + +
+
Logs emails + +
+
+ $fInfo): ?> + + + + + +
+
+ + +

Aucun email enregistré.

+ +
+ + + + + + + + + + + + + Envoyé' . ($emSentAt ? ' ' . $emSentAt : '') . ''; + } elseif ($em['status'] === 'error') { + $emBadge = 'Erreur'; + } else { + $emBadge = 'En file'; + } + ?> + + + + + + + + + +
DateDestinataireSujetStatutContenu
+
+ Voir +
+ +

Erreur :

+ + +
+ +
+
+
+
+ + 0 || count($adminData['emails']) === 50): ?> + + + + + + + + + + + 0, 'pending' => 0, 'verified' => 0, 'hidden' => 0]; + $cmtFilters = [ + '' => ['label' => 'Tous', 'count' => $cmtCounts['all']], + 'pending' => ['label' => 'En attente', 'count' => $cmtCounts['pending']], + 'verified' => ['label' => 'Vérifiés', 'count' => $cmtCounts['verified']], + 'hidden' => ['label' => 'Masqués', 'count' => $cmtCounts['hidden']], + ]; + ?> + +
+
Commentaires + +
+
+ $fInfo): ?> + + + + + +
+
+ + +

Aucun commentaire pour ce filtre.

+ +
+ + + + + + + + + + + + + + + Envoyé'; + } elseif ($mailStatus === 'error') { + $mailBadge = 'Erreur'; + } elseif ($mailStatus === 'queued') { + $mailBadge = 'En file'; + } else { + $mailBadge = '-'; + } + ?> + + + + + + + + + + + +
ArticleAuteurCommentaireDateEmailStatut
+ + + + + + + + +
+
+
+ + + + +
Code : + +
+ + En attente + + Vérifié + + Publié + + Masqué + + + + +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+
+ + + + + + + + +
+
Termes recherchés + +
+ Derniers 14 jours de logs · cache 10 min +
+ + +
+ Les logs Apache ne sont pas lisibles par PHP. Vérifiez que www-data appartient au groupe adm. +
+ +

Aucune recherche trouvée dans les logs.

+ + +
+ + + + + + + + + + + $count): ?> + + + + + + + + + +
#Terme recherchéFois
+ + + + +
+
+
+
+
+ + + + + + +
+ ← Retour +

Rôle :

+ +
+ +
+ +
+ + +
+ + +
+ Le rôle admin a toutes les permissions implicitement — les cases à cocher sont ignorées. +
+ + +
+
+ $groupCaps): ?> +
+
+ + +
+ > + +
+ +
+ +
+
+ + +
+ + +
+
+
+
+ + +
+ + Annuler +
+
+
+ +
+
+ + + +
+ +

Articles de

+
+ + +

Aucun article publié.

+ +
+text($post['content']); + $preview = mb_strimwidth(strip_tags($html), 0, 120, '…'); + $category = trim((string)($post['category'] ?? '')); + $gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []); + $postUrl = '/post/' . rawurlencode($post['slug']); + $coverFile = $post['cover'] ?? ''; + $coverStyle = $coverFile !== '' + ? 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')' + : 'background: ' . $gradient; + $isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time(); + $isLocked = $isAvantPremiere && !hasCapability('view_previews'); + ?> +
+ +
Avant-première
+ +
+ + + +
+
+

+ + + + + +

+

+ +
+ + + +
+ +
+ + + + + + + + +
+
+
+

+ + + ↗ + + + Mes liens +
+ +
+

+ +
+ + +
+ + +

Aucun article publié.

+ +
+ text($post['content']); + $preview = mb_strimwidth(strip_tags($html), 0, 120, '…'); + $category = trim((string)($post['category'] ?? '')); + $gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []); + $postUrl = '/post/' . rawurlencode($post['slug']); + $coverFile = $post['cover'] ?? ''; + $coverStyle = $coverFile !== '' + ? 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')' + : 'background: ' . $gradient; + ?> +
+
+ + + +
+
+

+ +

+

+ +
+ +
+ +
+ 6): ?> +

+ Voir tous les articles → +

+ + + + + +
+

Catégories

+ ← Retour +
+ +
+ + +
+
Catégories existantes
+ + +

Aucune catégorie définie.

+ +
+ $count): + $gradient = coverGradient($cat, $cats); ?> +
+
+ + +
+ + +
+ + article 1 ? 's' : '' ?> +
+ + +
+ + + +
+ + + +
+ + +
+ + +
+ + +
+ +
+
+ +
+ +
+ + +
+
Nouvelle catégorie
+
+
+

+ Créez une catégorie en l'assignant à un article. + La prochaine reçoit la couleur n°. +

+ + +
+ $rgb): + $g = _paletteGradient($rgb, 0); + $active = $i === $nextIdx; + ?> +
+
+ +
+
+
+
+ +
+ + +
+ +
+

Types de tags

+
+ +
+ + +
+ +

Aucun type de tag défini.

+ +
+ $_label): ?> +
+
+ + +
+ + +
+
+
+ +
+ +
+ + +
+
Nouveau type
+
+
+
+
+ + +
Minuscules, chiffres, _ (sans accent)
+
+
+ + +
+ +
+
+
+
+ +
+ + + +// $visitorReactions — string[] (types déjà cliqués par ce visiteur) +// $comments — array de commentaires publiés +// $commentFlash — bool|null (commentaire soumis, email envoyé) +// $commentVerified — bool|null (commentaire vérifié et publié) +// $commentError — string|null (message d'erreur) + +$_reactionDefs = [ + 'useful' => ['👍', 'Utile'], + 'important' => ['🔥', 'Important'], + 'interesting' => ['🤔', 'À creuser'], +]; + +$_csrfToken = bin2hex(random_bytes(16)); +$_SESSION['comment_csrf'] = $_csrfToken; +?> + + + +
+
À lire aussi
+ +
+ + + +
+ +
+ Commentaires + + + +
+ + +
+ Un code de confirmation vous a été envoyé par email. + Cliquez sur le lien reçu, puis saisissez le code à 6 chiffres pour publier votre commentaire. +
+ + + +
Votre commentaire a été publié. Merci !
+ + + +
+ + + +
+
+
+ + + + +
+
+ +
+
+ + + +
+
+ +
+
+ + + +

Aucun commentaire pour l'instant. Soyez le premier !

+ + + +
+
+
Laisser un commentaire
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Un code de vérification sera envoyé à votre adresse email. +
+
+
+
+
+ +
diff --git a/templates/contact.php b/templates/contact.php new file mode 100644 index 0000000..ca88305 --- /dev/null +++ b/templates/contact.php @@ -0,0 +1,145 @@ + 100) { + $error = 'Nom invalide.'; + } elseif (!filter_var($from, FILTER_VALIDATE_EMAIL)) { + $error = 'Adresse e-mail invalide.'; + } elseif ($body === '' || mb_strlen($body) > 5000) { + $error = 'Message vide ou trop long (max 5000 caractères).'; + } + } + + if (!$error && $contactEmail !== '') { + $subjectClean = mb_encode_mimeheader( + '[' . siteTitle() . ' contact] ' . mb_strimwidth($subject, 0, 100, '…'), + 'UTF-8', + 'B' + ); + $nameClean = mb_encode_mimeheader($name, 'UTF-8', 'B'); + + $fromEmail = $_ENV['CONTACT_FROM_EMAIL'] ?? ('noreply@' . (parse_url(APP_URL, PHP_URL_HOST) ?? 'localhost')); + $headers = 'From: =?UTF-8?B?' . base64_encode(siteTitle() . ' contact') . "?= <{$fromEmail}>\r\n"; + $headers .= "Reply-To: {$nameClean} <{$from}>\r\n"; + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $headers .= "Content-Transfer-Encoding: 8bit\r\n"; + + $fullBody = "De : {$name} <{$from}>\n\n{$body}\n\n---\nEnvoyé depuis varlog"; + + if (@mail($contactEmail, $subjectClean, $fullBody, $headers)) { + $_SESSION['contact_last_sent'] = time(); + $success = true; + } else { + $error = 'Erreur lors de l\'envoi. Veuillez réessayer plus tard.'; + } + } elseif (!$error) { + $error = 'Formulaire de contact non configuré.'; + } +} + +// Génère un nouveau token CSRF à chaque affichage du formulaire +if (!$success) { + $_SESSION['contact_csrf'] = bin2hex(random_bytes(16)); +} + +ob_start(); +?> + +
+

Contact

+

Envoyez-moi un message. Votre adresse e-mail ne sera pas publiée.

+ + + + + + + + + + + +
+
+
+ + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+ + + +
+
+ +
+ ← Retour +

Confirmation — droits d'auteur

+
+ +
+ +
+ Vous êtes sur le point de copier ce fichier sur votre serveur : + +
+
+ + +
+
Ce que dit la loi française
+
+

En France, toute œuvre de l'esprit est protégée dès sa création sans + formalité d'enregistrement (art. L.111-1 CPI). Cela inclut les photographies, illustrations, + textes, vidéos et musiques.

+ +

Reproduire ou diffuser publiquement une œuvre sans l'autorisation de son auteur constitue + une contrefaçon (art. L.335-2 CPI), passible de :

+
    +
  • 3 ans d'emprisonnement
  • +
  • 300 000 € d'amende
  • +
+ +

L'exception d'usage privé (art. L.122-5 1° CPI) est strictement + personnelle et ne couvre pas la publication sur un blog, même non commercial + et même à audience restreinte.

+
+
+ + +
+
+ ✓ Cas où vous pouvez légalement télécharger ce fichier +
+
    +
  • + Vous êtes l'auteur ou co-auteur du fichier +
  • +
  • + Le fichier est distribué sous une licence libre compatible avec la + reproduction publique : CC0, + CC BY, + CC BY-SA, + CC BY-ND, + domaine public, etc.
    + ⚠ CC BY-NC ne suffit pas si le blog génère des revenus, + même indirects. +
  • +
  • + Vous disposez d'une autorisation écrite explicite de l'auteur ou + du titulaire des droits patrimoniaux +
  • +
  • + L'œuvre est dans le domaine public : 70 ans révolus après le décès + de l'auteur en Union Européenne (art. L.123-1 CPI) +
  • +
+
+ + +
+ + + + + + + + + + + +
+
+
+ + +
+
+
+ +
+ + Annuler +
+
+ +
+
+ + + +
+ ← Retour +
+ + — révision # + + du + + +
+
+ +
+ − Supprimé + + Ajouté + = Inchangé +
+ + +
Aucune différence — le contenu est identique.
+ + + +
+ + + + +
+ + + +
+ +
− 
+ +
+ +
  
+ + +
+ + + + + + + +
+ ← Retour à l'édition +
+

+ Suggestions : +

+

+ +

+
+
+ +
+ + + +

+ Sélectionnez une catégorie. Les chiffres indiquent combien de fois le mot apparaît dans le texte. +

+ +
+ $_cnt): ?> + +
+ +
+ +
+ + + + $s['current']); + $_known = array_filter($suggestions, fn ($s) => !$s['current'] && $s['known']); + $_abbrevs = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && $s['group'] === 'abbrev'); + $_camel = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && $s['group'] === 'camel'); + $_proper = array_filter($suggestions, fn ($s) => !$s['current'] && !$s['known'] && in_array($s['group'], ['proper'], true)); + ?> + +

+ Cochez les termes à associer à cet article. Les termes déjà taggués sont présélectionnés. +

+ + + +
+ 10): ?> +
+ + + + +
+ +
+ +
+ + +
+ $_info): ?> + + +
+ + 10): ?> +
+
+ +
+ + + + + + + + +

Aucun terme détecté dans cet article.

+ + + + +
+ + Annuler +
+ +
+ + + +
+

Flux agrégés

+
+ + +

Aucun article disponible pour l'instant.

+ +
+ 0 ? date('d/m/Y', $_item['date']) : ''; + $_authorName = $_item['author_name'] ?? ''; + $_authorSlug = $_item['author_slug'] ?? ''; + ?> +
+
+ + + + + + + + + + + +
+

+ + ↗ + +

+ +

+ +
+ +
+ + + + + + diff --git a/templates/header.php b/templates/header.php new file mode 100644 index 0000000..7fec33a --- /dev/null +++ b/templates/header.php @@ -0,0 +1,49 @@ + + +
+
+ + + Mug ALPINUX + + + + + +sessionAlready()) { + ?> + + + + + +
+
+ + diff --git a/templates/import_image.php b/templates/import_image.php new file mode 100644 index 0000000..7e48911 --- /dev/null +++ b/templates/import_image.php @@ -0,0 +1,41 @@ + + +
+ ← Retour +

Importer un fichier depuis une URL

+
+ +

+ Article : +

+ + +
+ URL invalide ou inaccessible — vérifiez que le lien est correct et que le serveur peut y accéder. +
+ + +
+
+
+
+ + +
Les métadonnées seront récupérées automatiquement à l'étape suivante.
+
+
+ + Annuler +
+
+
+
+ + 'Type', + 'size' => 'Taille', + // PDF + 'pages' => 'Pages', + 'page_size' => 'Format', + 'pdf_version' => 'Version PDF', + // Image + 'width' => 'Dimensions', + 'camera' => 'Appareil', + // HTML + 'site_name' => 'Site', + 'og_type' => 'Type', + 'language' => 'Langue', + // Commun + 'author' => $isHtml ? 'Auteur' : ($isPdf ? 'Auteur' : 'Auteur EXIF'), + 'date' => $isPdf ? 'Créé le' : ($isHtml ? 'Publié le' : 'Prise de vue'), + 'description' => 'Description', + 'subject' => 'Sujet', + 'keywords' => 'Mots-clés', + 'copyright' => 'Copyright', + // PDF logiciel + 'creator' => 'Créé avec', + 'producer' => 'Produit par', + // HTML liens + 'canonical' => 'URL canonique', + 'og_image' => 'Image OG', +]; + +$hasTitle = !empty($step2Meta['title']); +$preAuthor = $step2Meta['author'] ?? $step2Meta['credit'] ?? ''; +$preSource = $step2Meta['canonical'] ?? $step2Meta['source'] ?? $step2Url; +?> + +
+ ← Retour +

Importer un fichier

+
+ +

+ Article : +

+ + +
+ Le site a bloqué la récupération automatique des métadonnées (protection anti-bot). + Renseignez le titre manuellement ci-dessous. +
+ + + + +
+

Aperçu de la page

+ + Aperçu +
+ + + + !empty($step2Meta[$key]), ARRAY_FILTER_USE_BOTH); +if ($visibleRows): ?> +
+
Métadonnées du fichier
+
+ + + $label): ?> + htmlspecialchars(round($val / 1024) . ' Ko'), + 'width' => htmlspecialchars($val . ' × ' . ($step2Meta['height'] ?? '?') . ' px'), + 'og_image' => '', + 'canonical' => '' . htmlspecialchars((string)$val) . '', + default => htmlspecialchars((string)$val), + }; + ?> + + + + + + +
+
+
+ + + +
+
+
+ + + + + $v !== null && $v !== '' + ); +?> + + + +
+ + + +
+ Titre non trouvé dans les métadonnées — saisie requise. +
+ +
+ + +
+

Mode

+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
> + +
+ + +
+ + +
+ + +
Laissé vide → URL du fichier utilisée comme source.
+
+ + +
+
+ + +
+
+ +
+ +
+ + Annuler +
+
+
+
+ + + + + + <?= htmlspecialchars(($seoTitle ?? '') ?: ($title ?? siteTitle())) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + class=""> + +
+ +
+ +
+ +
+ + + + + + + + + + + + diff --git a/templates/legal.php b/templates/legal.php new file mode 100644 index 0000000..ee6f011 --- /dev/null +++ b/templates/legal.php @@ -0,0 +1,10 @@ + +
+ +
+ +
+ +
+ + +
+ +
+
+

+ +

+ +
+ + +
+ + +
+ $_link): + $_btnBg = $_palette[$_i % count($_palette)]; + ?> + + + + + + + +
+ + + + + +
+ + + +
+ ← Retour à l'édition +

Confirmer les modifications

+
+ +
+ + + + + + + + + + + $_ctv): ?> + + + + + + $fname): ?> + + + + + +
+
+ + +
+ +
+ +
Aucune modification détectée.
+ + + +
+

Diff du contenu

+ +
Contenu identique.
+ +
+ − Supprimé + + Ajouté +
+
+ + + + +
+ + + +
+ +
− 
+ +
+ +
  
+ + +
+ +
+ + + +
+ + + +
+ Slug recalculé depuis le nouveau titre. Slug initial : + + +
+ +
+ + +
+ + +
+ +
+ + ← Retour +
+ +
+ + +
+
+
+ SEO — titre, description, image +
+
+
+ + +
+ Idéal : 30–60 car. + 0 / 60 +
+
+ +
+ + +
+ Idéal : 120–155 car. + 0 / 155 +
+
+ +
+
+ +
+
+ Aperçu dans les moteurs de recherche +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Auteur
Publication
Modification (après enreg.)
Languefr-FR
Catégorie—' ?>
og:image + ' . htmlspecialchars($coverFilename) . '' : '— (pas de couverture)' ?> +
+
+
+
+ +
+
+ + + + + + + + + +
+
+
+ +

Modifier l'article

+ + + +

Nouvel article

+ + + + + + +
+
    + +
  • + +
+
+ + +
+ + +
+ + + + +
+ + +
+ + + +
+ +
+ +
+
+ +
+
+ + +
+

Tags

+ $_tagLabel): + $_tagVal = implode(', ', $articleTags[$_tagKey] ?? []); + $_suggestId = 'tag-suggest-create-' . htmlspecialchars($_tagKey); + $_tagVals = $allTagValues[$_tagKey] ?? []; + ?> +
+ + + + + +
+ +
+ + + +
+ + Écris en Markdown — les fichiers uploadés sont référençables dans le contenu : + ![alt](nom-du-fichier.jpg) + +
+ +
+ + +
+ + +
+
+ + +
+
+
+ > + +
+
+
+ +
+ + +
Images → nommées sha256-taille.ext. Vidéos, PDF → nom sanitisé.
+
+ + + +
+ +
+ +
+
+ + ⚡ Suggestions +
+
+ +
+
+ +
+
+ + +
+

Tags

+ $_tagLabel): + $_tagVal = implode(', ', $articleTags[$_tagKey] ?? []); + $_suggestId = 'tag-suggest-' . htmlspecialchars($_tagKey); + $_tagVals = $allTagValues[$_tagKey] ?? []; + ?> +
+
+ + + ⚡ Suggestions + +
+ + + + +
+ +
+ + +
+ + +
+ > + +
+
+ + +
+ + + + +
+ + +
+ + Annuler + +
+ +
+ + + + +
+

Fichiers existants

+
+ $f): ?> + +
+
+ + + + + + + + '🎬', + str_starts_with($f['mime'], 'audio/') => '🎵', + $f['mime'] === 'application/pdf' => '📑', + default => '📄', + } ?> + + + + +
+
+ + Ko + + cover + +
+
+ + + +
+
+ +
+ + +
+ + + ✓ Cover + +
+ + +
+
+
+
+
+ +
+
+
+ + + $f['is_image']); ?> + + +
+

+ Images disponibles + (clic → insère dans le contenu) +

+
+ + + <?= htmlspecialchars($img['name']) ?> + +
+
+ + + + +
+

Liens externes

+
    + + +
  • + + + + + + +
    + + +
    +
  • + +
+
+ + + + +
+
+ + + + $f): ?> +
+ +
+ + + + + +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + +
#DateTitre à l'époqueCommentaire
+ + + + + –' ?> + + + Diff + +
+ + +
+
+
+
+ + + + +
+
+ +
+
+
+
+ + +
+ Idéal : 30–60 caractères + 0 / 60 +
+
+ +
+ + +
+ Idéal : 120–155 caractères + 0 / 155 +
+
+ +
+ + +
+
+
+
+ +
+ + Annuler +
+ + + + + +text($post['content'])), 0, $len, '…'); +} + +function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown $pd): void +{ + $postUrl = '/post/' . rawurlencode($post['slug']); + $isDraft = !$post['published']; + $isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time(); + $postCat = trim($post['category'] ?? ''); + $isPrivate = $postCat !== '' && in_array($postCat, $privateCats, true); + $isLocked = $isAvantPremiere && !hasCapability('view_previews'); + $coverStyle = _cardCoverStyle($post, $allCats); + $preview = _cardExcerpt($post, $pd); + ?> +
+ +
Brouillon
+ +
Avant-première
+ +
Privé
+ +
+ + + +
+
+

+ + + + + +

+

+ +
+ + + +
+ + + + + + + + + + +
+

+ Derniers articles + + À la une + +

+ + time() && !hasCapability('view_previews'); + ?> + +
+ + À la une + +
+ +
+ +

+

+
 → lire
+
+
+
+ + +
+ + + +
+ + + + 6): ?> + + +
+ + + + +
+

+ Tendances · 10 derniers jours +

+
+ + + +
+
+ + + + +
+

Récemment mis à jour

+ +
+ + + + +
+

Redécouvertes

+ +
+ + + + + + + + +
+ + + +
+ + + + + + + + isLoggedIn() || !in_array($cat, $privateCats ?? [], true), + ARRAY_FILTER_USE_KEY +); +arsort($_tagCats); +if (!empty($_tagCats)): + $_minCount = min($_tagCats); + $_maxCount = max($_tagCats); + ?> + + + + ++ + + + 'https://schema.org', + '@type' => 'WebSite', + 'name' => siteTitle(), + 'url' => rtrim(APP_URL, '/') . '/', + 'description' => siteClaim(), + 'inLanguage' => siteLang(), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +$mainClass = 'container-fluid'; +include __DIR__ . '/layout.php'; diff --git a/templates/post_view.php b/templates/post_view.php new file mode 100644 index 0000000..b14ef62 --- /dev/null +++ b/templates/post_view.php @@ -0,0 +1,354 @@ + 'a','â' => 'a','ä' => 'a','á' => 'a','é' => 'e','è' => 'e','ê' => 'e','ë' => 'e', + 'î' => 'i','ï' => 'i','í' => 'i','ô' => 'o','ö' => 'o','ó' => 'o','ù' => 'u','û' => 'u', + 'ü' => 'u','ú' => 'u','ç' => 'c','ñ' => 'n','æ' => 'ae','œ' => 'oe', +]; +$_tocItems = []; +$_tocSeen = []; +$_renderedContent = preg_replace_callback( + '/<(h[23])>(.+?)<\/h[23]>/i', + function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) { + $tag = $m[1]; + $inner = $m[2]; + $level = (int) substr($tag, 1); + $plain = strip_tags($inner); + $slug = trim(preg_replace( + '/[^a-z0-9]+/', + '-', + mb_strtolower(strtr($plain, $_accentMap), 'UTF-8') + ), '-') ?: 'section'; + if (isset($_tocSeen[$slug])) { + $_tocSeen[$slug]++; + $id = $slug . '-' . $_tocSeen[$slug]; + } else { + $_tocSeen[$slug] = 0; + $id = $slug; + } + $_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id]; + return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}"; + }, + $Parsedown->text($rawContent) +); + +ob_start(); + +$coverFile = $article['cover'] ?? ''; +$ogImage = $coverFile !== '' + ? url('file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($coverFile)) + : null; + +$category = trim((string)($article['category'] ?? '')); +$gradient = coverGradient($category !== '' ? $category : $article['uuid'], $allCats ?? []); + +// Pièces jointes (hors fichiers intégrés, thumbs et cover) +$attachments = []; +if ($files) { + $referenced = []; + preg_match_all('/\(\/file\?uuid=[^&]+&name=([^)]+)\)/', $rawContent, $m); + foreach ($m[1] as $encodedName) { + $referenced[rawurldecode($encodedName)] = true; + } + $attachments = array_values(array_filter( + $files, + static fn ($f) => + !isset($referenced[$f['name']]) + && !str_starts_with($f['name'], '_thumb_') + && $f['name'] !== $coverFile + )); +} + +$externalLinks = $article['external_links'] ?? []; +?> +
+ + +
+ +
+ +
Brouillon
+ +
Privé
+ + +
> + + <?= htmlspecialchars($article['title']) ?> + +
+ + +
+ ← Retour + + ✎ Modifier + 🗑 Supprimer + +
+ + +
+
+ + + +

+

+ + + + + + + · + + +

+
+
+ + ℹ Sources + + ['👍', 'Utile'], + 'important' => ['🔥', 'Important'], + 'interesting' => ['🤔', 'À creuser'], + ]; +?> +
+ [$icon, $label]): ?> + +
+ + + + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+ + + + +
+ + + +
+ + + +text($article['content'])); + $plain = preg_replace('/\s+/', ' ', $plain); + $seoDescription = mb_strimwidth(trim((string)$plain), 0, 155, '…'); +} + +// og:image : cover puis fallback og_image du meta +if ($ogImage === null || $ogImage === '') { + $ogImage = $article['og_image'] ?? ''; +} + +// Auteur : nom et URL de profil résolus depuis le champ author du JSON de l'article +$metaAuthor = $authorName; +$metaAuthorUrl = $authorProfileUrl; + +// JSON-LD Article +$jsonLdData = [ + '@context' => 'https://schema.org', + '@type' => 'BlogPosting', + 'headline' => $seoTitle, + 'description' => $seoDescription, + 'url' => $canonical, + 'datePublished' => date('c', strtotime((string)$articlePublishedAt)), + 'dateModified' => date('c', strtotime((string)($article['updated_at'] ?? $articlePublishedAt))), + 'author' => array_filter([ + '@type' => 'Person', + 'name' => $metaAuthor !== '' ? $metaAuthor : siteTitle(), + 'url' => $metaAuthorUrl !== '' ? $metaAuthorUrl : null, + ]), + 'publisher' => [ + '@type' => 'Blog', + 'name' => siteTitle(), + 'url' => rtrim(APP_URL, '/'), + ], + 'inLanguage' => siteLang(), +]; +if (!empty($ogImage)) { + $jsonLdData['image'] = $ogImage; +} +$jsonLd = json_encode($jsonLdData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + +include __DIR__ . '/layout.php'; diff --git a/templates/profile.php b/templates/profile.php new file mode 100644 index 0000000..778ec69 --- /dev/null +++ b/templates/profile.php @@ -0,0 +1,227 @@ + + +
+
+

Mon profil

+ +
+ + +
Profil mis à jour.
+ + +
+ +
+ + +
+
+
Identité
+
+
+ + +
+ Affiché comme auteur sur vos articles. + +
Page publique : /profil/ + +
+
+
+ + +
+
+
+
+ + +
+
+
Page publique
+
+
+ + +
Affichée sur votre page de profil public.
+
+
+ + +
Lien vers un site ou profil externe (utilisé dans les métadonnées article:author, JSON-LD).
+
+
+
+
+ +
+
+ + + + + +
+

Flux RSS

+
+
+ +
+
    + +
  • +
    +
    +
    +
    +
    + + +
    +
  • + +
+
+ +
+
+
+
Ajouter un flux
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+ +prepare( + 'SELECT r.name, r.label, COALESCE(array_agg(rc.capability) FILTER (WHERE rc.capability IS NOT NULL), \'{}\') AS caps + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + LEFT JOIN role_capabilities rc ON rc.role_id = r.id + WHERE ur.user_email = :email + GROUP BY r.id, r.name, r.label + ORDER BY r.name' + ); + $st->execute([':email' => currentUserEmail()]); + $_profileRoles = $st->fetchAll(PDO::FETCH_ASSOC); +} +if (!empty($_profileRoles)): ?> +
+

Rôles & droits

+
+ $c !== '' + ); + ?> +
+
+
+ + +
+ +
    + +
  • + +
+ +
Aucun droit associé à ce rôle.
+ +
+
+ +
+
+ + + + +
+ + + + + + +

Aucun résultat pour .

+ +

+ résultat 1 ? 's' : '' ?> + pour +

+ + 'Dans le titre', 2 => 'Dans le texte', 3 => 'À peu près']; + $byTier = []; + foreach ($searchResults as $r) { + $byTier[$r['tier'] ?? 3][] = $r; + } + $multiTier = count($byTier) > 1; + ?> + +
+ $tierResults): ?> + +

+ + +
+
+ + + + + + +
+

+ +

+ +

+ +
+ + +
+ + + + +
+ + 'Type MIME', + 'size' => 'Taille originale', + 'pages' => 'Pages', + 'page_size' => 'Format', + 'pdf_version' => 'Version PDF', + 'width' => 'Dimensions', + 'camera' => 'Appareil photo', + 'site_name' => 'Site', + 'og_type' => 'Type OG', + 'language' => 'Langue', + 'date' => 'Date', + 'description' => 'Description', + 'subject' => 'Sujet', + 'keywords' => 'Mots-clés', + 'copyright' => 'Copyright', + 'credit' => 'Crédit', + 'creator' => 'Créé avec', + 'producer' => 'Produit par', + 'canonical' => 'URL canonique', + 'og_image' => 'Image OG', +]; + +function renderMetaCell(string $key, mixed $val, array $row = []): string +{ + return match($key) { + 'size' => htmlspecialchars(number_format((float)$val / 1024, 1)) . ' Ko', + 'width' => htmlspecialchars((string)$val) . ' × ' . htmlspecialchars((string)($row['height'] ?? '?')) . ' px', + 'og_image' => str_starts_with((string)$val, '/') + ? '' + : '' . htmlspecialchars((string)$val) . '', + 'canonical' => '' . htmlspecialchars((string)$val) . '', + default => htmlspecialchars((string)$val), + }; +} +?> + +
+ ← Modifier +

Sources & médias

+
+

+ + +
+

+ Liens & sources externes + +

+ + +

Aucun lien externe enregistré.

+ +
+ +
+
+
+ + + + + +
+ +
+ + +
+ +
+ + + + + +
+ + Auteur : + + + Ajouté le + +
+ + + isset($metaLabels[$k]) && $v !== null && $v !== '' && $k !== 'height', ARRAY_FILTER_USE_BOTH); + ?> + + + + $label): + if (!isset($lMeta[$key]) || $lMeta[$key] === '' || $lMeta[$key] === null) { + continue; + } + ?> + + + + + + +
+ + +
+
+
+
+ +
+ +
+ + !str_starts_with($f['name'], '_thumb_'))); +?> +
+

+ Pièces jointes + +

+ + +

Aucun fichier joint.

+ +
+ +
+
+
+ + + + + + + +
+ '🎬', + str_starts_with($f['mime'], 'audio/') => '🎵', + $f['mime'] === 'application/pdf' => '📑', + default => '📄', + } ?> +
+ + +
+ +
+ + + + + + + + cover + +
+ + +
+ Ko + + Auteur : + + + + Source : + + + + + +
+ + + isset($metaLabels[$k]) && $v !== null && $v !== '' && $k !== 'height', ARRAY_FILTER_USE_BOTH); + ?> + + + + $label): + if (!isset($fExtra[$key]) || $fExtra[$key] === '' || $fExtra[$key] === null) { + continue; + } + ?> + + + + + + +
+ + + + Pas de métadonnées enregistrées. + +
+
+
+
+ +
+ +
+ +