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,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

View file

@ -0,0 +1,27 @@
{
"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"
}
}

1505
site/plugins/loop/frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

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

@ -0,0 +1,19 @@
/**
* 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

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

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

View file

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

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

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

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

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

@ -0,0 +1,352 @@
/**
* 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

@ -0,0 +1,130 @@
/**
* 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

@ -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>

View file

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

View file

@ -0,0 +1,111 @@
import type { Comment, CommentPayload, Reply, ReplyPayload } from '../types';
export const store: { comments: Comment[] } = $state({
comments: []
});
const apiPrefix = 'loop';
const KirbyLoop = document.querySelector('kirby-loop');
const csrfToken = KirbyLoop?.getAttribute('csrf-token') || '';
const apiBase = KirbyLoop?.getAttribute('apibase') || '/';
const headers = {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken || ''
};
const buildApiUrl = (endpoint: string): string => {
const url = new URL(`${apiBase}/${apiPrefix}/${endpoint}`, window.location.origin);
// Add token query params from current page if they exist
const currentParams = new URLSearchParams(window.location.search);
const token = currentParams.get('token') || currentParams.get('_token');
if (token) {
url.searchParams.set(currentParams.has('token') ? 'token' : '_token', token);
}
return url.toString();
};
export const getComments = async (pageId: string): Promise<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

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

View file

@ -0,0 +1,19 @@
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;
};

View file

@ -0,0 +1,34 @@
export const panel = $state({
open: false,
currentCommentId: 0,
showResolvedOnly: false,
pulseMarkerId: 0
});
export const overlay = $state({ open: false });
// Guest name management
let guestNameValue = $state("");
export const guestName = {
get value() {
return guestNameValue;
},
set(name: string) {
guestNameValue = name;
if (typeof window !== 'undefined') {
sessionStorage.setItem('loop-guest-name', name);
}
},
get() {
if (!guestNameValue && typeof window !== 'undefined') {
guestNameValue = sessionStorage.getItem('loop-guest-name') || "";
}
return guestNameValue;
},
clear() {
guestNameValue = "";
if (typeof window !== 'undefined') {
sessionStorage.removeItem('loop-guest-name');
}
}
};

View file

@ -0,0 +1,13 @@
kirby-loop {
font-family: var(--font-family);
line-height: var(--line-height);
font-weight: var(--font-weight-normal);
font-size: var(--font-size-7);
box-sizing: border-box;
}
html.loop-overlay-open {
a {
pointer-events: none;
}
}

View file

@ -0,0 +1,35 @@
kirby-loop[theme="dark"] {
/* Accent lightness values */
--color-accent-l: 0.85;
/* Neutral lightness values */
--color-neutral-l-0: 0;
--color-neutral-l-100: 0.1;
--color-neutral-l-200: 0.2;
--color-neutral-l-300: 0.45;
--color-neutral-l-400: 0.5;
--color-neutral-l-600: 0.55;
--color-neutral-l-500: 0.6;
--color-neutral-l-700: 0.7;
--color-neutral-l-800: 0.8;
--color-neutral-l-900: 0.95;
--color-neutral-l-1000: 1;
/* Shadow tokens */
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
--shadow-m: 0 2px 8px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
0 8px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
0 16px 24px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
--shadow-l: 0 4px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
0 12px 32px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
0 24px 48px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
0 48px 80px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
--shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
--shadow-dark-edge: inset -1px -1px 1px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
/* Background tokens */
--background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-50));
/* Panel */
--panel-threads-background: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h) / 0.99)
}

View file

@ -0,0 +1,23 @@
kirby-loop {
/* Color Customization */
--color-neutral-h: 900;
--color-neutral-c: 0;
--color-accent-h: 900;
--color-accent-c: 0.18;
--color-accent-l: 0.75;
--color-accent-dark-factor: 0.4;
--color-accent-light-factor: 1.2;
/* Neutral lightness values */
--color-neutral-l-0: 1;
--color-neutral-l-100: 0.95;
--color-neutral-l-200: 0.9;
--color-neutral-l-300: 0.7;
--color-neutral-l-400: 0.6;
--color-neutral-l-600: 0.4;
--color-neutral-l-500: 0.5;
--color-neutral-l-700: 0.3;
--color-neutral-l-800: 0.2;
--color-neutral-l-900: 0.1;
--color-neutral-l-1000: 0;
}

