705 lines
32 KiB
Vue
705 lines
32 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
IconEdit,
|
|
IconMoodSmile,
|
|
IconSend,
|
|
IconTrash,
|
|
IconCornerDownRight,
|
|
IconLoader2,
|
|
IconX,
|
|
} from '@tabler/icons-vue';
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import date from '@/date';
|
|
import {
|
|
index as fetchComments,
|
|
store as storeComment,
|
|
update as updateComment,
|
|
destroy as destroyComment,
|
|
react as reactComment,
|
|
} from '@/routes/app/posts/comments';
|
|
|
|
interface User {
|
|
id: string;
|
|
name: string;
|
|
avatar_url?: string | null;
|
|
profile_photo_url?: string | null;
|
|
}
|
|
|
|
interface Reaction {
|
|
user_id: string;
|
|
emoji: string;
|
|
}
|
|
|
|
interface Comment {
|
|
id: string;
|
|
body: string;
|
|
user_id: string;
|
|
parent_id: string | null;
|
|
reactions: Reaction[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
user: User;
|
|
replies?: Comment[];
|
|
}
|
|
|
|
interface PaginatedResponse {
|
|
data: Comment[];
|
|
current_page: number;
|
|
last_page: number;
|
|
next_page_url: string | null;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
postId: string;
|
|
currentUserId: string;
|
|
}>();
|
|
|
|
const EMOJIS = ['👍', '❤️', '😂', '🎉', '🔥', '👏', '😍', '🤔', '👀', '💯'];
|
|
|
|
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
|
|
|
const comments = ref<Comment[]>([]);
|
|
const currentPage = ref(1);
|
|
const lastPage = ref(1);
|
|
const loading = ref(false);
|
|
const sending = ref(false);
|
|
|
|
const newBody = ref('');
|
|
const replyingTo = ref<Comment | null>(null);
|
|
const editingComment = ref<Comment | null>(null);
|
|
const editBody = ref('');
|
|
const hoveredCommentId = ref<string | null>(null);
|
|
const emojiPickerCommentId = ref<string | null>(null);
|
|
|
|
const scrollContainer = ref<HTMLDivElement | null>(null);
|
|
const textareaRef = ref<InstanceType<typeof Textarea> | null>(null);
|
|
|
|
const hasOlderComments = computed(() => currentPage.value < lastPage.value);
|
|
|
|
const getInitials = (name: string): string => {
|
|
return name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
};
|
|
|
|
const getAvatarUrl = (user: User): string | null => {
|
|
return user.avatar_url || user.profile_photo_url || null;
|
|
};
|
|
|
|
const groupedReactions = (reactions: Reaction[]): { emoji: string; count: number; hasReacted: boolean }[] => {
|
|
if (!reactions || reactions.length === 0) return [];
|
|
const map = new Map<string, { count: number; hasReacted: boolean }>();
|
|
for (const r of reactions) {
|
|
const existing = map.get(r.emoji) || { count: 0, hasReacted: false };
|
|
existing.count++;
|
|
if (r.user_id === props.currentUserId) existing.hasReacted = true;
|
|
map.set(r.emoji, existing);
|
|
}
|
|
return Array.from(map.entries()).map(([emoji, data]) => ({ emoji, ...data }));
|
|
};
|
|
|
|
const loadComments = async (page = 1) => {
|
|
loading.value = true;
|
|
try {
|
|
const url = fetchComments.url(props.postId, { query: { page } });
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
if (!response.ok) return;
|
|
const data: PaginatedResponse = await response.json();
|
|
currentPage.value = data.current_page;
|
|
lastPage.value = data.last_page;
|
|
|
|
if (page === 1) {
|
|
// Reverse so newest is at bottom
|
|
comments.value = [...data.data].reverse();
|
|
await nextTick();
|
|
scrollToBottom();
|
|
} else {
|
|
// Prepend older comments at top (also reversed)
|
|
const older = [...data.data].reverse();
|
|
comments.value = [...older, ...comments.value];
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const loadOlderComments = () => {
|
|
if (hasOlderComments.value && !loading.value) {
|
|
loadComments(currentPage.value + 1);
|
|
}
|
|
};
|
|
|
|
const scrollToBottom = () => {
|
|
if (scrollContainer.value) {
|
|
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight;
|
|
}
|
|
};
|
|
|
|
const sendComment = async () => {
|
|
const body = newBody.value.trim();
|
|
if (!body || sending.value) return;
|
|
|
|
sending.value = true;
|
|
try {
|
|
const payload: Record<string, string> = { body };
|
|
if (replyingTo.value) {
|
|
payload.parent_id = replyingTo.value.id;
|
|
}
|
|
|
|
const response = await fetch(storeComment.url(props.postId), {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) return;
|
|
|
|
const created: Comment = await response.json();
|
|
|
|
if (created.parent_id) {
|
|
// Add reply to parent
|
|
const parent = comments.value.find((c) => c.id === created.parent_id);
|
|
if (parent) {
|
|
if (!parent.replies) parent.replies = [];
|
|
parent.replies.push(created);
|
|
}
|
|
} else {
|
|
// Add to bottom (newest)
|
|
created.replies = [];
|
|
comments.value.push(created);
|
|
}
|
|
|
|
newBody.value = '';
|
|
replyingTo.value = null;
|
|
await nextTick();
|
|
scrollToBottom();
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
};
|
|
|
|
const startReply = (comment: Comment) => {
|
|
replyingTo.value = comment;
|
|
editingComment.value = null;
|
|
nextTick(() => {
|
|
const el = textareaRef.value?.$el as HTMLTextAreaElement | undefined;
|
|
el?.focus();
|
|
});
|
|
};
|
|
|
|
const cancelReply = () => {
|
|
replyingTo.value = null;
|
|
};
|
|
|
|
const startEdit = (comment: Comment) => {
|
|
editingComment.value = comment;
|
|
editBody.value = comment.body;
|
|
replyingTo.value = null;
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
editingComment.value = null;
|
|
editBody.value = '';
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingComment.value || !editBody.value.trim()) return;
|
|
|
|
const comment = editingComment.value;
|
|
try {
|
|
const response = await fetch(
|
|
updateComment.url({ post: props.postId, comment: comment.id }),
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ body: editBody.value.trim() }),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) return;
|
|
|
|
const updated: Comment = await response.json();
|
|
|
|
// Update in top-level or in replies
|
|
const topIndex = comments.value.findIndex((c) => c.id === comment.id);
|
|
if (topIndex !== -1) {
|
|
comments.value[topIndex].body = updated.body;
|
|
comments.value[topIndex].updated_at = updated.updated_at;
|
|
} else {
|
|
for (const parent of comments.value) {
|
|
const replyIndex = parent.replies?.findIndex((r) => r.id === comment.id) ?? -1;
|
|
if (replyIndex !== -1 && parent.replies) {
|
|
parent.replies[replyIndex].body = updated.body;
|
|
parent.replies[replyIndex].updated_at = updated.updated_at;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
cancelEdit();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const deleteComment = async (comment: Comment) => {
|
|
try {
|
|
const response = await fetch(
|
|
destroyComment.url({ post: props.postId, comment: comment.id }),
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) return;
|
|
|
|
// Remove from top-level
|
|
const topIndex = comments.value.findIndex((c) => c.id === comment.id);
|
|
if (topIndex !== -1) {
|
|
comments.value.splice(topIndex, 1);
|
|
} else {
|
|
// Remove from replies
|
|
for (const parent of comments.value) {
|
|
const replyIndex = parent.replies?.findIndex((r) => r.id === comment.id) ?? -1;
|
|
if (replyIndex !== -1 && parent.replies) {
|
|
parent.replies.splice(replyIndex, 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const toggleReaction = async (comment: Comment, emoji: string) => {
|
|
emojiPickerCommentId.value = null;
|
|
try {
|
|
const response = await fetch(
|
|
reactComment.url({ post: props.postId, comment: comment.id }),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: JSON.stringify({ emoji }),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) return;
|
|
|
|
const updated: Comment = await response.json();
|
|
|
|
// Update reactions in the right place
|
|
const topIndex = comments.value.findIndex((c) => c.id === comment.id);
|
|
if (topIndex !== -1) {
|
|
comments.value[topIndex].reactions = updated.reactions;
|
|
} else {
|
|
for (const parent of comments.value) {
|
|
const replyIndex = parent.replies?.findIndex((r) => r.id === comment.id) ?? -1;
|
|
if (replyIndex !== -1 && parent.replies) {
|
|
parent.replies[replyIndex].reactions = updated.reactions;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const toggleEmojiPicker = (commentId: string) => {
|
|
emojiPickerCommentId.value = emojiPickerCommentId.value === commentId ? null : commentId;
|
|
};
|
|
|
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
sendComment();
|
|
}
|
|
};
|
|
|
|
const handleEditKeydown = (event: KeyboardEvent) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
saveEdit();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
cancelEdit();
|
|
}
|
|
};
|
|
|
|
const addCommentFromBroadcast = (comment: Comment) => {
|
|
// Avoid duplicates
|
|
if (comment.user_id === props.currentUserId) return;
|
|
|
|
if (comment.parent_id) {
|
|
const parent = comments.value.find((c) => c.id === comment.parent_id);
|
|
if (parent) {
|
|
const exists = parent.replies?.some((r) => r.id === comment.id);
|
|
if (!exists) {
|
|
if (!parent.replies) parent.replies = [];
|
|
parent.replies.push(comment);
|
|
}
|
|
}
|
|
} else {
|
|
const exists = comments.value.some((c) => c.id === comment.id);
|
|
if (!exists) {
|
|
comment.replies = comment.replies || [];
|
|
comments.value.push(comment);
|
|
nextTick(() => scrollToBottom());
|
|
}
|
|
}
|
|
};
|
|
|
|
defineExpose({ addCommentFromBroadcast });
|
|
|
|
onMounted(() => {
|
|
loadComments(1);
|
|
});
|
|
|
|
watch(() => props.postId, () => {
|
|
comments.value = [];
|
|
currentPage.value = 1;
|
|
loadComments(1);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col">
|
|
<!-- Comment list (inverted scroll: newest at bottom) -->
|
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto">
|
|
<!-- Load older button -->
|
|
<div v-if="hasOlderComments" class="flex justify-center py-2">
|
|
<Button variant="ghost" size="sm" :disabled="loading" @click="loadOlderComments">
|
|
<IconLoader2 v-if="loading" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
{{ $t('comments.load_more') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="!loading && comments.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
|
<p class="text-sm text-muted-foreground">{{ $t('comments.empty') }}</p>
|
|
</div>
|
|
|
|
<!-- Comments -->
|
|
<div class="space-y-1 p-2">
|
|
<template v-for="comment in comments" :key="comment.id">
|
|
<!-- Top-level comment -->
|
|
<div
|
|
class="group relative rounded-lg p-2 hover:bg-muted/50"
|
|
@mouseenter="hoveredCommentId = comment.id"
|
|
@mouseleave="hoveredCommentId = null"
|
|
>
|
|
<!-- Editing mode -->
|
|
<div v-if="editingComment?.id === comment.id" class="space-y-2">
|
|
<Textarea
|
|
v-model="editBody"
|
|
class="min-h-[60px] resize-none text-sm"
|
|
@keydown="handleEditKeydown"
|
|
/>
|
|
<div class="flex items-center gap-1.5">
|
|
<Button size="sm" variant="default" @click="saveEdit">{{ $t('comments.save') }}</Button>
|
|
<Button size="sm" variant="ghost" @click="cancelEdit">{{ $t('comments.cancel') }}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display mode -->
|
|
<template v-else>
|
|
<div class="flex items-start gap-2">
|
|
<Avatar class="h-6 w-6 shrink-0">
|
|
<AvatarImage v-if="getAvatarUrl(comment.user)" :src="getAvatarUrl(comment.user)!" />
|
|
<AvatarFallback class="text-[10px]">{{ getInitials(comment.user.name) }}</AvatarFallback>
|
|
</Avatar>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-baseline gap-1.5">
|
|
<span class="text-xs font-medium">{{ comment.user.name }}</span>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(comment.created_at) }}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<span class="text-xs">{{ date.formatDateTime(comment.created_at) }}</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<span v-if="comment.updated_at !== comment.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
|
|
</div>
|
|
<p class="mt-0.5 whitespace-pre-wrap text-sm">{{ comment.body }}</p>
|
|
|
|
<!-- Reactions -->
|
|
<div v-if="groupedReactions(comment.reactions).length > 0" class="mt-1 flex flex-wrap gap-1">
|
|
<button
|
|
v-for="r in groupedReactions(comment.reactions)"
|
|
:key="r.emoji"
|
|
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
|
|
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
|
|
@click="toggleReaction(comment, r.emoji)"
|
|
>
|
|
<span>{{ r.emoji }}</span>
|
|
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hover actions -->
|
|
<div
|
|
v-if="hoveredCommentId === comment.id && editingComment?.id !== comment.id"
|
|
class="absolute -top-2 right-1 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
|
|
>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="toggleEmojiPicker(comment.id)">
|
|
<IconMoodSmile class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">React</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startReply(comment)">
|
|
<IconCornerDownRight class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">{{ $t('comments.reply') }}</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<template v-if="comment.user_id === currentUserId">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(comment)">
|
|
<IconEdit class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">{{ $t('comments.edit') }}</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(comment)">
|
|
<IconTrash class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">{{ $t('comments.delete') }}</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Emoji picker dropdown -->
|
|
<div
|
|
v-if="emojiPickerCommentId === comment.id"
|
|
class="absolute -top-10 right-1 z-10 flex items-center gap-0.5 rounded-lg border bg-background p-1.5 shadow-md"
|
|
>
|
|
<button
|
|
v-for="emoji in EMOJIS"
|
|
:key="emoji"
|
|
class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted"
|
|
@click="toggleReaction(comment, emoji)"
|
|
>
|
|
{{ emoji }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Replies (1 level) -->
|
|
<template v-if="comment.replies && comment.replies.length > 0">
|
|
<div
|
|
v-for="reply in comment.replies"
|
|
:key="reply.id"
|
|
class="group relative ml-6 rounded-lg border-l-2 border-border p-2 pl-3 hover:bg-muted/50"
|
|
@mouseenter="hoveredCommentId = reply.id"
|
|
@mouseleave="hoveredCommentId = null"
|
|
>
|
|
<!-- Editing reply -->
|
|
<div v-if="editingComment?.id === reply.id" class="space-y-2">
|
|
<Textarea
|
|
v-model="editBody"
|
|
class="min-h-[60px] resize-none text-sm"
|
|
@keydown="handleEditKeydown"
|
|
/>
|
|
<div class="flex items-center gap-1.5">
|
|
<Button size="sm" variant="default" @click="saveEdit">{{ $t('comments.save') }}</Button>
|
|
<Button size="sm" variant="ghost" @click="cancelEdit">{{ $t('comments.cancel') }}</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display reply -->
|
|
<template v-else>
|
|
<div class="flex items-start gap-2">
|
|
<Avatar class="h-5 w-5 shrink-0">
|
|
<AvatarImage v-if="getAvatarUrl(reply.user)" :src="getAvatarUrl(reply.user)!" />
|
|
<AvatarFallback class="text-[9px]">{{ getInitials(reply.user.name) }}</AvatarFallback>
|
|
</Avatar>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-baseline gap-1.5">
|
|
<span class="text-xs font-medium">{{ reply.user.name }}</span>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(reply.created_at) }}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
<span class="text-xs">{{ date.formatDateTime(reply.created_at) }}</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<span v-if="reply.updated_at !== reply.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
|
|
</div>
|
|
<p class="mt-0.5 whitespace-pre-wrap text-sm">{{ reply.body }}</p>
|
|
|
|
<!-- Reply reactions -->
|
|
<div v-if="groupedReactions(reply.reactions).length > 0" class="mt-1 flex flex-wrap gap-1">
|
|
<button
|
|
v-for="r in groupedReactions(reply.reactions)"
|
|
:key="r.emoji"
|
|
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
|
|
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
|
|
@click="toggleReaction(reply, r.emoji)"
|
|
>
|
|
<span>{{ r.emoji }}</span>
|
|
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reply hover actions -->
|
|
<div
|
|
v-if="hoveredCommentId === reply.id && editingComment?.id !== reply.id"
|
|
class="absolute -top-2 right-1 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
|
|
>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="toggleEmojiPicker(reply.id)">
|
|
<IconMoodSmile class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">React</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<template v-if="reply.user_id === currentUserId">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(reply)">
|
|
<IconEdit class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">{{ $t('comments.edit') }}</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(reply)">
|
|
<IconTrash class="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top"><span class="text-xs">{{ $t('comments.delete') }}</span></TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Reply emoji picker -->
|
|
<div
|
|
v-if="emojiPickerCommentId === reply.id"
|
|
class="absolute -top-10 right-1 z-10 flex items-center gap-0.5 rounded-lg border bg-background p-1.5 shadow-md"
|
|
>
|
|
<button
|
|
v-for="emoji in EMOJIS"
|
|
:key="emoji"
|
|
class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted"
|
|
@click="toggleReaction(reply, emoji)"
|
|
>
|
|
{{ emoji }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Loading spinner for initial load -->
|
|
<div v-if="loading && comments.length === 0" class="flex items-center justify-center py-8">
|
|
<IconLoader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Input area -->
|
|
<div class="shrink-0 border-t p-2">
|
|
<!-- Replying to indicator -->
|
|
<div v-if="replyingTo" class="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
<IconCornerDownRight class="h-3 w-3" />
|
|
<span>{{ $t('comments.replying_to', { name: replyingTo.user.name }) }}</span>
|
|
<button class="ml-auto rounded p-0.5 hover:bg-muted" @click="cancelReply">
|
|
<IconX class="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-end gap-1.5">
|
|
<Textarea
|
|
ref="textareaRef"
|
|
v-model="newBody"
|
|
:placeholder="replyingTo ? $t('comments.reply_placeholder') : $t('comments.placeholder')"
|
|
class="min-h-[36px] max-h-[120px] flex-1 resize-none text-sm"
|
|
rows="1"
|
|
@keydown="handleKeydown"
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
class="h-9 w-9 shrink-0"
|
|
:disabled="!newBody.trim() || sending"
|
|
@click="sendComment"
|
|
>
|
|
<IconLoader2 v-if="sending" class="h-4 w-4 animate-spin" />
|
|
<IconSend v-else class="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|