main
vue 191 lines 5.2 KB
Raw
1 <template>
2 <div
3 class="group flex flex-col gap-0.5"
4 :class="{
5 'items-end text-right': entity.sender === 'user'
6 }"
7 >
8 <template v-if="entity.sender === 'server'">
9 <div class="text-secondary inline-flex items-center gap-1 text-sm font-semibold">
10 <span>{{ entity.server }}:</span>
11
12 <span
13 v-if="thought && isThoughtVisible"
14 class="text-tertiary inline-flex cursor-pointer items-center gap-1"
15 @click="isThoughtCollapsed = !isThoughtCollapsed"
16 >
17 {{ isThinking ? "thinking..." : "thought" }}
18
19 <Icon
20 name="carbon:chevron-right"
21 :size="14"
22 class="transition-transform"
23 :class="{ 'rotate-90': !isThoughtCollapsed }"
24 />
25 </span>
26
27 <div
28 v-if="isCopySupported"
29 class="pointer-events-none ml-1 flex items-center opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100"
30 >
31 <n-tooltip>
32 <template #trigger>
33 <n-button size="tiny" text @click="copyLink()">
34 <template #icon>
35 <Icon name="carbon:copy" :size="13" />
36 </template>
37 </n-button>
38 </template>
39 <div class="text-xs">{{ showCopyTooltip ? "Copied!" : "Copy" }}</div>
40 </n-tooltip>
41 </div>
42 </div>
43
44 <div v-if="thought && isThoughtVisible">
45 <CollapseKeepAlive :show="!isThoughtCollapsed">
46 <div class="text-secondary bg-secondary mb-2 rounded-md p-2 **:text-[10px]">
47 <Markdown :source="thought" class="animate-fade overflow-hidden" />
48 </div>
49 </CollapseKeepAlive>
50 </div>
51 <div v-if="body && isBodyVisible" class="**:text-default **:text-sm [&_*:last-child]:mb-0!">
52 <Markdown :source="body" class="animate-fade overflow-hidden" />
53 </div>
54 </template>
55
56 <template v-if="entity.sender === 'user'">
57 <div class="flex items-end justify-end gap-3 pl-6">
58 <div
59 class="pointer-events-none mb-1 flex items-center gap-3 opacity-0 transition-opacity duration-300 group-hover:pointer-events-auto group-hover:opacity-100"
60 >
61 <div v-if="isCopySupported" class="flex items-center">
62 <n-tooltip>
63 <template #trigger>
64 <n-button size="tiny" text @click="copyLink()">
65 <template #icon>
66 <Icon name="carbon:copy" :size="13" />
67 </template>
68 </n-button>
69 </template>
70 <div class="text-xs">{{ showCopyTooltip ? "Copied!" : "Copy" }}</div>
71 </n-tooltip>
72 </div>
73 <div class="flex items-center">
74 <n-tooltip>
75 <template #trigger>
76 <n-button size="tiny" text @click="handleEdit()">
77 <template #icon>
78 <Icon name="carbon:edit" :size="13" />
79 </template>
80 </n-button>
81 </template>
82 <div class="text-xs">Edit</div>
83 </n-tooltip>
84 </div>
85 </div>
86 <div class="bg-secondary **:text-default rounded-lg px-2 py-1 text-sm [&_*:last-child]:mb-0!">
87 <Markdown :source="entity.body" class="animate-fade overflow-hidden" />
88 </div>
89 </div>
90 </template>
91
92 <div class="text-tertiary mt-1 text-xs">
93 {{ formatDate(entity.datetime, dFormats.datetime) }}
94 </div>
95 </div>
96 </template>
97
98 <script setup lang="ts">
99 import type { Ref } from "vue"
100 import { useClipboard } from "@vueuse/core"
101 import { NButton, NTooltip } from "naive-ui"
102 import { onMounted, ref } from "vue"
103 import CollapseKeepAlive from "@/components/common/CollapseKeepAlive.vue"
104 import Icon from "@/components/common/Icon.vue"
105 import Markdown from "@/components/common/Markdown.vue"
106 import { useSettingsStore } from "@/stores/settings"
107 import { formatDate } from "@/utils/format"
108
109 export interface ChatBubble {
110 id: string
111 datetime: Date
112 body: string
113 thought?: string
114 server: string
115 new?: boolean
116 sender: "user" | "server"
117 }
118
119 const { entity } = defineProps<{ entity: ChatBubble }>()
120
121 const emit = defineEmits<{
122 (e: "update"): void
123 (e: "edit"): void
124 }>()
125
126 const dFormats = useSettingsStore().dateFormat
127 const { copy: copyLink, copied: showCopyTooltip, isSupported: isCopySupported } = useClipboard({ source: entity.body })
128
129 const isThinking = ref(false)
130 const isThoughtCollapsed = ref(false)
131 const isThoughtVisible = ref(false)
132 const isBodyVisible = ref(false)
133
134 const thought = ref("")
135 const body = ref("")
136
137 function startTypingEffect(text: string, variable: Ref<string>, duration = 2000) {
138 variable.value = ""
139
140 const totalChars = text.length
141 if (totalChars === 0) return
142
143 const delay = duration / totalChars
144 let currentIndex = 0
145
146 const interval = setInterval(() => {
147 variable.value += text[currentIndex]
148 currentIndex++
149
150 if (currentIndex >= totalChars) {
151 clearInterval(interval)
152 emit("update")
153 }
154 }, delay)
155 }
156
157 function setAnimation() {
158 if (entity.new) {
159 if (entity.thought) {
160 isThinking.value = true
161 isThoughtVisible.value = true
162 startTypingEffect(entity.thought, thought, 2000)
163 setTimeout(() => {
164 isThinking.value = false
165 isBodyVisible.value = true
166 startTypingEffect(entity.body, body, 4000)
167 }, 2000)
168 } else {
169 isBodyVisible.value = true
170 startTypingEffect(entity.body, body, 4000)
171 }
172 } else {
173 if (entity.thought) {
174 isThoughtCollapsed.value = true
175 isThoughtVisible.value = true
176 thought.value = entity.thought
177 }
178
179 isBodyVisible.value = true
180 body.value = entity.body
181 }
182 }
183
184 function handleEdit() {
185 emit("edit")
186 }
187
188 onMounted(() => {
189 setAnimation()
190 })
191 </script>