View file

@ -0,0 +1,421 @@
@import "./theme-default.css";
@import "./theme-dark.css";
kirby-loop {
/* Colors */
--color-base: var(--color-neutral-900);
--color-base-background: var(--color-neutral-0);
--color-base-background-o-5: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.05);
--color-base-background-o-10: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
--color-base-background-o-20: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.2);
--color-base-background-o-50: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.5);
--color-base-background-o-60: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.6);
--color-base-background-o-75: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.75);
--color-base-background-o-95: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.95);
--color-accent-light: oklch(calc(var(--color-accent-l) * var(--color-accent-light-factor)) var(--color-accent-c) var(--color-accent-h));
--color-accent: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
--color-accent-dark: oklch(calc(var(--color-accent-l) * var(--color-accent-dark-factor)) var(--color-accent-c) var(--color-accent-h));
--color-neutral-0: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-100: oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-200: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-300: oklch(var(--color-neutral-l-300) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-400: oklch(var(--color-neutral-l-400) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-500: oklch(var(--color-neutral-l-500) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-600: oklch(var(--color-neutral-l-600) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-700: oklch(var(--color-neutral-l-700) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-800: oklch(var(--color-neutral-l-800) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-900: oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h));
--color-neutral-1000: oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h));
--color-success: oklch(0.65 0.15 150);
--color-warning: oklch(0.75 0.15 80);
--color-error: oklch(0.65 0.18 25);
--color-info: oklch(0.65 0.15 220);
--font-family: -apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif,
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
sans-serif;
--line-height: 1.4;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 550;
--font-weight-bold: 700;
--font-size-3: clamp(1.9531rem, 1.4262rem + 1.7565vw, 3.5339rem);
--font-size-4: clamp(1.5625rem, 1.2503rem + 1.0408vw, 2.4992rem);
--font-size-5: clamp(1.25rem, 1.0775rem + 0.575vw, 1.7675rem);
--font-size-6: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
--font-size-7: clamp(0.8rem, 0.772rem + 0.0934vw, 0.884rem);
--font-size-8: clamp(0.6252rem, 0.6449rem + -0.0165vw, 0.64rem);
--border-radius-s: 0.125rem;
--border-radius: 0.25rem;
--border-radius-rounded: 4096px;
--space-2xs: clamp(0.25rem, 0.2292rem + 0.0694vw, 0.3125rem);
--space-xs: clamp(0.5rem, 0.4583rem + 0.1389vw, 0.625rem);
--space-s: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
--space-m: clamp(1.5rem, 1.375rem + 0.4167vw, 1.875rem);
--space-l: clamp(2rem, 1.8333rem + 0.5556vw, 2.5rem);
--space-2xs-xs: clamp(0.25rem, 0.125rem + 0.4167vw, 0.625rem);
--space-xs-s: clamp(0.5rem, 0.25rem + 0.8333vw, 1.25rem);
--space-s-m: clamp(1rem, 0.7083rem + 0.9722vw, 1.875rem);
--space-m-l: clamp(1.5rem, 1.1667rem + 1.1111vw, 2.5rem);
--space-s-l: clamp(1rem, 0.5rem + 1.6667vw, 2.5rem);
/* Shadow tokens */
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
--shadow-m: 0 2px 8px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
0 8px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
0 16px 24px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
--shadow-l: 0 4px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
0 12px 32px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
0 24px 48px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
0 48px 80px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
--shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
--shadow-dark-edge: inset 0 -1px 1px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
/* Backdrop tokens */
--backdrop-blur: blur(6px);
--backdrop-glass: var(--backdrop-blur) saturate(1.4) brightness(1.2);
/* Background tokens */
--background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-95));
--background-glass-frosted: linear-gradient(0deg, var(--color-base-background-o-75) 0%, var(--color-base-background-o-95) 50%);
/* Opacity tokens */
--opacity-subtle: 0.5;
--opacity-medium: 0.7;
--opacity-strong: 0.9;
/* Outline tokens */
--outline-color: var(--color-accent);
--outline-offset: 0.25rem;
/* Transition tokens */
--transition-duration: 0.2s;
--transition-duration-jump: 0.4s;
--transition-easing-jump: cubic-bezier(0.44, 1.2, 0.64, 1);
--transition-easing: cubic-bezier(0, 0, 0.2, 1);
/* Z-index tokens */
--z-loop-marker: 9998;
--z-loop-panel: 9999;
--z-loop-dialog: 10000;
/* Author */
--author-avatar-color: var(--color-neutral-600);
--author-avatar-background-color: var(--color-neutral-100);
--author-avatar-size: 2.5rem;
--author-avatar-border-radius: var(--border-radius-rounded);
--author-avatar-font-size: var(--font-size-6);
/* Button */
--button-background: transparent;
--button-color: var(--color-neutral-600);
--button-border-radius: var(--border-radius);
--button-padding: 0 var(--space-xs);
--button-gap: var(--space-2xs);
--button-font-size: var(--font-size-7);
--button-font-weight: var(--font-weight-medium);
--button-height: 2.25rem;
--button-transition: var(--transition-duration) var(--transition-easing);
--button-outline-color: var(--outline-color);
--button-outline-offset: var(--outline-offset);
--button-hover-color: var(--color-neutral-900);
--button-hover-background: var(--color-neutral-200);
--button-header-background: transparent;
--button-header-height: 3rem;
--button-header-padding: 0 var(--space-s);
--button-header-hover-background: var(--color-base-background-o-95);
--button-header-blend-mode: multiply;
--button-panel-background: transparent;
--button-panel-padding: 0 calc(var(--space-s) * 0.4);
--button-solid-background: var(--color-neutral-100);
--button-solid-hover-color: var(--color-neutral-900);
--button-solid-hover-background: var(--color-neutral-200);
--button-small-height: 1.5rem;
--button-small-font-size: var(--font-size-7);
--button-icon-background: var(--color-neutral-0);
--button-icon-color: var(--color-neutral-500);
--button-icon-height: 3rem;
--button-icon-shadow: var(--shadow-s);
--button-icon-border-radius: var(--border-radius-rounded);
--button-icon-font-size: var(--font-size-6);
--button-icon-hover-background: var(--color-neutral-200);
--button-icon-hover-color: var(--color-neutral-900);
--button-marker-background: var(--color-accent);
--button-marker-color: var(--color-accent-dark);
--button-marker-font-weight: var(--font-weight-bold);
--button-marker-border-radius: var(--border-radius-rounded);
--button-marker-highlighted-background: var(--color-accent);
--button-marker-highlighted-color: var(--color-accent-dark);
--button-filter-background: transparent;
--button-filter-color: var(--color-neutral-500);
--button-filter-height: 1.75rem;
--button-filter-font-size: var(--font-size-8);
--button-filter-padding: 0 var(--space-xs);
--button-filter-border-radius: calc(var(--border-radius) - 2px);
--button-filter-hover-color: var(--color-neutral-700);
--button-filter-hover-background: var(--color-neutral-200);
--button-filter-active-background: var(--color-base-background);
--button-filter-active-color: var(--color-base);
--button-filter-active-font-weight: var(--font-weight-medium);
--button-menu-item-background: transparent;
--button-menu-item-color: var(--color-neutral-700);
--button-menu-item-padding: var(--space-2xs) var(--space-xs);
--button-menu-item-border-radius: calc(var(--border-radius) - 2px);
--button-menu-item-font-size: var(--font-size-7);
--button-menu-item-gap: var(--space-2xs);
--button-menu-item-hover-background: var(--color-neutral-100);
--button-menu-item-hover-color: var(--color-neutral-900);
--button-menu-item-active-background: var(--color-accent-light);
--button-menu-item-active-color: var(--color-accent-dark);
--button-menu-item-active-font-weight: var(--font-weight-medium);
--button-active-background: var(--color-accent);
--button-active-color: var(--color-accent-dark);
--button-disabled-opacity: var(--opacity-subtle);
--button-disabled-hover-color: var(--color-neutral-700);
--button-disabled-hover-background: var(--color-neutral-100);
/* Comment */
--comment-avatar-size: 2.5rem;
--comment-marker-background: var(--color-neutral-200);
--comment-marker-color: var(--color-neutral-800);
--comment-line-background: var(--color-neutral-100);
--comment-line-width: 0.1rem;
--comment-line-offset: calc(var(--space-s) + var(--comment-avatar-size) / 2);
--comment-header-font-size: var(--font-size-7);
--comment-header-padding: var(--space-s);
--comment-header-gap: var(--space-s);
--comment-header-outline-color: var(--outline-color);
--comment-header-outline-offset: -2px;
--comment-header-border-radius: var(--border-radius);
--comment-content-padding: var(--space-xs);
--comment-content-background: var(--color-neutral-100);
--comment-content-background-dark: var(--color-neutral-200);
--comment-content-border-radius: var(--border-radius);
--comment-author-gap: var(--space-xs);
--comment-author-margin-bottom: var(--space-2xs);
--comment-timestamp-font-size: var(--font-size-8);
--comment-timestamp-color: var(--color-neutral-300);
--comment-replies-padding: 0 var(--space-s);
--comment-replies-gap: var(--space-s);
--comment-footer-padding: var(--space-s);
--comment-footer-gap: var(--space-s);
--comment-buttons-gap: var(--space-xs);
/* CommentDialog */
--comment-dialog-position: absolute;
--comment-dialog-max-width: 300px;
--comment-dialog-border-radius: var(--border-radius);
--comment-dialog-shadow: var(--shadow-s);
--comment-dialog-backdrop-background: transparent;
--comment-dialog-textarea-font-size: var(--font-size-6);
/* CommentForm */
--comment-form-background: var(--color-base-background);
--comment-form-color: var(--color-base);
--comment-form-border: 1px solid var(--color-neutral-200);
--comment-form-border-radius: var(--border-radius);
--comment-form-textarea-height: 15ch;
--comment-form-textarea-padding: var(--space-s);
--comment-form-textarea-background: var(--color-base-background);
--comment-form-textarea-font-family: var(--font-family);
--comment-form-textarea-font-size: var(--font-size-7);
--comment-form-footer-padding: var(--space-xs);
--comment-form-footer-gap: var(--space-xs);
--comment-form-hint-font-size: var(--font-size-8);
--comment-form-hint-color: var(--color-neutral-300);
--comment-form-hint-padding: 0 var(--space-xs) var(--space-xs) var(--space-xs);
/* ContextMenu */
--context-menu-container-bottom: var(--space-s);
--context-menu-container-right: var(--space-s);
--context-menu-container-z-index: 10;
--context-menu-trigger-size: 2.5rem;
--context-menu-trigger-border-radius: var(--border-radius-rounded);
--context-menu-background: var(--color-base-background);
--context-menu-border-radius: var(--border-radius);
--context-menu-shadow: var(--shadow-s);
--context-menu-padding: var(--space-xs);
--context-menu-min-width: 12rem;
--context-menu-backdrop-background: transparent;
--context-menu-section-gap: var(--space-2xs);
--context-menu-title-font-size: var(--font-size-8);
--context-menu-title-font-weight: var(--font-weight-medium);
--context-menu-title-color: var(--color-neutral-500);
--context-menu-title-margin-bottom: var(--space-2xs);
--context-menu-title-letter-spacing: 0.05em;
--context-menu-filter-gap: 1px;
--context-menu-filter-dot-size: 0.5em;
--context-menu-filter-dot-border-radius: 50%;
--context-menu-filter-dot-margin-right: var(--space-2xs);
--context-menu-filter-dot-open-background: var(--color-accent);
--context-menu-filter-dot-resolved-background: var(--color-neutral-400);
/* Header */
--header-position: fixed;
--header-top: var(--space-xs);
--header-transform: translateX(-50%);
--header-color: var(--color-base);
--header-border-radius: var(--border-radius-rounded);
--header-z-index: 9999;
--header-bottom-position: var(--space-xs);
--header-backdrop-filter: var(--backdrop-glass);
--header-background: var(--background-glass);
--header-count-size: 2rem;
--header-count-border-radius: var(--border-radius-rounded);
--header-count-backdrop-filter: var(--backdrop-glass);
--header-count-background: var(--background-glass);
/* Marker */
--marker-size: 2rem;
--marker-position: absolute;
--marker-z-index: var(--z-loop-marker);
--marker-transform: translate(-50%, -50%);
--marker-border-radius: var(--border-radius-rounded);
/* Panel */
--panel-width: 380px;
--panel-mobile-width: 85svw;
--panel-position: fixed;
--panel-right: var(--space-xs);
--panel-top: var(--space-xs);
--panel-height: calc(100svh - var(--space-xs) * 2);
--panel-transform-closed: translateX(calc(100% + var(--space-xs)));
--panel-transform-open: translateX(0);
--panel-color: var(--color-base);
--panel-border-radius: var(--border-radius);
--panel-border-top-left-radius: 0;
--panel-transition: var(--transition-duration-jump) var(--transition-easing-jump);
--panel-z-index: var(--z-loop-panel);
--panel-shadow: var(--shadow-m);
--panel-header-transform-closed: translate(-95%);
--panel-header-transform-open: translate(calc(-100% + 1px));
--panel-header-transform-hover: translate(calc(-100% + 1px));
--panel-header-border-radius: var(--border-radius-rounded);
--panel-header-gap: var(--space-xs);
--panel-header-backdrop-filter: var(--backdrop-glass);
--panel-header-background: var(--background-glass);
--panel-threads-background: var(--color-base-background-o-95);
--panel-threads-backdrop: var(--backdrop-blur);
--panel-threads-border-radius: var(--border-radius);
--panel-threads-border-top-left-radius: 0;
--panel-threads-padding: 0 0 var(--space-s) 0;
--panel-threads-item-margin: var(--space-s);
--panel-threads-scrollbar-width: thin;
--panel-no-threads-padding: var(--space-s) var(--space-l);
--panel-no-threads-font-size: var(--font-size-6);
--panel-no-threads-color: var(--color-neutral-300);
/* Reply */
--reply-gap: var(--space-s);
--reply-content-padding: var(--space-xs);
--reply-content-background: var(--color-neutral-100);
--reply-content-background-dark: var(--color-neutral-200);
--reply-content-border-radius: var(--border-radius);
--reply-header-gap: var(--space-xs);
--reply-header-margin-bottom: var(--space-2xs);
--reply-timestamp-font-size: var(--font-size-8);
--reply-timestamp-color: var(--color-neutral-300);
/* WelcomeDialog */
--welcome-dialog-background: var(--background-glass-frosted);
--welcome-dialog-backdrop-filter: var(--backdrop-glass);
--welcome-dialog-border: 0px;
--welcome-dialog-border-radius: var(--border-radius);
--welcome-dialog-shadow: var(--shadow-l), var(--shadow-light-edge),
var(--shadow-dark-edge);
--welcome-dialog-max-width: 500px;
--welcome-dialog-backdrop-background: var(--color-base-background-o-10);
--welcome-dialog-backdrop-backdrop-filter: none;
--welcome-dialog-form-padding: var(--space-l);
--welcome-dialog-title-margin: 0 0 var(--space-s) 0;
--welcome-dialog-title-font-size: var(--font-size-4);
--welcome-dialog-title-color: var(--color-base);
--welcome-dialog-title-font-weight: var(--font-weight-bold);
--welcome-dialog-text-margin: 0 0 var(--space-m) 0;
--welcome-dialog-text-font-size: var(--font-size-6);
--welcome-dialog-text-color: var(--color-neutral-600);
--welcome-dialog-text-line-height: var(--line-height);
--welcome-dialog-name-section-margin: var(--space-l);
--welcome-dialog-input-border: 1px solid var(--color-neutral-300);
--welcome-dialog-input-border-radius: var(--border-radius-s);
--welcome-dialog-input-padding: var(--space-xs);
--welcome-dialog-input-font-family: var(--font-family);
--welcome-dialog-input-font-size: var(--font-size-6);
--welcome-dialog-input-color: var(--color-base);
--welcome-dialog-input-background: var(--color-base-background);
--welcome-dialog-input-outline-color: var(--outline-color);
--welcome-dialog-input-outline-offset: var(--outline-offset);
--welcome-dialog-footer-gap: var(--space-xs);
/* Icon */
--icon-size: 1em;
}
/* Dark theme overrides */
kirby-loop[data-theme="dark"] {
--color-neutral-l-0: 0;
--color-neutral-l-100: 0.1;
--color-neutral-l-200: 0.2;
--color-neutral-l-300: 0.3;
--color-neutral-l-400: 0.4;
--color-neutral-l-500: 0.5;
--color-neutral-l-600: 0.6;
--color-neutral-l-700: 0.7;
--color-neutral-l-800: 0.9;
--color-neutral-l-900: 0.95;
--color-neutral-l-1000: 1;
}

