Compare commits

..

No commits in common. "main" and "light_mod" have entirely different histories.

160 changed files with 9375 additions and 19343 deletions

View file

@ -1,32 +0,0 @@
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy to Production
runs-on: docker
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch main https://oauth2:${{ github.token }}@forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Deploy via FTP
env:
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
HOST: ${{ secrets.HOST }}
run: |
apt-get update -qq && apt-get install -y -qq lftp
cat > /tmp/lftp-script.txt <<SCRIPT
set ftp:ssl-allow no
open -u $USERNAME,$PASSWORD $HOST
mirror --reverse --verbose --ignore-time --parallel=10 -x static/ assets assets
mirror --reverse --verbose --ignore-time --parallel=10 -x accounts/ -x cache/ -x sessions/ site site
quit
SCRIPT
lftp -f /tmp/lftp-script.txt

12
.gitignore vendored
View file

@ -51,20 +51,12 @@ Icon
/site/config/.license
# Local files
# ---------------
/0_local
# Managed through composer
# ---------------
/kirby
/vendor
/node_modules
/content
# Claude settings
# ---------------
.claude
/content

View file

@ -9,13 +9,10 @@
color: #000;
}
.k-panel[data-template="year"]
.k-section-name-texts
.k-list-items
.k-item:first-child {
.k-panel[data-template="year"] .k-list-items .k-item:first-child {
margin-bottom: 2rem;
}
.k-panel[data-template="year"] .k-section-name-texts .k-list-items::before {
.k-panel[data-template="year"] .k-list-items::before {
content: "Texte princeps";
display: block;
padding: 0.5rem;
@ -26,7 +23,3 @@
background-color: #000;
color: #fff;
}
.k-block-type-quote-citation {
font-style: normal;
}

View file

@ -1,88 +0,0 @@
[data-template="linear"],
[data-template="grid"] {
article #main-content {
scroll-margin-block-start: calc(var(--unit--vertical) * 6);
padding-bottom: calc(var(--unit--vertical) * 2);
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .texts {
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .see-more {
margin-top: calc(var(--unit--vertical) * 0.5);
}
[data-template="year"] article > h1 {
margin-bottom: 0;
}
article #main-content #chapo::after {
content: "";
display: block;
margin-top: calc(2 * var(--unit--vertical));
margin-bottom: calc(4 * var(--unit--vertical));
width: 100%;
height: 1px;
background-color: var(--color-background);
}
article #main-content li:not(.text) {
list-style-type: inherit;
}
article h3,
article h4 {
scroll-margin-top: calc(var(--unit--vertical) * 1);
margin-bottom: calc(1 * var(--unit--vertical));
}
article h3 {
margin-top: calc(3 * var(--unit--vertical));
}
article h4 {
margin-top: calc(2 * var(--unit--vertical));
}
article li,
article ol {
margin-left: var(--unit--horizontal);
}
article figure {
margin: 4rem 0;
}
article figure img {
max-width: 100%;
}
// Quotes
//
blockquote {
border-left: 1px solid #fff;
margin: calc(var(--unit--vertical) * 2) 0;
padding-left: var(--unit--horizontal);
}
blockquote footer {
margin-top: calc(var(--unit--vertical) / 2);
font-size: var(--font-size-m);
}
blockquote.big {
font-weight: var(--font-weight-light);
font-size: var(--font-size-l);
}
@media screen and (max-width: 640px) {
article h3,
article h4 {
scroll-margin-top: calc(var(--unit--vertical) * 5);
}
}
@media screen and (min-width: 640px) {
article #main-content {
max-width: auto;
}
}
}

View file

@ -1,45 +0,0 @@
.toc {
display: flex;
flex-direction: column;
justify-content: center;
}
.page-cover .toc {
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);
}
}
.side-panel__view[data-view="toc"] .toc {
padding: var(--unit--vertical) var(--unit--horizontal);
}
.toc_label {
font-size: var(--font-size-m);
margin-bottom: calc(var(--unit--vertical) / 4);
}
.toc > ul {
display: flex;
flex-direction: column;
gap: calc(var(--unit--vertical) / 4);
> li {
> ul {
margin-left: var(--unit--horizontal);
}
}
}
.toc li {
margin-left: 0;
}

View file

@ -1,34 +0,0 @@
.toggle-light {
position: fixed;
right: 0;
bottom: 0;
padding: calc((var(--unit--vertical) / 2) / 2)
calc(var(--unit--horizontal) / 2);
margin: calc((var(--unit--vertical) / 2) / 2)
calc(var(--unit--horizontal) / 2);
margin-bottom: calc(
var(--unit--vertical) - ((var(--unit--vertical) / 2) / 2)
);
z-index: 1;
}
.toggle-light-icon {
width: 1.2rem;
height: 1.2rem;
background-color: var(--color-primary);
mask-size: cover;
-webkit-mask-size: cover;
mask: var(--icon-toggle-light) no-repeat center;
-webkit-mask: var(--icon-toggle-light) no-repeat center;
}
@media screen and (max-width: 640px) {
.toggle-light {
margin-bottom: calc((var(--unit--vertical) / 2) / 2);
}
.toggle-light-icon {
width: 1.1rem;
height: 1.1rem;
}
}

View file

@ -1,13 +0,0 @@
[data-template="year"] {
#main-content {
ul.texts {
margin-top: calc(var(--unit--vertical) * 2);
> li:first-child {
padding-left: var(--unit--horizontal);
border-left: 1px solid #fff;
margin-bottom: calc(var(--unit--vertical) * 2);
}
}
}
}

View file

@ -0,0 +1,43 @@
article #main-content {
scroll-margin-block-start: calc(var(--unit--vertical) * 6);
padding-bottom: calc(var(--unit--vertical) * 2);
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .texts {
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .see-more {
margin-top: calc(var(--unit--vertical) * 0.5);
}
[data-template="year"] article > h1 {
margin-bottom: 0;
}
article #main-content #chapo::after {
content: "";
display: block;
margin-top: calc(2 * var(--unit--vertical));
margin-bottom: calc(4 * var(--unit--vertical));
width: 100%;
height: 1px;
background-color: var(--color-background);
}
article #main-content li:not(.text) {
list-style-type: inherit;
}
article h3 {
margin-top: calc(3 * var(--unit--vertical));
margin-bottom: calc(1 * var(--unit--vertical));
}
@media screen and (min-width: 640px) {
article #main-content {
max-width: auto;
}
}

View file

@ -2,8 +2,8 @@
position: fixed;
bottom: 0;
box-sizing: border-box;
border-bottom: 0;
z-index: 2;
}
[data-template="home"] #main-footer {
@ -12,43 +12,33 @@
bottom: 0;
}
#main-footer li:not(.footer-btn-wrapper) {
#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);
}
#main-footer button.plus {
transform: translateY(-2px);
}
[data-template="home"] .title-wrapper button.plus[data-open-panel] {
[data-template="home"] .title-wrapper button.open-nav {
display: inline-block !important;
}
@media screen and (max-width: 640px) {
#main-footer .footer-btn-wrapper button {
display: flex;
justify-content: center;
outline: none;
font-size: var(--font-size-m);
background-color: var(--color-background);
color: var(--color-primary);
line-height: 1;
padding: calc(var(--unit--vertical) / 2) var(--unit--horizontal);
}
#main-footer ul {
#main-footer .open-nav {
box-sizing: border-box;
bottom: 0;
display: flex;
justify-content: space-around;
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;
}
}
@ -68,22 +58,17 @@
display: block;
}
#main-footer button.plus {
#main-footer button.open-nav {
margin-bottom: var(--unit--vertical);
}
[data-template="home"] #main-footer .footer-btn-wrapper {
[data-template="home"] #main-footer .open-nav-wrapper {
display: none !important;
}
.footer-btn-wrapper {
.open-nav-wrapper {
padding: 0;
border: none;
background-color: transparent;
}
.side-panel-button-wrapper {
position: fixed;
top: 15vw;
}
}

View file

@ -138,3 +138,5 @@ button.less::after {
width: 100%;
margin-top: calc(var(--unit--vertical) / 2);
}

View file

