echoctl 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="visible"
5
+ class="echo-sel-popup"
6
+ :style="{ top: popupTop + 'px', left: popupLeft + 'px' }"
7
+ >
8
+ <button class="echo-btn" @click="startComment">评论选中文字</button>
9
+ </div>
10
+ </Teleport>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { ref, onMounted, onUnmounted, computed } from 'vue'
15
+ import { useData } from 'vitepress'
16
+ import { useEchoStatus } from '../lib/useEchoStatus'
17
+ import { postComment } from '../lib/echo-api'
18
+
19
+ const { frontmatter } = useData()
20
+ const articleId = computed(() => (frontmatter.value as any)?.echo?.articleId as string | undefined)
21
+ const projectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
22
+ const { status } = useEchoStatus(articleId)
23
+
24
+ const visible = ref(false)
25
+ const popupTop = ref(0)
26
+ const popupLeft = ref(0)
27
+ let currentQuote = ''
28
+ let currentPrefix = ''
29
+ let currentSuffix = ''
30
+ let currentOccurrence = 1
31
+
32
+ function getArticleText(): string {
33
+ const doc = document.querySelector('.vp-doc')
34
+ if (!doc) return ''
35
+ return (doc as HTMLElement).innerText || ''
36
+ }
37
+
38
+ function computeAnchor(quote: string): { prefix: string; suffix: string; occurrence: number } {
39
+ const body = getArticleText()
40
+ const idx = body.indexOf(quote)
41
+ const prefix = body.slice(Math.max(0, idx - 100), idx).trim()
42
+ const suffix = body.slice(idx + quote.length, idx + quote.length + 100).trim()
43
+
44
+ let occurrence = 0
45
+ let pos = -1
46
+ while ((pos = body.indexOf(quote, pos + 1)) !== -1) {
47
+ occurrence++
48
+ if (pos === idx) break
49
+ }
50
+
51
+ return { prefix, suffix, occurrence }
52
+ }
53
+
54
+ function handleMouseUp(e: MouseEvent) {
55
+ const target = e.target as HTMLElement
56
+ if (!target) return
57
+
58
+ // Only handle selections inside .vp-doc content area
59
+ const doc = target.closest('.vp-doc')
60
+ if (!doc) return
61
+
62
+ // Exclude echo components, textareas, buttons, nav
63
+ if (target.closest('.echo-sel-popup, .echo-comment-box, .echo-toolbar, .echo-modal, textarea, button, nav')) return
64
+
65
+ const sel = window.getSelection()
66
+ if (!sel || sel.rangeCount === 0) return
67
+ const text = sel.toString().trim()
68
+ if (text.length < 3 || text.length > 500) {
69
+ visible.value = false
70
+ return
71
+ }
72
+
73
+ const range = sel.getRangeAt(0)
74
+ const rect = range.getBoundingClientRect()
75
+ popupTop.value = window.scrollY + rect.bottom + 8
76
+ popupLeft.value = window.scrollX + rect.left
77
+ currentQuote = text
78
+
79
+ const anchor = computeAnchor(text)
80
+ currentPrefix = anchor.prefix
81
+ currentSuffix = anchor.suffix
82
+ currentOccurrence = anchor.occurrence
83
+
84
+ visible.value = true
85
+ }
86
+
87
+ function hide() {
88
+ visible.value = false
89
+ currentQuote = ''
90
+ }
91
+
92
+ function startComment() {
93
+ const comment = prompt(`评论 "${currentQuote.slice(0, 60)}...":`)
94
+ if (!comment) { hide(); return }
95
+ postComment({
96
+ articleId: articleId.value || '',
97
+ comment: comment.trim(),
98
+ quote: currentQuote,
99
+ prefix: currentPrefix,
100
+ suffix: currentSuffix,
101
+ occurrence: currentOccurrence,
102
+ author: status.value?.author,
103
+ projectId: projectId.value ?? null,
104
+ }).then(() => {
105
+ setTimeout(() => location.reload(), 800)
106
+ }).catch((err) => {
107
+ alert('评论失败: ' + (err.message || '未知错误'))
108
+ })
109
+ hide()
110
+ }
111
+
112
+ // Hide on click outside
113
+ function handleClick(e: MouseEvent) {
114
+ const target = e.target as HTMLElement
115
+ if (target && !target.closest('.echo-sel-popup')) {
116
+ hide()
117
+ }
118
+ }
119
+
120
+ onMounted(() => {
121
+ document.addEventListener('mouseup', handleMouseUp)
122
+ document.addEventListener('click', handleClick)
123
+ })
124
+
125
+ onUnmounted(() => {
126
+ document.removeEventListener('mouseup', handleMouseUp)
127
+ document.removeEventListener('click', handleClick)
128
+ })
129
+ </script>
@@ -0,0 +1,301 @@
1
+ <template>
2
+ <div class="echo-tag-cloud" aria-label="标签筛选">
3
+ <!-- Existing tag chips with action buttons -->
4
+ <div
5
+ v-for="group in groups"
6
+ :key="group.anchor"
7
+ class="echo-tag-chip-row"
8
+ >
9
+ <!-- Inline rename for editing tag -->
10
+ <template v-if="editingTag === group.tag">
11
+ <input
12
+ :ref="(el) => { if (el) renameInput = el as HTMLInputElement }"
13
+ v-model="renameValue"
14
+ class="echo-tag-rename-input"
15
+ maxlength="60"
16
+ @keydown.enter="confirmRename(group.tag)"
17
+ @keydown.escape="cancelRename"
18
+ @blur="confirmRename(group.tag)"
19
+ />
20
+ <button class="echo-tag-action-btn echo-tag-action-confirm" title="确认" @mousedown.prevent="confirmRename(group.tag)">✓</button>
21
+ <button class="echo-tag-action-btn echo-tag-action-cancel" title="取消" @mousedown.prevent="cancelRename">✕</button>
22
+ </template>
23
+
24
+ <!-- Normal display -->
25
+ <template v-else>
26
+ <a
27
+ :href="`#${encodeURIComponent(group.anchor)}`"
28
+ class="echo-tag-chip"
29
+ :class="{ 'echo-tag-chip-active': group.anchor === selectedAnchor, 'echo-tag-chip-muted': selectedAnchor && group.anchor !== selectedAnchor }"
30
+ @click="select(group.anchor)"
31
+ >
32
+ {{ group.tag }}<span>{{ group.articles.length }}</span>
33
+ </a>
34
+ <button
35
+ class="echo-tag-action-btn"
36
+ title="重命名"
37
+ :disabled="busyTag === group.tag"
38
+ @click.stop="startRename(group.tag)"
39
+ >✏️</button>
40
+ <button
41
+ class="echo-tag-action-btn echo-tag-action-danger"
42
+ title="删除"
43
+ :disabled="busyTag === group.tag"
44
+ @click.stop="promptDelete(group.tag, group.articles.length)"
45
+ >🗑️</button>
46
+ </template>
47
+ </div>
48
+
49
+ <!-- New tag button -->
50
+ <button
51
+ class="echo-tag-new-btn"
52
+ :disabled="busy"
53
+ @click="showNewTagModal = true"
54
+ >+ 新建标签</button>
55
+ </div>
56
+
57
+ <!-- Delete confirmation -->
58
+ <div v-if="deleteTarget" class="echo-modal" @click.self="deleteTarget = null">
59
+ <div class="echo-modal-content">
60
+ <h3>删除标签「{{ deleteTarget.tag }}」</h3>
61
+ <p>此标签将从 {{ deleteTarget.count }} 篇文章中移除。此操作不可撤销。</p>
62
+ <div class="echo-modal-btns">
63
+ <button class="echo-btn" :disabled="busy" @click="doDelete">确认删除</button>
64
+ <button class="echo-btn echo-btn-off" :disabled="busy" @click="deleteTarget = null">取消</button>
65
+ </div>
66
+ <p v-if="error" class="echo-serve-notice">{{ error }}</p>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- New tag modal -->
71
+ <div v-if="showNewTagModal" class="echo-modal" @click.self="showNewTagModal = false">
72
+ <div class="echo-modal-content">
73
+ <h3>新建标签</h3>
74
+ <input
75
+ v-model="newTagName"
76
+ class="echo-tag-rename-input"
77
+ placeholder="输入标签名..."
78
+ maxlength="60"
79
+ style="width:100%;box-sizing:border-box;margin:12px 0;"
80
+ @keydown.enter="doCreateTag"
81
+ />
82
+ <p class="echo-muted-text" style="font-size:13px;margin:0 0 12px;">
83
+ 选择要添加此标签的文章(至少选择一篇):
84
+ </p>
85
+ <div class="echo-new-tag-articles">
86
+ <label
87
+ v-for="article in allArticles"
88
+ :key="article.id"
89
+ class="echo-new-tag-article-label"
90
+ >
91
+ <input
92
+ type="checkbox"
93
+ :value="article.id"
94
+ v-model="selectedArticleIds"
95
+ />
96
+ <span class="echo-new-tag-article-title">{{ article.title }}</span>
97
+ </label>
98
+ </div>
99
+ <div class="echo-modal-btns">
100
+ <button
101
+ class="echo-btn"
102
+ :disabled="busy || !newTagName.trim() || selectedArticleIds.length === 0"
103
+ @click="doCreateTag"
104
+ >创建</button>
105
+ <button class="echo-btn echo-btn-off" :disabled="busy" @click="showNewTagModal = false">取消</button>
106
+ </div>
107
+ <p v-if="error" class="echo-serve-notice">{{ error }}</p>
108
+ </div>
109
+ </div>
110
+
111
+ <!-- Toast -->
112
+ <div v-if="toast" class="echo-toast">{{ toast }}</div>
113
+
114
+ <!-- Results -->
115
+ <div v-if="selectedGroup" class="echo-tag-result">
116
+ <h2 :id="selectedGroup.anchor">{{ selectedGroup.tag }} ({{ selectedGroup.articles.length }})</h2>
117
+ <div class="echo-article-grid">
118
+ <a
119
+ v-for="article in selectedGroup.articles"
120
+ :key="article.href"
121
+ class="echo-article-card"
122
+ :href="article.href"
123
+ >
124
+ <strong>{{ article.title }}</strong>
125
+ <p>{{ article.summary || '无摘要' }}</p>
126
+ </a>
127
+ </div>
128
+ </div>
129
+
130
+ <div v-else class="echo-tag-result">
131
+ <h2>选择一个标签</h2>
132
+ <p class="echo-muted-text">点击上方标签后,只显示对应文章。</p>
133
+ </div>
134
+ </template>
135
+
136
+ <script setup lang="ts">
137
+ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
138
+ import { renameTag, purgeTag, postTag } from '../lib/echo-api'
139
+
140
+ type TagArticle = {
141
+ id: string
142
+ href: string
143
+ summary?: string
144
+ title: string
145
+ }
146
+
147
+ type TagGroup = {
148
+ anchor: string
149
+ articles: TagArticle[]
150
+ tag: string
151
+ }
152
+
153
+ const props = defineProps<{ payload: string }>()
154
+
155
+ const groups = computed<TagGroup[]>(() => {
156
+ try {
157
+ return JSON.parse(decodeURIComponent(props.payload)) as TagGroup[]
158
+ } catch {
159
+ return []
160
+ }
161
+ })
162
+
163
+ const selectedAnchor = ref('')
164
+
165
+ const selectedGroup = computed(() => {
166
+ const anchor = selectedAnchor.value || groups.value[0]?.anchor || ''
167
+ return groups.value.find((group) => group.anchor === anchor) || null
168
+ })
169
+
170
+ function syncFromHash() {
171
+ selectedAnchor.value = decodeURIComponent(window.location.hash.slice(1) || '') || groups.value[0]?.anchor || ''
172
+ }
173
+
174
+ function select(anchor: string) {
175
+ selectedAnchor.value = anchor
176
+ }
177
+
178
+ // --- Rename ---
179
+ const editingTag = ref('')
180
+ const renameValue = ref('')
181
+ const renameInput = ref<HTMLInputElement | null>(null)
182
+ const busy = ref(false)
183
+ const busyTag = ref('')
184
+ const error = ref('')
185
+ const toast = ref('')
186
+
187
+ function startRename(tag: string) {
188
+ editingTag.value = tag
189
+ renameValue.value = tag
190
+ error.value = ''
191
+ nextTick(() => renameInput.value?.focus())
192
+ }
193
+
194
+ function cancelRename() {
195
+ editingTag.value = ''
196
+ renameValue.value = ''
197
+ error.value = ''
198
+ }
199
+
200
+ async function confirmRename(oldTag: string) {
201
+ const newTag = renameValue.value.trim()
202
+ if (!newTag || newTag === oldTag) {
203
+ cancelRename()
204
+ return
205
+ }
206
+ busy.value = true
207
+ busyTag.value = oldTag
208
+ error.value = ''
209
+ try {
210
+ const result = await renameTag({ oldTag, newTag })
211
+ editingTag.value = ''
212
+ renameValue.value = ''
213
+ toast.value = `已重命名:${oldTag} → ${newTag}(${result.renamed} 篇文章)`
214
+ setTimeout(() => { toast.value = '' }, 3000)
215
+ // Reload the page to get updated tags payload
216
+ setTimeout(() => { window.location.reload() }, 800)
217
+ } catch (err: any) {
218
+ error.value = err?.message || '重命名失败'
219
+ } finally {
220
+ busy.value = false
221
+ busyTag.value = ''
222
+ }
223
+ }
224
+
225
+ // --- Delete ---
226
+ const deleteTarget = ref<{ tag: string; count: number } | null>(null)
227
+
228
+ function promptDelete(tag: string, count: number) {
229
+ deleteTarget.value = { tag, count }
230
+ error.value = ''
231
+ }
232
+
233
+ async function doDelete() {
234
+ if (!deleteTarget.value) return
235
+ const tag = deleteTarget.value.tag
236
+ busy.value = true
237
+ busyTag.value = tag
238
+ error.value = ''
239
+ try {
240
+ const result = await purgeTag({ tag })
241
+ deleteTarget.value = null
242
+ toast.value = `已删除标签「${tag}」(从 ${result.purged} 篇文章中移除)`
243
+ setTimeout(() => { toast.value = '' }, 3000)
244
+ setTimeout(() => { window.location.reload() }, 800)
245
+ } catch (err: any) {
246
+ error.value = err?.message || '删除失败'
247
+ } finally {
248
+ busy.value = false
249
+ busyTag.value = ''
250
+ }
251
+ }
252
+
253
+ // --- Create ---
254
+ const showNewTagModal = ref(false)
255
+ const newTagName = ref('')
256
+ const selectedArticleIds = ref<string[]>([])
257
+
258
+ const allArticles = computed(() => {
259
+ const seen = new Map<string, TagArticle>()
260
+ for (const group of groups.value) {
261
+ for (const article of group.articles) {
262
+ if (!seen.has(article.id)) {
263
+ seen.set(article.id, article)
264
+ }
265
+ }
266
+ }
267
+ return [...seen.values()]
268
+ })
269
+
270
+ async function doCreateTag() {
271
+ const tag = newTagName.value.trim()
272
+ if (!tag || selectedArticleIds.value.length === 0) return
273
+ busy.value = true
274
+ error.value = ''
275
+ try {
276
+ // Add tag to each selected article
277
+ for (const articleId of selectedArticleIds.value) {
278
+ await postTag({ articleId, tag })
279
+ }
280
+ showNewTagModal.value = false
281
+ newTagName.value = ''
282
+ selectedArticleIds.value = []
283
+ toast.value = `已创建标签「${tag}」(${selectedArticleIds.value.length} 篇文章)`
284
+ setTimeout(() => { toast.value = '' }, 3000)
285
+ setTimeout(() => { window.location.reload() }, 800)
286
+ } catch (err: any) {
287
+ error.value = err?.message || '创建失败'
288
+ } finally {
289
+ busy.value = false
290
+ }
291
+ }
292
+
293
+ onMounted(() => {
294
+ syncFromHash()
295
+ window.addEventListener('hashchange', syncFromHash)
296
+ })
297
+
298
+ onUnmounted(() => {
299
+ window.removeEventListener('hashchange', syncFromHash)
300
+ })
301
+ </script>