View file

@ -0,0 +1,100 @@
// TypeScript interfaces for loop
export interface LoopProps {
position: 'top' | 'bottom';
language?: string;
apibase?: string;
pageId: string;
authenticated?: 'true' | 'false';
'welcome-enabled'?: 'true' | 'false';
'welcome-headline'?: string;
'welcome-text'?: string;
translations?: string;
}
export interface Comment {
id: number;
author: string;
url: string;
page: string;
comment: string;
selector: string;
selectorOffsetX: number;
selectorOffsetY: number;
pagePositionX: number;
pagePositionY: number;
status: string;
timestamp: number;
lang: string;
replies: Reply[];
}
export interface Reply {
id?: number;
author: string;
comment: string;
parentId: number | null;
timestamp: number;
}
export interface CommentPayload {
url: string;
comment: string;
selector: string;
selectorOffsetX: number;
selectorOffsetY: number;
pagePositionX: number;
pagePositionY: number;
parentId: number | null;
lang: string;
pageId: string;
}
export interface ReplyPayload {
comment: string;
parentId: number | null;
}
export interface MarkerPosition {
selector: string;
selectorOffsetX: number;
selectorOffsetY: number;
pagePositionX: number;
pagePositionY: number;
}
export interface ApiResponse<T = any> {
status: 'ok' | 'error';
message?: string;
code?: string;
data?: T;
}
export interface CommentsResponse extends ApiResponse {
comments: Comment[];
}
export interface CommentResponse extends ApiResponse {
comment: Comment;
}
export interface ReplyResponse extends ApiResponse {
reply: Reply;
}
// Store interfaces
export interface FormData {
text: string;
parentId: number | null;
}
export interface UIState {
open: boolean;
sidebarOpen: boolean;
}
export interface APIStore {
comments: Comment[];
loading: boolean;
error: string | null;
}

