All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
5.9 KiB
Svelte
227 lines
5.9 KiB
Svelte
<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>
|