add kirby-loop plugin with French translations
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-23 21:41:50 +01:00
parent 8ea5f0c462
commit ab7fd8b2ea
74 changed files with 16423 additions and 2 deletions

View file

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

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

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

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

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

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

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

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

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

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

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