View file

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View file

@ -0,0 +1,10 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
compilerOptions: {
customElement: true,
},
};

View file

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,69 @@
/// <reference types="vitest" />
import { defineConfig, loadEnv } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import { ViteEjsPlugin } from 'vite-plugin-ejs'
import { browserslistToTargets } from 'lightningcss';
import browserslist from "browserslist"
// Isomorphic dirname
const _dirname =
typeof __dirname !== "undefined"
? __dirname
: dirname(fileURLToPath(import.meta.url));
// Config
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
base: env.VITE_DEMO_BASE,
compilerOptions: {
hmr: !process.env.VITEST && mode !== 'production',
},
build: {
cssMinify: 'lightningcss',
minify: true,
lib: {
entry: resolve(_dirname, "src/main.ts"),
name: "Loop",
fileName: "loop",
formats: ["es"],
},
outDir: "../assets",
},
css: {
transformer: 'lightningcss',
lightningcss: {
drafts: {
customMedia: true
},
targets: browserslistToTargets(browserslist(["last 2 versions", ">= 0.4%", "not dead", "Firefox ESR", "not op_mini all", "not and_uc > 0"]))
}
},
define: {
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},
plugins: [
svelte({ compilerOptions: { customElement: true } }),
cssInjectedByJsPlugin(),
ViteEjsPlugin((viteConfig) => ({
// viteConfig is the current Vite resolved config
env: viteConfig.env,
}))
],
test: {
globals: true,
environment: "jsdom",
},
server: {
allowedHosts: ['kirby-loop.test'],
cors: {
// Allow ddev and .test domains
origin: /https?:\/\/([A-Za-z0-9\-\.]+)?(\.(ddev\.site|test))(?::\d+)?$/,
},
}
}
});