Calendar events support multiple reminder offsets with presets and per-user defaults. Lists index adds inline task entry and list property editing. Generic entity comments on list detail. New bangs.stats dashboard widget. Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { MessageSquare, Trash2 } from "lucide-react";
|
|
import { useEffect, useState, useTransition } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
|
|
|
|
type Props = {
|
|
entityType: string;
|
|
entityId: string;
|
|
currentUserId: string;
|
|
compact?: boolean;
|
|
};
|
|
|
|
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
|
|
const [open, setOpen] = useState(!compact);
|
|
const [comments, setComments] = useState<CommentDto[]>([]);
|
|
const [draft, setDraft] = useState("");
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
listComments(entityType, entityId)
|
|
.then(setComments)
|
|
.catch(() => setComments([]));
|
|
}, [entityType, entityId, open]);
|
|
|
|
function submitComment() {
|
|
const body = draft.trim();
|
|
if (!body) return;
|
|
startTransition(async () => {
|
|
const created = await addComment({ entityType, entityId, body });
|
|
setComments((current) => [...current, created]);
|
|
setDraft("");
|
|
});
|
|
}
|
|
|
|
function removeComment(id: string) {
|
|
startTransition(async () => {
|
|
await deleteComment({ id });
|
|
setComments((current) => current.filter((comment) => comment.id !== id));
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
|
|
{compact ? (
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
|
|
onClick={() => setOpen((value) => !value)}
|
|
>
|
|
<MessageSquare className="size-3.5" />
|
|
{open ? "Hide comments" : "Comments"}
|
|
{comments.length > 0 ? ` (${comments.length})` : ""}
|
|
</button>
|
|
) : (
|
|
<div className="eyebrow mb-2">Comments</div>
|
|
)}
|
|
|
|
{open && (
|
|
<div className="mt-2 space-y-2">
|
|
{comments.length === 0 ? (
|
|
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{comments.map((comment) => (
|
|
<li
|
|
key={comment.id}
|
|
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="text-[11px] text-[var(--ink-mute)]">
|
|
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
|
|
</div>
|
|
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
|
|
</div>
|
|
{comment.authorId === currentUserId ? (
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
aria-label="Delete comment"
|
|
disabled={isPending}
|
|
onClick={() => removeComment(comment.id)}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Add a comment…"
|
|
disabled={isPending}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
submitComment();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!draft.trim() || isPending}
|
|
onClick={submitComment}
|
|
>
|
|
Post
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatCommentTime(iso: string): string {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
}
|