popilot 0.7.0 → 0.8.0
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.
- package/package.json +1 -1
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/spec-site/package-lock.json +852 -0
- package/scaffold/spec-site/package.json +12 -1
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- package/scaffold/spec-site/src/utils/timezone.ts +18 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Icon from '@/components/Icon.vue'
|
|
3
|
+
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
4
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
5
|
+
import { apiGet, apiPost, apiPatch } from '@/composables/useTurso'
|
|
6
|
+
import MentionInput from '@/components/MentionInput.vue'
|
|
7
|
+
import { renderMarkdown } from '@/utils/markdown'
|
|
8
|
+
import MemoRelations from '@/components/MemoRelations.vue'
|
|
9
|
+
import MemoTimeline from '@/components/MemoTimeline.vue'
|
|
10
|
+
import MemoChecklist from '@/components/MemoChecklist.vue'
|
|
11
|
+
import MemoGraph from '@/components/MemoGraph.vue'
|
|
12
|
+
import { useAuth } from '@/composables/useAuth'
|
|
13
|
+
|
|
14
|
+
const { authUser } = useAuth()
|
|
15
|
+
|
|
16
|
+
interface Memo {
|
|
17
|
+
id: number
|
|
18
|
+
page_id: string
|
|
19
|
+
content: string
|
|
20
|
+
status: string
|
|
21
|
+
created_by: string
|
|
22
|
+
assigned_to: string | null
|
|
23
|
+
created_at: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Reply {
|
|
27
|
+
id: number
|
|
28
|
+
memo_id: number
|
|
29
|
+
content: string
|
|
30
|
+
created_by: string
|
|
31
|
+
review_type: string
|
|
32
|
+
created_at: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const route = useRoute()
|
|
36
|
+
const router = useRouter()
|
|
37
|
+
const memos = ref<Memo[]>([])
|
|
38
|
+
const replies = ref<Record<number, Reply[]>>({})
|
|
39
|
+
const loading = ref(true)
|
|
40
|
+
const selectedId = computed(() => route.params.id ? Number(route.params.id) : null)
|
|
41
|
+
|
|
42
|
+
// Search / Filter
|
|
43
|
+
const searchKeyword = ref('')
|
|
44
|
+
const filterStatus = ref('')
|
|
45
|
+
const filterAuthor = ref('')
|
|
46
|
+
const memberList = ref<string[]>([])
|
|
47
|
+
const showNewMemo = ref(false)
|
|
48
|
+
const newMemoContent = ref('')
|
|
49
|
+
const newMemoChannel = ref('general')
|
|
50
|
+
const memoTemplates = ref<any[]>([])
|
|
51
|
+
const showTemplateModal = ref(false)
|
|
52
|
+
|
|
53
|
+
async function loadMemoTemplates() {
|
|
54
|
+
const { data } = await apiGet('/api/v2/memos/templates')
|
|
55
|
+
memoTemplates.value = (data as any)?.templates || []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function applyTemplate(tmpl: any) {
|
|
59
|
+
const fields = JSON.parse(tmpl.fields || '[]')
|
|
60
|
+
let content = `# ${tmpl.name}\n\n`
|
|
61
|
+
for (const f of fields) {
|
|
62
|
+
content += `## ${f.label}\n\n`
|
|
63
|
+
}
|
|
64
|
+
newMemoContent.value = content
|
|
65
|
+
showTemplateModal.value = false
|
|
66
|
+
}
|
|
67
|
+
const newMemoType = ref('memo')
|
|
68
|
+
const newMemoAssignees = ref<string[]>([])
|
|
69
|
+
const pageSize = 20
|
|
70
|
+
const currentOffset = ref(0)
|
|
71
|
+
const totalMemos = ref(0)
|
|
72
|
+
const hasMore = computed(() => currentOffset.value + pageSize < totalMemos.value)
|
|
73
|
+
const loadingMore = ref(false)
|
|
74
|
+
|
|
75
|
+
async function loadMemos(append = false) {
|
|
76
|
+
if (append) { loadingMore.value = true } else { loading.value = true; currentOffset.value = 0 }
|
|
77
|
+
const params = new URLSearchParams({ limit: String(pageSize), offset: String(currentOffset.value) })
|
|
78
|
+
if (searchKeyword.value) params.set('keyword', searchKeyword.value)
|
|
79
|
+
if (filterStatus.value) params.set('status', filterStatus.value)
|
|
80
|
+
if (filterAuthor.value) params.set('author', filterAuthor.value)
|
|
81
|
+
const { data, error } = await apiGet(`/api/v2/memos/all?${params}`)
|
|
82
|
+
if (!error && data?.memos) {
|
|
83
|
+
if (append) {
|
|
84
|
+
memos.value = [...memos.value, ...(data.memos as Memo[])]
|
|
85
|
+
} else {
|
|
86
|
+
memos.value = data.memos as Memo[]
|
|
87
|
+
}
|
|
88
|
+
totalMemos.value = Number(data.total) || 0
|
|
89
|
+
// Load all replies
|
|
90
|
+
const ids = memos.value.map(m => m.id)
|
|
91
|
+
if (ids.length) {
|
|
92
|
+
const { data: rData } = await apiGet(`/api/v2/memos/replies?memoIds=${ids.join(',')}`)
|
|
93
|
+
if (rData?.replies) {
|
|
94
|
+
const grouped: Record<number, Reply[]> = {}
|
|
95
|
+
for (const r of rData.replies as Reply[]) {
|
|
96
|
+
if (!grouped[r.memo_id]) grouped[r.memo_id] = []
|
|
97
|
+
grouped[r.memo_id].push(r)
|
|
98
|
+
}
|
|
99
|
+
replies.value = grouped
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
loading.value = false
|
|
104
|
+
loadingMore.value = false
|
|
105
|
+
|
|
106
|
+
// Deep link: fetch individually if not in list
|
|
107
|
+
if (selectedId.value && !memos.value.find(m => m.id === selectedId.value)) {
|
|
108
|
+
const { data: singleData } = await apiGet(`/api/v2/memos/by-id/${selectedId.value}`)
|
|
109
|
+
const singleMemos = singleData?.memos as Memo[] | undefined
|
|
110
|
+
if (singleMemos?.length) {
|
|
111
|
+
memos.value = [singleMemos[0], ...memos.value]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Load replies for deep-linked memo
|
|
115
|
+
if (selectedId.value && !replies.value[selectedId.value]) {
|
|
116
|
+
const { data: rData } = await apiGet(`/api/v2/memos/replies?memoIds=${selectedId.value}`)
|
|
117
|
+
if (rData?.replies) {
|
|
118
|
+
const grouped: Record<number, Reply[]> = { ...replies.value }
|
|
119
|
+
for (const r of rData.replies as Reply[]) {
|
|
120
|
+
if (!grouped[r.memo_id]) grouped[r.memo_id] = []
|
|
121
|
+
grouped[r.memo_id].push(r)
|
|
122
|
+
}
|
|
123
|
+
replies.value = grouped
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function loadMore() {
|
|
129
|
+
currentOffset.value += pageSize
|
|
130
|
+
loadMemos(true)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const activeTab = ref<'thread' | 'timeline'>('thread')
|
|
134
|
+
const showGraph = ref(false)
|
|
135
|
+
|
|
136
|
+
// Channels
|
|
137
|
+
const channels = ref<any[]>([])
|
|
138
|
+
const activeChannel = ref('')
|
|
139
|
+
|
|
140
|
+
// Read receipts + Presence
|
|
141
|
+
const readers = ref<any[]>([])
|
|
142
|
+
const presentUsers = ref<any[]>([])
|
|
143
|
+
const readMemoIds = ref<Set<number>>(new Set())
|
|
144
|
+
let presenceTimer: ReturnType<typeof setInterval> | null = null
|
|
145
|
+
|
|
146
|
+
async function loadMyReads() {
|
|
147
|
+
const { data } = await apiGet(`/api/v2/memos/my-reads?userName=${encodeURIComponent(currentUser.value)}`)
|
|
148
|
+
const ids = (data as any)?.memoIds || []
|
|
149
|
+
readMemoIds.value = new Set(ids)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function markRead(memoId: number) {
|
|
153
|
+
await apiPost(`/api/v2/memos/${memoId}/read`, {})
|
|
154
|
+
readMemoIds.value.add(memoId)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadReaders(memoId: number) {
|
|
158
|
+
const { data } = await apiGet(`/api/v2/memos/${memoId}/readers`)
|
|
159
|
+
readers.value = (data as any)?.readers || []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function sendPresence(memoId: number) {
|
|
163
|
+
await apiPost(`/api/v2/memos/${memoId}/presence`, {})
|
|
164
|
+
const { data } = await apiGet(`/api/v2/memos/${memoId}/presence`)
|
|
165
|
+
presentUsers.value = (data as any)?.present || []
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Typing indicator
|
|
169
|
+
const typingUsers = ref<string[]>([])
|
|
170
|
+
let typingTimer: ReturnType<typeof setInterval> | null = null
|
|
171
|
+
|
|
172
|
+
async function sendTyping(memoId: number) {
|
|
173
|
+
await apiPost(`/api/v2/memos/${memoId}/typing`, {})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pollTyping(memoId: number) {
|
|
177
|
+
const { data } = await apiGet(`/api/v2/memos/${memoId}/typing`)
|
|
178
|
+
typingUsers.value = ((data as any)?.typing || []).map((t: any) => t.user_name)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function startTypingPoll(memoId: number) {
|
|
182
|
+
if (typingTimer) clearInterval(typingTimer)
|
|
183
|
+
typingTimer = setInterval(() => pollTyping(memoId), 3000)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stopTypingPoll() {
|
|
187
|
+
if (typingTimer) { clearInterval(typingTimer); typingTimer = null }
|
|
188
|
+
typingUsers.value = []
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function startPresence(memoId: number) {
|
|
192
|
+
if (presenceTimer) clearInterval(presenceTimer)
|
|
193
|
+
sendPresence(memoId)
|
|
194
|
+
presenceTimer = setInterval(() => sendPresence(memoId), 5000)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stopPresence() {
|
|
198
|
+
if (presenceTimer) { clearInterval(presenceTimer); presenceTimer = null }
|
|
199
|
+
presentUsers.value = []
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const unreadCounts = ref<Record<string, number>>({})
|
|
203
|
+
const showMobileSearch = ref(false)
|
|
204
|
+
|
|
205
|
+
async function loadChannels() {
|
|
206
|
+
const { data } = await apiGet('/api/v2/memos/channels')
|
|
207
|
+
channels.value = (data as any)?.channels || []
|
|
208
|
+
// Unread counts
|
|
209
|
+
const { data: ucData } = await apiGet('/api/v2/memos/channels/unread-counts')
|
|
210
|
+
unreadCounts.value = (ucData as any)?.counts || {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Extended filters
|
|
214
|
+
const currentUser = ref(localStorage.getItem('spec-user-name') || '')
|
|
215
|
+
const filterAssignedTo = ref('')
|
|
216
|
+
const filterDateFrom = ref('')
|
|
217
|
+
|
|
218
|
+
const filteredMemos = computed(() => {
|
|
219
|
+
let result = memos.value
|
|
220
|
+
if (activeChannel.value) result = result.filter(m => (m as any).channel_id === activeChannel.value)
|
|
221
|
+
if (filterStatus.value) result = result.filter(m => m.status === filterStatus.value)
|
|
222
|
+
if (filterAssignedTo.value === 'me') result = result.filter(m => m.assigned_to === currentUser.value)
|
|
223
|
+
else if (filterAssignedTo.value) result = result.filter(m => m.assigned_to === filterAssignedTo.value)
|
|
224
|
+
if (filterDateFrom.value) result = result.filter(m => m.created_at >= filterDateFrom.value)
|
|
225
|
+
return result
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Presets
|
|
229
|
+
function presetMyMemos() { filterAssignedTo.value = 'me'; filterStatus.value = '' }
|
|
230
|
+
function presetOpen() { filterStatus.value = 'open'; filterAssignedTo.value = '' }
|
|
231
|
+
function presetThisWeek() {
|
|
232
|
+
const now = new Date()
|
|
233
|
+
const day = now.getDay()
|
|
234
|
+
const diff = day === 0 ? 6 : day - 1
|
|
235
|
+
now.setDate(now.getDate() - diff)
|
|
236
|
+
filterDateFrom.value = now.toISOString().split('T')[0]
|
|
237
|
+
filterStatus.value = ''; filterAssignedTo.value = ''
|
|
238
|
+
}
|
|
239
|
+
function clearFilters() { filterStatus.value = ''; filterAssignedTo.value = ''; filterDateFrom.value = '' }
|
|
240
|
+
|
|
241
|
+
// Saved views
|
|
242
|
+
const savedViews = ref<any[]>([])
|
|
243
|
+
async function loadViews() {
|
|
244
|
+
const { data } = await apiGet('/api/v2/memos/views')
|
|
245
|
+
savedViews.value = (data as any)?.views || []
|
|
246
|
+
}
|
|
247
|
+
async function saveView() {
|
|
248
|
+
const name = prompt('View name:')
|
|
249
|
+
if (!name) return
|
|
250
|
+
await apiPost('/api/v2/memos/views', { name, filters: { status: filterStatus.value, assignedTo: filterAssignedTo.value, dateFrom: filterDateFrom.value } })
|
|
251
|
+
await loadViews()
|
|
252
|
+
}
|
|
253
|
+
function applyView(v: any) {
|
|
254
|
+
const f = JSON.parse(v.filters || '{}')
|
|
255
|
+
filterStatus.value = f.status || ''; filterAssignedTo.value = f.assignedTo || ''; filterDateFrom.value = f.dateFrom || ''
|
|
256
|
+
}
|
|
257
|
+
const linkedDocs = ref<any[]>([])
|
|
258
|
+
|
|
259
|
+
async function loadLinkedDocs(memoId: number) {
|
|
260
|
+
const { data } = await apiGet(`/api/v2/memos/${memoId}/linked-docs`)
|
|
261
|
+
linkedDocs.value = (data as any)?.links || []
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function createDocFromMemo() {
|
|
265
|
+
if (!selectedMemo.value) return
|
|
266
|
+
const { data } = await apiPost(`/api/v2/memos/${selectedMemo.value.id}/create-doc`, {})
|
|
267
|
+
if ((data as any)?.docId) {
|
|
268
|
+
alert('Document has been created.')
|
|
269
|
+
await loadLinkedDocs(selectedMemo.value.id)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const selectedMemo = computed(() =>
|
|
274
|
+
selectedId.value ? memos.value.find(m => m.id === selectedId.value) : null
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const selectedReplies = computed(() =>
|
|
278
|
+
selectedId.value ? (replies.value[selectedId.value] || []) : []
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
function selectMemo(id: number) {
|
|
282
|
+
router.push(`/memos/${id}`)
|
|
283
|
+
loadLinkedDocs(id)
|
|
284
|
+
markRead(id)
|
|
285
|
+
loadReaders(id)
|
|
286
|
+
startPresence(id)
|
|
287
|
+
startTypingPoll(id)
|
|
288
|
+
nextTick(() => {
|
|
289
|
+
const main = document.querySelector('.app-main')
|
|
290
|
+
if (main) main.scrollTo({ top: 0, behavior: 'smooth' })
|
|
291
|
+
else window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function goBackClean() {
|
|
296
|
+
stopPresence(); stopTypingPoll()
|
|
297
|
+
router.push('/memos')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function goBack() {
|
|
301
|
+
router.push('/memos')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function formatDate(d: string) {
|
|
305
|
+
// DB stores UTC without Z suffix
|
|
306
|
+
const date = new Date(d.endsWith('Z') ? d : d + 'Z')
|
|
307
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function statusLabel(s: string) {
|
|
311
|
+
const map: Record<string, string> = {
|
|
312
|
+
open: 'OPEN', resolved: 'RESOLVED', 'request-changes': 'REQUEST_CHANGES'
|
|
313
|
+
}
|
|
314
|
+
return map[s] || s
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function statusClass(s: string) {
|
|
318
|
+
return `status-${s}`
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Reply input
|
|
322
|
+
const replyContent = ref('')
|
|
323
|
+
async function submitReply() {
|
|
324
|
+
if (!selectedId.value || !replyContent.value.trim()) return
|
|
325
|
+
await apiPost('/api/v2/memos/replies', {
|
|
326
|
+
memoId: selectedId.value,
|
|
327
|
+
content: replyContent.value.trim(),
|
|
328
|
+
})
|
|
329
|
+
replyContent.value = ''
|
|
330
|
+
await loadMemos()
|
|
331
|
+
// Auto scroll
|
|
332
|
+
await nextTick()
|
|
333
|
+
const repliesEl = document.querySelector('.replies-section')
|
|
334
|
+
if (repliesEl) repliesEl.scrollTop = repliesEl.scrollHeight
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function highlightMentions(text: string): string {
|
|
338
|
+
return text.replace(/@([^\s@][^\n@]*?)(?=\s|$|@)/g, '<span class="mention-chip">@$1</span>')
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function deleteReply(replyId: number) {
|
|
342
|
+
if (!confirm('Delete this reply?')) return
|
|
343
|
+
const { apiDelete } = await import('@/composables/useTurso')
|
|
344
|
+
await apiDelete(`/api/v2/memos/replies/${replyId}`)
|
|
345
|
+
await loadMemos()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function quickResolve(id: number) {
|
|
349
|
+
await apiPatch(`/api/v2/memos/${id}/resolve`, {})
|
|
350
|
+
const memo = memos.value.find(m => m.id === id)
|
|
351
|
+
if (memo) memo.status = 'resolved'
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function resolveMemo() {
|
|
355
|
+
if (!selectedMemo.value) return
|
|
356
|
+
await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
|
|
357
|
+
selectedMemo.value.status = 'resolved'
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function reopenMemo() {
|
|
361
|
+
if (!selectedMemo.value) return
|
|
362
|
+
await apiPatch(`/api/v2/memos/${selectedMemo.value.id}`, { status: 'open' })
|
|
363
|
+
selectedMemo.value.status = 'open'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function convertToStory() {
|
|
367
|
+
if (!selectedMemo.value) return
|
|
368
|
+
const title = selectedMemo.value.content.split('\n')[0].slice(0, 100)
|
|
369
|
+
const ok = confirm(`Create story "${title}"?`)
|
|
370
|
+
if (!ok) return
|
|
371
|
+
const { error } = await apiPost('/api/v2/pm/stories', {
|
|
372
|
+
title,
|
|
373
|
+
description: selectedMemo.value.content,
|
|
374
|
+
status: 'backlog',
|
|
375
|
+
})
|
|
376
|
+
if (error) { alert(error); return }
|
|
377
|
+
// resolve memo
|
|
378
|
+
await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
|
|
379
|
+
await loadMemos()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function convertToInitiative() {
|
|
383
|
+
if (!selectedMemo.value) return
|
|
384
|
+
const title = selectedMemo.value.content.split('\n')[0].slice(0, 100)
|
|
385
|
+
const ok = confirm(`Create initiative "${title}"?`)
|
|
386
|
+
if (!ok) return
|
|
387
|
+
const { error } = await apiPost('/api/v2/initiatives', {
|
|
388
|
+
title,
|
|
389
|
+
content: selectedMemo.value.content,
|
|
390
|
+
source_context: `Memo #${selectedMemo.value.id}`,
|
|
391
|
+
})
|
|
392
|
+
if (error) { alert(error); return }
|
|
393
|
+
await apiPatch(`/api/v2/memos/${selectedMemo.value.id}/resolve`, {})
|
|
394
|
+
await loadMemos()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function loadMembers() {
|
|
398
|
+
const { data } = await apiGet('/api/v2/admin/members')
|
|
399
|
+
if (data?.members) memberList.value = (data.members as any[]).filter(m => m.is_active).map(m => m.display_name)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function createMemo() {
|
|
403
|
+
if (!newMemoContent.value.trim()) return
|
|
404
|
+
if (!newMemoAssignees.value.length) {
|
|
405
|
+
if (!confirm('No recipients specified. Post anyway?')) return
|
|
406
|
+
}
|
|
407
|
+
await apiPost('/api/v2/memos', {
|
|
408
|
+
pageId: 'general',
|
|
409
|
+
content: newMemoContent.value,
|
|
410
|
+
memoType: newMemoType.value,
|
|
411
|
+
assignedTo: newMemoAssignees.value.join(', ') || null,
|
|
412
|
+
})
|
|
413
|
+
newMemoContent.value = ''; newMemoAssignees.value = []; showNewMemo.value = false
|
|
414
|
+
await loadMemos()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
onMounted(() => { loadMemos(); loadMembers(); loadViews(); loadChannels(); loadMemoTemplates(); loadMyReads() })
|
|
418
|
+
watch(() => route.params.id, () => {
|
|
419
|
+
if (route.params.id && !memos.value.length) loadMemos()
|
|
420
|
+
})
|
|
421
|
+
</script>
|
|
422
|
+
|
|
423
|
+
<template>
|
|
424
|
+
<div class="memos-page">
|
|
425
|
+
<div class="memos-header">
|
|
426
|
+
<h1>Memos</h1>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<!-- New memo modal -->
|
|
430
|
+
<div v-if="showNewMemo" class="new-memo-card">
|
|
431
|
+
<button v-if="memoTemplates.length" class="btn btn--xs btn--ghost" @click="showTemplateModal = true"><Icon name="sprint" :size="14" /> Template</button>
|
|
432
|
+
<select v-model="newMemoChannel" class="channel-select">
|
|
433
|
+
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.icon }} {{ ch.name }}</option>
|
|
434
|
+
</select>
|
|
435
|
+
<MentionInput v-model="newMemoContent" placeholder="Memo content..." :rows="3" @submit="createMemo" />
|
|
436
|
+
<div class="new-memo-actions">
|
|
437
|
+
<select v-model="newMemoType" class="filter-select">
|
|
438
|
+
<option value="memo">Memo</option>
|
|
439
|
+
<option value="decision">Decision</option>
|
|
440
|
+
<option value="request">Request</option>
|
|
441
|
+
<option value="feature_request">Feature Request</option>
|
|
442
|
+
<option value="policy_request">Policy Request</option>
|
|
443
|
+
</select>
|
|
444
|
+
<div class="assignee-multi">
|
|
445
|
+
<div v-if="newMemoAssignees.length" class="assignee-tags">
|
|
446
|
+
<span v-for="a in newMemoAssignees" :key="a" class="assignee-tag">
|
|
447
|
+
{{ a }} <button class="tag-remove" @click="newMemoAssignees = newMemoAssignees.filter(x => x !== a)">x</button>
|
|
448
|
+
</span>
|
|
449
|
+
</div>
|
|
450
|
+
<select class="filter-select" @change="(e: Event) => { const v = (e.target as HTMLSelectElement).value; if (v && !newMemoAssignees.includes(v)) newMemoAssignees.push(v); (e.target as HTMLSelectElement).value = '' }">
|
|
451
|
+
<option value="">+ Add recipient</option>
|
|
452
|
+
<option v-for="m in memberList.filter(n => !newMemoAssignees.includes(n))" :key="m" :value="m">{{ m }}</option>
|
|
453
|
+
</select>
|
|
454
|
+
</div>
|
|
455
|
+
<button class="btn btn--primary" @click="createMemo" :disabled="!newMemoContent.trim()">Submit</button>
|
|
456
|
+
<button class="btn" @click="showNewMemo = false">Cancel</button>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
<!-- Detail view -->
|
|
461
|
+
<div v-if="selectedMemo" class="memo-detail">
|
|
462
|
+
<button class="btn-back" @click="goBack">Back to list</button>
|
|
463
|
+
|
|
464
|
+
<div class="memo-card detail-card">
|
|
465
|
+
<div class="memo-meta">
|
|
466
|
+
<span class="memo-id">#{{ selectedMemo.id }}</span>
|
|
467
|
+
<span :class="['memo-status', statusClass(selectedMemo.status)]">{{ statusLabel(selectedMemo.status) }}</span>
|
|
468
|
+
<span class="memo-author">{{ selectedMemo.created_by }}</span>
|
|
469
|
+
<span class="memo-date">{{ formatDate(selectedMemo.created_at) }}</span>
|
|
470
|
+
</div>
|
|
471
|
+
<div v-if="selectedMemo.assigned_to" class="memo-assigned">
|
|
472
|
+
To: {{ selectedMemo.assigned_to }}
|
|
473
|
+
</div>
|
|
474
|
+
<div class="memo-content" v-html="renderMarkdown(selectedMemo.content)"></div>
|
|
475
|
+
<div class="memo-actions">
|
|
476
|
+
<button v-if="selectedMemo.status === 'open'" class="btn btn--primary btn--sm" @click="resolveMemo"><Icon name="check" :size="14" /> Resolve</button>
|
|
477
|
+
<button v-if="selectedMemo.status === 'resolved'" class="btn btn--ghost btn--sm" @click="reopenMemo"><Icon name="refreshCw" :size="14" /> Reopen</button>
|
|
478
|
+
<button v-if="selectedMemo.status === 'open'" class="btn btn--convert" @click="convertToStory">Convert to Story</button>
|
|
479
|
+
<button v-if="selectedMemo.status === 'open'" class="btn btn--initiative" @click="convertToInitiative">Convert to Initiative</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<!-- Read receipts + Presence -->
|
|
484
|
+
<div v-if="readers.length || presentUsers.length" class="memo-presence-bar">
|
|
485
|
+
<div v-if="presentUsers.length" class="presence-now">
|
|
486
|
+
<span class="presence-dot" />
|
|
487
|
+
<span v-for="u in presentUsers" :key="u.user_name" class="presence-avatar" :title="u.user_name">{{ u.user_name.length <= 3 ? u.user_name : u.user_name.slice(0, 3) }}</span>
|
|
488
|
+
</div>
|
|
489
|
+
<div v-if="readers.length" class="readers-list">
|
|
490
|
+
Read by: {{ readers.map(r => r.user_name).join(', ') }}
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<!-- Template modal is moved via Teleport -->
|
|
495
|
+
|
|
496
|
+
<!-- Typing indicator -->
|
|
497
|
+
<div v-if="typingUsers.length" class="typing-indicator">
|
|
498
|
+
{{ typingUsers.join(', ') }} {{ typingUsers.length === 1 ? 'is' : 'are' }} typing...
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<!-- Checklist -->
|
|
502
|
+
<MemoChecklist v-if="selectedMemo" :memo-id="selectedMemo.id" :members="memberList" />
|
|
503
|
+
|
|
504
|
+
<!-- Related memos -->
|
|
505
|
+
<MemoRelations v-if="selectedMemo" :memo-id="selectedMemo.id" />
|
|
506
|
+
|
|
507
|
+
<!-- Linked documents -->
|
|
508
|
+
<div v-if="selectedMemo && linkedDocs.length" class="linked-docs">
|
|
509
|
+
<span class="linked-docs-title">Linked Documents</span>
|
|
510
|
+
<div v-for="d in linkedDocs" :key="d.doc_id" class="linked-doc-item" @click="$router.push(`/docs/${d.doc_id}`)">
|
|
511
|
+
{{ d.icon || '📄' }} {{ d.title }}
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
<button v-if="selectedMemo" class="btn btn--xs btn--ghost" @click="createDocFromMemo">📄 Create Document</button>
|
|
515
|
+
|
|
516
|
+
<!-- Tabs -->
|
|
517
|
+
<div class="memo-tabs">
|
|
518
|
+
<button class="memo-tab" :class="{ active: activeTab === 'thread' }" @click="activeTab = 'thread'">Thread</button>
|
|
519
|
+
<button class="memo-tab" :class="{ active: activeTab === 'timeline' }" @click="activeTab = 'timeline'">Timeline</button>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<!-- Timeline -->
|
|
523
|
+
<MemoTimeline v-if="activeTab === 'timeline' && selectedMemo" :memo-id="selectedMemo.id" />
|
|
524
|
+
|
|
525
|
+
<!-- Reply thread -->
|
|
526
|
+
<div v-if="activeTab === 'thread'" class="replies-section">
|
|
527
|
+
<h3>Replies ({{ selectedReplies.length }})</h3>
|
|
528
|
+
<div v-for="r in selectedReplies" :key="r.id" class="reply-card">
|
|
529
|
+
<div class="reply-meta">
|
|
530
|
+
<span class="reply-author">{{ r.created_by }}</span>
|
|
531
|
+
<span v-if="r.review_type !== 'comment'" :class="['reply-type', `type-${r.review_type}`]">{{ r.review_type }}</span>
|
|
532
|
+
<span class="reply-date">{{ formatDate(r.created_at) }}</span>
|
|
533
|
+
</div>
|
|
534
|
+
<div class="reply-content" v-html="renderMarkdown(highlightMentions(r.content))"></div>
|
|
535
|
+
<button v-if="r.created_by === authUser" class="reply-delete-btn" @click="deleteReply(r.id)">Delete</button>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<div class="reply-input">
|
|
539
|
+
<MentionInput v-model="replyContent" placeholder="Write a reply... (@ to mention)" @submit="submitReply" />
|
|
540
|
+
<button class="btn btn--primary" @click="submitReply" :disabled="!replyContent.trim()">Reply</button>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
|
|
545
|
+
<!-- List view -->
|
|
546
|
+
<!-- Channels -->
|
|
547
|
+
<div class="channel-tabs">
|
|
548
|
+
<button class="channel-tab" :class="{ active: !activeChannel }" @click="activeChannel = ''">All</button>
|
|
549
|
+
<button v-for="ch in channels" :key="ch.id" class="channel-tab" :class="{ active: activeChannel === ch.id }" @click="activeChannel = ch.id">
|
|
550
|
+
{{ ch.icon }} {{ ch.name }}
|
|
551
|
+
<span v-if="unreadCounts[ch.id]" class="unread-badge">{{ unreadCounts[ch.id] }}</span>
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<!-- Filters -->
|
|
556
|
+
<div class="memo-filter-bar">
|
|
557
|
+
<button class="btn btn--xs" :class="filterAssignedTo === 'me' ? 'btn--primary' : 'btn--ghost'" @click="presetMyMemos">My Memos</button>
|
|
558
|
+
<button class="btn btn--xs" :class="filterStatus === 'open' ? 'btn--primary' : 'btn--ghost'" @click="presetOpen">Unresolved</button>
|
|
559
|
+
<button class="btn btn--xs btn--ghost" @click="presetThisWeek">This Week</button>
|
|
560
|
+
<button v-if="filterStatus || filterAssignedTo || filterDateFrom" class="btn btn--xs btn--ghost" @click="clearFilters">✕ Reset</button>
|
|
561
|
+
<button class="btn btn--xs btn--ghost" @click="saveView">💾 Save View</button>
|
|
562
|
+
<select v-if="savedViews.length" class="filter-view-select" @change="applyView(savedViews[($event.target as HTMLSelectElement).selectedIndex - 1])">
|
|
563
|
+
<option value="">Saved Views</option>
|
|
564
|
+
<option v-for="v in savedViews" :key="v.id">{{ v.name }}</option>
|
|
565
|
+
</select>
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
<!-- Graph view toggle -->
|
|
569
|
+
<div class="graph-toggle">
|
|
570
|
+
<button class="btn btn--xs" :class="showGraph ? 'btn--primary' : 'btn--ghost'" @click="showGraph = !showGraph"><Icon name="link" :size="14" /> Graph</button>
|
|
571
|
+
</div>
|
|
572
|
+
<MemoGraph v-if="showGraph" :memos="memos" />
|
|
573
|
+
|
|
574
|
+
<div v-else class="memo-list">
|
|
575
|
+
<div class="memo-filters">
|
|
576
|
+
<button class="mobile-search-btn" @click="showMobileSearch = !showMobileSearch"><Icon name="search" :size="14" /></button>
|
|
577
|
+
<input
|
|
578
|
+
v-model="searchKeyword"
|
|
579
|
+
type="text"
|
|
580
|
+
placeholder="Search by keyword..."
|
|
581
|
+
class="filter-input" :class="{ 'mobile-search-open': showMobileSearch }"
|
|
582
|
+
@keydown.enter="loadMemos()"
|
|
583
|
+
/>
|
|
584
|
+
<select v-model="filterStatus" class="filter-select" @change="loadMemos()">
|
|
585
|
+
<option value="">All Statuses</option>
|
|
586
|
+
<option value="open">OPEN</option>
|
|
587
|
+
<option value="resolved">RESOLVED</option>
|
|
588
|
+
<option value="request-changes">REQUEST_CHANGES</option>
|
|
589
|
+
</select>
|
|
590
|
+
<select v-model="filterAuthor" class="filter-select" @change="loadMemos()">
|
|
591
|
+
<option value="">All Authors</option>
|
|
592
|
+
<option v-for="m in memberList" :key="m" :value="m">{{ m }}</option>
|
|
593
|
+
</select>
|
|
594
|
+
<button class="btn btn--primary btn-search" @click="loadMemos()">Search</button>
|
|
595
|
+
<button class="btn-new-memo" @click="showNewMemo = true">+ New Memo</button>
|
|
596
|
+
</div>
|
|
597
|
+
<div v-if="loading" class="loading">Loading...</div>
|
|
598
|
+
<div v-else-if="!memos.length" class="empty">No memos found.</div>
|
|
599
|
+
<div
|
|
600
|
+
v-else
|
|
601
|
+
v-for="m in filteredMemos"
|
|
602
|
+
:key="m.id"
|
|
603
|
+
class="memo-card list-card" :class="{ unread: !readMemoIds.has(m.id) }"
|
|
604
|
+
@click="selectMemo(m.id)"
|
|
605
|
+
>
|
|
606
|
+
<div class="memo-meta">
|
|
607
|
+
<span class="memo-id">#{{ m.id }}</span>
|
|
608
|
+
<span :class="['memo-status', statusClass(m.status)]">{{ statusLabel(m.status) }}</span>
|
|
609
|
+
<span class="memo-author">{{ m.created_by }}</span>
|
|
610
|
+
<span class="memo-date">{{ formatDate(m.created_at) }}</span>
|
|
611
|
+
<span v-if="replies[m.id]?.length" class="reply-count">{{ replies[m.id].length }} {{ replies[m.id].length === 1 ? 'reply' : 'replies' }}</span>
|
|
612
|
+
<button v-if="m.status === 'open'" class="resolve-icon" @click.stop="quickResolve(m.id)" title="Resolve"><Icon name="check" :size="14" /></button>
|
|
613
|
+
</div>
|
|
614
|
+
<div class="memo-preview">{{ m.content.slice(0, 120) }}{{ m.content.length > 120 ? '...' : '' }}</div>
|
|
615
|
+
</div>
|
|
616
|
+
<div v-if="hasMore" class="load-more">
|
|
617
|
+
<button class="btn btn--primary" @click="loadMore" :disabled="loadingMore">
|
|
618
|
+
{{ loadingMore ? 'Loading...' : 'Load More' }}
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
<div v-if="memos.length" class="memo-count">
|
|
622
|
+
{{ memos.length }} / {{ totalMemos }} items
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<!-- Template modal (Teleport) -->
|
|
628
|
+
<Teleport to="body">
|
|
629
|
+
<div v-if="showTemplateModal" class="tmpl-overlay" @click.self="showTemplateModal = false">
|
|
630
|
+
<div class="tmpl-modal">
|
|
631
|
+
<h4>Memo Templates</h4>
|
|
632
|
+
<div v-for="t in memoTemplates" :key="t.id" class="tmpl-item" @click="applyTemplate(t)">
|
|
633
|
+
{{ t.icon || '📝' }} {{ t.name }}
|
|
634
|
+
</div>
|
|
635
|
+
<div v-if="!memoTemplates.length" class="tmpl-empty">No templates available.</div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
</Teleport>
|
|
639
|
+
</template>
|
|
640
|
+
|
|
641
|
+
<style scoped>
|
|
642
|
+
.memos-page {
|
|
643
|
+
max-width: 800px;
|
|
644
|
+
margin: 0 auto;
|
|
645
|
+
padding: 24px 16px;
|
|
646
|
+
background: var(--bg-main, #f5f6f8);
|
|
647
|
+
min-height: calc(100vh - 60px);
|
|
648
|
+
}
|
|
649
|
+
.memos-header h1 {
|
|
650
|
+
font-size: 20px;
|
|
651
|
+
font-weight: 700;
|
|
652
|
+
margin-bottom: 16px;
|
|
653
|
+
}
|
|
654
|
+
.memo-card {
|
|
655
|
+
background: var(--bg-card, #fff);
|
|
656
|
+
border: none;
|
|
657
|
+
border-radius: var(--radius-lg, 12px);
|
|
658
|
+
padding: 16px;
|
|
659
|
+
margin-bottom: 8px;
|
|
660
|
+
}
|
|
661
|
+
.list-card { cursor: pointer; transition: background 0.1s; }
|
|
662
|
+
.list-card:hover { background: var(--bg-hover, #f0f1f3); }
|
|
663
|
+
.memo-meta {
|
|
664
|
+
display: flex;
|
|
665
|
+
align-items: center;
|
|
666
|
+
gap: 8px;
|
|
667
|
+
font-size: 12px;
|
|
668
|
+
color: var(--text-muted, #888);
|
|
669
|
+
margin-bottom: 8px;
|
|
670
|
+
flex-wrap: wrap;
|
|
671
|
+
}
|
|
672
|
+
.memo-id { font-weight: 700; color: var(--text-primary, #333); }
|
|
673
|
+
.memo-status {
|
|
674
|
+
padding: 2px 8px;
|
|
675
|
+
border-radius: 10px;
|
|
676
|
+
font-size: 10px;
|
|
677
|
+
font-weight: 600;
|
|
678
|
+
}
|
|
679
|
+
.status-open { background: #dbeafe; color: #1d4ed8; }
|
|
680
|
+
.status-resolved { background: #dcfce7; color: #16a34a; }
|
|
681
|
+
.status-request-changes { background: #fef3c7; color: #d97706; }
|
|
682
|
+
.memo-content { font-size: 14px; line-height: 1.6; padding: 16px; }
|
|
683
|
+
.memo-content :deep(pre) { background: #1e293b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.6; margin: 12px 0; }
|
|
684
|
+
.memo-content :deep(code) { background: #f1f5f9; color: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
|
685
|
+
.memo-content :deep(pre code) { background: none; color: inherit; padding: 0; }
|
|
686
|
+
.memo-content :deep(blockquote) { border-left: 3px solid #3b82f6; padding: 8px 12px; margin: 12px 0; background: #f8fafc; border-radius: 0 8px 8px 0; }
|
|
687
|
+
.memo-content :deep(h1) { font-size: 20px; font-weight: 700; margin: 16px 0 8px; }
|
|
688
|
+
.memo-content :deep(h2) { font-size: 17px; font-weight: 600; margin: 14px 0 6px; }
|
|
689
|
+
.memo-content :deep(h3) { font-size: 15px; font-weight: 600; margin: 12px 0 4px; }
|
|
690
|
+
.memo-content :deep(ul), .memo-content :deep(ol) { padding-left: 20px; margin: 8px 0; }
|
|
691
|
+
.memo-content :deep(a) { color: #3b82f6; }
|
|
692
|
+
.memo-content :deep(hr) { border: none; border-top: 1px solid #e5e7eb; margin: 16px 0; }
|
|
693
|
+
.memo-content :deep(table) { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
|
694
|
+
.memo-content :deep(th), .memo-content :deep(td) { border: 1px solid #e5e7eb; padding: 8px; text-align: left; }
|
|
695
|
+
.memo-content :deep(th) { background: #f9fafb; font-weight: 600; }
|
|
696
|
+
.memo-preview { font-size: 13px; color: var(--text-secondary, #666); }
|
|
697
|
+
.memo-assigned { font-size: 12px; color: var(--text-muted, #888); margin-top: 8px; }
|
|
698
|
+
.memo-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
699
|
+
.resolve-icon { border: none; background: none; cursor: pointer; font-size: 14px; opacity: 0.4; transition: opacity 0.15s; }
|
|
700
|
+
.resolve-icon:hover { opacity: 1; }
|
|
701
|
+
.memo-tabs { display: flex; gap: 4px; margin: 12px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; }
|
|
702
|
+
.memo-tab { border: none; background: none; padding: 6px 12px; font-size: 13px; color: #6b7280; cursor: pointer; border-radius: 6px 6px 0 0; }
|
|
703
|
+
.memo-tab.active { color: #3b82f6; border-bottom: 2px solid #3b82f6; font-weight: 600; }
|
|
704
|
+
.linked-docs { margin: 8px 0; padding: 8px; background: #f8fafc; border-radius: 6px; }
|
|
705
|
+
.linked-docs-title { font-size: 12px; font-weight: 600; color: #6b7280; display: block; margin-bottom: 4px; }
|
|
706
|
+
.linked-doc-item { padding: 4px 0; font-size: 13px; color: #3b82f6; cursor: pointer; }
|
|
707
|
+
.linked-doc-item:hover { text-decoration: underline; }
|
|
708
|
+
.mobile-search-btn { display: none; border: none; background: #f3f4f6; padding: 4px 8px; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
|
709
|
+
.memo-filter-bar { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; align-items: center; }
|
|
710
|
+
.filter-view-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 11px; }
|
|
711
|
+
.channel-tabs { display: flex; gap: 4px; margin-bottom: 8px; overflow-x: auto; padding-bottom: 4px; }
|
|
712
|
+
.channel-tab { border: none; background: #f3f4f6; padding: 5px 12px; font-size: 12px; border-radius: 16px; cursor: pointer; white-space: nowrap; }
|
|
713
|
+
.channel-tab.active { background: #3b82f6; color: #fff; }
|
|
714
|
+
.channel-tab:hover:not(.active) { background: #e5e7eb; }
|
|
715
|
+
.unread-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; border-radius: 8px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; padding: 0 4px; margin-left: 4px; }
|
|
716
|
+
.channel-select { border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; font-size: 12px; margin-bottom: 4px; }
|
|
717
|
+
.memo-card.unread { border-left: 3px solid #3b82f6; font-weight: 600; }
|
|
718
|
+
.memo-presence-bar { display: flex; gap: 16px; align-items: center; padding: 8px 0; font-size: 12px; color: #6b7280; }
|
|
719
|
+
.presence-now { display: flex; align-items: center; gap: 4px; }
|
|
720
|
+
.presence-dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 2s infinite; }
|
|
721
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
722
|
+
.presence-avatar, .reader-avatar { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: #eff6ff; color: #3b82f6; font-size: 10px; font-weight: 600; cursor: pointer; }
|
|
723
|
+
.reader-names { font-size: 11px; color: var(--text-muted); margin-left: 4px; }
|
|
724
|
+
.readers-list { display: flex; align-items: center; gap: 4px; }
|
|
725
|
+
.tmpl-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 9999; display: flex; align-items: center; justify-content: center; }
|
|
726
|
+
.tmpl-modal { background: #fff; border-radius: 12px; padding: 20px; width: 320px; }
|
|
727
|
+
.tmpl-modal h4 { margin: 0 0 12px; }
|
|
728
|
+
.tmpl-item { padding: 10px; cursor: pointer; border-radius: 6px; font-size: 14px; }
|
|
729
|
+
.tmpl-item:hover { background: #eff6ff; }
|
|
730
|
+
.tmpl-empty { color: #9ca3af; text-align: center; padding: 16px; }
|
|
731
|
+
.typing-indicator { font-size: 12px; color: #6b7280; font-style: italic; padding: 4px 0; animation: blink 1.5s infinite; }
|
|
732
|
+
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
733
|
+
.btn--convert { background: #fef3c7; color: #d97706; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
|
|
734
|
+
.btn--initiative { background: #dbeafe; color: #1d4ed8; border: 1px solid #bfdbfe; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
|
|
735
|
+
.reply-count { background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 8px; font-size: 10px; }
|
|
736
|
+
|
|
737
|
+
.btn-back {
|
|
738
|
+
background: none;
|
|
739
|
+
border: 1px solid rgba(0,0,0,0.15);
|
|
740
|
+
border-radius: 8px;
|
|
741
|
+
padding: 6px 12px;
|
|
742
|
+
font-size: 13px;
|
|
743
|
+
cursor: pointer;
|
|
744
|
+
margin-bottom: 16px;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.replies-section { margin-top: 24px; }
|
|
748
|
+
.replies-section h3 { font-size: 14px; margin-bottom: 12px; }
|
|
749
|
+
.reply-card {
|
|
750
|
+
background: rgba(255,255,255,0.15);
|
|
751
|
+
border: 1px solid rgba(255,255,255,0.3);
|
|
752
|
+
border-radius: 12px;
|
|
753
|
+
padding: 12px;
|
|
754
|
+
margin-bottom: 8px;
|
|
755
|
+
}
|
|
756
|
+
.reply-meta {
|
|
757
|
+
display: flex;
|
|
758
|
+
align-items: center;
|
|
759
|
+
gap: 8px;
|
|
760
|
+
font-size: 11px;
|
|
761
|
+
color: var(--text-muted, #888);
|
|
762
|
+
margin-bottom: 6px;
|
|
763
|
+
}
|
|
764
|
+
.reply-author { font-weight: 600; color: var(--text-primary, #333); }
|
|
765
|
+
.reply-type { padding: 1px 4px; border-radius: 3px; font-size: 10px; }
|
|
766
|
+
.type-approve { background: #dcfce7; color: #16a34a; }
|
|
767
|
+
.type-request-changes { background: #fef3c7; color: #d97706; }
|
|
768
|
+
.reply-content { font-size: 13px; line-height: 1.5; }
|
|
769
|
+
.reply-content :deep(pre) { background: #1e293b; color: #e2e8f0; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; margin: 8px 0; }
|
|
770
|
+
.reply-content :deep(code) { background: #f1f5f9; color: #1e293b; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
|
771
|
+
.reply-content :deep(pre code) { background: none; color: inherit; padding: 0; }
|
|
772
|
+
.reply-content :deep(blockquote) { border-left: 2px solid #3b82f6; padding: 4px 8px; margin: 8px 0; background: #f8fafc; }
|
|
773
|
+
.reply-content :deep(h1), .reply-content :deep(h2), .reply-content :deep(h3) { font-size: 14px; font-weight: 600; margin: 8px 0 4px; }
|
|
774
|
+
.reply-content :deep(ul), .reply-content :deep(ol) { padding-left: 16px; margin: 4px 0; }
|
|
775
|
+
.reply-input { margin-top: 16px; }
|
|
776
|
+
.reply-input textarea {
|
|
777
|
+
width: 100%;
|
|
778
|
+
border: 1px solid rgba(0,0,0,0.15);
|
|
779
|
+
border-radius: 8px;
|
|
780
|
+
padding: 8px;
|
|
781
|
+
font-size: 13px;
|
|
782
|
+
resize: vertical;
|
|
783
|
+
margin-bottom: 8px;
|
|
784
|
+
box-sizing: border-box;
|
|
785
|
+
}
|
|
786
|
+
.btn--primary {
|
|
787
|
+
padding: 6px 16px;
|
|
788
|
+
border-radius: 8px;
|
|
789
|
+
font-size: 13px;
|
|
790
|
+
}
|
|
791
|
+
.memo-filters {
|
|
792
|
+
display: flex;
|
|
793
|
+
gap: 8px;
|
|
794
|
+
margin-bottom: 16px;
|
|
795
|
+
flex-wrap: wrap;
|
|
796
|
+
}
|
|
797
|
+
.filter-input {
|
|
798
|
+
flex: 1;
|
|
799
|
+
min-width: 120px;
|
|
800
|
+
border: 1px solid rgba(0,0,0,0.15);
|
|
801
|
+
border-radius: 8px;
|
|
802
|
+
padding: 6px 10px;
|
|
803
|
+
font-size: 13px;
|
|
804
|
+
}
|
|
805
|
+
.filter-author { max-width: 120px; }
|
|
806
|
+
.filter-select {
|
|
807
|
+
border: 1px solid rgba(0,0,0,0.15);
|
|
808
|
+
border-radius: 8px;
|
|
809
|
+
padding: 6px 10px;
|
|
810
|
+
font-size: 13px;
|
|
811
|
+
background: #fff;
|
|
812
|
+
}
|
|
813
|
+
.btn-search { padding: 6px 16px; white-space: nowrap; }
|
|
814
|
+
.assignee-multi { display: flex; flex-direction: column; gap: 4px; }
|
|
815
|
+
.assignee-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
816
|
+
.assignee-tag { background: #dbeafe; color: #1d4ed8; padding: 2px 8px; border-radius: 12px; font-size: 12px; display: flex; align-items: center; gap: 4px; }
|
|
817
|
+
.tag-remove { background: none; border: none; color: #1d4ed8; cursor: pointer; font-size: 12px; padding: 0; }
|
|
818
|
+
.btn-new-memo { background: #3b82f6; color: #fff; border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
|
|
819
|
+
.new-memo-card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
|
820
|
+
.new-memo-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
|
821
|
+
.load-more { text-align: center; padding: 16px 0; }
|
|
822
|
+
.memo-count { text-align: center; font-size: 12px; color: var(--text-muted, #888); padding: 8px 0; }
|
|
823
|
+
.mention-chip { background: #dbeafe; color: #1d4ed8; padding: 1px 4px; border-radius: 4px; font-weight: 500; }
|
|
824
|
+
.reply-delete-btn { background: none; border: none; color: #ef4444; font-size: 11px; cursor: pointer; padding: 2px 4px; }
|
|
825
|
+
.reply-delete-btn:hover { text-decoration: underline; }
|
|
826
|
+
.loading, .empty { text-align: center; color: var(--text-muted, #888); padding: 40px; }
|
|
827
|
+
@media (max-width: 767px) {
|
|
828
|
+
.memos-page { padding: 8px; }
|
|
829
|
+
.channel-tabs { overflow-x: auto; flex-wrap: nowrap; gap: 4px; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
|
|
830
|
+
.channel-tab { padding: 4px 10px; font-size: 12px; flex-shrink: 0; }
|
|
831
|
+
.memo-filter-bar { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
|
832
|
+
.memo-filter-bar .btn { padding: 4px 10px; font-size: 11px; }
|
|
833
|
+
.filter-view-select { width: auto; font-size: 11px; }
|
|
834
|
+
.memo-filter-bar { flex-direction: row; flex-wrap: nowrap; overflow-x: auto; gap: 4px; -webkit-overflow-scrolling: touch; }
|
|
835
|
+
.filter-view-select { font-size: 10px; padding: 2px 6px; width: auto; }
|
|
836
|
+
.mobile-search-btn { display: inline-flex; }
|
|
837
|
+
.filter-input { display: none; }
|
|
838
|
+
.filter-input.mobile-search-open { display: block; width: 100%; }
|
|
839
|
+
.memo-filters { flex-direction: row; flex-wrap: wrap; gap: 4px; }
|
|
840
|
+
.memo-filters select { width: auto; min-width: 80px; font-size: 12px; padding: 4px 6px; min-height: 36px; }
|
|
841
|
+
.graph-toggle { display: none; }
|
|
842
|
+
.memo-graph { display: none; }
|
|
843
|
+
.memo-card { padding: 10px; margin-bottom: 6px; border-radius: 10px; }
|
|
844
|
+
.memo-meta { flex-wrap: wrap; gap: 4px; font-size: 11px; }
|
|
845
|
+
.memo-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
846
|
+
.detail-card { padding: 10px; }
|
|
847
|
+
.memo-actions { flex-wrap: wrap; gap: 4px; }
|
|
848
|
+
.memo-actions .btn { min-height: 40px; }
|
|
849
|
+
.replies-section { padding: 0; }
|
|
850
|
+
.reply-card { padding: 8px; }
|
|
851
|
+
.memo-tabs { overflow-x: auto; flex-wrap: nowrap; }
|
|
852
|
+
.memo-presence-bar { flex-wrap: wrap; font-size: 11px; }
|
|
853
|
+
h1 { font-size: 18px; }
|
|
854
|
+
.btn { min-height: 40px; }
|
|
855
|
+
input, select, textarea { min-height: 40px; font-size: 16px; }
|
|
856
|
+
}
|
|
857
|
+
</style>
|