mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 01:16:17 +02:00
* feat(permissions): add core permission module and shared UI primitives
Foundation for permission-aware UI: pure rules that mirror the Go backend
permission gates, lightweight per-resource hooks, and two reusable display
components used across agent/skill/runtime detail pages.
- packages/core/permissions: types, rules, hooks (Decision-shaped — carries
reason + message so UI can render disabled state, tooltip, and banner
copy from one source)
- packages/core/agents/visibility-label: VISIBILITY_LABEL/DESCRIPTION/TOOLTIP
constants ("Personal" / "Workspace") to replace scattered hard-coded copy
- packages/views/agents/visibility-badge: read-only visibility chip used on
hover cards, list rows, and inspector when not editable
- packages/ui/components/common/capability-banner: "View only — only X and
admins can edit Y" banner shown on agent / skill detail when current user
lacks edit permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(views): permission-aware UI across agent/comment/runtime/skill surfaces
Apply the new permission rules to every surface where the UI was either
lying about who can do what or letting users hit 403s by clicking buttons
the backend would reject.
Agent detail
- Hide archive/restore actions for non-owner non-admin
- Replace inline editors (avatar, name, description, runtime/model/visibility/
concurrency picker, skill-attach) with read-only display when canEdit is
false — value is information, the editor is the action
- Show CapabilityBanner under the header explaining who can edit
Visibility surfaces
- visibility-picker / create-agent-dialog: replace "only you can assign"
(false) with "Only you and workspace admins can assign" via shared
VISIBILITY_DESCRIPTION constants
- agent-columns: truthful tooltip + "You" badge on agents the current user
owns
Comments
- Restore admin override on comment edit/delete (backend already permits
it via comment.go:507-512; the frontend was incorrectly hiding the menu).
canModerate is computed once in issue-detail and threaded down.
Other
- Members tab: disable "demote" options for the last owner with tooltip
- Assignee picker: tooltip on disabled personal agents the user can't assign
- Runtime delete: tooltip and dialog explain the gate; owner column gains
a name label next to the avatar in All scope
- Skill detail: page-level CapabilityBanner alongside the existing lock chip
- Issue delete (single + batch): note that any workspace member can delete
issues — by-design semantics, made transparent
Backend is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): hide personal agents from list and @mention for non-owners
Until now an agent's "Personal" visibility only narrowed the assign-to-issue
gate — every workspace member still saw every personal agent in the list
and the @mention dropdown. Members would see, click, and fail.
This filters those surfaces with the canonical canAssignAgentToIssue rule:
regular members only see workspace-visibility agents and the personal
agents they own; workspace owners and admins continue to see everything
(admin override path is intact).
- agents-page: visibleInView layer between active/archived and Mine/All
scope so segment counts also reflect the filter
- mention-suggestion: filter agentItems before they enter the recency-
ranked list; expand the test mock to cover the auth + visibility paths
and add two assertions (member hides others' personal agents; admin
still sees them)
Backend keeps returning every agent — admin tools and direct API access
are unaffected. This is a UI-only filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
610 lines
23 KiB
TypeScript
610 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Card } from "@multica/ui/components/ui/card";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@multica/ui/components/ui/alert-dialog";
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
|
|
import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useActorName } from "@multica/core/workspace/hooks";
|
|
import { timeAgo } from "@multica/core/utils";
|
|
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay } from "../../editor";
|
|
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
|
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
|
import { api } from "@multica/core/api";
|
|
import { ReplyInput } from "./reply-input";
|
|
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
|
import { useCommentCollapseStore } from "@multica/core/issues/stores";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CommentCardProps {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
allReplies: Map<string, TimelineEntry[]>;
|
|
currentUserId?: string;
|
|
/**
|
|
* True when the current user is a workspace owner/admin and can therefore
|
|
* moderate comments authored by anyone — restoring the admin override that
|
|
* the backend already grants at `comment.go:507-512`. Computed once in
|
|
* `issue-detail.tsx` and threaded down so neither this component nor
|
|
* `CommentRow` has to rerun the rule per row.
|
|
*/
|
|
canModerate?: boolean;
|
|
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
|
onEdit: (commentId: string, content: string) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
/** ID of the comment to highlight (flash animation). */
|
|
highlightedCommentId?: string | null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared delete confirmation dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DeleteCommentDialog({
|
|
open,
|
|
onOpenChange,
|
|
onConfirm,
|
|
hasReplies,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onConfirm: () => void;
|
|
hasReplies?: boolean;
|
|
}) {
|
|
return (
|
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete comment</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{hasReplies
|
|
? "This comment and all its replies will be permanently deleted. This cannot be undone."
|
|
: "This comment will be permanently deleted. This cannot be undone."}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Standalone attachment list — renders attachments not already in the markdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
|
if (!attachments?.length) return null;
|
|
// Skip attachments whose URL is already referenced in the markdown content,
|
|
// and duplicates of the same file (same name/type/size) that are referenced.
|
|
const standalone = content
|
|
? attachments.filter((a) => {
|
|
if (content.includes(a.url)) return false;
|
|
// Dedup: if another attachment with the same file identity is already
|
|
// inline in the content, this is a duplicate upload — skip it.
|
|
const hasSiblingInContent = attachments.some(
|
|
(other) =>
|
|
other.id !== a.id &&
|
|
other.filename === a.filename &&
|
|
other.content_type === a.content_type &&
|
|
other.size_bytes === a.size_bytes &&
|
|
content.includes(other.url),
|
|
);
|
|
if (hasSiblingInContent) return false;
|
|
return true;
|
|
})
|
|
: attachments;
|
|
if (!standalone.length) return null;
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-1", className)}>
|
|
{standalone.map((a) => (
|
|
<div
|
|
key={a.id}
|
|
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
|
>
|
|
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm">{a.filename}</p>
|
|
</div>
|
|
{a.download_url && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
|
onClick={() => window.open(a.download_url, "_blank", "noopener,noreferrer")}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single comment row (used for both parent and replies within the same Card)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentRow({
|
|
issueId,
|
|
entry,
|
|
currentUserId,
|
|
canModerate = false,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
}: {
|
|
issueId: string;
|
|
entry: TimelineEntry;
|
|
currentUserId?: string;
|
|
canModerate?: boolean;
|
|
onEdit: (commentId: string, content: string) => Promise<void>;
|
|
onDelete: (commentId: string) => void;
|
|
onToggleReaction: (commentId: string, emoji: string) => void;
|
|
}) {
|
|
const { getActorName } = useActorName();
|
|
const [editing, setEditing] = useState(false);
|
|
const editEditorRef = useRef<ContentEditorRef>(null);
|
|
const cancelledRef = useRef(false);
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const { isDragOver, dropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
|
enabled: editing,
|
|
});
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
|
const canDeleteEntry = isOwn || canModerate;
|
|
const isTemp = entry.id.startsWith("temp-");
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const startEdit = () => {
|
|
cancelledRef.current = false;
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
cancelledRef.current = true;
|
|
setEditing(false);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (cancelledRef.current) return;
|
|
const trimmed = editEditorRef.current
|
|
?.getMarkdown()
|
|
?.replace(/(\n\s*)+$/, "")
|
|
.trim();
|
|
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
try {
|
|
await onEdit(entry.id, trimmed);
|
|
setEditing(false);
|
|
} catch {
|
|
toast.error("Failed to update comment");
|
|
}
|
|
};
|
|
|
|
const reactions = entry.reactions ?? [];
|
|
const contentText = entry.content ?? "";
|
|
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
|
|
|
return (
|
|
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
|
|
<div className="flex items-center gap-2.5">
|
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
|
<span className="cursor-pointer text-sm font-medium">
|
|
{getActorName(entry.actor_type, entry.actor_id)}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span className="text-xs text-muted-foreground cursor-default">
|
|
{timeAgo(entry.created_at)}
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">
|
|
{new Date(entry.created_at).toLocaleString()}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{!isTemp && (
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<QuickEmojiPicker
|
|
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
align="end"
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => {
|
|
copyMarkdown(entry.content ?? "");
|
|
toast.success("Copied");
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Copy
|
|
</DropdownMenuItem>
|
|
{(canEditEntry || canDeleteEntry) && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
{canEditEntry && (
|
|
<DropdownMenuItem onClick={startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
)}
|
|
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
|
{canDeleteEntry && (
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{editing ? (
|
|
<div
|
|
{...dropZoneProps}
|
|
className="relative mt-1.5 pl-8"
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={editEditorRef}
|
|
defaultValue={entry.content ?? ""}
|
|
placeholder="Edit comment..."
|
|
onSubmit={saveEdit}
|
|
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<FileUploadButton
|
|
size="sm"
|
|
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
|
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
|
</div>
|
|
</div>
|
|
{isDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
|
|
{!isTemp && (
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
hideAddButton={!isLongContent}
|
|
className="mt-1.5 pl-8"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CommentCard — One Card per thread (parent + all replies flat inside)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CommentCard({
|
|
issueId,
|
|
entry,
|
|
allReplies,
|
|
currentUserId,
|
|
canModerate = false,
|
|
onReply,
|
|
onEdit,
|
|
onDelete,
|
|
onToggleReaction,
|
|
highlightedCommentId,
|
|
}: CommentCardProps) {
|
|
const { getActorName } = useActorName();
|
|
const { uploadWithToast } = useFileUpload(api);
|
|
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
|
|
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
|
|
const open = !isCollapsed;
|
|
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
|
|
const [editing, setEditing] = useState(false);
|
|
const editEditorRef = useRef<ContentEditorRef>(null);
|
|
const cancelledRef = useRef(false);
|
|
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
|
|
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
|
|
enabled: editing,
|
|
});
|
|
|
|
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
|
|
// Author-only edit is the same as before; admins additionally get edit
|
|
// *and* delete on member-authored comments, plus delete on agent-authored
|
|
// ones. Edit on agent comments is intentionally never offered — agents
|
|
// own their own outputs.
|
|
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
|
|
const canDeleteEntry = isOwn || canModerate;
|
|
const isTemp = entry.id.startsWith("temp-");
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
const startEdit = () => {
|
|
cancelledRef.current = false;
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
cancelledRef.current = true;
|
|
setEditing(false);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (cancelledRef.current) return;
|
|
const trimmed = editEditorRef.current
|
|
?.getMarkdown()
|
|
?.replace(/(\n\s*)+$/, "")
|
|
.trim();
|
|
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
try {
|
|
await onEdit(entry.id, trimmed);
|
|
setEditing(false);
|
|
} catch {
|
|
toast.error("Failed to update comment");
|
|
}
|
|
};
|
|
|
|
// Collect all nested replies recursively into a flat list
|
|
const allNestedReplies: TimelineEntry[] = [];
|
|
const collectReplies = (parentId: string) => {
|
|
const children = allReplies.get(parentId) ?? [];
|
|
for (const child of children) {
|
|
allNestedReplies.push(child);
|
|
collectReplies(child.id);
|
|
}
|
|
};
|
|
collectReplies(entry.id);
|
|
|
|
const replyCount = allNestedReplies.length;
|
|
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
|
const reactions = entry.reactions ?? [];
|
|
const contentText = entry.content ?? "";
|
|
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
|
|
|
|
const isHighlighted = highlightedCommentId === entry.id;
|
|
|
|
return (
|
|
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
|
|
<Collapsible open={open} onOpenChange={handleOpenChange}>
|
|
{/* Header — always visible, acts as toggle */}
|
|
<div className="px-4 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
|
|
</CollapsibleTrigger>
|
|
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
|
|
<span className="shrink-0 cursor-pointer text-sm font-medium">
|
|
{getActorName(entry.actor_type, entry.actor_id)}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
|
|
{timeAgo(entry.created_at)}
|
|
</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">
|
|
{new Date(entry.created_at).toLocaleString()}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{!open && contentPreview && (
|
|
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{contentPreview}
|
|
</span>
|
|
)}
|
|
{!open && replyCount > 0 && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{replyCount} {replyCount === 1 ? "reply" : "replies"}
|
|
</span>
|
|
)}
|
|
|
|
{open && !isTemp && (
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
<QuickEmojiPicker
|
|
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
align="end"
|
|
/>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => {
|
|
copyMarkdown(entry.content ?? "");
|
|
toast.success("Copied");
|
|
}}>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
Copy
|
|
</DropdownMenuItem>
|
|
{(canEditEntry || canDeleteEntry) && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
{canEditEntry && (
|
|
<DropdownMenuItem onClick={startEdit}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
)}
|
|
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
|
|
{canDeleteEntry && (
|
|
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DeleteCommentDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
onConfirm={() => onDelete(entry.id)}
|
|
hasReplies
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible body */}
|
|
<CollapsibleContent>
|
|
{/* Parent comment body */}
|
|
<div className="px-4 pb-3">
|
|
{editing ? (
|
|
<div
|
|
{...parentDropZoneProps}
|
|
className="relative pl-10"
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
>
|
|
<div className="text-sm leading-relaxed">
|
|
<ContentEditor
|
|
ref={editEditorRef}
|
|
defaultValue={entry.content ?? ""}
|
|
placeholder="Edit comment..."
|
|
onSubmit={saveEdit}
|
|
onUploadFile={(file) => uploadWithToast(file, { issueId })}
|
|
debounceMs={100}
|
|
currentIssueId={issueId}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<FileUploadButton
|
|
size="sm"
|
|
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
|
|
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
|
|
</div>
|
|
</div>
|
|
{parentDragOver && <FileDropOverlay />}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
|
|
<ReadonlyContent content={entry.content ?? ""} />
|
|
</div>
|
|
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
|
|
{!isTemp && (
|
|
<ReactionBar
|
|
reactions={reactions}
|
|
currentUserId={currentUserId}
|
|
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
|
|
getActorName={getActorName}
|
|
hideAddButton={!isLongContent}
|
|
className="mt-1.5 pl-10"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Replies */}
|
|
{allNestedReplies.map((reply) => (
|
|
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
|
|
<CommentRow
|
|
issueId={issueId}
|
|
entry={reply}
|
|
currentUserId={currentUserId}
|
|
canModerate={canModerate}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onToggleReaction={onToggleReaction}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Reply input */}
|
|
<div className="border-t border-border/50 px-4 py-2.5">
|
|
<ReplyInput
|
|
issueId={issueId}
|
|
placeholder="Leave a reply..."
|
|
size="sm"
|
|
avatarType="member"
|
|
avatarId={currentUserId ?? ""}
|
|
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export { CommentCard, type CommentCardProps };
|