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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue