add kirby-loop plugin with French translations
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
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:
parent
8ea5f0c462
commit
ab7fd8b2ea
74 changed files with 16423 additions and 2 deletions
3
site/plugins/loop/frontend/.vscode/extensions.json
vendored
Normal file
3
site/plugins/loop/frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
27
site/plugins/loop/frontend/package.json
Normal file
27
site/plugins/loop/frontend/package.json
Normal 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
1505
site/plugins/loop/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
216
site/plugins/loop/frontend/src/App.svelte
Normal file
216
site/plugins/loop/frontend/src/App.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
return text.replace(/&[#\w]+;/g, (entity) => entityMap[entity] || entity);
|
||||
}
|
||||
31
site/plugins/loop/frontend/src/composables/formatDate.ts
Normal file
31
site/plugins/loop/frontend/src/composables/formatDate.ts
Normal 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" });
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function formatDateISO(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toISOString();
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
32
site/plugins/loop/frontend/src/composables/setNewMarker.ts
Normal file
32
site/plugins/loop/frontend/src/composables/setNewMarker.ts
Normal 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;
|
||||
|
|
@ -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));
|
||||
}
|
||||
130
site/plugins/loop/frontend/src/composables/useResizeHandler.ts
Normal file
130
site/plugins/loop/frontend/src/composables/useResizeHandler.ts
Normal 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;
|
||||
22
site/plugins/loop/frontend/src/lib/Author.svelte
Normal file
22
site/plugins/loop/frontend/src/lib/Author.svelte
Normal 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>
|
||||
256
site/plugins/loop/frontend/src/lib/Button.svelte
Normal file
256
site/plugins/loop/frontend/src/lib/Button.svelte
Normal 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>
|
||||
227
site/plugins/loop/frontend/src/lib/Comment.svelte
Normal file
227
site/plugins/loop/frontend/src/lib/Comment.svelte
Normal 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>
|
||||
56
site/plugins/loop/frontend/src/lib/CommentDialog.svelte
Normal file
56
site/plugins/loop/frontend/src/lib/CommentDialog.svelte
Normal 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>
|
||||
99
site/plugins/loop/frontend/src/lib/CommentForm.svelte
Normal file
99
site/plugins/loop/frontend/src/lib/CommentForm.svelte
Normal 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>
|
||||
187
site/plugins/loop/frontend/src/lib/ContextMenu.svelte
Normal file
187
site/plugins/loop/frontend/src/lib/ContextMenu.svelte
Normal 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>
|
||||
89
site/plugins/loop/frontend/src/lib/Header.svelte
Normal file
89
site/plugins/loop/frontend/src/lib/Header.svelte
Normal 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>
|
||||
179
site/plugins/loop/frontend/src/lib/Marker.svelte
Normal file
179
site/plugins/loop/frontend/src/lib/Marker.svelte
Normal 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>
|
||||
181
site/plugins/loop/frontend/src/lib/Panel.svelte
Normal file
181
site/plugins/loop/frontend/src/lib/Panel.svelte
Normal 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>
|
||||
67
site/plugins/loop/frontend/src/lib/Reply.svelte
Normal file
67
site/plugins/loop/frontend/src/lib/Reply.svelte
Normal 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>
|
||||
168
site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
Normal file
168
site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
Normal 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>
|
||||
5
site/plugins/loop/frontend/src/main.ts
Normal file
5
site/plugins/loop/frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from './App.svelte'
|
||||
import "./styles/variables.css"
|
||||
import "./styles/app.css"
|
||||
|
||||
export default App;
|
||||
111
site/plugins/loop/frontend/src/store/api.svelte.ts
Normal file
111
site/plugins/loop/frontend/src/store/api.svelte.ts
Normal 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;
|
||||
11
site/plugins/loop/frontend/src/store/form.svelte.ts
Normal file
11
site/plugins/loop/frontend/src/store/form.svelte.ts
Normal 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
|
||||
}
|
||||
19
site/plugins/loop/frontend/src/store/translations.svelte.ts
Normal file
19
site/plugins/loop/frontend/src/store/translations.svelte.ts
Normal 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;
|
||||
};
|
||||
34
site/plugins/loop/frontend/src/store/ui.svelte.ts
Normal file
34
site/plugins/loop/frontend/src/store/ui.svelte.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
||||
13
site/plugins/loop/frontend/src/styles/app.css
Normal file
13
site/plugins/loop/frontend/src/styles/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
35
site/plugins/loop/frontend/src/styles/theme-dark.css
Normal file
35
site/plugins/loop/frontend/src/styles/theme-dark.css
Normal 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)
|
||||
}
|
||||
23
site/plugins/loop/frontend/src/styles/theme-default.css
Normal file
23
site/plugins/loop/frontend/src/styles/theme-default.css
Normal 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;
|
||||
}
|
||||
421
site/plugins/loop/frontend/src/styles/variables.css
Normal file
421
site/plugins/loop/frontend/src/styles/variables.css
Normal 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;
|
||||
}
|
||||
100
site/plugins/loop/frontend/src/types.ts
Normal file
100
site/plugins/loop/frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
2
site/plugins/loop/frontend/src/vite-env.d.ts
vendored
Normal file
2
site/plugins/loop/frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
10
site/plugins/loop/frontend/svelte.config.js
Normal file
10
site/plugins/loop/frontend/svelte.config.js
Normal 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,
|
||||
},
|
||||
};
|
||||
20
site/plugins/loop/frontend/tsconfig.app.json
Normal file
20
site/plugins/loop/frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
site/plugins/loop/frontend/tsconfig.json
Normal file
7
site/plugins/loop/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
site/plugins/loop/frontend/tsconfig.node.json
Normal file
24
site/plugins/loop/frontend/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
69
site/plugins/loop/frontend/vite.config.ts
Normal file
69
site/plugins/loop/frontend/vite.config.ts
Normal 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+)?$/,
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue