= $slots->text() ?>
diff --git a/site/snippets/toc.php b/site/snippets/toc.php
new file mode 100644
index 0000000..24226ad
--- /dev/null
+++ b/site/snippets/toc.php
@@ -0,0 +1,13 @@
+
+
diff --git a/site/templates/linear.php b/site/templates/linear.php
index 9177510..c58cd5c 100644
--- a/site/templates/linear.php
+++ b/site/templates/linear.php
@@ -24,6 +24,7 @@
+
chapo()->isNotEmpty()): ?>
@@ -33,7 +34,7 @@
isHtmlMode()->isTrue()): ?>
= $page->htmlBody()->kt() ?>
bodyBlocks()->isNotEmpty()): ?>
- = $page->bodyBlocks()->toBlocks() ?>
+ = addAnchors($page->bodyBlocks()->toBlocks()) ?>
= $page->body() ?>
From d51fc592ede190c7e055390af585059347dd6355 Mon Sep 17 00:00:00 2001
From: antonin gallon
Date: Tue, 17 Feb 2026 17:32:27 +0100
Subject: [PATCH 14/64] =?UTF-8?q?impl=C3=A9mentation=20de=20la=20toc=20com?=
=?UTF-8?q?plete=20pour=20linear?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/css/src/footer.css | 40 ++++++++++++++++++++++++--------
assets/css/src/header.css | 1 +
assets/css/src/toc.css | 46 +++++++++++++++++++++++++++++++------
assets/js/script.js | 33 ++++++++++++++++++--------
site/snippets/cover.php | 2 +-
site/snippets/footer.php | 9 ++++++--
site/snippets/header.php | 14 ++++++++++-
site/snippets/nav.php | 5 ++--
site/snippets/panel-toc.php | 6 +++++
site/snippets/toc.php | 2 +-
10 files changed, 125 insertions(+), 33 deletions(-)
create mode 100644 site/snippets/panel-toc.php
diff --git a/assets/css/src/footer.css b/assets/css/src/footer.css
index a50de8a..6f0f5da 100644
--- a/assets/css/src/footer.css
+++ b/assets/css/src/footer.css
@@ -12,12 +12,37 @@
bottom: 0;
}
+@media screen and (max-width: 640px) {
+ #main-footer ul {
+ bottom: 0;
+ display: flex;
+ justify-content: space-around;
+ border-top: 1px solid var(--color-primary);
+ background-color: var(--color-background);
+ }
+ #main-footer .open-nav-wrapper_toc{
+ margin-right: 50px;
+ }
+}
+@media screen and (min-width: 1100px) {
+ #main-footer .open-nav-wrapper_toc{
+ display: none !important;
+ }
+}
+
#main-footer li:not(.open-nav-wrapper) {
display: none;
}
+#main-footer li{
+ flex: 1;
+}
+#main-footer li > *{
+ width:calc(100% - var(--unit--vertical) * 2);
+}
+
#main-footer button.open-nav {
- transform: translateY(-1px);
+ transform: translateY(-2px);
}
[data-template="home"] .title-wrapper button.open-nav {
@@ -26,20 +51,17 @@
@media screen and (max-width: 640px) {
#main-footer .open-nav {
- box-sizing: border-box;
- bottom: 0;
display: flex;
- justify-content: center;
- width: 100%;
outline: none;
- border-top: 1px solid var(--color-primary);
font-size: var(--font-size-m);
background-color: var(--color-background);
- padding: calc(var(--unit--vertical) / 2) var(--unit--horizontal);
- margin-bottom: env(safe-area-inset-bottom);
color: var(--color-primary);
line-height: 1;
- }
+ padding: calc(var(--unit--vertical) / 2) var(--unit--horizontal);
+ }
+ [data-is_toc="false"] #main-footer .open-nav {
+ justify-content: center;
+ }
}
@media screen and (min-width: 640px) {
diff --git a/assets/css/src/header.css b/assets/css/src/header.css
index 783e683..a2cce66 100644
--- a/assets/css/src/header.css
+++ b/assets/css/src/header.css
@@ -82,6 +82,7 @@ article > h1 {
display: flex;
flex-direction: column;
+ gap: var(--unit--vertical);
}
[data-template="home"] .page-cover {
diff --git a/assets/css/src/toc.css b/assets/css/src/toc.css
index 9239140..a2faacd 100644
--- a/assets/css/src/toc.css
+++ b/assets/css/src/toc.css
@@ -1,15 +1,47 @@
+.toc{
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
.page-cover .toc{
- position: fixed;
- display: block;
- width: calc(var(--body-padding) - var(--unit--horizontal) * 2) ;
- left: 0;
- top: 15vw;
- padding-inline: var(--unit--horizontal);
+ flex: 1;
+}
+@media (min-width: 1100px){
+ .page-cover .toc{
+ position: fixed;
+ width: calc(var(--body-padding) - var(--unit--horizontal) * 2) ;
+ left: 0;
+ top: 15vw;
+ padding-inline: var(--unit--horizontal);
+ padding-top: calc(var(--unit--vertical) / 2);
+ }
+}
+.page-cover .toc{
+ flex: 1;
+}
+.panel-toc .toc{
+ padding: var(--unit--vertical) var(--unit--horizontal);
+
}
.toc_label{
font-size: var(--font-size-m);
+ margin-bottom: calc(var(--unit--vertical) / 4); /*option 1*/
+}
+.toc ul{
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--unit--vertical) / 4); /*option 1*/
}
.toc li{
margin-left: 0;
-}
\ No newline at end of file
+ /* text-indent: var(--unit--horizontal) hanging; */ /*option 2*/
+
+ /* list-style: square; */ /*option 3*/
+}
+
+
+[data-is_toc="false"] .if_toc,
+[data-is_toc="false"] #main-footer li.if_toc{ /*obliger d'être si précis car si non pas la priorité*/
+ display: none !important;
+}
diff --git a/assets/js/script.js b/assets/js/script.js
index f734272..65b8735 100644
--- a/assets/js/script.js
+++ b/assets/js/script.js
@@ -69,9 +69,13 @@ function toggleLogoState() {
}
function toggleFooterState() {
if (scrollY > 90) {
- document.querySelector(".open-nav-wrapper").classList.remove("hidden");
+ document.querySelectorAll(".open-nav-wrapper").forEach(element => {
+ element.classList.remove("hidden");
+ });
} else {
- document.querySelector(".open-nav-wrapper").classList.add("hidden");
+ document.querySelectorAll(".open-nav-wrapper").forEach(element => {
+ element.classList.add("hidden");
+ });
}
}
@@ -125,12 +129,16 @@ function subscribe(event) {
}
}
-const panelNav = document.querySelector(".panel");
+const panelsNav = document.querySelectorAll(".panel");
+const panelNavText = document.querySelector(".panel-text");
+const panelNavToc = document.querySelector(".panel-toc");
const navOverlay = document.querySelector("#nav-overlay");
const openNavBtns = document.querySelectorAll("button.open-nav");
-const closeNavBtn = document.querySelector(".panel-close");
+const closeNavBtns = document.querySelectorAll(".panel-close");
function closeNav() {
- panelNav.classList.remove("panel--visible");
+ panelsNav.forEach(element => {
+ element.classList.remove("panel--visible");
+ });
navOverlay.classList.remove("nav-overlay--visible");
document.body.classList.remove("no-scroll");
}
@@ -194,15 +202,22 @@ document.addEventListener("DOMContentLoaded", () => {
});
openNavBtns.forEach((openNavBtn) => {
- openNavBtn.addEventListener("click", () => {
- panelNav.classList.add("panel--visible");
+ openNavBtn.addEventListener("click", (event) => {
+ target = event.currentTarget;
+ if(target.classList.contains("open-nav_text")){
+ panelNavText.classList.add("panel--visible");
+ }else if(target.classList.contains("open-nav_toc")){
+ panelNavToc.classList.add("panel--visible");
+ }
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
});
});
- closeNavBtn.addEventListener("click", () => {
- closeNav();
+ closeNavBtns.forEach(element => {
+ element.addEventListener("click", () => {
+ closeNav();
+ });
});
navOverlay.addEventListener("click", () => {
closeNav();
diff --git a/site/snippets/cover.php b/site/snippets/cover.php
index 1b2f77a..37ed66a 100644
--- a/site/snippets/cover.php
+++ b/site/snippets/cover.php
@@ -8,7 +8,7 @@ $isOpen ??= false;
= $slots->title() ?>
- parent()->parent()->is('textes')){
+ parent() && $page->parent()->parent()->is('textes')){
snippet('toc', ["content" => $page->bodyBlocks()->toBlocks()]);
} ?>
text()): ?>
diff --git a/site/snippets/footer.php b/site/snippets/footer.php
index 87457ce..8ea5582 100644
--- a/site/snippets/footer.php
+++ b/site/snippets/footer.php
@@ -2,9 +2,14 @@
is(page('lettre')) && !$page->is(page('a-propos'))): ?>
- parent() && $page->parent()->parent()->is('textes')){
- snippet('toc', ["content" => $page->bodyBlocks()->toBlocks()]);
- } ?>
+ hasToc()): ?>
+
+
text()): ?>
= $slots->text() ?>
diff --git a/site/snippets/footer.php b/site/snippets/footer.php
index 8ea5582..61b668b 100644
--- a/site/snippets/footer.php
+++ b/site/snippets/footer.php
@@ -2,12 +2,12 @@
is(page('lettre')) && !$page->is(page('a-propos'))): ?>
-
+
\ No newline at end of file
diff --git a/site/snippets/panel-toc.php b/site/snippets/panel-toc.php
index f72eb4c..c4e62dc 100644
--- a/site/snippets/panel-toc.php
+++ b/site/snippets/panel-toc.php
@@ -1,4 +1,4 @@
-
-
+
+
\ No newline at end of file
From 1f024a7e71788d49e34f2786ea65a5f699649fbe Mon Sep 17 00:00:00 2001
From: antonin gallon
Date: Fri, 20 Feb 2026 17:14:40 +0100
Subject: [PATCH 17/64] =?UTF-8?q?sommaire=20>=20table=20des=20mati=C3=A8re?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
assets/css/src/footer.css | 8 ++++----
site/snippets/toc.php | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/assets/css/src/footer.css b/assets/css/src/footer.css
index d575fb6..53d7479 100644
--- a/assets/css/src/footer.css
+++ b/assets/css/src/footer.css
@@ -26,7 +26,7 @@
}
@media screen and (min-width: 1100px) {
- /* On mobile > 1100px, le bouton sommaire n'est pas nécessaire car la TOC est visible */
+ /* On mobile > 1100px, le bouton table des matières n'est pas nécessaire car la TOC est visible */
#main-footer .open-nav-wrapper:has([data-open-panel="toc"]) {
display: none !important;
}
@@ -36,11 +36,11 @@
display: none;
}
-#main-footer li{
+#main-footer li {
flex: 1;
}
-#main-footer li > *{
- width:calc(100% - var(--unit--vertical) * 2);
+#main-footer li > * {
+ width: calc(100% - var(--unit--vertical) * 2);
}
#main-footer button.open-nav {
diff --git a/site/snippets/toc.php b/site/snippets/toc.php
index 861d0c1..e684ad5 100644
--- a/site/snippets/toc.php
+++ b/site/snippets/toc.php
@@ -1,8 +1,8 @@
+
\ No newline at end of file
From d3b92209317cb8ef0218f5bb51c0af7c519f0ecd Mon Sep 17 00:00:00 2001
From: isUnknown
Date: Mon, 9 Mar 2026 10:14:02 +0100
Subject: [PATCH 18/64] add plugin web2print
---
.gitignore | 12 +-
site/plugins/web2print/.editorconfig | 20 ++
site/plugins/web2print/.gitattributes | 11 +
site/plugins/web2print/.gitignore | 14 ++
site/plugins/web2print/LICENSE.md | 21 ++
site/plugins/web2print/README.md | 117 +++++++++++
site/plugins/web2print/SECURITY.md | 18 ++
site/plugins/web2print/composer.json | 21 ++
site/plugins/web2print/composer.lock | 66 ++++++
site/plugins/web2print/index.css | 0
site/plugins/web2print/index.js | 1 +
site/plugins/web2print/index.php | 33 +++
site/plugins/web2print/package.json | 12 ++
.../src/blueprints/tabs/web2print.yml | 20 ++
.../web2print/src/components/Web2PrintBtn.vue | 36 ++++
site/plugins/web2print/src/index.js | 7 +
.../web2print/src/routes/web2print.php | 189 ++++++++++++++++++
17 files changed, 596 insertions(+), 2 deletions(-)
create mode 100644 site/plugins/web2print/.editorconfig
create mode 100644 site/plugins/web2print/.gitattributes
create mode 100644 site/plugins/web2print/.gitignore
create mode 100644 site/plugins/web2print/LICENSE.md
create mode 100644 site/plugins/web2print/README.md
create mode 100644 site/plugins/web2print/SECURITY.md
create mode 100644 site/plugins/web2print/composer.json
create mode 100644 site/plugins/web2print/composer.lock
create mode 100644 site/plugins/web2print/index.css
create mode 100644 site/plugins/web2print/index.js
create mode 100644 site/plugins/web2print/index.php
create mode 100644 site/plugins/web2print/package.json
create mode 100644 site/plugins/web2print/src/blueprints/tabs/web2print.yml
create mode 100644 site/plugins/web2print/src/components/Web2PrintBtn.vue
create mode 100644 site/plugins/web2print/src/index.js
create mode 100644 site/plugins/web2print/src/routes/web2print.php
diff --git a/.gitignore b/.gitignore
index b180bd0..0811e04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,12 +51,20 @@ Icon
/site/config/.license
-
+# Local files
+# ---------------
/0_local
+# Managed through composer
+# ---------------
/kirby
/vendor
/node_modules
-/content
\ No newline at end of file
+/content
+
+# Claude settings
+# ---------------
+.claude
+
diff --git a/site/plugins/web2print/.editorconfig b/site/plugins/web2print/.editorconfig
new file mode 100644
index 0000000..3b762c9
--- /dev/null
+++ b/site/plugins/web2print/.editorconfig
@@ -0,0 +1,20 @@
+# This file is for unifying the coding style for different editors and IDEs
+# editorconfig.org
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.php]
+indent_size = 4
+
+[*.md,*.txt]
+trim_trailing_whitespace = false
+insert_final_newline = false
+
+[composer.json]
+indent_size = 4
diff --git a/site/plugins/web2print/.gitattributes b/site/plugins/web2print/.gitattributes
new file mode 100644
index 0000000..033ba13
--- /dev/null
+++ b/site/plugins/web2print/.gitattributes
@@ -0,0 +1,11 @@
+# Note: You need to uncomment the lines you want to use; the other lines can be deleted
+
+# Git
+# .gitattributes export-ignore
+# .gitignore export-ignore
+
+# Tests
+# /.coveralls.yml export-ignore
+# /.travis.yml export-ignore
+# /phpunit.xml.dist export-ignore
+# /tests/ export-ignore
diff --git a/site/plugins/web2print/.gitignore b/site/plugins/web2print/.gitignore
new file mode 100644
index 0000000..4d81cf5
--- /dev/null
+++ b/site/plugins/web2print/.gitignore
@@ -0,0 +1,14 @@
+# OS files
+.DS_Store
+
+# npm modules
+/node_modules
+
+# Parcel cache folder
+.cache
+
+# Composer files
+/vendor
+
+# kirbyup temp development entry
+/index.dev.mjs
diff --git a/site/plugins/web2print/LICENSE.md b/site/plugins/web2print/LICENSE.md
new file mode 100644
index 0000000..8e663d7
--- /dev/null
+++ b/site/plugins/web2print/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c)
+
+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/site/plugins/web2print/README.md b/site/plugins/web2print/README.md
new file mode 100644
index 0000000..ad2b202
--- /dev/null
+++ b/site/plugins/web2print/README.md
@@ -0,0 +1,117 @@
+# Kirby Pluginkit: Example plugin for Kirby
+
+> Variant "Panel plugin setup"
+
+This is a boilerplate for a Kirby Panel plugin that can be installed via all three [supported installation methods](https://getkirby.com/docs/guide/plugins/plugin-setup-basic#the-three-plugin-installation-methods).
+
+You can find a list of Pluginkit variants on the [`master` branch](https://github.com/getkirby/pluginkit/tree/master).
+
+****
+
+## How to use the Pluginkit
+
+1. Fork this repository
+2. Change the plugin name and description in the `composer.json`
+3. Change the plugin name in the `index.php` and `src/index.js`
+4. Change the license if you don't want to publish under MIT
+5. Add your plugin code to the `index.php` and `src/index.js`
+6. Update this `README` with instructions for your plugin
+
+### Install the development and build setup
+
+We use [kirbyup](https://github.com/johannschopplich/kirbyup) for the development and build setup.
+
+You can start developing directly. kirbyup will be fetched remotely with your first `npm run` command, which may take a short amount of time.
+
+### Development
+
+You can start the dev process with:
+
+```bash
+npm run dev
+```
+
+This will automatically update the `index.js` and `index.css` of your plugin as soon as you make changes.
+Reload the Panel to see your code changes reflected.
+
+With kirbyup 2.0.0+ and Kirby 3.7.4+ you can alternatively use hot module reloading (HMR):
+
+```bash
+npm run serve
+```
+
+This will start a development server that updates the page as soon as you make changes. Some updates are instant, like CSS or Vue template changes, others require a reload of the page, which happens automatically.
+
+> [!NOTE]
+> The live reload functionality requires top level await, [which is only supported in modern browsers](https://caniuse.com/mdn-javascript_operators_await_top_level). If you're developing in older browsers, use `npm run dev` and reload the page manually to see changes.
+
+### Production
+
+As soon as you are happy with your plugin, you should build the final version with:
+
+```bash
+npm run build
+```
+
+This will automatically create a minified and optimized version of your `index.js` and `index.css`
+which you can ship with your plugin.
+
+We have a tutorial on how to build your own plugin based on the Pluginkit [in the Kirby documentation](https://getkirby.com/docs/guide/plugins/plugin-setup-basic).
+
+### Build reproducibility
+
+While kirbyup will stay backwards compatible, exact build reproducibility may be of importance to you. If so, we recommend to target a specific package version, rather than using npx:
+
+```json
+{
+ "scripts": {
+ "dev": "kirbyup src/index.js --watch",
+ "build": "kirbyup src/index.js"
+ },
+ "devDependencies": {
+ "kirbyup": "^3.1.0"
+ }
+}
+```
+
+What follows is an example README for your plugin.
+
+****
+
+## Installation
+
+### Download
+
+Download and copy this repository to `/site/plugins/{{ plugin-name }}`.
+
+### Git submodule
+
+```bash
+git submodule add https://github.com/{{ your-name }}/{{ plugin-name }}.git site/plugins/{{ plugin-name }}
+```
+
+### Composer
+
+```bash
+composer require {{ your-name }}/{{ plugin-name }}
+```
+
+## Setup
+
+*Additional instructions on how to configure the plugin (e.g. blueprint setup, config options, etc.)*
+
+## Options
+
+*Document the options and APIs that this plugin offers*
+
+## Development
+
+*Add instructions on how to help working on the plugin (e.g. npm setup, Composer dev dependencies, etc.)*
+
+## License
+
+MIT
+
+## Credits
+
+- [Your Name](https://github.com/ghost)
diff --git a/site/plugins/web2print/SECURITY.md b/site/plugins/web2print/SECURITY.md
new file mode 100644
index 0000000..3726336
--- /dev/null
+++ b/site/plugins/web2print/SECURITY.md
@@ -0,0 +1,18 @@
+# Security Policy
+
+## Supported Versions
+
+*Use this section to tell people about which versions of your project are currently being supported with security updates.*
+
+| Version | Supported |
+| ------- | ------------------ |
+| 5.1.x | :white_check_mark: |
+| 5.0.x | :x: |
+| 4.0.x | :white_check_mark: |
+| < 4.0 | :x: |
+
+## Reporting a Vulnerability
+
+*Use this section to tell people how to report a vulnerability.*
+
+*Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc.*
diff --git a/site/plugins/web2print/composer.json b/site/plugins/web2print/composer.json
new file mode 100644
index 0000000..fa07b14
--- /dev/null
+++ b/site/plugins/web2print/composer.json
@@ -0,0 +1,21 @@
+{
+ "name": "getkirby/pluginkit",
+ "description": "Kirby Example Plugin",
+ "license": "MIT",
+ "type": "kirby-plugin",
+ "version": "1.0.0",
+ "authors": [
+ {
+ "name": "Your Name",
+ "email": "you@example.com"
+ }
+ ],
+ "require": {
+ "getkirby/composer-installer": "^1.1"
+ },
+ "config": {
+ "allow-plugins": {
+ "getkirby/composer-installer": true
+ }
+ }
+}
diff --git a/site/plugins/web2print/composer.lock b/site/plugins/web2print/composer.lock
new file mode 100644
index 0000000..a5ae0fa
--- /dev/null
+++ b/site/plugins/web2print/composer.lock
@@ -0,0 +1,66 @@
+{
+ "_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": "37a8e61308b9b6f49cb9835f477f0c64",
+ "packages": [
+ {
+ "name": "getkirby/composer-installer",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/getkirby/composer-installer.git",
+ "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d",
+ "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^1.0 || ^2.0"
+ },
+ "require-dev": {
+ "composer/composer": "^1.8 || ^2.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Kirby\\ComposerInstaller\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "Kirby\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins",
+ "homepage": "https://getkirby.com",
+ "support": {
+ "issues": "https://github.com/getkirby/composer-installer/issues",
+ "source": "https://github.com/getkirby/composer-installer/tree/1.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://getkirby.com/buy",
+ "type": "custom"
+ }
+ ],
+ "time": "2020-12-28T12:54:39+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "2.6.0"
+}
diff --git a/site/plugins/web2print/index.css b/site/plugins/web2print/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/site/plugins/web2print/index.js b/site/plugins/web2print/index.js
new file mode 100644
index 0000000..351c544
--- /dev/null
+++ b/site/plugins/web2print/index.js
@@ -0,0 +1 @@
+(function(){"use strict";function f(t,n,e,i,r,s,g,a){var o=typeof t=="function"?t.options:t;return n&&(o.render=n,o.staticRenderFns=e,o._compiled=!0),{exports:t,options:o}}const l={__name:"Web2PrintBtn",props:{htmlPageString:String,cssPath:String,printFormat:String,pageId:String},setup(t){const{htmlPageString:n,cssPath:e,printFormat:i,pageId:r}=t;async function s(){const a={method:"POST",body:JSON.stringify({html:n,cssPath:e,printFormat:i,pageId:r})},c=await(await fetch("/web2print.json",a)).json();console.log(c),c.success&&window.panel.view.reload()}return{__sfc:!0,getPdf:s}}};var u=function(){var n=this,e=n._self._c,i=n._self._setupProxy;return e("k-button",{attrs:{variant:"filled"},on:{click:function(r){return i.getPdf()}}},[n._v("Générer")])},_=[],d=f(l,u,_);const p=d.exports;window.panel.plugin("studio-variable/web2print",{fields:{web2print:p}})})();
diff --git a/site/plugins/web2print/index.php b/site/plugins/web2print/index.php
new file mode 100644
index 0000000..dc19457
--- /dev/null
+++ b/site/plugins/web2print/index.php
@@ -0,0 +1,33 @@
+ [
+ 'web2print' => [
+ 'props' => [
+ 'cssPath' => function($cssPath = null) {
+ return $cssPath;
+ }
+ ],
+ 'computed' => [
+ 'htmlPageString' => function() {
+ return $this->model()->render();
+ },
+ 'cssPath' => function() {
+ return $this->cssPath ?? 'assets/css/style.css';
+ },
+ 'printFormat' => function() {
+ return $this->model()->printFormat()->value() ?? 'A4';
+ },
+ 'pageId' => function() {
+ return $this->model()->id();
+ }
+ ]
+ ]
+ ],
+ 'routes' => [
+ require __DIR__ . '/src/routes/web2print.php',
+ ],
+ 'blueprints' => [
+ 'tabs/web2print' => __DIR__ . '/src/blueprints/tabs/web2print.yml'
+ ]
+]);
diff --git a/site/plugins/web2print/package.json b/site/plugins/web2print/package.json
new file mode 100644
index 0000000..1913c34
--- /dev/null
+++ b/site/plugins/web2print/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "getkirby/pluginkit",
+ "description": "Kirby Example Plugin",
+ "license": "MIT",
+ "type": "kirby-plugin",
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "npx -y kirbyup src/index.js --watch",
+ "serve": "npx -y kirbyup serve src/index.js",
+ "build": "npx -y kirbyup src/index.js"
+ }
+}
diff --git a/site/plugins/web2print/src/blueprints/tabs/web2print.yml b/site/plugins/web2print/src/blueprints/tabs/web2print.yml
new file mode 100644
index 0000000..09ac537
--- /dev/null
+++ b/site/plugins/web2print/src/blueprints/tabs/web2print.yml
@@ -0,0 +1,20 @@
+label: web2print
+icon: print
+sections:
+ web2printSection:
+ type: fields
+ fields:
+ printFormat:
+ label: Format
+ type: select
+ options:
+ - A4
+ - A5
+ width: 1/4
+ generatePdfBtn:
+ type: web2print
+ width: 1/4
+ generatedPdfs:
+ label: PDF générés
+ type: files
+ width: 1/4
diff --git a/site/plugins/web2print/src/components/Web2PrintBtn.vue b/site/plugins/web2print/src/components/Web2PrintBtn.vue
new file mode 100644
index 0000000..51f331c
--- /dev/null
+++ b/site/plugins/web2print/src/components/Web2PrintBtn.vue
@@ -0,0 +1,36 @@
+
+ Générer
+
+
+
diff --git a/site/plugins/web2print/src/index.js b/site/plugins/web2print/src/index.js
new file mode 100644
index 0000000..9990c88
--- /dev/null
+++ b/site/plugins/web2print/src/index.js
@@ -0,0 +1,7 @@
+import Web2PrintBtn from "./components/Web2PrintBtn.vue";
+
+window.panel.plugin("studio-variable/web2print", {
+ fields: {
+ web2print: Web2PrintBtn,
+ },
+});
diff --git a/site/plugins/web2print/src/routes/web2print.php b/site/plugins/web2print/src/routes/web2print.php
new file mode 100644
index 0000000..088d7a9
--- /dev/null
+++ b/site/plugins/web2print/src/routes/web2print.php
@@ -0,0 +1,189 @@
+ '/web2print.json',
+ 'method' => 'POST',
+ 'action' => function () {
+ header('Content-Type: application/json');
+
+ $jsonRequest = file_get_contents('php://input');
+ $body = json_decode($jsonRequest);
+
+ if (!$body || !isset($body->html)) {
+ http_response_code(400);
+ return json_encode(['error' => 'Missing html parameter']);
+ }
+
+ if (!isset($body->pageId) || empty($body->pageId)) {
+ http_response_code(400);
+ return json_encode(['error' => 'Missing pageId parameter']);
+ }
+
+ // Récupérer le cssPath depuis le body ou utiliser la valeur par défaut
+ $cssPath = isset($body->cssPath) && !empty($body->cssPath)
+ ? $body->cssPath
+ : 'assets/css/style.css';
+
+ // Récupérer le format d'impression
+ $printFormat = isset($body->printFormat) && !empty($body->printFormat)
+ ? $body->printFormat
+ : 'A4';
+
+ // Récupérer la page Kirby
+ $page = kirby()->page($body->pageId);
+ if (!$page) {
+ http_response_code(404);
+ return json_encode(['error' => 'Page not found']);
+ }
+
+ $ch = curl_init('https://web2print.studio-variable.com/generate');
+
+ $html = $body->html;
+
+ // Nettoyer le HTML pour la génération PDF
+ $dom = new DOMDocument();
+ @$dom->loadHTML('' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+
+ // Supprimer tous les scripts
+ $scripts = $dom->getElementsByTagName('script');
+ while ($scripts->length > 0) {
+ $scripts->item(0)->parentNode->removeChild($scripts->item(0));
+ }
+
+ // Supprimer les attributs Alpine.js (x-data, x-show, @click, etc.)
+ $xpath = new DOMXPath($dom);
+ $elements = $xpath->query('//*[@*[starts-with(name(), "x-") or starts-with(name(), "@") or starts-with(name(), ":")]]');
+ foreach ($elements as $element) {
+ $attributesToRemove = [];
+ foreach ($element->attributes as $attr) {
+ if (strpos($attr->name, 'x-') === 0 || strpos($attr->name, '@') === 0 || strpos($attr->name, ':') === 0) {
+ $attributesToRemove[] = $attr->name;
+ }
+ }
+ foreach ($attributesToRemove as $attrName) {
+ $element->removeAttribute($attrName);
+ }
+ }
+
+ // Supprimer les liens CSS et les remplacer par un style inline
+ $head = $dom->getElementsByTagName('head')->item(0);
+ if ($head) {
+ // Supprimer tous les
+ $links = $dom->getElementsByTagName('link');
+ $linksToRemove = [];
+ foreach ($links as $link) {
+ if ($link->getAttribute('rel') === 'stylesheet') {
+ $linksToRemove[] = $link;
+ }
+ }
+ foreach ($linksToRemove as $link) {
+ $link->parentNode->removeChild($link);
+ }
+
+
+
+ // Charger et résoudre le CSS
+ $fullCssPath = kirby()->root() . '/' . $cssPath;
+ $resolvedCss = resolveCssImports($fullCssPath);
+
+ // Ajouter les règles @page pour le format d'impression
+ $pageRules = "\n\n@page {\n size: " . $printFormat . ";\n}\n";
+ $resolvedCss .= $pageRules;
+
+ // Créer une balise
diff --git a/site/plugins/loop/frontend/src/lib/Button.svelte b/site/plugins/loop/frontend/src/lib/Button.svelte
new file mode 100644
index 0000000..75f7063
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Button.svelte
@@ -0,0 +1,256 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/Comment.svelte b/site/plugins/loop/frontend/src/lib/Comment.svelte
new file mode 100644
index 0000000..cd74c17
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Comment.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/CommentDialog.svelte b/site/plugins/loop/frontend/src/lib/CommentDialog.svelte
new file mode 100644
index 0000000..5e21a08
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/CommentDialog.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/CommentForm.svelte b/site/plugins/loop/frontend/src/lib/CommentForm.svelte
new file mode 100644
index 0000000..d8d9cd1
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/CommentForm.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/ContextMenu.svelte b/site/plugins/loop/frontend/src/lib/ContextMenu.svelte
new file mode 100644
index 0000000..f90fb93
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/ContextMenu.svelte
@@ -0,0 +1,187 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/Header.svelte b/site/plugins/loop/frontend/src/lib/Header.svelte
new file mode 100644
index 0000000..711bef3
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Header.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/Marker.svelte b/site/plugins/loop/frontend/src/lib/Marker.svelte
new file mode 100644
index 0000000..96bcd52
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Marker.svelte
@@ -0,0 +1,179 @@
+
+
+{#if comment}
+
+{/if}
+
+
diff --git a/site/plugins/loop/frontend/src/lib/Panel.svelte b/site/plugins/loop/frontend/src/lib/Panel.svelte
new file mode 100644
index 0000000..8cfbc4e
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Panel.svelte
@@ -0,0 +1,181 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/Reply.svelte b/site/plugins/loop/frontend/src/lib/Reply.svelte
new file mode 100644
index 0000000..8902dab
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/Reply.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ {reply.author}
+
+
+
{decodeHTMLEntities(reply.comment)}
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte b/site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
new file mode 100644
index 0000000..2c9b412
--- /dev/null
+++ b/site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
@@ -0,0 +1,168 @@
+
+
+
+
+
diff --git a/site/plugins/loop/frontend/src/main.ts b/site/plugins/loop/frontend/src/main.ts
new file mode 100644
index 0000000..04ebb82
--- /dev/null
+++ b/site/plugins/loop/frontend/src/main.ts
@@ -0,0 +1,5 @@
+import App from './App.svelte'
+import "./styles/variables.css"
+import "./styles/app.css"
+
+export default App;
diff --git a/site/plugins/loop/frontend/src/store/api.svelte.ts b/site/plugins/loop/frontend/src/store/api.svelte.ts
new file mode 100644
index 0000000..bdc5c71
--- /dev/null
+++ b/site/plugins/loop/frontend/src/store/api.svelte.ts
@@ -0,0 +1,111 @@
+import type { Comment, CommentPayload, Reply, ReplyPayload } from '../types';
+
+export const store: { comments: Comment[] } = $state({
+ comments: []
+});
+
+const apiPrefix = 'loop';
+const KirbyLoop = document.querySelector('kirby-loop');
+const csrfToken = KirbyLoop?.getAttribute('csrf-token') || '';
+const apiBase = KirbyLoop?.getAttribute('apibase') || '/';
+const headers = {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken || ''
+};
+
+const buildApiUrl = (endpoint: string): string => {
+ const url = new URL(`${apiBase}/${apiPrefix}/${endpoint}`, window.location.origin);
+
+ // Add token query params from current page if they exist
+ const currentParams = new URLSearchParams(window.location.search);
+ const token = currentParams.get('token') || currentParams.get('_token');
+ if (token) {
+ url.searchParams.set(currentParams.has('token') ? 'token' : '_token', token);
+ }
+
+ return url.toString();
+};
+
+export const getComments = async (pageId: string): Promise => {
+ const url = buildApiUrl(`comments/${pageId}`);
+ const response = await fetch(url, {
+ headers
+ });
+ const data = await response.json();
+ if (data.status === 'ok') {
+ store.comments = data.comments;
+ }
+ return data.status === 'ok';
+}
+
+export const addComment = async (comment: CommentPayload) => {
+ const url = buildApiUrl('comment/new');
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(comment)
+ });
+ const data: { comment: Comment, status: string } = await response.json();
+ if (data.status === 'ok') {
+ store.comments = [data.comment, ...store.comments];
+ }
+}
+
+export const resolveComment = async (comment: Comment) => {
+ const url = buildApiUrl('comment/resolve');
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ id: comment.id })
+ });
+ const data: { success: boolean } = await response.json();
+ if (data.success) {
+ const commentIndex = store.comments.findIndex(c => c.id === comment.id);
+ if (commentIndex !== -1) {
+ store.comments[commentIndex].status = 'RESOLVED';
+ }
+ }
+ return data.success;
+}
+
+export const unresolveComment = async (comment: Comment) => {
+ const url = buildApiUrl('comment/unresolve');
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ id: comment.id })
+ });
+ const data: { success: boolean } = await response.json();
+ if (data.success) {
+ const commentIndex = store.comments.findIndex(c => c.id === comment.id);
+ if (commentIndex !== -1) {
+ store.comments[commentIndex].status = 'OPEN';
+ }
+ }
+ return data.success;
+}
+
+export const setGuestName = async (name: string) => {
+ const response = await fetch(buildApiUrl('guest/name'), {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ name })
+ });
+ return await response.json();
+}
+
+export const addReply = async (reply: ReplyPayload) => {
+ const url = buildApiUrl('comment/reply');
+ const response = await fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(reply)
+ });
+ const data: { reply: Reply, status: string } = await response.json();
+ if (data.status === 'ok') {
+ const parent = store.comments.find(c => c.id === data.reply.parentId)
+ if (parent) parent.replies = [...parent.replies, data.reply];
+ }
+}
+
+export default store;
diff --git a/site/plugins/loop/frontend/src/store/form.svelte.ts b/site/plugins/loop/frontend/src/store/form.svelte.ts
new file mode 100644
index 0000000..a33d7a5
--- /dev/null
+++ b/site/plugins/loop/frontend/src/store/form.svelte.ts
@@ -0,0 +1,11 @@
+import type { FormData } from '../types';
+
+export const formData: FormData = $state({
+ text: "",
+ parentId: null
+});
+
+export const reset = () => {
+ formData.text = ""
+ formData.parentId = null
+}
diff --git a/site/plugins/loop/frontend/src/store/translations.svelte.ts b/site/plugins/loop/frontend/src/store/translations.svelte.ts
new file mode 100644
index 0000000..5edeb33
--- /dev/null
+++ b/site/plugins/loop/frontend/src/store/translations.svelte.ts
@@ -0,0 +1,19 @@
+let translations = $state>({});
+
+export const t = (key: string, fallback?: string): string => {
+ return translations[key] || fallback || key;
+};
+
+export const tt = (key: string, fallback: string, replacements: Record): string => {
+ let text = translations[key] || fallback || key;
+
+ for (const [placeholder, value] of Object.entries(replacements)) {
+ text = text.replace(`{${placeholder}}`, value);
+ }
+
+ return text;
+};
+
+export const setTranslations = (newTranslations: Record) => {
+ translations = newTranslations;
+};
\ No newline at end of file
diff --git a/site/plugins/loop/frontend/src/store/ui.svelte.ts b/site/plugins/loop/frontend/src/store/ui.svelte.ts
new file mode 100644
index 0000000..305b47c
--- /dev/null
+++ b/site/plugins/loop/frontend/src/store/ui.svelte.ts
@@ -0,0 +1,34 @@
+export const panel = $state({
+ open: false,
+ currentCommentId: 0,
+ showResolvedOnly: false,
+ pulseMarkerId: 0
+});
+export const overlay = $state({ open: false });
+
+// Guest name management
+let guestNameValue = $state("");
+
+export const guestName = {
+ get value() {
+ return guestNameValue;
+ },
+ set(name: string) {
+ guestNameValue = name;
+ if (typeof window !== 'undefined') {
+ sessionStorage.setItem('loop-guest-name', name);
+ }
+ },
+ get() {
+ if (!guestNameValue && typeof window !== 'undefined') {
+ guestNameValue = sessionStorage.getItem('loop-guest-name') || "";
+ }
+ return guestNameValue;
+ },
+ clear() {
+ guestNameValue = "";
+ if (typeof window !== 'undefined') {
+ sessionStorage.removeItem('loop-guest-name');
+ }
+ }
+};
diff --git a/site/plugins/loop/frontend/src/styles/app.css b/site/plugins/loop/frontend/src/styles/app.css
new file mode 100644
index 0000000..fd92a49
--- /dev/null
+++ b/site/plugins/loop/frontend/src/styles/app.css
@@ -0,0 +1,13 @@
+kirby-loop {
+ font-family: var(--font-family);
+ line-height: var(--line-height);
+ font-weight: var(--font-weight-normal);
+ font-size: var(--font-size-7);
+ box-sizing: border-box;
+}
+
+html.loop-overlay-open {
+ a {
+ pointer-events: none;
+ }
+}
diff --git a/site/plugins/loop/frontend/src/styles/theme-dark.css b/site/plugins/loop/frontend/src/styles/theme-dark.css
new file mode 100644
index 0000000..a7ce655
--- /dev/null
+++ b/site/plugins/loop/frontend/src/styles/theme-dark.css
@@ -0,0 +1,35 @@
+kirby-loop[theme="dark"] {
+ /* Accent lightness values */
+ --color-accent-l: 0.85;
+
+ /* Neutral lightness values */
+ --color-neutral-l-0: 0;
+ --color-neutral-l-100: 0.1;
+ --color-neutral-l-200: 0.2;
+ --color-neutral-l-300: 0.45;
+ --color-neutral-l-400: 0.5;
+ --color-neutral-l-600: 0.55;
+ --color-neutral-l-500: 0.6;
+ --color-neutral-l-700: 0.7;
+ --color-neutral-l-800: 0.8;
+ --color-neutral-l-900: 0.95;
+ --color-neutral-l-1000: 1;
+
+ /* Shadow tokens */
+ --shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
+ --shadow-m: 0 2px 8px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
+ 0 8px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
+ 0 16px 24px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
+ --shadow-l: 0 4px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
+ 0 12px 32px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
+ 0 24px 48px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
+ 0 48px 80px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
+ --shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
+ --shadow-dark-edge: inset -1px -1px 1px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
+
+ /* Background tokens */
+ --background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-50));
+
+ /* Panel */
+ --panel-threads-background: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h) / 0.99)
+}
\ No newline at end of file
diff --git a/site/plugins/loop/frontend/src/styles/theme-default.css b/site/plugins/loop/frontend/src/styles/theme-default.css
new file mode 100644
index 0000000..f8f39bc
--- /dev/null
+++ b/site/plugins/loop/frontend/src/styles/theme-default.css
@@ -0,0 +1,23 @@
+kirby-loop {
+ /* Color Customization */
+ --color-neutral-h: 900;
+ --color-neutral-c: 0;
+ --color-accent-h: 900;
+ --color-accent-c: 0.18;
+ --color-accent-l: 0.75;
+ --color-accent-dark-factor: 0.4;
+ --color-accent-light-factor: 1.2;
+
+ /* Neutral lightness values */
+ --color-neutral-l-0: 1;
+ --color-neutral-l-100: 0.95;
+ --color-neutral-l-200: 0.9;
+ --color-neutral-l-300: 0.7;
+ --color-neutral-l-400: 0.6;
+ --color-neutral-l-600: 0.4;
+ --color-neutral-l-500: 0.5;
+ --color-neutral-l-700: 0.3;
+ --color-neutral-l-800: 0.2;
+ --color-neutral-l-900: 0.1;
+ --color-neutral-l-1000: 0;
+}
\ No newline at end of file
diff --git a/site/plugins/loop/frontend/src/styles/variables.css b/site/plugins/loop/frontend/src/styles/variables.css
new file mode 100644
index 0000000..19d41d1
--- /dev/null
+++ b/site/plugins/loop/frontend/src/styles/variables.css
@@ -0,0 +1,421 @@
+@import "./theme-default.css";
+@import "./theme-dark.css";
+
+kirby-loop {
+ /* Colors */
+ --color-base: var(--color-neutral-900);
+ --color-base-background: var(--color-neutral-0);
+
+ --color-base-background-o-5: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.05);
+ --color-base-background-o-10: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
+ --color-base-background-o-20: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.2);
+ --color-base-background-o-50: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.5);
+ --color-base-background-o-60: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.6);
+ --color-base-background-o-75: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.75);
+ --color-base-background-o-95: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.95);
+
+ --color-accent-light: oklch(calc(var(--color-accent-l) * var(--color-accent-light-factor)) var(--color-accent-c) var(--color-accent-h));
+ --color-accent: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
+ --color-accent-dark: oklch(calc(var(--color-accent-l) * var(--color-accent-dark-factor)) var(--color-accent-c) var(--color-accent-h));
+
+ --color-neutral-0: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-100: oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-200: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-300: oklch(var(--color-neutral-l-300) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-400: oklch(var(--color-neutral-l-400) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-500: oklch(var(--color-neutral-l-500) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-600: oklch(var(--color-neutral-l-600) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-700: oklch(var(--color-neutral-l-700) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-800: oklch(var(--color-neutral-l-800) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-900: oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h));
+ --color-neutral-1000: oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h));
+
+ --color-success: oklch(0.65 0.15 150);
+ --color-warning: oklch(0.75 0.15 80);
+ --color-error: oklch(0.65 0.18 25);
+ --color-info: oklch(0.65 0.15 220);
+
+ --font-family: -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ Helvetica,
+ Arial,
+ sans-serif,
+ "Apple Color Emoji",
+ "Segoe UI Emoji",
+ "Segoe UI Symbol",
+ sans-serif;
+
+ --line-height: 1.4;
+
+ --font-weight-light: 300;
+ --font-weight-normal: 400;
+ --font-weight-medium: 550;
+ --font-weight-bold: 700;
+
+ --font-size-3: clamp(1.9531rem, 1.4262rem + 1.7565vw, 3.5339rem);
+ --font-size-4: clamp(1.5625rem, 1.2503rem + 1.0408vw, 2.4992rem);
+ --font-size-5: clamp(1.25rem, 1.0775rem + 0.575vw, 1.7675rem);
+ --font-size-6: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
+ --font-size-7: clamp(0.8rem, 0.772rem + 0.0934vw, 0.884rem);
+ --font-size-8: clamp(0.6252rem, 0.6449rem + -0.0165vw, 0.64rem);
+
+ --border-radius-s: 0.125rem;
+ --border-radius: 0.25rem;
+ --border-radius-rounded: 4096px;
+
+ --space-2xs: clamp(0.25rem, 0.2292rem + 0.0694vw, 0.3125rem);
+ --space-xs: clamp(0.5rem, 0.4583rem + 0.1389vw, 0.625rem);
+ --space-s: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
+ --space-m: clamp(1.5rem, 1.375rem + 0.4167vw, 1.875rem);
+ --space-l: clamp(2rem, 1.8333rem + 0.5556vw, 2.5rem);
+ --space-2xs-xs: clamp(0.25rem, 0.125rem + 0.4167vw, 0.625rem);
+ --space-xs-s: clamp(0.5rem, 0.25rem + 0.8333vw, 1.25rem);
+ --space-s-m: clamp(1rem, 0.7083rem + 0.9722vw, 1.875rem);
+ --space-m-l: clamp(1.5rem, 1.1667rem + 1.1111vw, 2.5rem);
+ --space-s-l: clamp(1rem, 0.5rem + 1.6667vw, 2.5rem);
+
+ /* Shadow tokens */
+ --shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
+ --shadow-m: 0 2px 8px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
+ 0 8px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
+ 0 16px 24px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
+ --shadow-l: 0 4px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
+ 0 12px 32px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
+ 0 24px 48px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
+ 0 48px 80px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
+ --shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
+ --shadow-dark-edge: inset 0 -1px 1px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
+
+ /* Backdrop tokens */
+ --backdrop-blur: blur(6px);
+ --backdrop-glass: var(--backdrop-blur) saturate(1.4) brightness(1.2);
+
+ /* Background tokens */
+ --background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-95));
+ --background-glass-frosted: linear-gradient(0deg, var(--color-base-background-o-75) 0%, var(--color-base-background-o-95) 50%);
+
+ /* Opacity tokens */
+ --opacity-subtle: 0.5;
+ --opacity-medium: 0.7;
+ --opacity-strong: 0.9;
+
+ /* Outline tokens */
+ --outline-color: var(--color-accent);
+ --outline-offset: 0.25rem;
+
+ /* Transition tokens */
+ --transition-duration: 0.2s;
+ --transition-duration-jump: 0.4s;
+ --transition-easing-jump: cubic-bezier(0.44, 1.2, 0.64, 1);
+ --transition-easing: cubic-bezier(0, 0, 0.2, 1);
+
+ /* Z-index tokens */
+ --z-loop-marker: 9998;
+ --z-loop-panel: 9999;
+ --z-loop-dialog: 10000;
+
+ /* Author */
+ --author-avatar-color: var(--color-neutral-600);
+ --author-avatar-background-color: var(--color-neutral-100);
+ --author-avatar-size: 2.5rem;
+ --author-avatar-border-radius: var(--border-radius-rounded);
+ --author-avatar-font-size: var(--font-size-6);
+
+ /* Button */
+ --button-background: transparent;
+ --button-color: var(--color-neutral-600);
+ --button-border-radius: var(--border-radius);
+ --button-padding: 0 var(--space-xs);
+ --button-gap: var(--space-2xs);
+ --button-font-size: var(--font-size-7);
+ --button-font-weight: var(--font-weight-medium);
+ --button-height: 2.25rem;
+ --button-transition: var(--transition-duration) var(--transition-easing);
+ --button-outline-color: var(--outline-color);
+ --button-outline-offset: var(--outline-offset);
+
+ --button-hover-color: var(--color-neutral-900);
+ --button-hover-background: var(--color-neutral-200);
+
+ --button-header-background: transparent;
+ --button-header-height: 3rem;
+ --button-header-padding: 0 var(--space-s);
+ --button-header-hover-background: var(--color-base-background-o-95);
+ --button-header-blend-mode: multiply;
+
+ --button-panel-background: transparent;
+ --button-panel-padding: 0 calc(var(--space-s) * 0.4);
+
+ --button-solid-background: var(--color-neutral-100);
+ --button-solid-hover-color: var(--color-neutral-900);
+ --button-solid-hover-background: var(--color-neutral-200);
+
+ --button-small-height: 1.5rem;
+ --button-small-font-size: var(--font-size-7);
+
+ --button-icon-background: var(--color-neutral-0);
+ --button-icon-color: var(--color-neutral-500);
+ --button-icon-height: 3rem;
+ --button-icon-shadow: var(--shadow-s);
+ --button-icon-border-radius: var(--border-radius-rounded);
+ --button-icon-font-size: var(--font-size-6);
+ --button-icon-hover-background: var(--color-neutral-200);
+ --button-icon-hover-color: var(--color-neutral-900);
+
+ --button-marker-background: var(--color-accent);
+ --button-marker-color: var(--color-accent-dark);
+ --button-marker-font-weight: var(--font-weight-bold);
+ --button-marker-border-radius: var(--border-radius-rounded);
+ --button-marker-highlighted-background: var(--color-accent);
+ --button-marker-highlighted-color: var(--color-accent-dark);
+
+ --button-filter-background: transparent;
+ --button-filter-color: var(--color-neutral-500);
+ --button-filter-height: 1.75rem;
+ --button-filter-font-size: var(--font-size-8);
+ --button-filter-padding: 0 var(--space-xs);
+ --button-filter-border-radius: calc(var(--border-radius) - 2px);
+ --button-filter-hover-color: var(--color-neutral-700);
+ --button-filter-hover-background: var(--color-neutral-200);
+ --button-filter-active-background: var(--color-base-background);
+ --button-filter-active-color: var(--color-base);
+ --button-filter-active-font-weight: var(--font-weight-medium);
+
+ --button-menu-item-background: transparent;
+ --button-menu-item-color: var(--color-neutral-700);
+ --button-menu-item-padding: var(--space-2xs) var(--space-xs);
+ --button-menu-item-border-radius: calc(var(--border-radius) - 2px);
+ --button-menu-item-font-size: var(--font-size-7);
+ --button-menu-item-gap: var(--space-2xs);
+ --button-menu-item-hover-background: var(--color-neutral-100);
+ --button-menu-item-hover-color: var(--color-neutral-900);
+ --button-menu-item-active-background: var(--color-accent-light);
+ --button-menu-item-active-color: var(--color-accent-dark);
+ --button-menu-item-active-font-weight: var(--font-weight-medium);
+
+ --button-active-background: var(--color-accent);
+ --button-active-color: var(--color-accent-dark);
+
+ --button-disabled-opacity: var(--opacity-subtle);
+ --button-disabled-hover-color: var(--color-neutral-700);
+ --button-disabled-hover-background: var(--color-neutral-100);
+
+ /* Comment */
+ --comment-avatar-size: 2.5rem;
+ --comment-marker-background: var(--color-neutral-200);
+ --comment-marker-color: var(--color-neutral-800);
+ --comment-line-background: var(--color-neutral-100);
+ --comment-line-width: 0.1rem;
+ --comment-line-offset: calc(var(--space-s) + var(--comment-avatar-size) / 2);
+
+ --comment-header-font-size: var(--font-size-7);
+ --comment-header-padding: var(--space-s);
+ --comment-header-gap: var(--space-s);
+ --comment-header-outline-color: var(--outline-color);
+ --comment-header-outline-offset: -2px;
+ --comment-header-border-radius: var(--border-radius);
+
+ --comment-content-padding: var(--space-xs);
+ --comment-content-background: var(--color-neutral-100);
+ --comment-content-background-dark: var(--color-neutral-200);
+ --comment-content-border-radius: var(--border-radius);
+
+ --comment-author-gap: var(--space-xs);
+ --comment-author-margin-bottom: var(--space-2xs);
+ --comment-timestamp-font-size: var(--font-size-8);
+ --comment-timestamp-color: var(--color-neutral-300);
+
+ --comment-replies-padding: 0 var(--space-s);
+ --comment-replies-gap: var(--space-s);
+
+ --comment-footer-padding: var(--space-s);
+ --comment-footer-gap: var(--space-s);
+ --comment-buttons-gap: var(--space-xs);
+
+ /* CommentDialog */
+ --comment-dialog-position: absolute;
+ --comment-dialog-max-width: 300px;
+ --comment-dialog-border-radius: var(--border-radius);
+ --comment-dialog-shadow: var(--shadow-s);
+ --comment-dialog-backdrop-background: transparent;
+ --comment-dialog-textarea-font-size: var(--font-size-6);
+
+ /* CommentForm */
+ --comment-form-background: var(--color-base-background);
+ --comment-form-color: var(--color-base);
+ --comment-form-border: 1px solid var(--color-neutral-200);
+ --comment-form-border-radius: var(--border-radius);
+
+ --comment-form-textarea-height: 15ch;
+ --comment-form-textarea-padding: var(--space-s);
+ --comment-form-textarea-background: var(--color-base-background);
+ --comment-form-textarea-font-family: var(--font-family);
+ --comment-form-textarea-font-size: var(--font-size-7);
+
+ --comment-form-footer-padding: var(--space-xs);
+ --comment-form-footer-gap: var(--space-xs);
+
+ --comment-form-hint-font-size: var(--font-size-8);
+ --comment-form-hint-color: var(--color-neutral-300);
+ --comment-form-hint-padding: 0 var(--space-xs) var(--space-xs) var(--space-xs);
+
+ /* ContextMenu */
+ --context-menu-container-bottom: var(--space-s);
+ --context-menu-container-right: var(--space-s);
+ --context-menu-container-z-index: 10;
+
+ --context-menu-trigger-size: 2.5rem;
+ --context-menu-trigger-border-radius: var(--border-radius-rounded);
+
+ --context-menu-background: var(--color-base-background);
+ --context-menu-border-radius: var(--border-radius);
+ --context-menu-shadow: var(--shadow-s);
+ --context-menu-padding: var(--space-xs);
+ --context-menu-min-width: 12rem;
+ --context-menu-backdrop-background: transparent;
+
+ --context-menu-section-gap: var(--space-2xs);
+
+ --context-menu-title-font-size: var(--font-size-8);
+ --context-menu-title-font-weight: var(--font-weight-medium);
+ --context-menu-title-color: var(--color-neutral-500);
+ --context-menu-title-margin-bottom: var(--space-2xs);
+ --context-menu-title-letter-spacing: 0.05em;
+
+ --context-menu-filter-gap: 1px;
+
+ --context-menu-filter-dot-size: 0.5em;
+ --context-menu-filter-dot-border-radius: 50%;
+ --context-menu-filter-dot-margin-right: var(--space-2xs);
+ --context-menu-filter-dot-open-background: var(--color-accent);
+ --context-menu-filter-dot-resolved-background: var(--color-neutral-400);
+
+ /* Header */
+ --header-position: fixed;
+ --header-top: var(--space-xs);
+ --header-transform: translateX(-50%);
+ --header-color: var(--color-base);
+ --header-border-radius: var(--border-radius-rounded);
+ --header-z-index: 9999;
+ --header-bottom-position: var(--space-xs);
+ --header-backdrop-filter: var(--backdrop-glass);
+ --header-background: var(--background-glass);
+
+ --header-count-size: 2rem;
+ --header-count-border-radius: var(--border-radius-rounded);
+ --header-count-backdrop-filter: var(--backdrop-glass);
+ --header-count-background: var(--background-glass);
+
+ /* Marker */
+ --marker-size: 2rem;
+ --marker-position: absolute;
+ --marker-z-index: var(--z-loop-marker);
+ --marker-transform: translate(-50%, -50%);
+ --marker-border-radius: var(--border-radius-rounded);
+
+ /* Panel */
+ --panel-width: 380px;
+ --panel-mobile-width: 85svw;
+ --panel-position: fixed;
+ --panel-right: var(--space-xs);
+ --panel-top: var(--space-xs);
+ --panel-height: calc(100svh - var(--space-xs) * 2);
+ --panel-transform-closed: translateX(calc(100% + var(--space-xs)));
+ --panel-transform-open: translateX(0);
+ --panel-color: var(--color-base);
+ --panel-border-radius: var(--border-radius);
+ --panel-border-top-left-radius: 0;
+ --panel-transition: var(--transition-duration-jump) var(--transition-easing-jump);
+ --panel-z-index: var(--z-loop-panel);
+ --panel-shadow: var(--shadow-m);
+
+ --panel-header-transform-closed: translate(-95%);
+ --panel-header-transform-open: translate(calc(-100% + 1px));
+ --panel-header-transform-hover: translate(calc(-100% + 1px));
+ --panel-header-border-radius: var(--border-radius-rounded);
+ --panel-header-gap: var(--space-xs);
+ --panel-header-backdrop-filter: var(--backdrop-glass);
+ --panel-header-background: var(--background-glass);
+
+ --panel-threads-background: var(--color-base-background-o-95);
+ --panel-threads-backdrop: var(--backdrop-blur);
+ --panel-threads-border-radius: var(--border-radius);
+ --panel-threads-border-top-left-radius: 0;
+ --panel-threads-padding: 0 0 var(--space-s) 0;
+ --panel-threads-item-margin: var(--space-s);
+ --panel-threads-scrollbar-width: thin;
+
+ --panel-no-threads-padding: var(--space-s) var(--space-l);
+ --panel-no-threads-font-size: var(--font-size-6);
+ --panel-no-threads-color: var(--color-neutral-300);
+
+ /* Reply */
+ --reply-gap: var(--space-s);
+ --reply-content-padding: var(--space-xs);
+ --reply-content-background: var(--color-neutral-100);
+ --reply-content-background-dark: var(--color-neutral-200);
+ --reply-content-border-radius: var(--border-radius);
+
+ --reply-header-gap: var(--space-xs);
+ --reply-header-margin-bottom: var(--space-2xs);
+ --reply-timestamp-font-size: var(--font-size-8);
+ --reply-timestamp-color: var(--color-neutral-300);
+
+ /* WelcomeDialog */
+ --welcome-dialog-background: var(--background-glass-frosted);
+ --welcome-dialog-backdrop-filter: var(--backdrop-glass);
+ --welcome-dialog-border: 0px;
+ --welcome-dialog-border-radius: var(--border-radius);
+ --welcome-dialog-shadow: var(--shadow-l), var(--shadow-light-edge),
+ var(--shadow-dark-edge);
+ --welcome-dialog-max-width: 500px;
+
+ --welcome-dialog-backdrop-background: var(--color-base-background-o-10);
+ --welcome-dialog-backdrop-backdrop-filter: none;
+
+ --welcome-dialog-form-padding: var(--space-l);
+
+ --welcome-dialog-title-margin: 0 0 var(--space-s) 0;
+ --welcome-dialog-title-font-size: var(--font-size-4);
+ --welcome-dialog-title-color: var(--color-base);
+ --welcome-dialog-title-font-weight: var(--font-weight-bold);
+
+ --welcome-dialog-text-margin: 0 0 var(--space-m) 0;
+ --welcome-dialog-text-font-size: var(--font-size-6);
+ --welcome-dialog-text-color: var(--color-neutral-600);
+ --welcome-dialog-text-line-height: var(--line-height);
+
+ --welcome-dialog-name-section-margin: var(--space-l);
+
+ --welcome-dialog-input-border: 1px solid var(--color-neutral-300);
+ --welcome-dialog-input-border-radius: var(--border-radius-s);
+ --welcome-dialog-input-padding: var(--space-xs);
+ --welcome-dialog-input-font-family: var(--font-family);
+ --welcome-dialog-input-font-size: var(--font-size-6);
+ --welcome-dialog-input-color: var(--color-base);
+ --welcome-dialog-input-background: var(--color-base-background);
+ --welcome-dialog-input-outline-color: var(--outline-color);
+ --welcome-dialog-input-outline-offset: var(--outline-offset);
+
+ --welcome-dialog-footer-gap: var(--space-xs);
+
+ /* Icon */
+ --icon-size: 1em;
+}
+
+/* Dark theme overrides */
+kirby-loop[data-theme="dark"] {
+ --color-neutral-l-0: 0;
+ --color-neutral-l-100: 0.1;
+ --color-neutral-l-200: 0.2;
+ --color-neutral-l-300: 0.3;
+ --color-neutral-l-400: 0.4;
+ --color-neutral-l-500: 0.5;
+ --color-neutral-l-600: 0.6;
+ --color-neutral-l-700: 0.7;
+ --color-neutral-l-800: 0.9;
+ --color-neutral-l-900: 0.95;
+ --color-neutral-l-1000: 1;
+}
diff --git a/site/plugins/loop/frontend/src/types.ts b/site/plugins/loop/frontend/src/types.ts
new file mode 100644
index 0000000..0c726fa
--- /dev/null
+++ b/site/plugins/loop/frontend/src/types.ts
@@ -0,0 +1,100 @@
+// TypeScript interfaces for loop
+
+export interface LoopProps {
+ position: 'top' | 'bottom';
+ language?: string;
+ apibase?: string;
+ pageId: string;
+ authenticated?: 'true' | 'false';
+ 'welcome-enabled'?: 'true' | 'false';
+ 'welcome-headline'?: string;
+ 'welcome-text'?: string;
+ translations?: string;
+}
+
+export interface Comment {
+ id: number;
+ author: string;
+ url: string;
+ page: string;
+ comment: string;
+ selector: string;
+ selectorOffsetX: number;
+ selectorOffsetY: number;
+ pagePositionX: number;
+ pagePositionY: number;
+ status: string;
+ timestamp: number;
+ lang: string;
+ replies: Reply[];
+}
+
+export interface Reply {
+ id?: number;
+ author: string;
+ comment: string;
+ parentId: number | null;
+ timestamp: number;
+}
+
+export interface CommentPayload {
+ url: string;
+ comment: string;
+ selector: string;
+ selectorOffsetX: number;
+ selectorOffsetY: number;
+ pagePositionX: number;
+ pagePositionY: number;
+ parentId: number | null;
+ lang: string;
+ pageId: string;
+}
+
+export interface ReplyPayload {
+ comment: string;
+ parentId: number | null;
+}
+
+export interface MarkerPosition {
+ selector: string;
+ selectorOffsetX: number;
+ selectorOffsetY: number;
+ pagePositionX: number;
+ pagePositionY: number;
+}
+
+export interface ApiResponse {
+ status: 'ok' | 'error';
+ message?: string;
+ code?: string;
+ data?: T;
+}
+
+export interface CommentsResponse extends ApiResponse {
+ comments: Comment[];
+}
+
+export interface CommentResponse extends ApiResponse {
+ comment: Comment;
+}
+
+export interface ReplyResponse extends ApiResponse {
+ reply: Reply;
+}
+
+// Store interfaces
+export interface FormData {
+ text: string;
+ parentId: number | null;
+}
+
+export interface UIState {
+ open: boolean;
+ sidebarOpen: boolean;
+}
+
+export interface APIStore {
+ comments: Comment[];
+ loading: boolean;
+ error: string | null;
+}
diff --git a/site/plugins/loop/frontend/src/vite-env.d.ts b/site/plugins/loop/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..4078e74
--- /dev/null
+++ b/site/plugins/loop/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/site/plugins/loop/frontend/svelte.config.js b/site/plugins/loop/frontend/svelte.config.js
new file mode 100644
index 0000000..bb11984
--- /dev/null
+++ b/site/plugins/loop/frontend/svelte.config.js
@@ -0,0 +1,10 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+ compilerOptions: {
+ customElement: true,
+ },
+};
diff --git a/site/plugins/loop/frontend/tsconfig.app.json b/site/plugins/loop/frontend/tsconfig.app.json
new file mode 100644
index 0000000..55a2f9b
--- /dev/null
+++ b/site/plugins/loop/frontend/tsconfig.app.json
@@ -0,0 +1,20 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "resolveJsonModule": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable checkJs if you'd like to use dynamic types in JS.
+ * Note that setting allowJs false does not prevent the use
+ * of JS in `.svelte` files.
+ */
+ "allowJs": true,
+ "checkJs": true,
+ "isolatedModules": true,
+ "moduleDetection": "force"
+ },
+ "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
+}
diff --git a/site/plugins/loop/frontend/tsconfig.json b/site/plugins/loop/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/site/plugins/loop/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/site/plugins/loop/frontend/tsconfig.node.json b/site/plugins/loop/frontend/tsconfig.node.json
new file mode 100644
index 0000000..db0becc
--- /dev/null
+++ b/site/plugins/loop/frontend/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/site/plugins/loop/frontend/vite.config.ts b/site/plugins/loop/frontend/vite.config.ts
new file mode 100644
index 0000000..f7c36cd
--- /dev/null
+++ b/site/plugins/loop/frontend/vite.config.ts
@@ -0,0 +1,69 @@
+///
+
+import { defineConfig, loadEnv } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
+import { ViteEjsPlugin } from 'vite-plugin-ejs'
+import { browserslistToTargets } from 'lightningcss';
+import browserslist from "browserslist"
+
+// Isomorphic dirname
+const _dirname =
+ typeof __dirname !== "undefined"
+ ? __dirname
+ : dirname(fileURLToPath(import.meta.url));
+
+// Config
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd());
+ return {
+ base: env.VITE_DEMO_BASE,
+ compilerOptions: {
+ hmr: !process.env.VITEST && mode !== 'production',
+ },
+ build: {
+ cssMinify: 'lightningcss',
+ minify: true,
+ lib: {
+ entry: resolve(_dirname, "src/main.ts"),
+ name: "Loop",
+ fileName: "loop",
+ formats: ["es"],
+ },
+ outDir: "../assets",
+ },
+ css: {
+ transformer: 'lightningcss',
+ lightningcss: {
+ drafts: {
+ customMedia: true
+ },
+ targets: browserslistToTargets(browserslist(["last 2 versions", ">= 0.4%", "not dead", "Firefox ESR", "not op_mini all", "not and_uc > 0"]))
+ }
+ },
+ define: {
+ APP_VERSION: JSON.stringify(process.env.npm_package_version),
+ },
+ plugins: [
+ svelte({ compilerOptions: { customElement: true } }),
+ cssInjectedByJsPlugin(),
+ ViteEjsPlugin((viteConfig) => ({
+ // viteConfig is the current Vite resolved config
+ env: viteConfig.env,
+ }))
+ ],
+ test: {
+ globals: true,
+ environment: "jsdom",
+ },
+ server: {
+ allowedHosts: ['kirby-loop.test'],
+ cors: {
+ // Allow ddev and .test domains
+ origin: /https?:\/\/([A-Za-z0-9\-\.]+)?(\.(ddev\.site|test))(?::\d+)?$/,
+ },
+ }
+ }
+});
diff --git a/site/plugins/loop/index.php b/site/plugins/loop/index.php
new file mode 100644
index 0000000..5f34c32
--- /dev/null
+++ b/site/plugins/loop/index.php
@@ -0,0 +1,333 @@
+ 'src/App.php',
+ 'moinframe\\loop\\Database' => 'src/Database.php',
+ 'moinframe\\loop\\Middleware' => 'src/Middleware.php',
+ 'moinframe\\loop\\Options' => 'src/Options.php',
+ 'moinframe\\loop\\Routes' => 'src/Routes.php',
+ 'moinframe\\loop\\Models\\Comment' => 'src/Models/Comment.php',
+ 'moinframe\\loop\\Models\\Reply' => 'src/Models/Reply.php',
+ 'moinframe\\loop\\Enums\\CommentStatus' => 'src/Enums/CommentStatus.php',
+], __DIR__);
+
+Kirby::plugin('moinframe/loop', [
+ 'translations' => [
+ 'en' => [
+ // General errors
+ 'moinframe.loop.csrf.invalid' => 'Invalid CSRF token',
+ 'moinframe.loop.field.required' => 'Missing required field: {field}',
+
+ // Page errors
+ 'moinframe.loop.page.not.found' => 'Page with id {pageId} not found',
+ 'moinframe.loop.page.path.not.found' => 'Page not found: {path}',
+
+ // Comment validation
+ 'moinframe.loop.comment.required' => 'Comment text is required',
+ 'moinframe.loop.comment.max.length' => 'Comment text must be less than 5000 characters',
+ 'moinframe.loop.comment.validation.failed' => 'Comment validation failed: {errors}',
+ 'moinframe.loop.comment.validation.error' => 'Comment validation failed: {errors}',
+ 'moinframe.loop.comment.creation.failed' => 'Comment creation failed: {error}',
+ 'moinframe.loop.comment.add.failed' => 'Failed to add comment: {error}',
+
+ // Reply validation
+ 'moinframe.loop.reply.validation.failed' => 'Reply validation failed: {errors}',
+ 'moinframe.loop.reply.validation.error' => 'Reply validation failed: {errors}',
+ 'moinframe.loop.reply.creation.failed' => 'Reply creation failed: {error}',
+ 'moinframe.loop.reply.add.failed' => 'Failed to add reply: {error}',
+ 'moinframe.loop.reply.index.error' => 'Reply {index}: {error}',
+
+ // Author validation
+ 'moinframe.loop.author.required' => 'Author is required',
+ 'moinframe.loop.author.max.length' => 'Author name must be less than 255 characters',
+
+ // Page field validation
+ 'moinframe.loop.page.required' => 'Page identifier is required',
+ 'moinframe.loop.page.max.length' => 'Page identifier must be less than 255 characters',
+
+ // Selector validation
+ 'moinframe.loop.selector.required' => 'Element selector is required',
+ 'moinframe.loop.selector.max.length' => 'Element selector must be less than 1000 characters',
+ 'moinframe.loop.selector.offset.x.min' => 'Selector offset X must be non-negative',
+ 'moinframe.loop.selector.offset.y.min' => 'Selector offset Y must be non-negative',
+
+ // URL validation
+ 'moinframe.loop.url.format.invalid' => 'URL format is invalid',
+ 'moinframe.loop.url.max.length' => 'URL must be less than 2048 characters',
+
+ // Position validation
+ 'moinframe.loop.page.position.x.min' => 'Page position X must be non-negative',
+ 'moinframe.loop.page.position.y.min' => 'Page position Y must be non-negative',
+
+ // Other field validation
+ 'moinframe.loop.timestamp.min' => 'Timestamp must be non-negative',
+ 'moinframe.loop.parent.id.min' => 'Parent ID must be non-negative',
+ 'moinframe.loop.parent.id.required' => 'Valid parent comment ID is required',
+
+ // Welcome dialog
+ 'moinframe.loop.welcome.headline' => 'Welcome! 👋',
+ 'moinframe.loop.welcome.text' => 'We\'re excited to hear your thoughts! This page has an interactive feedback system that lets you comment directly on any element. Simply use the action bar at the {position} of your screen to switch between browsing and commenting mode. When in commenting mode, click anywhere on the page to leave your feedback.',
+
+ // Frontend UI translations
+ 'moinframe.loop.ui.comment.placeholder' => 'Enter your comment...',
+ 'moinframe.loop.ui.comment.submit' => 'Submit',
+ 'moinframe.loop.ui.comment.cancel' => 'Cancel',
+ 'moinframe.loop.ui.comment.keyboardHint' => '⌘+Enter or Ctrl+Enter to submit',
+ 'moinframe.loop.ui.comment.replies.aria.label' => 'Show replies',
+ 'moinframe.loop.ui.reply.placeholder' => 'Write a reply...',
+ 'moinframe.loop.ui.reply.submit' => 'Reply',
+ 'moinframe.loop.ui.panel.no.comments' => 'No comments. Add your first comment to get started.',
+ 'moinframe.loop.ui.header.browse.mode' => 'Browse',
+ 'moinframe.loop.ui.header.comment.mode' => 'Comment',
+ 'moinframe.loop.ui.header.aria.count' => 'unresolved comments',
+ 'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Enter your name',
+ 'moinframe.loop.ui.welcome.continue' => 'Continue',
+ 'moinframe.loop.ui.welcome.dismiss' => 'Dismiss',
+ 'moinframe.loop.ui.header.position.top' => 'top',
+ 'moinframe.loop.ui.header.position.bottom' => 'bottom',
+ 'moinframe.loop.ui.comment.mark.solved' => 'Resolve',
+ 'moinframe.loop.ui.comment.mark.unsolved' => 'Reopen',
+ 'moinframe.loop.ui.comment.maker.aria.label' => 'Jump to marker',
+ 'moinframe.loop.ui.comment.summary.aria.label' => 'Comment by',
+ 'moinframe.loop.ui.reply.aria.label' => 'Reply by',
+ 'moinframe.loop.ui.panel.open' => 'Open comments',
+ 'moinframe.loop.ui.panel.show.resolved' => 'Show Resolved Only',
+ 'moinframe.loop.ui.panel.show.all' => 'Show All Comments',
+ 'moinframe.loop.ui.panel.filter.open' => 'Open',
+ 'moinframe.loop.ui.panel.filter.resolved' => 'Resolved',
+ 'moinframe.loop.ui.panel.filter.open.active' => 'Show open comments (currently selected)',
+ 'moinframe.loop.ui.panel.filter.open.inactive' => 'Show open comments',
+ 'moinframe.loop.ui.panel.filter.resolved.active' => 'Show resolved comments (currently selected)',
+ 'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Show resolved comments',
+ 'moinframe.loop.ui.panel.menu.open' => 'Open menu',
+ 'moinframe.loop.ui.panel.menu.filter.title' => 'Show Comments',
+ 'moinframe.loop.ui.panel.no.resolved' => 'No resolved comments yet.',
+
+ // Time formatting
+ 'moinframe.loop.ui.time.just_now' => 'just now',
+ 'moinframe.loop.ui.time.minute_ago' => 'a minute ago',
+ 'moinframe.loop.ui.time.minutes_ago' => '{count} minutes ago',
+ 'moinframe.loop.ui.time.hour_ago' => 'an hour ago',
+ 'moinframe.loop.ui.time.hours_ago' => '{count} hours ago',
+ 'moinframe.loop.ui.time.yesterday' => 'yesterday',
+ 'moinframe.loop.ui.time.days_ago' => '{count} days ago'
+ ],
+ 'de' => [
+ // General errors
+ 'moinframe.loop.csrf.invalid' => 'Ungültiges CSRF-Token',
+ 'moinframe.loop.field.required' => 'Pflichtfeld fehlt: {field}',
+
+ // Page errors
+ 'moinframe.loop.page.not.found' => 'Seite mit ID {pageId} nicht gefunden',
+ 'moinframe.loop.page.path.not.found' => 'Seite nicht gefunden: {path}',
+
+ // Comment validation
+ 'moinframe.loop.comment.required' => 'Kommentartext ist erforderlich',
+ 'moinframe.loop.comment.max.length' => 'Kommentartext darf maximal 5000 Zeichen lang sein',
+ 'moinframe.loop.comment.validation.failed' => 'Kommentar-Validierung fehlgeschlagen: {errors}',
+ 'moinframe.loop.comment.validation.error' => 'Kommentar-Validierung fehlgeschlagen: {errors}',
+ 'moinframe.loop.comment.creation.failed' => 'Kommentar-Erstellung fehlgeschlagen: {error}',
+ 'moinframe.loop.comment.add.failed' => 'Kommentar konnte nicht hinzugefügt werden: {error}',
+
+ // Reply validation
+ 'moinframe.loop.reply.validation.failed' => 'Antwort-Validierung fehlgeschlagen: {errors}',
+ 'moinframe.loop.reply.validation.error' => 'Antwort-Validierung fehlgeschlagen: {errors}',
+ 'moinframe.loop.reply.creation.failed' => 'Antwort-Erstellung fehlgeschlagen: {error}',
+ 'moinframe.loop.reply.add.failed' => 'Antwort konnte nicht hinzugefügt werden: {error}',
+ 'moinframe.loop.reply.index.error' => 'Antwort {index}: {error}',
+
+ // Author validation
+ 'moinframe.loop.author.required' => 'Autor ist erforderlich',
+ 'moinframe.loop.author.max.length' => 'Autorname darf maximal 255 Zeichen lang sein',
+
+ // Page field validation
+ 'moinframe.loop.page.required' => 'Seiten-Identifikator ist erforderlich',
+ 'moinframe.loop.page.max.length' => 'Seiten-Identifikator darf maximal 255 Zeichen lang sein',
+
+ // Selector validation
+ 'moinframe.loop.selector.required' => 'Element-Selektor ist erforderlich',
+ 'moinframe.loop.selector.max.length' => 'Element-Selektor darf maximal 1000 Zeichen lang sein',
+ 'moinframe.loop.selector.offset.x.min' => 'Selektor-Offset X muss nicht-negativ sein',
+ 'moinframe.loop.selector.offset.y.min' => 'Selektor-Offset Y muss nicht-negativ sein',
+
+ // URL validation
+ 'moinframe.loop.url.format.invalid' => 'URL-Format ist ungültig',
+ 'moinframe.loop.url.max.length' => 'URL darf maximal 2048 Zeichen lang sein',
+
+ // Position validation
+ 'moinframe.loop.page.position.x.min' => 'Seitenposition X muss nicht-negativ sein',
+ 'moinframe.loop.page.position.y.min' => 'Seitenposition Y muss nicht-negativ sein',
+
+ // Other field validation
+ 'moinframe.loop.timestamp.min' => 'Zeitstempel muss nicht-negativ sein',
+ 'moinframe.loop.parent.id.min' => 'Eltern-ID muss nicht-negativ sein',
+ 'moinframe.loop.parent.id.required' => 'Gültige Eltern-Kommentar-ID ist erforderlich',
+
+ // Welcome dialog
+ 'moinframe.loop.welcome.headline' => 'Willkommen! 👋',
+ 'moinframe.loop.welcome.text' => 'Wir freuen uns auf Ihr Feedback! Diese Seite verfügt über ein interaktives Feedback-System, mit dem Sie direkt zu jedem Element kommentieren können. Verwenden Sie einfach die Aktionsleiste {position} an ihrem Bildschirm, um zwischen Browse- und Kommentar-Modus zu wechseln. Im Kommentar-Modus klicken Sie einfach irgendwo auf die Seite, um Ihr Feedback zu hinterlassen.',
+
+ // Frontend UI translations
+ 'moinframe.loop.ui.comment.placeholder' => 'Ihr Kommentar...',
+ 'moinframe.loop.ui.comment.submit' => 'Senden',
+ 'moinframe.loop.ui.comment.cancel' => 'Abbrechen',
+ 'moinframe.loop.ui.comment.keyboardHint' => '⌘+Enter oder Strg+Enter zum Senden',
+ 'moinframe.loop.ui.comment.replies.aria.label' => 'Antworten anzeigen',
+ 'moinframe.loop.ui.comment.maker.aria.label' => 'Springe zu Marker',
+ 'moinframe.loop.ui.comment.summary.aria.label' => 'Kommentar von',
+ 'moinframe.loop.ui.reply.aria.label' => 'Antwort von',
+ 'moinframe.loop.ui.reply.placeholder' => 'Antwort schreiben...',
+ 'moinframe.loop.ui.reply.submit' => 'Antworten',
+ 'moinframe.loop.ui.panel.no.comments' => 'Keine Kommentare. Fügen Sie Ihren ersten Kommentar hinzu, um zu beginnen.',
+ 'moinframe.loop.ui.panel.open' => 'Kommentare öffnen',
+ 'moinframe.loop.ui.header.browse.mode' => 'Navigieren',
+ 'moinframe.loop.ui.header.comment.mode' => 'Kommentieren',
+ 'moinframe.loop.ui.header.aria.count' => 'offene Kommentare',
+ 'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Geben Sie Ihren Namen ein',
+ 'moinframe.loop.ui.welcome.continue' => 'Weiter',
+ 'moinframe.loop.ui.welcome.dismiss' => 'Schließen',
+ 'moinframe.loop.ui.header.position.top' => 'oben',
+ 'moinframe.loop.ui.header.position.bottom' => 'unten',
+ 'moinframe.loop.ui.comment.mark.solved' => 'Erledigt',
+ 'moinframe.loop.ui.comment.mark.unsolved' => 'Wieder öffnen',
+ 'moinframe.loop.ui.panel.show.resolved' => 'Nur erledigte anzeigen',
+ 'moinframe.loop.ui.panel.show.all' => 'Alle Kommentare anzeigen',
+ 'moinframe.loop.ui.panel.filter.open' => 'Offen',
+ 'moinframe.loop.ui.panel.filter.resolved' => 'Erledigt',
+ 'moinframe.loop.ui.panel.filter.open.active' => 'Offene Kommentare anzeigen (aktuell ausgewählt)',
+ 'moinframe.loop.ui.panel.filter.open.inactive' => 'Offene Kommentare anzeigen',
+ 'moinframe.loop.ui.panel.filter.resolved.active' => 'Erledigte Kommentare anzeigen (aktuell ausgewählt)',
+ 'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Erledigte Kommentare anzeigen',
+ 'moinframe.loop.ui.panel.menu.open' => 'Menü öffnen',
+ 'moinframe.loop.ui.panel.menu.filter.title' => 'Kommentare anzeigen',
+ 'moinframe.loop.ui.panel.no.resolved' => 'Noch keine erledigten Kommentare.',
+
+ // Time formatting
+ 'moinframe.loop.ui.time.just_now' => 'gerade eben',
+ 'moinframe.loop.ui.time.minute_ago' => 'vor einer Minute',
+ 'moinframe.loop.ui.time.minutes_ago' => 'vor {count} Minuten',
+ 'moinframe.loop.ui.time.hour_ago' => 'vor einer Stunde',
+ 'moinframe.loop.ui.time.hours_ago' => 'vor {count} Stunden',
+ 'moinframe.loop.ui.time.yesterday' => 'gestern',
+ 'moinframe.loop.ui.time.days_ago' => 'vor {count} Tagen'
+ ],
+ 'fr' => [
+ // General errors
+ 'moinframe.loop.csrf.invalid' => 'Jeton CSRF invalide',
+ 'moinframe.loop.field.required' => 'Champ obligatoire manquant : {field}',
+
+ // Page errors
+ 'moinframe.loop.page.not.found' => 'Page avec l\'id {pageId} introuvable',
+ 'moinframe.loop.page.path.not.found' => 'Page introuvable : {path}',
+
+ // Comment validation
+ 'moinframe.loop.comment.required' => 'Le texte du commentaire est requis',
+ 'moinframe.loop.comment.max.length' => 'Le commentaire ne doit pas dépasser 5000 caractères',
+ 'moinframe.loop.comment.validation.failed' => 'Validation du commentaire échouée : {errors}',
+ 'moinframe.loop.comment.validation.error' => 'Validation du commentaire échouée : {errors}',
+ 'moinframe.loop.comment.creation.failed' => 'Création du commentaire échouée : {error}',
+ 'moinframe.loop.comment.add.failed' => 'Impossible d\'ajouter le commentaire : {error}',
+
+ // Reply validation
+ 'moinframe.loop.reply.validation.failed' => 'Validation de la réponse échouée : {errors}',
+ 'moinframe.loop.reply.validation.error' => 'Validation de la réponse échouée : {errors}',
+ 'moinframe.loop.reply.creation.failed' => 'Création de la réponse échouée : {error}',
+ 'moinframe.loop.reply.add.failed' => 'Impossible d\'ajouter la réponse : {error}',
+ 'moinframe.loop.reply.index.error' => 'Réponse {index} : {error}',
+
+ // Author validation
+ 'moinframe.loop.author.required' => 'L\'auteur est requis',
+ 'moinframe.loop.author.max.length' => 'Le nom de l\'auteur ne doit pas dépasser 255 caractères',
+
+ // Page field validation
+ 'moinframe.loop.page.required' => 'L\'identifiant de page est requis',
+ 'moinframe.loop.page.max.length' => 'L\'identifiant de page ne doit pas dépasser 255 caractères',
+
+ // Selector validation
+ 'moinframe.loop.selector.required' => 'Le sélecteur d\'élément est requis',
+ 'moinframe.loop.selector.max.length' => 'Le sélecteur d\'élément ne doit pas dépasser 1000 caractères',
+ 'moinframe.loop.selector.offset.x.min' => 'L\'offset X du sélecteur doit être positif',
+ 'moinframe.loop.selector.offset.y.min' => 'L\'offset Y du sélecteur doit être positif',
+
+ // URL validation
+ 'moinframe.loop.url.format.invalid' => 'Format d\'URL invalide',
+ 'moinframe.loop.url.max.length' => 'L\'URL ne doit pas dépasser 2048 caractères',
+
+ // Position validation
+ 'moinframe.loop.page.position.x.min' => 'La position X doit être positive',
+ 'moinframe.loop.page.position.y.min' => 'La position Y doit être positive',
+
+ // Other field validation
+ 'moinframe.loop.timestamp.min' => 'L\'horodatage doit être positif',
+ 'moinframe.loop.parent.id.min' => 'L\'ID parent doit être positif',
+ 'moinframe.loop.parent.id.required' => 'Un ID de commentaire parent valide est requis',
+
+ // Welcome dialog
+ 'moinframe.loop.welcome.headline' => 'Nouveauté',
+ 'moinframe.loop.welcome.text' => 'Il est désormais possible de commenter directement n\'importe quel élément. Utilisez la barre d\'action en {position} de votre écran pour basculer entre le mode navigation et le mode commentaire. En mode commentaire, cliquez n\'importe où sur la page pour laisser votre message.',
+
+ // Frontend UI translations
+ 'moinframe.loop.ui.comment.placeholder' => 'Votre commentaire...',
+ 'moinframe.loop.ui.comment.submit' => 'Envoyer',
+ 'moinframe.loop.ui.comment.cancel' => 'Annuler',
+ 'moinframe.loop.ui.comment.keyboardHint' => '⌘+Entrée ou Ctrl+Entrée pour envoyer',
+ 'moinframe.loop.ui.comment.replies.aria.label' => 'Afficher les réponses',
+ 'moinframe.loop.ui.comment.maker.aria.label' => 'Aller au marqueur',
+ 'moinframe.loop.ui.comment.summary.aria.label' => 'Commentaire de',
+ 'moinframe.loop.ui.reply.aria.label' => 'Réponse de',
+ 'moinframe.loop.ui.reply.placeholder' => 'Écrire une réponse...',
+ 'moinframe.loop.ui.reply.submit' => 'Répondre',
+ 'moinframe.loop.ui.panel.no.comments' => 'Aucun commentaire. Ajoutez votre premier commentaire pour commencer.',
+ 'moinframe.loop.ui.panel.open' => 'Ouvrir les commentaires',
+ 'moinframe.loop.ui.header.browse.mode' => 'Naviguer',
+ 'moinframe.loop.ui.header.comment.mode' => 'Commenter',
+ 'moinframe.loop.ui.header.aria.count' => 'commentaires non résolus',
+ 'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Entrez votre nom',
+ 'moinframe.loop.ui.welcome.continue' => 'Continuer',
+ 'moinframe.loop.ui.welcome.dismiss' => 'Fermer',
+ 'moinframe.loop.ui.header.position.top' => 'haut',
+ 'moinframe.loop.ui.header.position.bottom' => 'bas',
+ 'moinframe.loop.ui.comment.mark.solved' => 'Résoudre',
+ 'moinframe.loop.ui.comment.mark.unsolved' => 'Rouvrir',
+ 'moinframe.loop.ui.panel.show.resolved' => 'Afficher les résolus uniquement',
+ 'moinframe.loop.ui.panel.show.all' => 'Afficher tous les commentaires',
+ 'moinframe.loop.ui.panel.filter.open' => 'Ouverts',
+ 'moinframe.loop.ui.panel.filter.resolved' => 'Résolus',
+ 'moinframe.loop.ui.panel.filter.open.active' => 'Afficher les commentaires ouverts (sélectionné)',
+ 'moinframe.loop.ui.panel.filter.open.inactive' => 'Afficher les commentaires ouverts',
+ 'moinframe.loop.ui.panel.filter.resolved.active' => 'Afficher les commentaires résolus (sélectionné)',
+ 'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Afficher les commentaires résolus',
+ 'moinframe.loop.ui.panel.menu.open' => 'Ouvrir le menu',
+ 'moinframe.loop.ui.panel.menu.filter.title' => 'Afficher les commentaires',
+ 'moinframe.loop.ui.panel.no.resolved' => 'Aucun commentaire résolu pour le moment.',
+
+ // Time formatting
+ 'moinframe.loop.ui.time.just_now' => 'à l\'instant',
+ 'moinframe.loop.ui.time.minute_ago' => 'il y a une minute',
+ 'moinframe.loop.ui.time.minutes_ago' => 'il y a {count} minutes',
+ 'moinframe.loop.ui.time.hour_ago' => 'il y a une heure',
+ 'moinframe.loop.ui.time.hours_ago' => 'il y a {count} heures',
+ 'moinframe.loop.ui.time.yesterday' => 'hier',
+ 'moinframe.loop.ui.time.days_ago' => 'il y a {count} jours'
+ ]
+ ],
+ 'hooks' => [
+ 'page.render:after' => function (string $contentType, array $data, string $html, \Kirby\Cms\Page $page) {
+ if ($contentType === 'html' && Options::autoInject() && Options::enabled()) {
+ $snippet = snippet('loop/app', ['page' => $page], true);
+ // @phpstan-ignore-next-line
+ $html = str_replace('
+ +
+
+ {comment.author}
+
+
+ {decodeHTMLEntities(comment.comment)}
+
+
+ {#if !detailsOpen}
+
+ {/if}
+
+ + {#if comment.replies?.length > 0} ++ {#each comment.replies as reply (reply.id)} +-
+
+
+ {/each}
+
+ {/if} + +