@ -82,7 +82,6 @@ article > h1 {
display: flex;
flex-direction: column;
gap: var(--unit--vertical);
}
[data-template="home"] .page-cover {

View file

@ -15,25 +15,9 @@ main {
} */
hr {
height: 1px;
border: none;
margin: calc(var(--unit--vertical) * 2) 0;
}
hr::before,
hr::after {
display: flex;
justify-content: center;
color: #fff;
font-size: var(--font-size-m);
font-weight: var(--font-weight-light);
line-height: 0.8;
}
hr::before {
content: "+";
}
hr::after {
content: "+ +";
background-color: var(--color-primary);
}
nav hr {
@ -75,19 +59,6 @@ html {
border: none;
}
code {
font-family: monospace;
font-size: 1rem;
padding: 0.1rem 0.4rem;
background-color: rgba(255, 255, 255, 0.7);
color: #000;
}
[data-theme="light"] code {
background-color: rgba(0, 0, 0, 0.7);
color: #ffff;
}
@media screen and (min-width: 640px) {
body.full-width {
--padding-body: calc(var(--unit--horizontal) * 10);

View file

@ -10,27 +10,27 @@
display: none;
}
.side-panel {
.panel {
display: none;
position: fixed;
overflow: auto;
width: 100vw;
height: 100dvh;
top: 0;
top: 0;
background-color: var(--color-background);
outline: 1px solid var(--color-primary);
transition: all 0.5s var(--curve-sine);
z-index: 3;
box-sizing: border-box;
scroll-behavior: smooth;
flex-direction: column;
}
.side-panel.side-panel--visible {
.panel.panel--visible {
display: flex;
}
.side-panel header {
.panel header {
position: sticky;
top: 0;
z-index: 1;
@ -110,7 +110,7 @@ button.search__icon {
cursor: pointer;
}
.side-panel__collection {
.panel__collection {
scroll-behavior: smooth;
height: 100%;
overflow: auto;
@ -120,7 +120,7 @@ button.search__icon {
footer {
width: 100%;
}
.side-panel-close {
.panel-close {
position: fixed;
box-sizing: border-box;
bottom: 0;
@ -138,27 +138,27 @@ footer {
/* ================= LISTS ================= */
.side-panel__toggle-btn {
.panel__toggle-btn {
width: 100%;
display: flex;
justify-content: space-between;
margin-bottom: var(--unit--vertical);
}
.side-panel__toggle-icon {
.panel__toggle-icon {
color: var(--color-secondary);
font-size: var(--font-size-xl);
}
/* ================= YEARS ================= */
.side-panel__collection .side-panel__item:last-child {
.panel__collection .panel__item:last-child {
margin-bottom: 6rem;
}
.side-panel-item-content__edito {
.panel-item-content__edito {
margin-bottom: calc(var(--unit--vertical) / 2);
}
.side-panel-item-content__edito p:not(:last-child) {
.panel-item-content__edito p:not(:last-child) {
margin-bottom: var(--unit--vertical);
}
@ -166,35 +166,29 @@ button.see-more {
margin-bottom: var(--unit--vertical);
}
.side-panel-item-content__texts:not(
.side-panel__collection .side-panel-item-content__texts
) {
.panel-item-content__texts:not(.panel__collection .panel-item-content__texts) {
padding: var(--unit--vertical) var(--unit--horizontal);
}
/* ================= TEXT ITEM ================= */
.side-panel .text:first-child,
.side-panel .text:last-child {
.panel .text:first-child,
.panel .text:last-child {
margin-bottom: calc(var(--unit--vertical) * 2);
}
.side-panel__item > a {
scroll-margin-top: 9rem;
}
.side-panel__collection--years .text:first-child .text__title {
.panel__collection--years .text:first-child .text__title {
display: inline-block;
padding-left: var(--unit--horizontal);
}
.side-panel .text__subtitle {
.panel .text__subtitle {
margin-bottom: calc(var(--unit--vertical) / 4);
}
.side-panel__collection--years .text:first-child .text__infos {
.panel__collection--years .text:first-child .text__infos {
padding-left: var(--unit--horizontal);
}
.side-panel__collection--years .text:first-child .text__infos::before {
.panel__collection--years .text:first-child .text__infos::before {
content: "";
position: absolute;
left: 0;
@ -205,21 +199,25 @@ button.see-more {
}
@media screen and (min-width: 640px) {
.side-panel {
nav.panel {
width: 40rem;
z-index: 4;
}
.side-panel-close {
.panel {
z-index: 4;
width: var(--padding-body);
}
.panel-close {
display: none;
}
.side-panel--right {
.panel--right {
right: auto;
left: calc(-100vw - 1px);
}
.side-panel--right.open {
.panel--right.open {
left: 0;
}
}

View file

@ -1,42 +1,66 @@
@media print {
.side-panel,
nav,
.toggle-light {
/* Hide header / footer infos. */
@page {
margin: 0;
size: auto;
@top-left {
content: none;
}
@top-right {
content: none;
}
@top-center {
content: none;
}
@bottom-left {
content: none;
}
@bottom-right {
content: none;
}
@bottom-center {
content: none;
}
}
.panel, nav {
display: none;
}
:root,
[data-theme="dark"] {
/* regle du light mode dupliqué*/
:root {
--color-background: #ffffff;
--color-primary: #000;
--color-secondary-rgb: 140, 140, 140;
--font-weight-light: 240;
}
body {
padding-top: 8rem;
padding-top: 4rem;
background-image: none;
background-color: var(--color-background) !important;
}
.minimized #inactuel {
.minimized #inactuel{
transform: none !important;
margin: 0 !important;
}
#logo h1 {
#logo h1{
display: grid;
grid-template-columns: 100%;
grid-template-rows: auto;
}
#logo #actuel,
#logo #inactuel {
#logo #actuel, #logo #inactuel{
grid-column: 1;
grid-row: 1;
}
#logo #actuel {
font-weight: 550;
color: rgb(254, 250, 254) !important;
text-shadow: -1px 0 var(--color-primary), 1px 0 var(--color-primary),
0 -1px var(--color-primary), 0 1px var(--color-primary);
text-shadow: -1px 0 var(--color-primary), 1px 0 var(--color-primary), 0 -1px var(--color-primary),0 1px var(--color-primary);
z-index: 100;
}
#logo #inactuel {
@ -60,12 +84,8 @@
text-decoration: none !important;
}
#chapo {
page-break-after: always;
}
article #main-content {
width: 70%;
width: 60%;
margin: auto;
}
}

View file

@ -6,8 +6,7 @@ h3,
h4,
h5,
p,
ul,
figure {
ul {
margin: 0;
padding: 0;
}

View file

@ -28,9 +28,7 @@ h4 *,
h5,
h5 *,
p,
p *:not(strong),
figcaption,
.toc {
p *:not(strong) {
font-weight: var(--font-weight-light);
line-height: 1;
}
@ -45,12 +43,6 @@ h3,
color: var(--color-primary);
}
h4,
.h4 {
font-size: var(--font-size-l);
color: var(--color-primary);
}
article h2 {
margin-bottom: calc(var(--unit--vertical) / 2);
}
@ -176,9 +168,7 @@ a:not(.no-underline) {
text-decoration-thickness: 0.5px;
}
article p:not(:last-child),
article ul:not(:last-child),
article figure:not(:last-child) {
article p:not(:last-child) {
margin-bottom: var(--unit--vertical);
}

View file

@ -0,0 +1,31 @@
.theme-toggler{
position: fixed;
right: 0;
bottom: 0;
padding: calc((var(--unit--vertical) / 2) / 2) calc(var(--unit--horizontal) / 2);
margin: calc((var(--unit--vertical) / 2) / 2) calc(var(--unit--horizontal) / 2);
margin-bottom: calc(var(--unit--vertical) - ((var(--unit--vertical) / 2) / 2));
z-index: 100;
}
.theme-toggler-icon {
width: 1.2rem;
height: 1.2rem;
background-color: var(--color-primary);
mask-size: cover;
-webkit-mask-size: cover;
mask: var(--icon-theme-toggler) no-repeat center;
-webkit-mask: var(--icon-theme-toggler) no-repeat center;
}
@media screen and (max-width: 640px) {
.theme-toggler{
margin-bottom: calc((var(--unit--vertical) / 2) / 2);
}
.theme-toggler-icon {
width: 1.1rem;
height: 1.1rem;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,84 +0,0 @@
@import "src/reset";
@import "src/html";
@import "src/generic";
@import "src/texts";
@import "src/header";
@import "src/side-panel";
@import "src/article";
@import "src/virtual";
@import "src/home";
@import "src/grid";
@import "src/newsletter";
@import "src/footer";
@import "src/toggle-light-mode";
@import "src/print";
@import "src/toc";
@import "src/year";
:root {
--color-primary--transparent: rgba(255, 255, 255, 0.86);
--color-secondary-rgb: 200, 200, 200;
--color-secondary: rgba(var(--color-secondary-rgb), 0.86);
--color-secondary--light: rgba(var(--color-secondary-rgb), 0.2);
--color-secondary--x-light: rgb(var(--color-secondary-rgb), 0.1);
--color-tertiary-rgb: 200, 200, 200;
--color-tertiary: rgba(var(--color-tertiary-rgb), 0.86);
--color-tertiary--light: rgba(var(--color-tertiary-rgb), 0.2);
--color-tertiary--x-light: rgb(var(--color-tertiary-rgb), 0.1);
--unit--horizontal: 5vw;
--unit--vertical: 1.7rem;
--unit--vertical-relative: calc(
var(--unit--vertical) * var(--window-height-factor)
);
--font-size-s: 0.8rem;
--font-size-m: calc(var(--font-size-s) * 1.5);
--font-size-l: calc(var(--font-size-m) * 1.5);
--font-size-xl: calc(var(--font-size-l) * 1.5);
--font-size-xxl: calc(var(--font-size-xl) * 1.5);
--font-weight-light: 200;
--font-weight-bold: 400;
--font-weight-extra-bold: 550;
--opacity-light: 0.6;
--curve-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
}
@media screen {
[data-theme="dark"] {
--color-background: #000;
--color-primary: #ffffff;
--font-weight-light: 200;
}
[data-theme="light"] {
--color-background: #ffffff;
--color-primary: #000;
--color-secondary-rgb: 140, 140, 140;
--font-weight-light: 240;
}
}
@media screen and (min-width: 640px) {
:root {
--unit--horizontal: 2.5vw;
--unit--vertical: 1.7rem;
--font-size-s: 0.9rem;
--font-size-m: calc(var(--font-size-s) * 1.5);
--font-size-l: calc(var(--font-size-m) * 1.5);
--font-size-xl: calc(var(--font-size-l) * 1.5);
--font-size-xxl: calc(var(--font-size-xl) * 1.5);
/* --font-weight-light: 200;
--font-weight-bold: 400;
--font-weight-extra-bold: 550; */
--body-padding: calc(10 * var(--unit--horizontal));
}
}

181
assets/dist/script.js vendored Normal file
View file

@ -0,0 +1,181 @@
"use strict";
var verticalUnit = getUnit("--unit--vertical");
function getUnit(id) {
var remFactor = 16;
var rawUnit = getComputedStyle(document.documentElement).getPropertyValue(id) || "1.7rem";
var remUnit = parseFloat(rawUnit);
var pxUnit = remUnit * remFactor;
return pxUnit;
}
// Throttle found here : https://gist.github.com/ionurboz/51b505ee3281cd713747b4a84d69f434
function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function later() {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
var now = Date.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
function setWindowHeightFactor() {
var windowHeight = window.innerHeight;
var min = 650;
var delta = windowHeight - min;
var factor = roundToNearestHalf(delta / 300) + 1;
document.querySelector(":root").style.setProperty("--window-height-factor", factor);
}
function roundToNearestHalf(num) {
var round = Math.round(num * 2) / 2;
return Math.max(round, 0);
}
function toggleLogoState() {
var scrollY = window.scrollY || window.pageYOffset;
if (scrollY > 10) {
document.querySelector("#main-header").classList.add("minimized");
} else {
document.querySelector("#main-header").classList.remove("minimized");
}
}
function toggleFooterState() {
if (scrollY > 90) {
document.querySelector("#main-footer").classList.add("main-footer--background");
} else {
document.querySelector("#main-footer").classList.remove("main-footer--background");
}
}
function fixFootNotes() {
var footnotes = document.querySelectorAll('a[href^="#sdfootnote"]');
footnotes.forEach(function (footnote) {
var href = footnote.href;
footnote.classList.add("footnote");
if (href.includes("sym")) {
footnote.id = footnote.hash.replace("sym", "anc").replace("#", "");
} else if (href.includes("anc")) {
footnote.id = footnote.hash.replace("anc", "sym").replace("#", "");
}
});
}
function removeAccents(str) {
var from = "áäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđ߯a·/_,:;";
var to = "aaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
for (var i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
}
return str;
}
function slugify(str) {
return removeAccents(str.toLowerCase());
}
function subscribe(event) {
event.preventDefault();
var emailInput = document.querySelector("#subscribe-form input");
if (emailInput.value.toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
var header = {
method: "POST",
body: JSON.stringify(emailInput.value)
};
fetch("/subscribe.json", header).then(function (res) {
return res.json();
}).then(function (data) {
var formNode = emailInput.parentNode.parentNode;
formNode.outerHTML = "<p>" + data.message + "</p>";
});
} else {
emailInput.value = "E-mail invalide. Recommencez.";
}
}
var panelNav = document.querySelector(".panel");
var navOverlay = document.querySelector("#nav-overlay");
var openNavBtn = document.querySelector("button.open-nav");
var closeNavBtn = document.querySelector(".panel-close");
function closeNav() {
panelNav.classList.remove("panel--visible");
navOverlay.classList.remove("nav-overlay--visible");
document.body.classList.remove("no-scroll");
}
document.addEventListener("DOMContentLoaded", function () {
ragadjust("h1, h2, h4, h5", ["all"]);
window.window.scrollTo({
top: 0
});
var handleScroll = throttle(function () {
toggleLogoState();
if (window.innerWidth <= 680) {
toggleFooterState();
}
}, 100);
window.addEventListener("scroll", handleScroll);
setWindowHeightFactor();
window.addEventListener("resize", function () {
setWindowHeightFactor();
});
fixFootNotes();
window.addEventListener("keyup", function (event) {
if (event.key === "Escape") {
closeNav();
}
});
document.querySelectorAll(".panel").forEach(function (panel) {
panel.addEventListener("click", function (event) {
event.stopPropagation();
});
});
var navSortBtns = document.querySelectorAll("nav .sort-btn");
var navSections = document.querySelectorAll(".panel__all-texts, .panel__collection");
navSortBtns.forEach(function (sortBtn) {
sortBtn.addEventListener("click", function () {
navSortBtns.forEach(function (btn) {
return btn.classList.remove("active");
});
sortBtn.classList.add("active");
var sections = {
"sort-btn--all": ".panel__all-texts",
"sort-btn--years": ".panel__collection--years",
"sort-btn--categories": ".panel__collection--categories"
};
navSections.forEach(function (navSection) {
return navSection.classList.add("hidden");
});
Object.keys(sections).forEach(function (key) {
if (sortBtn.classList.contains(key)) {
document.querySelector(sections[key]).classList.remove("hidden");
}
});
});
});
openNavBtn.addEventListener("click", function () {
panelNav.classList.add("panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
});
closeNavBtn.addEventListener("click", function () {
closeNav();
});
navOverlay.addEventListener("click", function () {
closeNav();
});
});

1
assets/dist/style.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -10,9 +10,7 @@
function init() {
const storedPreference = localStorage.getItem("theme");
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedPreference || (systemPrefersDark ? "dark" : "light");
// const theme = "dark";
@ -23,9 +21,9 @@
init();
document.addEventListener("DOMContentLoaded", function () {
const togglers = document.querySelectorAll(".toggle-light");
const togglers = document.querySelectorAll(".theme-toggler");
togglers.forEach((toggler) => {
toggler.addEventListener("click", toggleDarkMode);
});
});
})();
})();

View file

@ -1,20 +1,3 @@
const root = document.documentElement;
function initTheme() {
const storedPreference = localStorage.getItem("theme");
const theme = storedPreference || "dark";
root.setAttribute("data-theme", theme);
}
function toggleDarkMode() {
const currentTheme = root.getAttribute("data-theme");
const newTheme = currentTheme === "dark" ? "light" : "dark";
root.setAttribute("data-theme", newTheme);
localStorage.setItem("theme", newTheme);
}
initTheme();
const verticalUnit = getUnit("--unit--vertical");
function getUnit(id) {
@ -86,13 +69,9 @@ function toggleLogoState() {
}
function toggleFooterState() {
if (scrollY > 90) {
document.querySelectorAll(".footer-btn-wrapper").forEach(element => {
element.classList.remove("hidden");
});
document.querySelector(".open-nav-wrapper").classList.remove("hidden");
} else {
document.querySelectorAll(".footer-btn-wrapper").forEach(element => {
element.classList.add("hidden");
});
document.querySelector(".open-nav-wrapper").classList.add("hidden");
}
}
@ -146,29 +125,16 @@ function subscribe(event) {
}
}
const panels = document.querySelectorAll(".side-panel[data-panel]");
const panelNav = document.querySelector(".panel");
const navOverlay = document.querySelector("#nav-overlay");
function closeAllPanels() {
panels.forEach(panel => panel.classList.remove("side-panel--visible"));
const openNavBtns = document.querySelectorAll("button.open-nav");
const closeNavBtn = document.querySelector(".panel-close");
function closeNav() {
panelNav.classList.remove("panel--visible");
navOverlay.classList.remove("nav-overlay--visible");
document.body.classList.remove("no-scroll");
}
function openPanel(name, view) {
const panel = document.querySelector(`.side-panel[data-panel="${name}"]`);
if (panel) {
if (view) {
panel.querySelectorAll('[data-view]').forEach(v => v.classList.add('hidden'));
const target = panel.querySelector(`[data-view="${view}"]`);
if (target) target.classList.remove('hidden');
}
panel.classList.add("side-panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
}
}
document.addEventListener("DOMContentLoaded", () => {
ragadjust("h1, h2, h4, h5", ["all"]);
window.window.scrollTo({
@ -190,24 +156,20 @@ document.addEventListener("DOMContentLoaded", () => {
fixFootNotes();
document.querySelectorAll(".toggle-light").forEach((toggler) => {
toggler.addEventListener("click", toggleDarkMode);
});
window.addEventListener("keyup", (event) => {
if (event.key === "Escape") {
closeAllPanels();
closeNav();
}
});
panels.forEach((panel) => {
document.querySelectorAll(".panel").forEach((panel) => {
panel.addEventListener("click", (event) => {
event.stopPropagation();
});
});
const navSortBtns = document.querySelectorAll(".side-panel .sort-btn");
const navSortBtns = document.querySelectorAll("nav .sort-btn");
const navSections = document.querySelectorAll(
".side-panel__all-texts, .side-panel__collection"
".panel__all-texts, .panel__collection"
);
navSortBtns.forEach((sortBtn) => {
@ -216,9 +178,9 @@ document.addEventListener("DOMContentLoaded", () => {
sortBtn.classList.add("active");
const sections = {
"sort-btn--all": ".side-panel__all-texts",
"sort-btn--years": ".side-panel__collection--years",
"sort-btn--categories": ".side-panel__collection--categories",
"sort-btn--all": ".panel__all-texts",
"sort-btn--years": ".panel__collection--years",
"sort-btn--categories": ".panel__collection--categories",
};
navSections.forEach((navSection) => navSection.classList.add("hidden"));
@ -231,20 +193,18 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
document.querySelectorAll("[data-open-panel]").forEach((btn) => {
btn.addEventListener("click", () => {
openPanel(btn.dataset.openPanel, btn.dataset.view);
openNavBtns.forEach((openNavBtn) => {
openNavBtn.addEventListener("click", () => {
panelNav.classList.add("panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
});
});
document.querySelectorAll(".side-panel-close").forEach((btn) => {
btn.addEventListener("click", closeAllPanels);
closeNavBtn.addEventListener("click", () => {
closeNav();
});
navOverlay.addEventListener("click", closeAllPanels);
// Fermer le panel TOC quand on clique sur un lien
document.querySelectorAll('[data-view="toc"] .toc a').forEach((link) => {
link.addEventListener("click", closeAllPanels);
navOverlay.addEventListener("click", () => {
closeNav();
});
});

View file

@ -29,8 +29,7 @@
"php-http/guzzle7-adapter": "^1.1",
"mailersend/mailersend": "^0.28.0",
"sylvainjule/code-editor": "^1.0",
"tobimori/kirby-seo": "^1.1",
"moinframe/kirby-loop": "^1.0"
"tobimori/kirby-seo": "^1.1"
},
"config": {
"platform": {

63
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e9154be9f46dbe7bc999d4706958afae",
"content-hash": "30f9edc8f90ec79150fffac01e3b80fd",
"packages": [
{
"name": "beberlei/assert",
@ -1317,67 +1317,6 @@
},
"time": "2016-12-13T01:01:17+00:00"
},
{
"name": "moinframe/kirby-loop",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/moinframe/kirby-loop.git",
"reference": "1e7732a075e96ecca119032175f9a048cfa4784e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moinframe/kirby-loop/zipball/1e7732a075e96ecca119032175f9a048cfa4784e",
"reference": "1e7732a075e96ecca119032175f9a048cfa4784e",
"shasum": ""
},
"require": {
"getkirby/cms": "^4.0||^5.0",
"getkirby/composer-installer": "^1.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0"
},
"type": "kirby-plugin",
"extra": {
"installer-name": "loop"
},
"autoload": {
"psr-4": {
"Moinframe\\Loop\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Justus Kraft",
"email": "justus@moinfra.me",
"homepage": "https://moinfra.me"
}
],
"description": "Interactive feedback tool for Kirby CMS websites that allows users to add contextual comments directly on page elements",
"homepage": "https://github.com/moinframe/kirby-loop",
"keywords": [
"cms",
"comments",
"feedback",
"kirby",
"loop",
"plugin",
"review"
],
"support": {
"docs": "https://moinfra.me/docs/moinframe-loop",
"issues": "https://github.com/moinframe/kirby-loop/issues",
"source": "https://github.com/moinframe/kirby-loop"
},
"time": "2025-07-08T18:20:46+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",

36
gulpfile.mjs Normal file
View file

@ -0,0 +1,36 @@
import gulp from "gulp"
const { watch, parallel, src, dest } = gulp
import cssnano from "gulp-cssnano"
import autoprefixer from "gulp-autoprefixer"
import cssimport from "gulp-cssimport"
import babel from "gulp-babel"
function cssProcess() {
return src("assets/css/style.css")
.pipe(cssimport())
.pipe(
autoprefixer({
cascade: false,
})
)
.pipe(cssnano())
.pipe(dest("assets/dist"))
}
function jsProcess() {
return src("assets/js/script.js")
.pipe(
babel({
presets: ["@babel/env"],
})
)
.pipe(dest("assets/dist"))
}
function dev() {
watch("assets/css/src/*.css", cssProcess)
}
const build = parallel(cssProcess, jsProcess)
export { dev, build }

8813
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

11
package.json Normal file
View file

@ -0,0 +1,11 @@
{
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^9.0.0",
"gulp-babel": "^8.0.0",
"gulp-cssimport": "^7.0.0",
"gulp-cssnano": "^2.1.3"
}
}

View file

@ -1,27 +0,0 @@
name: field.blocks.image.name
icon: image
preview: image
fields:
image:
label: field.blocks.image.name
type: files
query: model.images
multiple: false
image:
back: black
uploads:
template: blocks/image
alt:
label: field.blocks.image.alt
type: text
icon: title
help: Lu par les robots d'indexation, par les lecteurs d'écran et affiché à la place de l'image si celle-ci ne charge pas.
caption:
label: field.blocks.image.caption
type: writer
icon: text
inline: true
link:
label: field.blocks.image.link
type: text
icon: url

View file

@ -1,4 +0,0 @@
name: Séparateur
icon: divider
preview: line
wysiwyg: true

View file

@ -1,33 +0,0 @@
name: field.blocks.quote.name
icon: quote
wysiwyg: true
preview: quote
fields:
mode:
type: toggles
default: long
options:
- value: long
text: Longue
icon: false
- value: big
text: Forte
icon: false
text:
label: field.blocks.quote.text.label
placeholder: field.blocks.quote.text.placeholder
type: writer
inline: true
icon: quote
citation:
label: field.blocks.quote.citation.label
placeholder: field.blocks.quote.citation.placeholder
type: writer
inline: true
icon: user
marks:
- bold
- italic
- underline
- link
- cite

View file

@ -2,10 +2,7 @@ label: Corps
type: writer
headings:
- 3
- 4
marks:
- bold
- italic
- link
- underline
- code

View file

@ -1,18 +0,0 @@
label: Corps
type: blocks
default:
- type: heading
content:
level: h2
fieldsets:
text:
name: Texte
icon: text
wysiwyg: true
fields:
text:
extends: fields/body
label: false
image: true
quote: true
line: true

View file

@ -17,12 +17,6 @@ tabs:
body:
label: Contenu
type: writer
nodes: false
marks:
- bold
- link
- italic
- email
sendTab:
label: Envoi
icon: plane

View file

@ -26,28 +26,10 @@ tabs:
label: Chapo
extends: fields/body
help: optionnel
isBlockMode:
label: Mode blocs
type: toggle
default: true
width: 1/4
help: |
Actif : éditeur en blocs (texte + médias). Inactif : ancien champ texte sans médias, utile pour éditer d'anciens articles uniquement.
when:
isHtmlMode: false
bodyBlocks:
extends: fields/bodyBlocks
width: 3/4
when:
isHtmlMode: false
isBlockMode: true
body:
extends: fields/body
help: Anciens champs conservés pour archive (ne pas remplir pour les nouveaux articles). Ce champs ne sera utiliser en front que si le champs "corp" normal est vide.
width: 3/4
when:
isHtmlMode: false
isBlockMode: false
htmlBody:
label: Corps
type: textarea

View file

@ -7,21 +7,16 @@ tabs:
fields:
body:
extends: fields/body
- width: 2/3
- width: 1/2
fields:
subscribers:
label: Abonnés
type: structure
sortBy: inscriptiondate desc
sortBy: email asc
fields:
email:
type: email
inscriptionDate:
label: Date d'inscription
type: date
display: DD/MM/YYYY
disabled: true
- width: 1/3
- width: 1/2
sections:
newsletters:
label: Lettres

View file

@ -15,7 +15,7 @@ tabs:
type: pages
template: year
sortBy: title desc
- width: 1/2
- width: 1/1
sections:
allTextsSection:
label: Tous les textes

View file

@ -24,14 +24,6 @@ tabs:
templates:
- linear
- grid
linkedTextsSection:
type: fields
fields:
linkedTexts:
label: Textes liés
type: pages
query : site.find('textes').grandChildren.not(page.children)
help: textes enregistrés à une autre année à inclure aussi dans celle-ci.
- width: 2/3
fields:
edito:

View file

@ -1,6 +1,7 @@
label: Métadonnées
columns:
- width: 3/4
sections:
metadata:
type: fields
fields:
keywords:
label: Mots-clés
@ -24,14 +25,3 @@ columns:
multiple: false
required: true
width: 1/3
- width: 1/4
sections:
referencedTexts:
label: Textes référencés
type: pages
template: linear
help: Les textes ajoutés ici n'apparaissent nulle part dans la navigation du site mais peuvent être référencés via leurs URLs.
referencedFiles:
label: Fichiers
type: files
help: Ce champ contient tous les médias stockés par la page, tels que les images intégrées au texte. On peut aussi y ajouter des fichiers (PDFs etc.) pour pouvoir les lier via leurs URLs.

View file

@ -1,5 +1,5 @@
<?php
return function ($site) {
return $site->find('textes')->children()->sort('title', 'desc');
return function ($site) {
return $site->find('textes')->children();
};

View file

@ -26,7 +26,4 @@ return [
'hooks' => [
'page.create:after' => require __DIR__ . '/hooks/prefill-test-adress-list.php',
],
'moinframe.loop.language' => 'fr',
'thumbs' => require __DIR__ . '/thumbs.php',
];

View file

@ -13,7 +13,7 @@ return [
$page = page('lettre');
$subscribers = $page->subscribers()->yaml();
$emailExists = in_array($email, array_column($subscribers, 'email'));
$emailExists = in_array(['email' => $email], $subscribers);
if ($emailExists) {
return [
@ -22,10 +22,7 @@ return [
];
}
$newSubscriber = [
'email' => $email,
'inscriptiondate' => date('Y-m-d')
];
$newSubscriber = ['email' => $email];
$subscribers[] = $newSubscriber;
$page->update([
@ -34,7 +31,7 @@ return [
return [
'status' => 'success',
'message' => 'Inscription réussie.',
'message' => 'lettre réussie.',
];
} else {
return [

View file

@ -1,44 +0,0 @@
<?php
/**
* Génère un preset srcset Kirby à partir d'un tableau [label => largeur_px].
* Applique le multiplicateur et optionnellement un format (ex: 'webp').
*/
function srcsetPreset(array $widths, float $mult, ?string $format = null): array
{
$preset = [];
foreach ($widths as $label => $px) {
$entry = ['width' => (int) round($px * $mult)];
if ($format !== null) {
$entry['format'] = $format;
}
$preset[$label] = $entry;
}
return $preset;
}
/**
* Génère une paire de presets (original + webp) depuis un tableau de widths.
*/
function srcsetPair(string $name, array $widths, float $mult): array
{
return [
$name => srcsetPreset($widths, $mult),
$name . '-webp' => srcsetPreset($widths, $mult, 'webp'),
];
}
// Pas de multiplicateur supplémentaire : les tailles retina sont déjà dans les widths
$m = 1;
// Bloc image — desktop max 720px, retina x2 = 1440px, mobile 95vw
$widths = [
'block-image' => ['360w' => 360, '720w' => 720, '1080w' => 1080, '1440w' => 1440],
];
return [
'quality' => 80,
'srcsets' => array_merge(
srcsetPair('block-image', $widths['block-image'], $m),
),
];

Binary file not shown.

View file

@ -1,12 +0,0 @@
<?php
class YearPage extends Page{
public function allTexts(){
$princeps = $this->children()->listed()->first();
$texts = $this->children()->listed()->without($princeps);
$linked = $this->linkedTexts()->toPages();
$allTexts = $texts->merge($linked);
return $allTexts->sort('published', 'desc')->prepend($princeps);
}
}

View file

@ -1,11 +0,0 @@
/* Mark Green - Couleur verte */
.k-writer span.green {
color: #04fea0;
}
/* Mark Pixel - Typo serif (placeholder pour future typo pixel) */
.k-writer span.pixel {
font-family: "Terminal", serif;
font-size: 1.15em;
text-transform: uppercase;
}

View file

@ -1,22 +0,0 @@
panel.plugin("custom/marks", {
icons: {
"single-quote":
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M9.58341 17.3211C8.55316 16.2274 8 15 8 13.0103C8 9.51086 10.4565 6.37366 14.0306 4.82318L14.9233 6.20079C11.588 8.00539 10.9362 10.346 10.6756 11.822C11.2126 11.5443 11.9156 11.4466 12.6047 11.5105C14.4091 11.6778 15.8312 13.159 15.8312 15C15.8312 16.933 14.2642 18.5 12.3312 18.5C11.2581 18.5 10.232 18.0095 9.58341 17.3211Z"></path></svg>',
},
writerMarks: {
cite: {
button: {
icon: "single-quote",
label: "Cite",
},
commands() {
return () => this.toggle();
},
name: "cite",
schema: {
parseDOM: [{ tag: "cite" }],
toDOM: () => ["cite", 0],
},
},
},
});

View file

@ -1,7 +0,0 @@
<?php
use Kirby\Sane\Html;
Kirby::plugin('custom/marks', []);
Html::$allowedTags['cite'] = true;

View file

@ -1,17 +1,5 @@
<?php
function allYears ($article) {
$years = new Pages([$article->parent()]);
foreach (site()->find('textes')->children() as $year) {
if ($year->linkedTexts()->toPages()->has($article)) {
$years = $years->add($year);
}
}
return $years;
}
function setTitleFontSizeClass($title, $level = 'h1')
{
$length = strlen($title);

View file

@ -1,20 +0,0 @@
# 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

View file

@ -1,3 +0,0 @@
{
"title": "Kirby Loop"
}

View file

@ -1,25 +0,0 @@
{
"git": {
"requireUpstream": true,
"push": true,
"tagName": "v${version}",
"commitMessage": "chore: release v${version}"
},
"npm": {
"publish": false
},
"github": {
"release": true,
"web": true
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "conventionalcommits",
"infile": "CHANGELOG.md"
},
"@release-it/bumper": {
"in": "composer.json",
"out": "composer.json"
}
}
}

View file

@ -1,90 +0,0 @@
# Changelog
## [1.0.1](///compare/v1.0.0...v1.0.1) (2025-07-08)
### Features
* add allow draft pages option 351471e
* add major version check 02d3690
### Bug Fixes
* add token to fetch universally (if set) 9c33fdd
* check for valid draft token 091a135
* check for verified token with kirby 4 too dec2d34
* remove draft page access docs 7e71ee9
* remove draftaccess option 98148a0
* show ui only if comments fetch goes through a5188f0
* use default minification b9ce0f6
## [1.0.0](///compare/v1.0.0-beta.7...v1.0.0) (2025-07-05)
## [1.0.0-beta.7](///compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-07-05)
### Bug Fixes
* use autoloader for classes 7464553
## [1.0.0-beta.6](///compare/v1.0.0-beta.5...v1.0.0-beta.6) (2025-07-02)
### Features
* add screenshot c3ef6f3
## [1.0.0-beta.5](///compare/v1.0.0-beta.4...v1.0.0-beta.5) (2025-06-30)
### Bug Fixes
* decode html entities cf00460
* make sure to add a guest name before replying or commenting 763c904
## [1.0.0-beta.4](///compare/v1.0.0-beta.3...v1.0.0-beta.4) (2025-06-30)
### Features
* add aria labels to improve voice over 63fc81d
* improve reply voiceover 93e294e
### Bug Fixes
* cleanup icons f956a34
* date display 9a4ad00
* header button focus styles 26ebb19
* hide reply button on solved 545a094
* improve context menu voiceover a6c514a
* outline on comment header a9c2d37
* pulse marker 8ec0bd5
* skip focus if panel closed ee23bde
* switch icons, little style fixes 958f0d1
* use dialog element for panel f3dff13
## [1.0.0-beta.3](///compare/v1.0.0-beta.2...v1.0.0-beta.3) (2025-06-22)
### Features
* dark theme e34269f
* add dark theme 5255d64
* add theme option 51e3b67
* add theming docs 2d74d63
* better shadows, rename theme light to default 90e6e6c
* refactor css custom properties 6daf0de
* refactor css custom properties f6ffb0b
### Bug Fixes
* frosted glass style 8807e3d
* Plugin name in paradocs 62ac94b
* remove cursor property, dont set to auto 7cf72c2
## [1.0.0-beta.2](///compare/v1.0.0-beta.1...v1.0.0-beta.2) (2025-06-21)
### Bug Fixes
* handle $page variable in hook 113f77d
* installer name 77c9e18
* update types b79d7b6
* use unified api base with language included 109d850
## [1.0.0-beta.1](///compare/v1.0.0-beta.0...v1.0.0-beta.1) (2025-06-21)
## 1.0.0-beta.0 (2025-06-21)

View file

@ -1,97 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Structure
This is a Kirby CMS plugin that provides a feedback tool for web pages. The architecture consists of:
**Backend (PHP):**
- Kirby plugin structure with main entry point at `index.php`
- Core logic in `src/App.php` with database abstraction
- API routes in `src/Routes.php` for comment management
- Models in `src/Models/` for Comment and Reply entities
- Database layer in `src/Database.php`
**Frontend (Svelte):**
- Svelte 5 component library in `frontend/src/`
- Builds to ES modules in `assets/` directory
- Uses Vite for build process with custom element compilation
- State management via Svelte stores in `frontend/src/store/`
## Development Commands
**Frontend Development:**
```bash
# Start development server
pnpm dev
# Build assets
pnpm build
# Type checking
pnpm --filter=frontend run check
```
**PHP Development:**
```bash
# Static analysis
vendor/bin/phpstan analyse
# PHP analysis level 8 with strict rules
# Configuration in phpstan.neon
```
**Documentation:**
Use context7 to find out about Kirby CMS, Documentation for this plugin is placed in the `docs/` folder.
## Key Architecture Details
**Plugin Integration:**
- Auto-injects feedback component into all HTML pages via `page.render:after` hook
- Component snippet located at `snippets/loop/app.php`
- Requires authenticated users (see `Middleware::auth()`)
**API Endpoints:**
- `GET /loop/comments/{pageId}` - Get comments for a specific page
- `POST /loop/comment/new` - Create new comment
- `POST /loop/comment/reply` - Reply to existing comment
- `POST /loop/comment/resolve` - Mark comment as resolved
- `POST /loop/comment/unresolve` - Mark comment as unresolved
- `POST /loop/guest/name` - Set guest name for non-authenticated users
**Data Flow:**
- Comments are tied to Kirby page ids
- Position tracking via CSS selectors and page coordinates
- Validation happens at model level before database operations
## Translations
**IMPORTANT: When adding new translatable text to the frontend:**
1. **Add translation key to PHP backend** (`index.php`):
```php
'moinframe.loop.ui.component.key' => 'Default English text',
```
2. **Add translation key to snippet** (`snippets/loop/app.php`):
```php
'ui.component.key' => t('moinframe.loop.ui.component.key'),
```
3. **Use translation in Svelte components**:
```svelte
{t("ui.component.key", "Default fallback text")}
```
**Translation Architecture:**
- PHP translations defined in `index.php` under `'translations'` key
- Frontend translations passed via `snippets/loop/app.php`
- Svelte components use `t()` function from `store/translations.svelte.ts`
- Always provide fallback text in components for development
## Linting and Code Quality
- Biome for frontend linting (config in `biome.json`)
- PHPStan level 8 analysis with strict rules
- TypeScript checking via `svelte-check`

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Justus Kraft
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.

View file

@ -1,96 +0,0 @@
![Kirby Loop](kirby-loop.png)
# Kirby Loop
Stay in the loop. A powerful visual feedback plugin for Kirby CMS that allows users to add comments directly on web pages by clicking on elements. Perfect for client reviews, content collaboration, and team feedback workflows.
## Features
- 🎯 **Click-to-comment**: Toggle between navigate mode for normal browsing and comment mode to click anywhere and add feedback
- 🌍 **Multi-language support**: Full support for Kirby's multi-language sites with automatic language detection
- 💬 **Threaded comments**: Reply to comments for contextual discussions
- 🔒 **Authentication**: Choose whether to restrict access to authenticated users only or allow guest commenting
- 🎨 **Theming**: Built-in light/dark themes with full customization support
- ⚙️ **Auto-injection**: Automatically inject into all pages or manually control placement
- 🗄️ **Local storage**: All data stored locally in SQLite - no external dependencies
## How It Works
Kirby Loop transforms your website into a collaborative workspace where teams can provide feedback directly on web pages.
**Visual Context**: Users can click on any element to leave specific comments, creating a direct connection between feedback and content.
**Streamlined Communication**: Team members, clients, and stakeholders can point out issues and suggest improvements right where they see them.
**Organized Discussions**: Comments support threaded replies and can be marked as resolved to maintain a clean feedback pipeline.
**Privacy & Data Control**: All feedback data is stored locally in a SQLite database on your server - no external services or cloud dependencies.
## Quick Start
1. **Install**: `composer require moinframe/kirby-loop`
2. **Use**: Kirby Loop is automatically active on all pages for authenticated users
3. **Configure**: Customize settings in `site/config/config.php` (optional)
## Documentation
Detailed documentation is available in the `docs/` folder:
- **[Installation Guide](https://moinfra.me/docs/moinframe-loop/01-installation)** - Complete installation instructions
- **[Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration)** - All configuration options and advanced settings
- **[Multi-Language Support](https://moinfra.me/docs/moinframe-loop/03-multi-language)** - Setup and customization for multi-language sites
- **[API Reference](https://moinfra.me/docs/moinframe-loop/05-api)** - API documentation
- **[Theming Guide](https://moinfra.me/docs/moinframe-loop/04-theming)** - Theme customization and creating custom themes
## Basic Configuration
Add these options to your `site/config/config.php`:
```php
return [
// Enable/disable loop (default: true)
'moinframe.loop.enabled' => true,
// Or use a callback for conditional enabling
'moinframe.loop.enabled' => function($page) {
return in_array($page->template()->name(), ['article', 'blog']);
},
// Disable auto-injection (default: true)
'moinframe.loop.auto-inject' => false,
// Set header position: 'top' or 'bottom' (default: 'top')
'moinframe.loop.position' => 'bottom',
// Make feedback public (default: false - requires auth)
'moinframe.loop.public' => true,
// Force UI language (default: null - auto-detect)
'moinframe.loop.language' => 'de',
// Set theme: 'default', 'dark', or custom theme name
'moinframe.loop.theme' => 'dark',
];
```
See the [Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration) for all available options.
## Requirements
- Kirby CMS 4.0+
- PHP 8.3+
- SQLite support
## Important Notes
> [!WARNING]
> Pages with the snippet automatically have Kirby's page **cache** **disabled**. This is necessary for CSRF token validation and User authentication checks.
## Support
- **Documentation**: See the [Documentation](https://moinfra.me/docs/moinframe-loop) for installation and usage instructions
- **Issues**: Report bugs on [GitHub Issues](https://github.com/moinframe/kirby-loop/issues)
## License
MIT License - see [LICENSE.md](LICENSE.md)

File diff suppressed because one or more lines are too long

View file

@ -1,15 +0,0 @@
{
"overrides": [
{
"include": ["*.svelte", "*.astro", "*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
}
}
}
}
]
}

View file

@ -1,55 +0,0 @@
{
"name": "moinframe/kirby-loop",
"description": "Interactive feedback tool for Kirby CMS websites that allows users to add contextual comments directly on page elements",
"homepage": "https://github.com/moinframe/kirby-loop",
"license": "MIT",
"type": "kirby-plugin",
"version": "1.0.1",
"keywords": [
"kirby",
"cms",
"plugin",
"feedback",
"comments",
"review",
"loop"
],
"authors": [
{
"name": "Justus Kraft",
"email": "justus@moinfra.me",
"homepage": "https://moinfra.me"
}
],
"require": {
"getkirby/composer-installer": "^1.1",
"getkirby/cms": "^4.0||^5.0"
},
"autoload": {
"psr-4": {
"Moinframe\\Loop\\": "src/"
}
},
"support": {
"docs": "https://moinfra.me/docs/moinframe-loop",
"source": "https://github.com/moinframe/kirby-loop",
"issues": "https://github.com/moinframe/kirby-loop/issues"
},
"scripts": {
"analyse": "vendor/bin/phpstan analyse"
},
"config": {
"optimize-autoloader": true,
"allow-plugins": {
"getkirby/composer-installer": true
}
},
"extra": {
"installer-name": "loop"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-strict-rules": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,90 +0,0 @@
---
title: Installation
---
This guide covers all installation methods for the Kirby Loop plugin.
## Prerequisites
Before installing the plugin, ensure your system meets these requirements:
- **Kirby CMS**: Version 4.0 or higher
- **PHP**: Version 8.3 or higher
- **SQLite**: Support enabled (usually included by default in PHP)
## Installation Methods
### Method 1: Composer (Recommended)
Composer is the preferred installation method
```bash
composer require moinframe/kirby-loop
```
### Method 2: Manual Installation
For environments where Composer isn't available or preferred:
1. **Download the plugin**
- Visit the [GitHub releases page](https://github.com/moinframe/kirby-loop/releases)
- Download the latest version as a ZIP file
2. **Extract and place**
- Unzip the downloaded archive
- Rename the folder to `loop` (remove version numbers)
- Move the folder to `/site/plugins/loop`
3. **Verify installation**
- The plugin folder should contain `index.php` and other plugin files
- Your final structure should be: `/site/plugins/loop/index.php`
### Method 3: Git Submodule
For projects using Git version control, submodules provide a clean way to include the plugin:
```bash
git submodule add https://github.com/moinframe/kirby-loop.git site/plugins/loop
```
## Next Steps
After successful installation:
1. **Configuration**: See [Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration) for customization options
2. **Multi-language**: If using multiple languages, review [Multi-language Setup](https://moinfra.me/docs/moinframe-loop/03-multi-language)
3. **API Integration**: For custom implementations, check the [API Reference](https://moinfra.me/docs/moinframe-loop/05-api)
## Updating
### Composer Updates
```bash
composer update moinframe/kirby-loop
```
### Manual Updates
1. Download the new version
2. Replace the plugin folder (backup first!)
3. Clear any caches
### Git Submodule Updates
```bash
git submodule update --remote site/plugins/loop
git add site/plugins/loop
git commit -m "Update loop plugin"
```
## Uninstallation
To remove the plugin:
1. **Remove plugin files**:
- Composer: `composer remove moinframe/kirby-loop`
- Manual: Delete `/site/plugins/loop/` folder
- Git submodule: `git submodule deinit site/plugins/loop`
2. **Clean up data** (optional):
- Delete `/site/logs/loop/` directory to remove all comments
- Remove configuration from `site/config/config.php`
3. **Clear caches**: Clear any site caches to ensure complete removal

View file

@ -1,252 +0,0 @@
---
title: Configuration
---
You can customize the plugin's look and behavior by adding configuration options.
Add configuration options to your `site/config/config.php` file:
```php
<?php
return [
// Your existing Kirby configuration...
// Loop Configuration
'moinframe.loop' => [
'auto-inject' => true,
...
]
];
```
## Configuration Options
### Enable/Disable Tool
**Option**: `moinframe.loop.enabled`
**Type**: `boolean|callable`
**Default**: `true`
Controls whether loop is enabled globally or conditionally.
```php
// Simple boolean enable/disable
'moinframe.loop.enabled' => false, // Disables globally
// Use a callback for dynamic control
'moinframe.loop.enabled' => function($page) {
// Only enable for specific templates
return in_array($page->template()->name(), ['article', 'blog']);
},
// Filter by page status
'moinframe.loop.enabled' => function($page) {
return $page->status() === 'published';
},
// Complex conditions
'moinframe.loop.enabled' => function($page) {
return $page->template()->name() === 'article'
&& $page->status() === 'published'
&& !$page->archived()->toBool();
}
```
**Callback function receives:**
- `$page` - The current Kirby page object
**Common use cases:**
- Disable feedback on specific page templates
- Enable only for published content
- Conditional enabling based on page fields or metadata
**Note**: This option is checked both during auto-injection and manual snippet usage.
### Auto-Injection
**Option**: `moinframe.loop.auto-inject`
**Type**: `boolean`
**Default**: `true`
Controls whether loop is automatically injected into all pages.
```php
// Disable auto-injection (requires manual snippet placement)
'moinframe.loop.auto-inject' => false,
```
When disabled, you must manually add the snippet to your templates:
```php
<?php snippet('loop/app') ?>
```
**Use cases for disabling auto-injection:**
- Custom page templates where you want precise control
- JavaScript-based routing (Swup, Taxi.js) that needs manual initialization
- Conditional loading based on user roles or page types
### Position
**Option**: `moinframe.loop.position`
**Type**: `string`
**Default**: `'top'`
**Values**: `'top'` | `'bottom'`
Sets the position of loop header on the page.
```php
// Position header at bottom of page
'moinframe.loop.position' => 'bottom',
```
### Database Path
**Option**: `moinframe.loop.database`
**Type**: `string|null`
**Default**: `null` (uses `site/logs/loop/comments.sqlite`)
Customize the SQLite database location.
```php
// Custom database path
'moinframe.loop.database' => '/custom/path/comments.sqlite',
// Alternative locations
'moinframe.loop.database' => kirby()->root('content') . '/feedback.sqlite',
'moinframe.loop.database' => '/var/www/data/feedback.sqlite',
```
**Important considerations:**
- Path must be absolute
- Directory must exist and be writable
- Consider backup strategies for custom locations
- Ensure path is outside web root for security
### Public Access
**Option**: `moinframe.loop.public`
**Type**: `boolean`
**Default**: `false`
Controls whether loop requires authentication.
```php
// Allow public access (no authentication required)
'moinframe.loop.public' => true,
```
**Security implications:**
- `false` (default): Only authenticated panel users can see/use the tool
- `true`: Anyone can add comments
**Recommended for public access:**
- Internal staging environments
- Client review sites with controlled access
- Public beta feedback collection
### Language Override
**Option**: `moinframe.loop.language`
**Type**: `string|null`
**Default**: `null` (auto-detect from Kirby)
Force a specific UI language regardless of the current page language.
```php
// Force German UI
'moinframe.loop.language' => 'de',
// Force English UI
'moinframe.loop.language' => 'en',
```
**When to use:**
- Single-language sites with non-English content but English-speaking editors
- Multi-language sites where editors prefer consistent UI language
### Theme
**Option**: `moinframe.loop.theme`
**Type**: `string`
**Default**: `'default'`
**Values**: `'default'` | `'dark'` | custom theme name
Sets the visual theme for the loop interface.
```php
// Use dark theme
'moinframe.loop.theme' => 'dark',
// Use custom theme
'moinframe.loop.theme' => 'custom',
```
**Available themes:**
- `'default'` - Light theme with clean, bright interface
- `'dark'` - Dark theme for low-light environments
- Custom theme names - See [Theming Guide](https://moinfra.me/docs/moinframe-loop/04-theming) for creating custom themes
### Welcome Dialog
The welcome dialog introduces new users to loop functionality.
#### Enable/Disable Welcome Dialog
**Option**: `moinframe.loop.welcome.enabled`
**Type**: `boolean`
**Default**: `true`
```php
// Disable welcome dialog
'moinframe.loop.welcome.enabled' => false,
```
#### Custom Welcome Headline
**Option**: `moinframe.loop.welcome.headline`
**Type**: `string|null`
**Default**: `null` (uses default translation)
```php
// Custom welcome headline
'moinframe.loop.welcome.headline' => 'Welcome to Our Review Tool!',
```
#### Custom Welcome Text
**Option**: `moinframe.loop.welcome.text`
**Type**: `string|null`
**Default**: `null` (uses default translation)
```php
// Custom welcome message
'moinframe.loop.welcome.text' => 'Click anywhere on the page to leave feedback. Use the toggle button to switch between navigation and comment modes.',
```
## Manual Snippet Usage
When auto-injection is disabled, you have full control over when and where loop appears.
### Basic Usage
```php
<?php snippet('loop/app') ?>
```
### Conditional Loading
```php
<?php if ($kirby->user() && $kirby->user()->role()->isAdmin()): ?>
<?php snippet('loop/app') ?>
<?php endif ?>
```
> [!TIPP]
> Manual snippets also respect the `enabled` configuration option. If you've set up conditional enabling via the `enabled` option, you don't need to duplicate that logic in your template - the snippet will automatically check the enabled status.
## Caching Behavior
> [!WARNING]
> Pages with loop automatically have Kirby's page **cache** **disabled**. This is necessary for CSRF token validation and User authentication checks.

View file

@ -1,94 +0,0 @@
---
title: Multi-Language
---
Kirby Loop provides comprehensive support for multi-language Kirby sites, including automatic language detection and customizable UI translations.
## How Multi-Language Support Works
The plugin automatically detects and adapts to your Kirby site's language configuration. No additional configuration is required - the plugin works automatically with Kirby's multi-language setup.
- **Single-language sites**: Uses the en translations
- **Multi-language sites**: Detects the current page language and adapts accordingly
## UI Language Override
### Forcing a Specific UI Language
By default, loop UI adapts to the current page language. You can override this behavior:
```php
// Always show German UI regardless of page language
'moinframe.loop.language' => 'de',
// Always show English UI regardless of page language
'moinframe.loop.language' => 'en',
```
### Use Cases for Language Override
**Consistent Editor Experience:**
```php
// Editors prefer English UI even on German pages
'moinframe.loop.language' => 'en',
```
**Single-Language website with non english content:**
```php
// German content site with German-speaking editors
'moinframe.loop.language' => 'de',
```
## Built-in Translations
The plugin includes complete translations for:
- English (en) - Default
- German (de)
## Custom Translations
### Adding New Languages
To add support for additional languages, create or extend your Kirby language files:
```php
// site/languages/fr.php
<?php
return [
'code' => 'fr',
'default' => false,
'direction' => 'ltr',
'locale' => 'fr_FR',
'name' => 'Français',
'translations' => [
// UI Elements
'moinframe.loop.ui.header.title' => 'Commentaires',
...
]
];
```
### Overriding Existing Translations
Customize existing translations by adding them to your language files:
```php
// site/languages/en.php - Override English defaults
return [
'code' => 'en',
'default' => true,
'translations' => [
'moinframe.loop.ui.header.title' => 'Page Feedback',
'moinframe.loop.ui.comment.placeholder' => 'What needs attention?',
'moinframe.loop.ui.welcome.headline' => 'Welcome to Our Review Tool',
]
];
```
### Translation Key Reference
For a complete list of available translation keys, see the [plugin's index file](https://github.com/moinframe/kirby-loop/blob/main/index.php).

View file

@ -1,110 +0,0 @@
---
title: Theming
---
Kirby Loop comes with built-in theming support, allowing you to customize the visual appearance to match your brand or provide different user experiences. The plugin includes a default (light) theme and a dark theme, with support for creating custom themes.
## Configuration
### Setting a Theme
Configure the theme in your `site/config/config.php`:
```php
return [
// Set theme: 'default', 'dark', or custom theme name
'moinframe.loop.theme' => 'dark',
];
```
**Available options:**
- `'default'` - Light theme (default)
- `'dark'` - Dark theme
- Custom theme name
## Creating Custom Themes
Custom themes are CSS files that override the default color and styling tokens. The theming system uses CSS custom properties (variables) for easy customization.
### Basic Custom Theme
Here's a minimal custom theme example:
```css
/* frontend/src/styles/theme-custom.css */
kirby-loop[theme="custom"] {
/* Accent color */
--color-accent-l: 0.6;
--color-accent-c: 0.15;
--color-accent-h: 280; /* Purple accent */
/* Neutral color lightness values */
--color-neutral-l-0: 0.98;
--color-neutral-l-100: 0.92;
--color-neutral-l-200: 0.86;
--color-neutral-l-300: 0.7;
--color-neutral-l-400: 0.6;
--color-neutral-l-500: 0.5;
--color-neutral-l-600: 0.4;
--color-neutral-l-700: 0.3;
--color-neutral-l-800: 0.15;
--color-neutral-l-900: 0.05;
--color-neutral-l-1000: 0;
}
```
### Configure Your Custom Theme
Set your custom theme in the configuration:
```php
// site/config/config.php
return [
'moinframe.loop.theme' => 'custom',
];
```
## Theme Architecture
### Color System
The theming system uses OKLCH color space for consistent, perceptually uniform colors:
```css
/* Accent colors */
--color-accent-l: 0.7; /* Lightness (0-1) */
--color-accent-c: 0.12; /* Chroma/saturation (0-0.4) */
--color-accent-h: 220; /* Hue (0-360) */
/* Neutral colors */
--color-neutral-l-0: 1; /* Lightest */
--color-neutral-l-100: 0.95;
--color-neutral-l-200: 0.9;
/* ... */
--color-neutral-l-900: 0.05;
--color-neutral-l-1000: 0; /* Darkest */
```
### Advanced Customization
You can override any design token in your custom theme:
```css
kirby-loop[theme="custom"] {
/* Colors */
--color-accent-l: 0.65;
--color-accent-c: 0.18;
--color-accent-h: 15; /* Orange accent */
/* Shadows with custom opacity */
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.15);
/* Custom border radius */
--border-radius: 0.5rem;
--border-radius-rounded: 1rem;
/* Custom fonts */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
```

View file

@ -1,325 +0,0 @@
---
title: API Reference
---
Kirby Loop provides a RESTful API for managing comments and feedback. All endpoints include CSRF protection.
## Authentication
All API endpoints require authentication, controlled by the `moinframe.loop.public` configuration option:
- **Default (private)**: Only authenticated Kirby users can access the API
- **Public mode**: Anyone can access the API
## CSRF Protection
All API requests must include a valid CSRF token in the request header:
```javascript
fetch('/loop/comments/page-id', {
headers: {
'X-CSRF-Token': '<csrf-token>'
}
});
```
## Base URL Structure
### Single Language Sites
```
/loop/comments/{pageId}
/loop/comment/new
/loop/comment/reply
/loop/comment/resolve
/loop/comment/unresolve
/loop/guest/name
```
### Multi-Language Sites
```
/{language}/loop/comments/{pageId}
/{language}/loop/comment/new
/{language}/loop/comment/reply
/{language}/loop/comment/resolve
/{language}/loop/comment/unresolve
/{language}/loop/guest/name
```
Where `{language}` is the language code (e.g., `en`, `de`).
## Endpoints
### GET /loop/comments/{pageId}
Retrieve all comments for a specific page.
**Parameters:**
- `pageId` (string): The page ID or 'home' for the homepage
**Response:**
```json
{
"status": "ok",
"comments": [
{
"id": 1,
"author": "John Doe",
"url": "https://example.com/page",
"page": "page-uuid",
"comment": "This needs to be updated",
"selector": ".header h1",
"selectorOffsetX": 10,
"selectorOffsetY": 20,
"pagePositionX": 150,
"pagePositionY": 300,
"timestamp": 1640995200,
"lang": "en",
"status": "OPEN",
"replies": [
{
"id": 1,
"author": "jane.smith",
"comment": "I'll fix this",
"parentId": 1,
"timestamp": 1640995800
}
]
}
]
}
```
**Error Responses:**
- `400`: Page not found
- `401`: Unauthorized (if authentication required)
- `403`: CSRF token invalid
### POST /loop/comment/new
Create a new comment on a page.
**Request Body:**
```json
{
"comment": "This section needs clarification",
"url": "https://example.com/page",
"selector": ".content p:nth-child(3)",
"selectorOffsetX": 15,
"selectorOffsetY": 25,
"pagePositionX": 200,
"pagePositionY": 450,
"pageId": "projects/project-alpha"
}
```
**Required Fields:**
- `comment` (string): The comment text (HTML stripped and sanitized)
- `url` (string): The full URL where the comment was made
- `selector` (string): CSS selector for the commented element
- `selectorOffsetX` (number): X offset within the selected element
- `selectorOffsetY` (number): Y offset within the selected element
- `pagePositionX` (number): X position on the page
- `pagePositionY` (number): Y position on the page
- `pageId` (string): Kirby page ID or 'home'
**Response:**
```json
{
"status": "ok",
"comment": {
"id": 15,
"author": "John Doe",
"url": "https://example.com/page",
"page": "page-uuid",
"comment": "This section needs clarification",
"selector": ".content p:nth-child(3)",
"selectorOffsetX": 15,
"selectorOffsetY": 25,
"pagePositionX": 200,
"pagePositionY": 450,
"timestamp": 1640995200,
"lang": "en",
"status": "OPEN",
"replies": []
}
}
```
**Error Responses:**
- `400`: Missing required fields, invalid selector format, or invalid data
- `401`: Unauthorized
- `403`: CSRF token invalid or disabled
- `404`: Page not found
### POST /loop/comment/reply
Add a reply to an existing comment.
**Request Body:**
```json
{
"comment": "I'll handle this update",
"parentId": 15
}
```
**Required Fields:**
- `comment` (string): The reply text (HTML stripped and sanitized)
- `parentId` (number): ID of the parent comment
**Response:**
```json
{
"status": "ok",
"reply": {
"id": 3,
"author": "John Doe",
"comment": "I'll handle this update",
"parentId": 15,
"timestamp": 1640995800
}
}
```
**Error Responses:**
- `400`: Missing required fields
- `401`: Unauthorized
- `403`: CSRF token invalid or disabled
### POST /loop/comment/resolve
Mark a comment as resolved.
**Request Body:**
```json
{
"id": 15
}
```
**Required Fields:**
- `id` (number): The comment ID to resolve
**Response:**
```json
{
"status": "ok",
"success": true
}
```
**Error Responses:**
- `400`: Missing comment ID
- `401`: Unauthorized
- `403`: CSRF token invalid or disabled
### POST /loop/comment/unresolve
Mark a resolved comment as unresolved.
**Request Body:**
```json
{
"id": 15
}
```
**Required Fields:**
- `id` (number): The comment ID to unresolve
**Response:**
```json
{
"status": "ok",
"success": true
}
```
**Error Responses:**
- `400`: Missing comment ID
- `401`: Unauthorized
- `403`: CSRF token invalid or disabled
### POST /loop/guest/name
Set a guest name for non-authenticated users (when public mode is enabled).
**Request Body:**
```json
{
"name": "John Doe"
}
```
**Required Fields:**
- `name` (string): The guest user's name
**Response:**
```json
{
"status": "ok",
"name": "John Doe"
}
```
**Error Responses:**
- `400`: Missing or empty name
- `401`: Unauthorized
- `403`: CSRF token invalid or disabled
## Data Models
### Comment Object
```typescript
interface Comment {
id: number;
author: string; // Resolved display name (user name, email prefix, or guest name)
url: string; // Full URL where comment was made
page: string; // Page UUID
comment: string; // Sanitized comment text
selector: string; // CSS selector for target element
selectorOffsetX: number; // X offset within element (float)
selectorOffsetY: number; // Y offset within element (float)
pagePositionX: number; // X position on page (float)
pagePositionY: number; // Y position on page (float)
timestamp: number; // Unix timestamp
lang: string; // Language code
status: string; // Status: OPEN, RESOLVED
replies: Reply[]; // Array of replies
}
```
### Reply Object
```typescript
interface Reply {
id: number;
author: string; // Resolved display name (user name, email prefix, or guest name)
comment: string; // Sanitized reply text
parentId: number; // Parent comment ID
timestamp: number; // Unix timestamp
}
```
## Error Handling
The api endpoints return consistent error responses. For more details, switch on the debug mode in your Kirby Installation.
```json
{
"status": "error",
"message": "Human-readable error message",
"code": "ERROR_CODE" // Optional error code
}
```
### Common Error Codes
- `CSRF_INVALID`: CSRF token is missing or invalid
- `PAGE_NOT_FOUND`: Specified page doesn't exist
- `FIELD_REQUIRED`: Required field is missing
- `UNAUTHORIZED`: Authentication required but not provided
- `INVALID_SELECTOR`: Invalid selector format
- `INVALID_NAME`: Invalid guest name
- `DISABLED`: Tool is disabled

View file

@ -1,3 +0,0 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View file

@ -1,27 +0,0 @@
{
"name": "loop-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^22.13.10",
"browserslist": "^4.24.4",
"lightningcss": "^1.29.3",
"svelte": "^5.20.2",
"svelte-check": "^4.1.4",
"terser": "^5.39.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-ejs": "^1.7.0",
"vitest": "^3.0.9"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,216 +0,0 @@
<svelte:options customElement="kirby-loop" />
<script lang="ts">
import { onMount } from "svelte";
import Header from "./lib/Header.svelte";
import Marker from "./lib/Marker.svelte";
import Panel from "./lib/Panel.svelte";
import store, { addReply, getComments } from "./store/api.svelte";
import setNewMarker from "./composables/setNewMarker";
import { addComment } from "./store/api.svelte";
import CommentDialog from "./lib/CommentDialog.svelte";
import WelcomeDialog from "./lib/WelcomeDialog.svelte";
import { formData, reset } from "./store/form.svelte";
import { overlay, guestName } from "./store/ui.svelte";
import { setTranslations } from "./store/translations.svelte";
import type {
LoopProps,
ReplyPayload,
CommentPayload,
MarkerPosition,
} from "./types";
const {
position,
language,
apibase,
pageId,
authenticated,
"welcome-enabled": welcomeEnabled,
"welcome-headline": welcomeHeadline,
"welcome-text": welcomeText,
translations,
}: LoopProps = $props();
let showLoop = $state(false);
// Feedback Dialog
let showModal = $state(false);
let welcomeDialog: { showModal: () => void; close: () => void };
let isAuthenticated = $derived(authenticated === "true");
let isWelcomeEnabled = $derived(welcomeEnabled === "true");
// Filter comments to show only non-resolved ones for markers
const visibleComments = $derived(
store.comments.filter((c) => c.status !== "RESOLVED"),
);
// Session storage key for tracking welcome dialog dismissal (global)
const welcomeDismissedKey = "loop-welcome-dismissed";
// Check if welcome was dismissed for authenticated users
const isWelcomeDismissed = () => {
if (!isAuthenticated) return false;
return sessionStorage.getItem(welcomeDismissedKey) === "true";
};
// Mark welcome as dismissed for authenticated users
const markWelcomeDismissed = () => {
if (isAuthenticated) {
sessionStorage.setItem(welcomeDismissedKey, "true");
}
};
// Default state for markers
let newMarker: MarkerPosition | null = $state(null);
/**
* Scroll a marker into view
* @param id The id of the marker
*/
const scrollIntoView = (id: string) => {
const marker = $host().shadowRoot?.getElementById(`marker-${id}`);
if (marker) marker.scrollIntoView({ behavior: "smooth", block: "center" });
};
/**
* Click to add a new comment
* @param e The click event
*/
const clickToComment = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const clickedOnLoop =
target.nodeName === "KIRBY-LOOP" || target.parentElement?.closest("loop");
// Do nothing if feedback mode is off or the click is on loop elements
if (!overlay.open || clickedOnLoop) return;
// For non-authenticated users, require a guest name before allowing comments
if (!isAuthenticated && !guestName.get()) {
welcomeDialog?.showModal();
return;
}
// Get new marker
const marker = setNewMarker(e);
if (!marker) return;
newMarker = marker;
// Open comment form dialog
showModal = true;
};
const cancel = () => {
showModal = false;
reset();
};
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault();
// For non-authenticated users, require a guest name before allowing comments or replies
if (!isAuthenticated && !guestName.get()) {
welcomeDialog?.showModal();
return;
}
const { text, parentId } = formData;
// submit is a reply
if (parentId) {
const reply: ReplyPayload = {
parentId,
comment: text,
};
// add reply to api
addReply(reply);
// reset form data
reset();
// submit is a comment
} else {
if (!newMarker) return;
// Use language from component attribute
const lang = language || "";
const comment: CommentPayload = {
url: window.location.href,
comment: text,
parentId: null,
lang,
pageId,
...newMarker,
};
// close modal
showModal = false;
// add comment to api
addComment(comment);
// reset form data
reset();
}
};
onMount(async () => {
// Initialize translations
const translationsData = JSON.parse(translations || "{}");
setTranslations(translationsData);
showLoop = await getComments(pageId);
// Initialize guest name from session storage
guestName.get();
// Show welcome dialog on page load if enabled and conditions are met
if (isWelcomeEnabled && showLoop) {
// For authenticated users, show only if not dismissed
// For unauthenticated users, show if no guest name is set (mandatory)
if (
(isAuthenticated && !isWelcomeDismissed()) ||
(!isAuthenticated && !guestName.get())
) {
welcomeDialog?.showModal();
}
}
// Even if welcome is disabled, show dialog for non-authenticated users without a name
else if (!isAuthenticated && !guestName.get() && showLoop) {
welcomeDialog?.showModal();
}
});
$effect(() => {
if (overlay.open) {
document.body.style.setProperty(
"cursor",
`url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23Z' stroke='white' stroke-width='0.75'/%3E%3Cpath d='M15 12H12M12 12H9M12 12V9M12 12V15' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M15 12H12M12 12H9M12 12V9M12 12V15' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"), auto`,
);
} else {
document.body.style.removeProperty("cursor");
}
document.documentElement.classList.toggle(
"loop-overlay-open",
overlay.open,
);
});
</script>
<svelte:document on:click={clickToComment} />
{#if showLoop}
<Header {position} commentsCount={visibleComments.length} />
<Panel {scrollIntoView} {handleSubmit} {cancel} />
{#each visibleComments as comment (comment.id)}
<Marker {comment} />
{/each}
<CommentDialog {handleSubmit} {showModal} {newMarker} {cancel} />
{/if}
<WelcomeDialog
bind:this={welcomeDialog}
headline={welcomeHeadline || ""}
text={welcomeText || ""}
authenticated={isAuthenticated}
welcomeEnabled={isWelcomeEnabled}
onDismiss={markWelcomeDismissed}
/>

View file

@ -1,19 +0,0 @@
/**
* Decodes HTML entities in a string
* @param text The text that may contain HTML entities
* @returns The decoded text
*/
export function decodeHTMLEntities(text: string): string {
const entityMap: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#x27;': "'",
'&#x2F;': '/',
'&#x60;': '`',
'&#x3D;': '='
};
return text.replace(/&[#\w]+;/g, (entity) => entityMap[entity] || entity);
}

View file

@ -1,31 +0,0 @@
import { t, tt } from "../store/translations.svelte";
export function formatDate(timestamp: number, humanize = true): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
// Show relative time for up to 3 days
if (humanize && diffInDays <= 3) {
if (diffInMinutes < 1) {
return t("ui.time.just_now", "just now");
} else if (diffInMinutes === 1) {
return t("ui.time.minute_ago", "a minute ago");
} else if (diffInMinutes < 60) {
return tt("ui.time.minutes_ago", "{count} minutes ago", { count: diffInMinutes.toString() });
} else if (diffInHours === 1) {
return t("ui.time.hour_ago", "an hour ago");
} else if (diffInHours < 24) {
return tt("ui.time.hours_ago", "{count} hours ago", { count: diffInHours.toString() });
} else if (diffInDays === 1) {
return t("ui.time.yesterday", "yesterday");
} else {
return tt("ui.time.days_ago", "{count} days ago", { count: diffInDays.toString() });
}
}
return date.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
}

View file

@ -1,3 +0,0 @@
export function formatDateISO(timestamp: number): string {
return new Date(timestamp * 1000).toISOString();
}

View file

@ -1,39 +0,0 @@
import { getDocumentHeight } from "./getDocumentHeight";
import { getDocumentWidth } from "./getDocumentWidth";
export const getDialogPosition = (marker: { pagePositionX: number, pagePositionY: number } | null,
dialogElement: HTMLDialogElement | null): { left: number, top: number } => {
// Default position (fallback)
let left = 0;
let top = 0;
if (!marker || !dialogElement) return { left, top };
// Get marker position
left = marker.pagePositionX;
top = marker.pagePositionY;
// Get dialog dimensions
const dialogWidth = dialogElement.offsetWidth;
const dialogHeight = dialogElement.offsetHeight;
// Get document dimensions
const documentWidth = getDocumentWidth();
const documentHeight = getDocumentHeight();
// Ensure dialog doesn't go off-screen to the right
if (left + dialogWidth > documentWidth) {
left = documentWidth - dialogWidth;
}
// Ensure dialog doesn't go off-screen to the bottom
if (top + dialogHeight > documentHeight) {
top = documentHeight - dialogHeight;
}
// Ensure dialog doesn't go off-screen to the left or top
left = Math.max(0, left);
top = Math.max(0, top);
return { left, top };
}

View file

@ -1,13 +0,0 @@
// Get the entire document height, including scrollable area
export const getDocumentHeight = (): number => {
const body = document.body;
const html = document.documentElement;
return Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
};

View file

@ -1,13 +0,0 @@
// Get the entire document width, including scrollable area
export const getDocumentWidth = (): number => {
const body = document.body;
const html = document.documentElement;
return Math.max(
body.scrollWidth,
body.offsetWidth,
html.clientWidth,
html.scrollWidth,
html.offsetWidth
);
};

View file

@ -1,26 +0,0 @@
export const getSelectorOffset = (e: MouseEvent, element: HTMLElement): { selectorOffsetX: number, selectorOffsetY: number } => {
// Get absolute click position (relative to the document)
const clickX = e.pageX;
const clickY = e.pageY;
// Get element's position relative to the document
const rect = element.getBoundingClientRect();
const elementX = rect.left + window.scrollX;
const elementY = rect.top + window.scrollY;
// Calculate relative offsets
const offsetXRel = clickX - elementX;
const offsetYRel = clickY - elementY;
// Convert to percentages
let offsetX = (offsetXRel / element.offsetWidth) * 100;
let offsetY = (offsetYRel / element.offsetHeight) * 100;
// Round to 2 decimal places
offsetX = Number(offsetX.toFixed(2));
offsetY = Number(offsetY.toFixed(2));
return {
selectorOffsetX: offsetX, selectorOffsetY: offsetY
};
}

View file

@ -1,32 +0,0 @@
import { useGenerateSelector } from "./useGenerateSelector";
import { getSelectorOffset } from "./getSelectorOffset";
export const setNewMarker = (e: MouseEvent) => {
const selector = useGenerateSelector(e);
const element: HTMLElement | null = document.querySelector(selector);
// error out, if no selector found
if (!element) return;
const { selectorOffsetX, selectorOffsetY } = getSelectorOffset(e, element);
// Store absolute position on the page
let pagePositionX = e.pageX;
let pagePositionY = e.pageY;
// Round to 2 digits
pagePositionX = Number(pagePositionX.toFixed(2));
pagePositionY = Number(pagePositionY.toFixed(2));
return {
selector,
selectorOffsetX,
selectorOffsetY,
pagePositionX,
pagePositionY
}
}
export default setNewMarker;

View file

@ -1,352 +0,0 @@
/**
* CSS Selector Generator Composable
* Generates reliable, unique CSS selectors for clicked DOM elements
*/
type SelectorStrategy = {
name: string;
generator: (element: Element) => string | null;
priority: number;
};
/**
* Main composable function to generate CSS selector from click event
* @param event - Mouse event from user click
* @returns CSS selector string that uniquely identifies the clicked element
*/
export function useGenerateSelector(event: MouseEvent): string {
const target = event.target as Element;
if (!target) {
throw new Error('No target element found in event');
}
// Try each strategy in priority order
const strategies = getSelectorStrategies();
for (const strategy of strategies) {
try {
const selector = strategy.generator(target);
if (selector && validateSelector(selector, target)) {
return selector;
}
} catch (error) {
console.warn(`Strategy ${strategy.name} failed:`, error);
}
}
// Ultimate fallback - generate a path selector
return generatePathSelector(target);
}
/**
* Define selector generation strategies in priority order
*/
function getSelectorStrategies(): SelectorStrategy[] {
return [
{
name: 'ID',
priority: 1,
generator: (element: Element) => {
if (element.id && isValidId(element.id)) {
return `#${CSS.escape(element.id)}`;
}
return null;
}
},
{
name: 'Unique Attributes',
priority: 2,
generator: (element: Element) => {
const uniqueAttrs = ['data-testid', 'data-id', 'name', 'for'];
for (const attr of uniqueAttrs) {
const value = element.getAttribute(attr);
if (value) {
const selector = `${element.tagName.toLowerCase()}[${attr}="${CSS.escape(value)}"]`;
if (isUniqueSelector(selector)) {
return selector;
}
}
}
return null;
}
},
{
name: 'Semantic Attributes',
priority: 3,
generator: (element: Element) => {
const semanticAttrs = [
'aria-label',
'aria-labelledby',
'role',
'type',
'placeholder',
'title',
'alt'
];
const tagName = element.tagName.toLowerCase();
const selectors: string[] = [tagName];
for (const attr of semanticAttrs) {
const value = element.getAttribute(attr);
if (value) {
selectors.push(`[${attr}="${CSS.escape(value)}"]`);
}
}
if (selectors.length > 1) {
const selector = selectors.join('');
if (isUniqueSelector(selector)) {
return selector;
}
}
return null;
}
},
{
name: 'Structural Attributes',
priority: 4,
generator: (element: Element) => {
const structuralAttrs = ['href', 'src', 'action', 'value'];
const tagName = element.tagName.toLowerCase();
for (const attr of structuralAttrs) {
const value = element.getAttribute(attr);
if (value && value.length > 0) {
const selector = `${tagName}[${attr}="${CSS.escape(value)}"]`;
if (isUniqueSelector(selector)) {
return selector;
}
}
}
return null;
}
},
{
name: 'Class Combinations',
priority: 5,
generator: (element: Element) => {
const classes = getStableClasses(element);
if (classes.length === 0) {
return null;
}
const tagName = element.tagName.toLowerCase();
// Try single class first
for (const className of classes) {
const selector = `${tagName}.${CSS.escape(className)}`;
if (isUniqueSelector(selector)) {
return selector;
}
}
// Try combinations of classes
if (classes.length >= 2) {
const classSelector = classes.slice(0, 3).map(c => `.${CSS.escape(c)}`).join('');
const selector = `${tagName}${classSelector}`;
if (isUniqueSelector(selector)) {
return selector;
}
}
return null;
}
},
{
name: 'Parent Context',
priority: 6,
generator: (element: Element) => {
const parent = element.parentElement;
if (!parent) return null;
// Try to get a unique selector for parent
const parentSelector = getSimpleSelector(parent);
if (!parentSelector) return null;
const tagName = element.tagName.toLowerCase();
const siblingIndex = getSiblingIndex(element);
if (siblingIndex > 0) {
const selector = `${parentSelector} > ${tagName}:nth-of-type(${siblingIndex})`;
if (isUniqueSelector(selector)) {
return selector;
}
}
// Try with classes
const classes = getStableClasses(element);
if (classes.length > 0) {
const selector = `${parentSelector} > ${tagName}.${CSS.escape(classes[0])}`;
if (isUniqueSelector(selector)) {
return selector;
}
}
return null;
}
}
];
}
/**
* Get stable classes (excluding utility/state classes)
*/
function getStableClasses(element: Element): string[] {
const classes = Array.from(element.classList);
// Filter out common utility/state classes
const unstablePatterns = [
/^(is-|has-|js-)/, // State prefixes
/^(active|disabled|loading|selected|hover|focus)/, // State classes
/^[a-z]+-[0-9]+$/, // Generated classes like 'item-123'
/^(sm-|md-|lg-|xl-)/, // Responsive utilities
/^(m-|p-|w-|h-|text-|bg-)/, // Tailwind-like utilities
/^[a-f0-9]{6,}$/, // Hash-like classes
];
return classes.filter(className => {
return !unstablePatterns.some(pattern => pattern.test(className));
});
}
/**
* Generate a path-based selector as fallback
*/
function generatePathSelector(element: Element): string {
const path: string[] = [];
let current: Element | null = element;
while (current && current !== document.body && path.length < 5) {
const selector = getElementSelector(current);
path.unshift(selector);
// Check if this partial path is unique
const partialSelector = path.join(' > ');
if (isUniqueSelector(partialSelector)) {
return partialSelector;
}
current = current.parentElement;
}
return path.join(' > ');
}
/**
* Get a simple selector for an element
*/
function getSimpleSelector(element: Element): string | null {
// Try ID first
if (element.id && isValidId(element.id)) {
return `#${CSS.escape(element.id)}`;
}
// Try unique attributes
const uniqueAttrs = ['data-testid', 'data-id', 'name'];
for (const attr of uniqueAttrs) {
const value = element.getAttribute(attr);
if (value) {
return `[${attr}="${CSS.escape(value)}"]`;
}
}
// Try tag + first stable class
const tagName = element.tagName.toLowerCase();
const classes = getStableClasses(element);
if (classes.length > 0) {
return `${tagName}.${CSS.escape(classes[0])}`;
}
return null;
}
/**
* Get selector for element in path
*/
function getElementSelector(element: Element): string {
const tagName = element.tagName.toLowerCase();
// Use ID if available
if (element.id && isValidId(element.id)) {
return `#${CSS.escape(element.id)}`;
}
// Use classes if available
const classes = getStableClasses(element);
if (classes.length > 0) {
return `${tagName}.${CSS.escape(classes[0])}`;
}
// Use nth-of-type for siblings
const index = getSiblingIndex(element);
if (index > 1) {
return `${tagName}:nth-of-type(${index})`;
}
return tagName;
}
/**
* Get sibling index for nth-of-type
*/
function getSiblingIndex(element: Element): number {
let index = 1;
let sibling = element.previousElementSibling;
while (sibling) {
if (sibling.tagName === element.tagName) {
index++;
}
sibling = sibling.previousElementSibling;
}
return index;
}
/**
* Validate that a selector uniquely identifies the target element
*/
function validateSelector(selector: string, target: Element): boolean {
try {
const matches = document.querySelectorAll(selector);
return matches.length === 1 && matches[0] === target;
} catch (error) {
console.warn(`Invalid selector: ${selector}`, error);
return false;
}
}
/**
* Check if a selector matches exactly one element
*/
function isUniqueSelector(selector: string): boolean {
try {
const matches = document.querySelectorAll(selector);
return matches.length === 1;
} catch (error) {
return false;
}
}
/**
* Check if ID is valid (not auto-generated)
*/
function isValidId(id: string): boolean {
// Skip IDs that look auto-generated
const invalidPatterns = [
/^[a-f0-9]{8,}$/, // Hex strings
/^(ember|react|vue)[0-9]+/, // Framework generated
/^[0-9]+$/, // Pure numbers
/^temp-/, // Temporary prefixes
];
return !invalidPatterns.some(pattern => pattern.test(id));
}

View file

@ -1,130 +0,0 @@
/**
* Performance-optimized resize handler with debouncing and RAF
*/
interface ResizeCallback {
(): void;
}
interface ResizeHandlerOptions {
/** Debounce delay in milliseconds (default: 100) */
debounceDelay?: number;
/** Whether to use requestAnimationFrame (default: true) */
useRAF?: boolean;
}
class ResizeHandler {
private callbacks = new Set<ResizeCallback>();
private debounceTimer: number | null = null;
private rafId: number | null = null;
private isListening = false;
private options: Required<ResizeHandlerOptions>;
constructor(options: ResizeHandlerOptions = {}) {
this.options = {
debounceDelay: options.debounceDelay ?? 100,
useRAF: options.useRAF ?? true,
};
}
private handleResize = () => {
// Clear existing timers
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
// Debounce the resize event
this.debounceTimer = window.setTimeout(() => {
if (this.options.useRAF) {
// Use RAF for smooth updates
this.rafId = requestAnimationFrame(() => {
this.executeCallbacks();
});
} else {
this.executeCallbacks();
}
}, this.options.debounceDelay);
};
private executeCallbacks() {
this.callbacks.forEach(callback => {
try {
callback();
} catch (error) {
console.error('Error in resize callback:', error);
}
});
}
private startListening() {
if (!this.isListening) {
window.addEventListener('resize', this.handleResize, { passive: true });
this.isListening = true;
}
}
private stopListening() {
if (this.isListening) {
window.removeEventListener('resize', this.handleResize);
this.isListening = false;
}
}
/**
* Add a callback to be executed on resize
*/
subscribe(callback: ResizeCallback): () => void {
this.callbacks.add(callback);
this.startListening();
// Return unsubscribe function
return () => {
this.callbacks.delete(callback);
if (this.callbacks.size === 0) {
this.stopListening();
}
};
}
/**
* Clean up all resources
*/
destroy() {
this.callbacks.clear();
this.stopListening();
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
}
}
// Singleton instance for global use
const globalResizeHandler = new ResizeHandler();
/**
* Svelte composable for handling window resize events with performance optimization
* @param callback Function to call on resize
* @param options Configuration options
* @returns Cleanup function
*/
export function useResizeHandler(
callback: ResizeCallback,
options?: ResizeHandlerOptions
): () => void {
if (options) {
// Create a new handler with custom options
const handler = new ResizeHandler(options);
return handler.subscribe(callback);
} else {
// Use the global handler
return globalResizeHandler.subscribe(callback);
}
}
export default useResizeHandler;

View file

@ -1,22 +0,0 @@
<script lang="ts">
const { initials } = $props();
</script>
<div class="author">
{initials}
</div>
<style scoped>
.author {
font-size: var(--author-avatar-font-size);
text-transform: uppercase;
color: var(--author-avatar-color);
background-color: var(--author-avatar-background-color);
aspect-ratio: 1;
flex: 0 0 var(--author-avatar-size);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--author-avatar-border-radius);
}
</style>

View file

@ -1,256 +0,0 @@
<script lang="ts">
const {
onclick,
onmouseenter,
onmouseout,
onblur,
active = false,
type = "button",
style = "",
disabled = false,
ariaLabel = "",
id = "",
ariaHaspopup = "",
ariaExpanded = "",
ariaControls = "",
}: {
onclick?: () => void;
onmouseenter?: () => void;
onmouseout?: () => void;
onblur?: () => void;
active?: boolean;
type?: "reset" | "submit" | "button";
style?: string;
disabled?: boolean;
ariaLabel?: string;
id?: string;
ariaHaspopup?: string;
ariaExpanded?: string;
ariaControls?: string;
} = $props();
</script>
<button
{onclick}
class="button {style}"
{type}
class:is-active={active}
aria-label={ariaLabel}
{id}
aria-haspopup={ariaHaspopup === "menu" ? "menu" : null}
aria-expanded={ariaExpanded === "true" ? true : ariaExpanded === "false" ? false : null}
aria-controls={ariaControls || null}
{disabled}
{onmouseenter}
{onmouseout}
{onblur}
>
<slot name="icon" />
{#if $$slots.default}<span><slot /></span>{/if}
</button>
<style>
button {
appearance: none;
background-color: var(--button-background);
color: var(--button-color);
padding: var(--button-padding);
border: 0;
font-family: var(--font-family);
letter-spacing: 0.01em;
border-radius: var(--button-border-radius);
display: inline-flex;
gap: var(--button-gap);
align-items: center;
cursor: pointer;
font-size: var(--button-font-size);
justify-content: center;
flex: 0 0 auto;
font-weight: var(--button-font-weight);
transition: var(--button-transition);
white-space: nowrap;
line-height: 1;
height: var(--button-height);
outline-color: var(--button-outline-color);
&:focus-visible {
outline-offset: var(--button-outline-offset);
}
&:hover,
&:focus-visible {
color: var(--button-hover-color);
background-color: var(--button-hover-background);
}
span {
text-overflow: ellipsis;
overflow-x: clip;
display: block;
min-width: 0;
}
&.button--header {
--icon-size: 1.25rem;
background-color: var(--button-header-background);
height: var(--button-header-height);
padding: var(--button-header-padding);
border-radius: 0;
border: 0;
mix-blend-mode: var(--button-header-blend-mode);
&:first-child {
border-top-left-radius: var(--border-radius-rounded);
border-bottom-left-radius: var(--border-radius-rounded);
}
&:hover,
&:focus-visible {
background-color: var(--button-header-hover-background);
}
}
&.button--panel {
background-color: var(--button-panel-background);
height: auto;
padding: var(--button-panel-padding);
border-radius: var(--border-radius-rounded);
border: 0;
span {
overflow: visible;
}
}
&.button--solid {
background-color: var(--button-solid-background);
&:hover,
&:focus-visible {
color: var(--button-solid-hover-color);
background-color: var(--button-solid-hover-background);
}
}
&.button--small {
height: var(--button-small-height);
font-size: var(--button-small-font-size);
}
&.button--icon {
background-color: var(--button-icon-background);
color: var(--button-icon-color);
height: var(--button-icon-height);
box-shadow: var(--button-icon-shadow);
aspect-ratio: 1;
padding: 0;
font-size: var(--button-icon-font-size);
border-radius: var(--button-icon-border-radius);
border: 0;
&:hover,
&:focus-visible {
background-color: var(--button-icon-hover-background);
color: var(--button-icon-hover-color);
}
}
&.button--marker {
background-color: var(--button-marker-background);
color: var(--button-marker-color);
padding: 0;
height: var(--marker-size);
width: var(--marker-size);
font-weight: var(--button-marker-font-weight);
border-radius: var(--button-marker-border-radius);
border: 0;
* {
pointer-events: none;
}
&.button--marker-highlighted {
background-color: var(--button-marker-highlighted-background);
color: var(--button-marker-highlighted-color);
}
}
&.button--marker-open {
background-color: var(--color-accent);
color: var(--color-accent-dark);
}
&.button--filter {
background-color: var(--button-filter-background);
color: var(--button-filter-color);
height: var(--button-filter-height);
flex: 1;
font-size: var(--button-filter-font-size);
padding: var(--button-filter-padding);
border-radius: var(--button-filter-border-radius);
&:hover,
&:focus-visible {
color: var(--button-filter-hover-color);
background-color: var(--button-filter-hover-background);
}
&.button--filter-active {
background-color: var(--button-filter-active-background);
color: var(--button-filter-active-color);
font-weight: var(--button-filter-active-font-weight);
&:hover,
&:focus-visible {
background-color: var(--button-filter-active-background);
color: var(--button-filter-active-color);
}
}
}
&.button--menu-item {
background-color: var(--button-menu-item-background);
color: var(--button-menu-item-color);
width: 100%;
justify-content: flex-start;
padding: var(--button-menu-item-padding);
border-radius: var(--button-menu-item-border-radius);
font-size: var(--button-menu-item-font-size);
gap: var(--button-menu-item-gap);
&:hover,
&:focus-visible {
background-color: var(--button-menu-item-hover-background);
color: var(--button-menu-item-hover-color);
}
&.button--menu-item-active {
background-color: var(--button-menu-item-active-background);
color: var(--button-menu-item-active-color);
font-weight: var(--button-menu-item-active-font-weight);
&:hover,
&:focus-visible {
background-color: var(--button-menu-item-active-background);
color: var(--button-menu-item-active-color);
}
}
}
&.is-active {
background-color: var(--button-active-background);
color: var(--button-active-color);
&:hover,
&:focus-visible {
color: var(--button-active-color);
background-color: var(--button-active-background);
}
}
&:disabled {
opacity: var(--button-disabled-opacity);
cursor: not-allowed;
&:hover {
color: var(--button-disabled-hover-color);
background-color: var(--button-disabled-hover-background);
}
}
}
</style>

View file

@ -1,227 +0,0 @@
<script lang="ts">
import { resolveComment, unresolveComment } from "../store/api.svelte";
import { t } from "../store/translations.svelte";
import type { Comment } from "../types";
import { panel } from "../store/ui.svelte";
import Button from "./Button.svelte";
import CommentForm from "./CommentForm.svelte";
import Reply from "./Reply.svelte";
import { formatDate } from "../composables/formatDate";
import { formatDateISO } from "../composables/formatDateISO";
import { decodeHTMLEntities } from "../composables/decodeHTMLEntities";
const {
comment,
scrollIntoView,
handleSubmit,
cancel,
}: {
comment: Comment;
scrollIntoView: (id: number) => void;
handleSubmit: (e: SubmitEvent) => void;
cancel: () => void;
} = $props();
let openReplyForm = $state(false);
let detailsOpen = $state(
comment.replies?.length > 0 && !panel.showResolvedOnly,
);
</script>
<details
id="comment-{comment.id}"
class="comment comment--{comment.status}"
class:comment--current={panel.currentCommentId === comment.id}
bind:open={detailsOpen}
>
<summary
class="comment__header"
aria-label="{t(
'ui.comment.summary.aria.label',
'Comment by',
)} {comment.author}: {decodeHTMLEntities(comment.comment)}"
>
<Button
style="button--marker button--marker-{comment.status} {panel.currentCommentId ===
comment.id
? 'button--marker-highlighted'
: ''}"
onclick={() => scrollIntoView(comment.id)}
onmouseenter={() => (panel.pulseMarkerId = comment.id)}
onmouseout={() => (panel.pulseMarkerId = 0)}
ariaLabel={`${t("ui.comment.maker.aria.label", "Jump to marker")} ${comment.id}`}
>
{comment.id}
</Button>
<div class="comment__content">
<header>
<strong>{comment.author}</strong>
<time
datetime={formatDateISO(comment.timestamp)}
title={formatDate(comment.timestamp, false)}
>
{formatDate(comment.timestamp)}
</time>
</header>
<div class="comment__text">{decodeHTMLEntities(comment.comment)}</div>
</div>
{#if !detailsOpen}
<Button
style="button--solid button--small comment__replies-count"
ariaLabel={`${t("ui.comment.replies.aria.label", "Show replies")} ${comment.id}`}
onclick={() => {
detailsOpen = !detailsOpen;
}}
>
{comment.replies?.length > 0 ? `+${comment.replies.length}` : "+"}
</Button>
{/if}
</summary>
{#if comment.replies?.length > 0}
<ul class="comment__replies">
{#each comment.replies as reply (reply.id)}
<li>
<Reply {reply} />
</li>
{/each}
</ul>
{/if}
<footer>
{#if openReplyForm}
<CommentForm
handleSubmit={(e) => {
openReplyForm = false;
handleSubmit(e);
}}
cancel={() => {
openReplyForm = false;
cancel();
}}
parentId={comment.id}
/>
{:else}
<div class="buttons">
{#if comment.status === "OPEN"}
<Button style="button--solid" onclick={() => (openReplyForm = true)}>
{t("ui.reply.submit", "Reply")}
</Button>
<Button onclick={() => resolveComment(comment)}>
{t("ui.comment.mark.solved", "Resolve")}
</Button>
{:else}
<Button onclick={() => unresolveComment(comment)}>
{t("ui.comment.mark.unsolved", "Reopen")}
</Button>
{/if}
</div>
{/if}
</footer>
</details>
<style>
.comment {
--loop-marker-background: var(--comment-marker-background);
--loop-marker-color: var(--comment-marker-color);
--marker-size: var(--comment-avatar-size);
position: relative;
> * {
z-index: 1;
position: relative;
}
&::after {
content: "";
position: absolute;
left: var(--comment-line-offset);
top: 1.5rem;
width: var(--comment-line-width);
height: calc(100% - 4rem);
background-color: var(--comment-line-background);
z-index: 0;
}
}
.comment:not([open]) {
&::after {
height: calc(100% - 2.75rem);
}
}
.comment__header {
display: flex;
align-items: center;
font-size: var(--comment-header-font-size);
padding: var(--comment-header-padding);
align-items: flex-start;
gap: var(--comment-header-gap);
cursor: pointer;
border-radius: var(--comment-header-border-radius);
&:focus-visible {
outline: 2px solid var(--comment-header-outline-color);
outline-offset: var(--comment-header-outline-offset);
}
:global(.comment__replies-count) {
position: absolute;
bottom: 0;
left: var(--space-s);
min-width: var(--comment-avatar-size);
}
header {
display: flex;
gap: var(--comment-author-gap);
align-items: center;
justify-content: flex-start;
margin-bottom: var(--comment-author-margin-bottom);
time {
font-size: var(--comment-timestamp-font-size);
color: var(--comment-timestamp-color);
}
}
.comment__content {
padding: var(--comment-content-padding);
background-color: var(--comment-content-background);
border-radius: var(--comment-content-border-radius);
flex: 1;
@media (prefers-color-scheme: dark) {
background-color: var(--comment-content-background-dark);
}
}
.comment__text {
white-space: pre-line;
}
}
.comment__replies {
list-style: none;
margin: 0;
padding: var(--comment-replies-padding);
display: flex;
flex-direction: column;
gap: var(--comment-replies-gap);
}
footer {
display: flex;
flex-direction: column;
gap: var(--comment-footer-gap);
padding: var(--comment-footer-padding);
.buttons {
display: flex;
gap: var(--comment-buttons-gap);
align-items: flex-end;
}
}
.is-hidden {
display: none;
}
</style>

View file

@ -1,56 +0,0 @@
<script lang="ts">
import { getDialogPosition } from "../composables/getDialogPosition";
import CommentForm from "./CommentForm.svelte";
const { handleSubmit, showModal, newMarker, cancel } = $props();
let dialogElement: HTMLDialogElement;
let dialogPosition: { left: number; top: number } = $state({
left: 0,
top: 0,
});
let ready = $state(false);
$effect(() => {
if (showModal) {
dialogElement.showModal();
dialogPosition = getDialogPosition(newMarker, dialogElement);
ready = true;
} else {
dialogElement.close();
ready = false;
}
});
</script>
<dialog
onclose={cancel}
bind:this={dialogElement}
class:is-visible={ready}
style="--left: {dialogPosition.left}px; --top: {dialogPosition.top}px;"
>
<CommentForm {handleSubmit} {cancel} />
</dialog>
<style>
dialog {
--loop-textarea-font-size: var(--comment-dialog-textarea-font-size);
position: var(--comment-dialog-position);
top: var(--top);
left: var(--left);
max-width: var(--comment-dialog-max-width);
max-height: none;
width: 100%;
margin: 0;
border: 0;
padding: 0;
border-radius: var(--comment-dialog-border-radius);
overflow: hidden;
visibility: hidden;
box-shadow: var(--comment-dialog-shadow);
&.is-visible {
visibility: visible;
}
&::backdrop {
background-color: var(--comment-dialog-backdrop-background);
}
}
</style>

View file

@ -1,99 +0,0 @@
<script lang="ts">
import Button from "./Button.svelte";
import { t } from "../store/translations.svelte";
const {
handleSubmit,
cancel,
parentId = null,
}: {
handleSubmit: (e: SubmitEvent) => void;
cancel: () => void;
parentId?: number | null;
} = $props();
import { formData } from "../store/form.svelte";
formData.parentId = parentId ? Number(parentId) : null;
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
const form = (e.currentTarget as Element)?.closest("form");
if (form) {
form.requestSubmit();
}
}
}
</script>
<form onsubmit={handleSubmit} method="POST">
<div class="input">
<textarea
bind:value={formData.text}
name="comment"
placeholder={parentId
? t("ui.reply.placeholder", "Write a reply...")
: t("ui.comment.placeholder", "Enter your comment...")}
onkeydown={handleKeydown}
required
></textarea>
</div>
<div class="keyboard-hint">
{t("ui.comment.keyboardHint", "⌘+Enter or Ctrl+Enter to submit")}
</div>
<footer>
<Button type="submit" style="button--solid">
{parentId
? t("ui.reply.submit", "Reply")
: t("ui.comment.submit", "Submit")}
</Button>
<Button onclick={cancel}>{t("ui.comment.cancel", "Cancel")}</Button>
</footer>
</form>
<style>
form {
padding: 0;
cursor: auto;
background-color: var(--comment-form-background);
color: var(--comment-form-color);
border-radius: var(--comment-form-border-radius);
overflow: hidden;
border: var(--comment-form-border);
}
textarea {
width: 100%;
border: 0;
height: var(--comment-form-textarea-height);
resize: none;
padding: var(--comment-form-textarea-padding);
box-sizing: border-box;
background-color: var(--comment-form-textarea-background);
font-family: var(--comment-form-textarea-font-family);
font-size: var(--comment-form-textarea-font-size);
color: currentColor;
margin: 0;
&:focus-visible,
&:focus {
outline: 0;
}
}
footer {
padding: var(--comment-form-footer-padding);
display: flex;
gap: var(--comment-form-footer-gap);
:global(button) {
flex: 1;
}
}
.keyboard-hint {
font-size: var(--comment-form-hint-font-size);
color: var(--comment-form-hint-color);
padding: var(--comment-form-hint-padding);
align-self: center;
white-space: nowrap;
margin-left: auto;
}
</style>

View file

@ -1,187 +0,0 @@
<script lang="ts">
import { panel } from "../store/ui.svelte";
import { t } from "../store/translations.svelte";
import Button from "./Button.svelte";
import IconDots from "./Icon/IconDots.svelte";
import IconSettings from "./Icon/IconSettings.svelte";
let contextMenu: HTMLElement;
let triggerButton: HTMLElement;
const toggleMenu = () => {
if (contextMenu.matches(":popover-open")) {
contextMenu.hidePopover();
} else {
contextMenu.showPopover();
// Position the popover relative to the trigger button
positionMenu();
}
};
const positionMenu = () => {
if (!triggerButton || !contextMenu) return;
const buttonRect = triggerButton.getBoundingClientRect();
const menuRect = contextMenu.getBoundingClientRect();
// Position above and to the left of the button
const top = buttonRect.top - menuRect.height - 8;
const left = buttonRect.left - menuRect.width + buttonRect.width;
contextMenu.style.position = "fixed";
contextMenu.style.top = `${Math.max(8, top)}px`;
contextMenu.style.left = `${Math.max(8, left)}px`;
contextMenu.style.margin = "0";
};
const closeMenu = () => {
contextMenu.hidePopover();
};
const setFilter = (showResolved: boolean) => {
panel.showResolvedOnly = showResolved;
closeMenu();
};
</script>
<div class="context-menu-container">
<div class="context-menu-trigger" bind:this={triggerButton}>
<Button
onclick={toggleMenu}
ariaLabel={t("ui.panel.menu.open", "Open menu")}
style="button--icon"
id="context-menu-trigger"
ariaHaspopup="menu"
ariaExpanded={contextMenu?.matches(":popover-open") ? "true" : "false"}
ariaControls="context-menu"
>
<IconSettings slot="icon" />
</Button>
</div>
<div
bind:this={contextMenu}
class="context-menu"
popover="auto"
role="menu"
aria-labelledby="context-menu-trigger"
id="context-menu"
>
<div class="menu-section">
<div class="menu-section-title">
{t("ui.panel.menu.filter.title", "Show Comments")}
</div>
<div class="filter-options">
<Button
style="button--menu-item {!panel.showResolvedOnly
? 'button--menu-item-active'
: ''}"
onclick={() => setFilter(false)}
ariaLabel={!panel.showResolvedOnly
? t(
"ui.panel.filter.open.active",
"Show open comments (currently selected)",
)
: t("ui.panel.filter.open.inactive", "Show open comments")}
>
<span
class="filter-dot filter-dot--open"
slot="icon"
aria-hidden="true"
></span>
{t("ui.panel.filter.open", "Open")}
</Button>
<Button
style="button--menu-item {panel.showResolvedOnly
? 'button--menu-item-active'
: ''}"
onclick={() => setFilter(true)}
ariaLabel={panel.showResolvedOnly
? t(
"ui.panel.filter.resolved.active",
"Show resolved comments (currently selected)",
)
: t("ui.panel.filter.resolved.inactive", "Show resolved comments")}
>
<span
class="filter-dot filter-dot--resolved"
slot="icon"
aria-hidden="true"
></span>
{t("ui.panel.filter.resolved", "Resolved")}
</Button>
</div>
</div>
</div>
</div>
<style>
.context-menu-container {
position: absolute;
bottom: var(--context-menu-container-bottom);
right: var(--context-menu-container-right);
z-index: var(--context-menu-container-z-index);
}
.context-menu-trigger {
width: var(--context-menu-trigger-size);
height: var(--context-menu-trigger-size);
border-radius: var(--context-menu-trigger-border-radius);
display: flex;
align-items: center;
justify-content: center;
}
.context-menu {
background: var(--context-menu-background);
border: 0;
border-radius: var(--context-menu-border-radius);
box-shadow: var(--context-menu-shadow);
padding: var(--context-menu-padding);
min-width: var(--context-menu-min-width);
position: fixed;
margin: 0;
&::backdrop {
background: var(--context-menu-backdrop-background);
}
}
.menu-section {
display: flex;
flex-direction: column;
gap: var(--context-menu-section-gap);
}
.menu-section-title {
font-size: var(--context-menu-title-font-size);
font-weight: var(--context-menu-title-font-weight);
color: var(--context-menu-title-color);
padding: 0;
margin-bottom: var(--context-menu-title-margin-bottom);
text-transform: uppercase;
letter-spacing: var(--context-menu-title-letter-spacing);
}
.filter-options {
display: flex;
flex-direction: column;
gap: var(--context-menu-filter-gap);
}
.filter-dot {
width: var(--context-menu-filter-dot-size);
height: var(--context-menu-filter-dot-size);
border-radius: var(--context-menu-filter-dot-border-radius);
display: inline-block;
margin-right: var(--context-menu-filter-dot-margin-right);
}
.filter-dot--open {
background: var(--context-menu-filter-dot-open-background);
}
.filter-dot--resolved {
background: var(--context-menu-filter-dot-resolved-background);
}
</style>

View file

@ -1,89 +0,0 @@
<script lang="ts">
import { panel, overlay } from "../store/ui.svelte";
import { t } from "../store/translations.svelte";
import IconComment from "./Icon/IconComment.svelte";
import Button from "./Button.svelte";
import IconBrowse from "./Icon/IconBrowse.svelte";
const {
position,
commentsCount,
}: {
position: "top" | "bottom";
commentsCount: number;
} = $props();
</script>
<header class:bottom={position === "bottom"}>
<div class="toggle">
<Button
onclick={() => {
overlay.open = false;
}}
active={!overlay.open}
style="button--header"
>
<IconBrowse --size="1.5em" slot="icon" />
{t("ui.header.browse.mode", "Browse")}
</Button>
<Button
onclick={() => {
overlay.open = true;
}}
style="button--header"
active={overlay.open}
>
<IconComment --size="1.5em" slot="icon" />
{t("ui.header.comment.mode", "Comment")}
</Button>
</div>
<Button
onclick={() => (panel.open = !panel.open)}
style="button--panel"
ariaLabel={`${commentsCount} ${t("ui.header.aria.count", "unresolved comments")}`}
>
<span class="count">{commentsCount}</span>
</Button>
</header>
<style>
.toggle {
display: flex;
}
header {
position: var(--header-position);
top: var(--header-top);
left: 50%;
max-width: 100%;
transform: var(--header-transform);
color: var(--header-color);
display: flex;
align-items: stretch;
justify-content: space-between;
border-radius: var(--header-border-radius);
z-index: var(--header-z-index);
backdrop-filter: var(--header-backdrop-filter);
box-shadow: var(--shadow-l), var(--shadow-light-edge),
var(--shadow-dark-edge);
background: var(--header-background);
&.bottom {
top: auto;
bottom: var(--header-bottom-position);
}
}
.count {
width: var(--header-count-size);
height: var(--header-count-size);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--header-count-border-radius);
backdrop-filter: var(--header-count-backdrop-filter);
box-shadow: var(--shadow-s), var(--shadow-light-edge),
var(--shadow-dark-edge);
background: var(--header-count-background);
}
</style>

View file

@ -1,179 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { Comment } from "../types";
import { panel } from "../store/ui.svelte";
import useResizeHandler from "../composables/useResizeHandler";
import { getDocumentHeight } from "../composables/getDocumentHeight";
import Button from "./Button.svelte";
const { comment }: { comment: Comment } = $props();
let shouldPulse = $state(false);
// Listen for marker pulse triggers from the panel
$effect(() => {
shouldPulse = panel.pulseMarkerId === comment.id;
});
let markerElement: HTMLElement | null = $state(null);
let targetElement: HTMLElement | null = $state(null);
let unsubscribeResize: (() => void) | null = $state(null);
onMount(() => {
// Delay initial positioning to ensure DOM is fully rendered
requestAnimationFrame(() => {
positionMarker();
});
// Subscribe to resize events for repositioning markers
unsubscribeResize = useResizeHandler(() => {
positionMarker();
});
});
onDestroy(() => {
// Clean up resize listener
if (unsubscribeResize) {
unsubscribeResize();
}
});
function positionMarker() {
if (!comment || !markerElement) return;
try {
targetElement = document.querySelector(comment.selector);
let absoluteX: number;
let absoluteY: number;
if (targetElement) {
// Position based on selector if element is found
const targetRect = targetElement.getBoundingClientRect();
// Calculate position using the percentage values directly
const offsetXInPixels =
(targetRect.width * comment.selectorOffsetX) / 100;
const offsetYInPixels =
(targetRect.height * comment.selectorOffsetY) / 100;
// Calculate absolute position accounting for scroll
absoluteX = targetRect.left + window.scrollX + offsetXInPixels;
absoluteY = targetRect.top + window.scrollY + offsetYInPixels;
} else {
// Fallback to absolute page position if selector is not found
absoluteX = Number(comment.pagePositionX);
absoluteY = Number(comment.pagePositionY);
}
// Get marker dimensions for boundary calculations
const markerRect = markerElement.getBoundingClientRect();
const markerWidth = markerRect.width || 32; // fallback to default marker size
const markerHeight = markerRect.height || 32;
// Calculate half dimensions for transform: translate(-50%, -50%)
const halfWidth = markerWidth / 2;
const halfHeight = markerHeight / 2;
// Get document and viewport dimensions
const documentHeight = getDocumentHeight();
const viewportWidth = window.innerWidth;
// Calculate boundaries
// X-axis: constrain to viewport to prevent horizontal scrolling
const minX = halfWidth;
const maxX = viewportWidth - halfWidth;
// Y-axis: constrain to document height to prevent overflow but allow positioning anywhere in document
const minY = halfHeight;
const maxY = documentHeight - halfHeight;
// Constrain position within boundaries
const constrainedX = Math.max(minX, Math.min(maxX, absoluteX));
const constrainedY = Math.max(minY, Math.min(maxY, absoluteY));
// Set absolute position
markerElement.style.left = `${constrainedX}px`;
markerElement.style.top = `${constrainedY}px`;
} catch (error) {
console.error("Error positioning marker:", error);
}
}
function handleMouseEnter(id: number) {
panel.currentCommentId = id;
}
function handleMouseOut() {
panel.currentCommentId = 0;
}
function handleClick() {
panel.open = true;
// Scroll to comment element
const commentElement = document
.querySelector(`loop`)
?.shadowRoot?.querySelector(`#comment-${comment.id}`);
if (commentElement) {
commentElement.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
</script>
{#if comment}
<div
bind:this={markerElement}
class="marker marker--{comment.status}"
class:marker--pulse={shouldPulse}
id="marker-{comment.id}"
>
<Button
onmouseenter={() => handleMouseEnter(comment.id)}
onmouseout={handleMouseOut}
onblur={handleMouseOut}
onclick={handleClick}
style="button--marker button--marker-{comment.status}"
>
{comment.id}
</Button>
</div>
{/if}
<style>
.marker {
position: var(--marker-position);
z-index: var(--marker-z-index);
transform: var(--marker-transform);
border-radius: var(--marker-border-radius);
}
.marker--pulse {
animation: kirby-loop-pulse 1.5s ease-in-out infinite;
}
@keyframes kirby-loop-pulse {
0% {
box-shadow:
0 0 0 0 var(--color-accent),
0 0 0 0 rgba(128, 128, 128, 0.3),
0 0 0 0 rgba(128, 128, 128, 0.2);
}
30% {
box-shadow:
0 0 0 8px transparent,
0 0 0 0 rgba(128, 128, 128, 0.3),
0 0 0 0 rgba(128, 128, 128, 0.2);
}
60% {
box-shadow:
0 0 0 8px rgba(128, 128, 128, 0.15),
0 0 0 12px transparent,
0 0 0 0 rgba(128, 128, 128, 0.2);
}
100% {
box-shadow:
0 0 0 16px transparent,
0 0 0 12px transparent,
0 0 0 8px transparent;
}
}
</style>

View file

@ -1,181 +0,0 @@
<script lang="ts">
import { store } from "../store/api.svelte";
import { panel } from "../store/ui.svelte";
import { t } from "../store/translations.svelte";
import Comment from "./Comment.svelte";
const { scrollIntoView, handleSubmit, cancel } = $props();
import Button from "./Button.svelte";
import ContextMenu from "./ContextMenu.svelte";
import IconChat from "./Icon/IconChat.svelte";
import { onMount } from "svelte";
let dialogEl: HTMLDialogElement;
// Filter comments based on resolved status
const filteredComments = $derived(
panel.showResolvedOnly
? store.comments.filter((comment) => comment.status === "RESOLVED")
: store.comments.filter((comment) => comment.status === "OPEN"),
);
// Sync dialog state with panel store
$effect(() => {
if (!dialogEl) return;
if (panel.open && !dialogEl.open) {
dialogEl.show();
} else if (!panel.open && dialogEl.open) {
dialogEl.close();
}
});
// Handle ESC key to close panel
onMount(() => {
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape" && panel.open) {
panel.open = false;
}
};
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
});
// Handle dialog close event
function handleDialogClose() {
panel.open = false;
}
</script>
<dialog
bind:this={dialogEl}
class="panel"
class:open={panel.open}
onclose={handleDialogClose}
>
<header>
<Button
onclick={() => (panel.open = !panel.open)}
style="button--header"
ariaLabel={t("ui.panel.open", "Open comments")}
>
<IconChat slot="icon" />
</Button>
</header>
<ul class="threads" data-lenis-prevent inert={!panel.open}>
{#if filteredComments.length === 0}
<li class="no-threads">
<p>
{t("ui.panel.no.comments", "No comments yet.")}
</p>
</li>
{:else if filteredComments.length === 0 && panel.showResolvedOnly}
<li class="no-threads">
<p>{t("ui.panel.no.resolved", "No resolved comments yet.")}</p>
</li>
{:else}
{#each filteredComments as comment (comment.id)}
<li>
<Comment {comment} {scrollIntoView} {cancel} {handleSubmit} />
</li>
{/each}
{/if}
</ul>
<ContextMenu />
</dialog>
<style>
.panel {
position: var(--panel-position);
right: var(--panel-right);
top: var(--panel-top);
left: auto;
bottom: auto;
transform: var(--panel-transform-closed);
width: var(--panel-width);
max-width: none;
margin: 0;
padding: 0;
background: none;
height: var(--panel-height);
border: 0;
color: var(--panel-color);
border-radius: var(--panel-border-radius);
border-top-left-radius: var(--panel-border-top-left-radius);
transition: var(--panel-transition);
display: flex;
flex-direction: column;
z-index: var(--panel-z-index);
cursor: auto;
justify-content: flex-start;
align-items: flex-start;
@media screen and (max-width: 600px) {
width: var(--panel-mobile-width);
}
&.open {
transform: var(--panel-transform-open);
box-shadow: var(--panel-shadow);
header {
transform: var(--panel-header-transform-open);
}
}
header {
transform: var(--panel-header-transform-closed);
border-top-left-radius: var(--panel-header-border-radius);
border-bottom-left-radius: var(--panel-header-border-radius);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
position: absolute;
display: flex;
flex-direction: column;
gap: var(--panel-header-gap);
backdrop-filter: var(--panel-header-backdrop-filter);
background: var(--panel-header-background);
box-shadow: var(--shadow-l), var(--shadow-light-edge),
var(--shadow-dark-edge);
transition: transform var(--transition-duration) var(--transition-easing);
&:hover,
&:focus-visible {
transform: var(--panel-header-transform-hover);
}
}
}
.threads {
flex: 1 1 100%;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
padding: var(--panel-threads-padding);
width: 100%;
box-sizing: border-box;
background-color: var(--panel-threads-background);
backdrop-filter: var(--panel-threads-backdrop);
z-index: 2;
border-radius: var(--panel-threads-border-radius);
border-top-left-radius: var(--panel-threads-border-top-left-radius);
scrollbar-width: var(--panel-threads-scrollbar-width);
scrollbar-gutter: stable;
li {
+ li {
margin-top: var(--panel-threads-item-margin);
}
}
.no-threads {
text-align: center;
padding: var(--panel-no-threads-padding);
font-size: var(--panel-no-threads-font-size);
color: var(--panel-no-threads-color);
margin-block: auto;
}
}
</style>

View file

@ -1,67 +0,0 @@
<script lang="ts">
import type { Reply } from "../types";
import Author from "./Author.svelte";
import { t } from "../store/translations.svelte";
import { formatDate } from "../composables/formatDate";
import { formatDateISO } from "../composables/formatDateISO";
import { decodeHTMLEntities } from "../composables/decodeHTMLEntities";
export let reply: Reply;
</script>
<article
class="reply"
data-id={reply.id}
aria-label="{t(
'ui.reply.aria.label',
'Reply by',
)} {reply.author}: {decodeHTMLEntities(reply.comment)}"
>
<Author initials={reply.author.substring(0, 1)} />
<div class="reply__content">
<header>
<strong>{reply.author}</strong>
<time
datetime={formatDateISO(reply.timestamp)}
title={formatDate(reply.timestamp, false)}
>
{formatDate(reply.timestamp)}
</time>
</header>
<div class="reply__text">{decodeHTMLEntities(reply.comment)}</div>
</div>
</article>
<style>
.reply {
display: flex;
gap: var(--reply-gap);
flex-direction: row;
align-items: start;
}
.reply__content {
padding: var(--reply-content-padding);
background-color: var(--reply-content-background);
border-radius: var(--reply-content-border-radius);
header {
display: flex;
gap: var(--reply-header-gap);
align-items: center;
justify-content: flex-start;
margin-bottom: var(--reply-header-margin-bottom);
time {
font-size: var(--reply-timestamp-font-size);
color: var(--reply-timestamp-color);
}
}
@media (prefers-color-scheme: dark) {
background-color: var(--reply-content-background-dark);
}
}
.reply__text {
white-space: pre-line;
}
</style>

View file

@ -1,168 +0,0 @@
<script lang="ts">
import Button from "./Button.svelte";
import { guestName } from "../store/ui.svelte";
import { setGuestName } from "../store/api.svelte";
import { t } from "../store/translations.svelte";
const {
headline,
text,
authenticated,
welcomeEnabled = true,
onDismiss,
}: {
headline: string;
text: string;
authenticated: boolean;
welcomeEnabled?: boolean;
onDismiss?: () => void;
} = $props();
let dialog: HTMLDialogElement;
let name = $state("");
let isSubmitting = $state(false);
export const showModal = () => dialog?.showModal();
export const close = () => dialog?.close();
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!authenticated) {
if (name.trim() && !isSubmitting) {
isSubmitting = true;
try {
await setGuestName(name.trim());
guestName.set(name.trim());
dialog?.close();
} catch (error) {
console.error("Failed to set guest name:", error);
} finally {
isSubmitting = false;
}
}
} else {
// For authenticated users, mark as dismissed when submitting
onDismiss?.();
dialog?.close();
}
}
function handleCancel() {
dialog?.close();
}
function handleDialogClose() {
// Reset form when dialog closes
name = "";
isSubmitting = false;
}
</script>
<dialog bind:this={dialog} onclose={handleDialogClose}>
<form onsubmit={handleSubmit}>
{#if welcomeEnabled}
<h2>{headline}</h2>
<p class="welcome-text">{text}</p>
{/if}
{#if !authenticated}
<div class="name-section" class:no-welcome={!welcomeEnabled}>
<div class="input">
<input
bind:value={name}
type="text"
placeholder={t(
"ui.welcome.guest.name.placeholder",
"Enter your name",
)}
required
/>
</div>
</div>
{/if}
<footer>
<Button type="submit" style="button--solid" disabled={isSubmitting}>
{#if !authenticated}
{isSubmitting ? "Saving..." : t("ui.welcome.continue", "Continue")}
{:else}
{t("ui.welcome.continue", "Continue")}
{/if}
</Button>
<Button onclick={handleCancel} disabled={isSubmitting}>
{t("ui.welcome.dismiss", "Dismiss")}
</Button>
</footer>
</form>
</dialog>
<style>
dialog {
backdrop-filter: var(--welcome-dialog-backdrop-filter);
border: var(--welcome-dialog-border);
border-radius: var(--welcome-dialog-border-radius);
box-shadow: var(--welcome-dialog-shadow);
width: 100%;
max-width: var(--welcome-dialog-max-width);
padding: 0;
background: var(--welcome-dialog-background);
&::backdrop {
background: var(--welcome-dialog-backdrop-background);
backdrop-filter: var(--welcome-dialog-backdrop-backdrop-filter);
}
}
form {
padding: var(--welcome-dialog-form-padding);
}
h2 {
margin: var(--welcome-dialog-title-margin);
font-size: var(--welcome-dialog-title-font-size);
color: var(--welcome-dialog-title-color);
font-weight: var(--welcome-dialog-title-font-weight);
}
.welcome-text {
margin: var(--welcome-dialog-text-margin);
font-size: var(--welcome-dialog-text-font-size);
color: var(--welcome-dialog-text-color);
line-height: var(--welcome-dialog-text-line-height);
}
.name-section {
margin-bottom: var(--welcome-dialog-name-section-margin);
}
.name-section.no-welcome {
border-top: none;
padding-top: 0;
}
input {
width: 100%;
border: var(--welcome-dialog-input-border);
border-radius: var(--welcome-dialog-input-border-radius);
padding: var(--welcome-dialog-input-padding);
box-sizing: border-box;
font-family: var(--welcome-dialog-input-font-family);
font-size: var(--welcome-dialog-input-font-size);
color: var(--welcome-dialog-input-color);
background: var(--welcome-dialog-input-background);
&:focus-visible {
outline-color: var(--welcome-dialog-input-outline-color);
outline-offset: var(--welcome-dialog-input-outline-offset);
}
}
footer {
display: flex;
gap: var(--welcome-dialog-footer-gap);
}
footer :global(button) {
flex: 1;
}
</style>

View file

@ -1,5 +0,0 @@
import App from './App.svelte'
import "./styles/variables.css"
import "./styles/app.css"
export default App;

View file

@ -1,111 +0,0 @@
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<boolean> => {
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;

View file

@ -1,11 +0,0 @@
import type { FormData } from '../types';
export const formData: FormData = $state({
text: "",
parentId: null
});
export const reset = () => {
formData.text = ""
formData.parentId = null
}

View file

@ -1,19 +0,0 @@
let translations = $state<Record<string, string>>({});
export const t = (key: string, fallback?: string): string => {
return translations[key] || fallback || key;
};
export const tt = (key: string, fallback: string, replacements: Record<string, string>): 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<string, string>) => {
translations = newTranslations;
};

Some files were not shown because too many files have changed in this diff Show more