feat: integrate MarkBase Search Session API into Studio
- Created useSearchSession composable for session management - Added 7 API endpoints in api/index.ts - Updated SearchView.vue to use sessions instead of history - Session sidebar with create, rename, delete functionality - Auto-save messages to current session after each search - Auto-create default session on first load - Updated CSS for session active state and new session button
This commit is contained in:
@@ -685,6 +685,22 @@ case 'search_keyword': {
|
||||
return { url: '/api/v2/admin/users', method: 'GET', requiresAdminToken: true }
|
||||
case 'list_shares':
|
||||
return { url: '/api/v2/admin/shares', method: 'GET' }
|
||||
|
||||
// --- Search Sessions ---
|
||||
case 'list_search_sessions':
|
||||
return { url: '/api/v2/search-sessions', method: 'GET', requiresAdminToken: true }
|
||||
case 'create_search_session':
|
||||
return { url: '/api/v2/search-sessions', method: 'POST', body: { title: a.title, mode: a.mode }, requiresAdminToken: true }
|
||||
case 'rename_search_session':
|
||||
return { url: `/api/v2/search-sessions/${a.id}`, method: 'PUT', body: { title: a.title }, requiresAdminToken: true }
|
||||
case 'delete_search_session':
|
||||
return { url: `/api/v2/search-sessions/${a.id}`, method: 'DELETE', requiresAdminToken: true }
|
||||
case 'get_session_messages':
|
||||
return { url: `/api/v2/search-sessions/${a.sessionId}/messages`, method: 'GET', requiresAdminToken: true }
|
||||
case 'save_session_message':
|
||||
return { url: `/api/v2/search-sessions/${a.sessionId}/messages`, method: 'POST', body: a.message, requiresAdminToken: true }
|
||||
case 'update_session_message':
|
||||
return { url: `/api/v2/search-sessions/${a.sessionId}/messages/${a.msgId}`, method: 'PATCH', body: a.message, requiresAdminToken: true }
|
||||
case 'admin_login':
|
||||
return { url: '/api/v2/admin/login', method: 'POST', body: { username: a.username, password: a.password } }
|
||||
|
||||
|
||||
219
src/composables/useSearchSession.ts
Normal file
219
src/composables/useSearchSession.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { ref } from 'vue'
|
||||
import { getApiBase } from '@/api/config'
|
||||
|
||||
export interface SearchSession {
|
||||
id: string
|
||||
title: string
|
||||
mode: 'keyword' | 'visual' | 'people' | 'agent'
|
||||
message_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SearchMessage {
|
||||
id: string
|
||||
session_id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
results: string
|
||||
mode: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useSearchSession() {
|
||||
const sessions = ref<SearchSession[]>([])
|
||||
const currentSession = ref<SearchSession | null>(null)
|
||||
const messages = ref<SearchMessage[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const stored = localStorage.getItem('mb_admin_token')
|
||||
if (stored) {
|
||||
try {
|
||||
const data = JSON.parse(stored)
|
||||
if (new Date(data.expires_at) > new Date()) {
|
||||
return { 'Authorization': `Bearer ${data.token}` }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
sessions.value = data.sessions || []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load sessions'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(title?: string, mode?: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ title: title || '未命名搜尋', mode: mode || 'keyword' })
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
await loadSessions()
|
||||
return data.session
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to create session'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSession(id: string, title: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ title })
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
await loadSessions()
|
||||
if (currentSession.value?.id === id) {
|
||||
currentSession.value.title = title
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to rename session'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(id: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
if (currentSession.value?.id === id) {
|
||||
currentSession.value = null
|
||||
messages.value = []
|
||||
}
|
||||
await loadSessions()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to delete session'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(sessionId: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
messages.value = data.messages || []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load messages'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMessage(sessionId: string, message: { role: string; content: string; mode?: string; results?: any }) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
mode: message.mode || 'keyword',
|
||||
results: message.results ? JSON.stringify(message.results) : ''
|
||||
})
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
await loadMessages(sessionId)
|
||||
await loadSessions()
|
||||
return data.message
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to save message'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMessage(sessionId: string, msgId: string, update: { role?: string; content?: string; results?: any }) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const base = getApiBase()
|
||||
const resp = await fetch(`${base}/api/v2/search-sessions/${sessionId}/messages/${msgId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
...update,
|
||||
results: update.results ? JSON.stringify(update.results) : undefined
|
||||
})
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
await loadMessages(sessionId)
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to update message'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentSession(session: SearchSession | null) {
|
||||
currentSession.value = session
|
||||
if (session) {
|
||||
loadMessages(session.id)
|
||||
} else {
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSession,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
loadSessions,
|
||||
createSession,
|
||||
renameSession,
|
||||
deleteSession,
|
||||
loadMessages,
|
||||
saveMessage,
|
||||
updateMessage,
|
||||
setCurrentSession
|
||||
}
|
||||
}
|
||||
@@ -34,31 +34,27 @@
|
||||
<button class="bkh-btn" title="New Chat" @click="startNewChat">+ New</button>
|
||||
<button class="bkx" @click="showHistory = false">x</button>
|
||||
</div>
|
||||
<button class="hs-new-btn" @click="handleNewSession">+ New Session</button>
|
||||
</div>
|
||||
<div id="msHSL">
|
||||
<div v-if="!historyList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No history yet</div>
|
||||
<div v-for="h in historyList" :key="h.id" class="hs-item" :class="{ pinned: h.pinned }">
|
||||
<div v-if="!sessionList.length" style="color:var(--muted-text);font-size:12px;padding:8px 0">No sessions yet</div>
|
||||
<div v-for="s in sessionList" :key="s.id" class="hs-item" :class="{ active: currentSession?.id === s.id }">
|
||||
<div class="hs-main">
|
||||
<div class="hs-row">
|
||||
<span class="hs-mode">{{ formatMode(h.mode) }}</span>
|
||||
<div class="hs-title" @click="restoreHistory(h)">{{ h.title || h.query }}</div>
|
||||
</div>
|
||||
<div class="hs-time">
|
||||
<template v-if="h.updated_at && h.updated_at !== h.created_at">{{ formatHistoryTime(h.created_at) }} - {{ formatHistoryTime(h.updated_at) }}</template>
|
||||
<template v-else>{{ formatHistoryTime(h.created_at || h.updated_at) }}</template>
|
||||
<span class="hs-mode">{{ formatMode(s.mode) }}</span>
|
||||
<div class="hs-title" @click="handleSelectSession(s)">{{ s.title }}</div>
|
||||
</div>
|
||||
<div class="hs-time">{{ s.message_count }} messages · {{ formatSessionTime(s.updated_at) }}</div>
|
||||
</div>
|
||||
<button class="hs-menu-btn" @click.stop="toggleHistoryMenu(h.id)">⋯</button>
|
||||
<div v-if="activeHistoryMenu === h.id" class="hs-menu">
|
||||
<button v-if="!h.pinned" @click="pinHistoryItem(h.id)">Pin</button>
|
||||
<button v-else @click="unpinHistoryItem(h.id)">Unpin</button>
|
||||
<button @click="startRenameHistory(h)">Rename</button>
|
||||
<button class="hs-menu-del" @click="deleteHistoryItem(h.id)">Delete</button>
|
||||
<button class="hs-menu-btn" @click.stop="toggleSessionMenu(s.id)">⋯</button>
|
||||
<div v-if="activeSessionMenu === s.id" class="hs-menu">
|
||||
<button @click="startRenameSession(s)">Rename</button>
|
||||
<button class="hs-menu-del" @click="handleDeleteSession(s.id)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="renamingHistory" class="rename-overlay" @click.self="cancelRename">
|
||||
<div v-if="renamingSession" class="rename-overlay" @click.self="cancelRename">
|
||||
<div class="rename-dialog">
|
||||
<div class="rename-label">Rename conversation</div>
|
||||
<input class="rename-input" v-model="renameValue" @keyup.enter="confirmRename" ref="renameInputEl" />
|
||||
@@ -127,7 +123,7 @@
|
||||
<span class="card-file-uuid-badge">{{ r.file_uuid }}</span>
|
||||
</div>
|
||||
<div class="card-thumb" @click="playVideo(r)">
|
||||
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy" @error="$event.target.style.display='none'">
|
||||
<img v-if="r.thumbnail_url" :src="r.thumbnail_url" alt="" loading="lazy" @error="($event.target as HTMLElement).style.display='none'">
|
||||
<img v-else-if="thumbs[r.file_uuid + ':' + r.start_frame]" :src="thumbs[r.file_uuid + ':' + r.start_frame]" alt="" loading="lazy">
|
||||
<div v-else class="thumb-placeholder" v-observe="() => loadThumb(r.file_uuid, r.start_frame || 0)">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
@@ -296,9 +292,10 @@ import { apiCall } from '@/api'
|
||||
import { getApiBase } from '@/api/config'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSearchHistory, useBookmarks } from '@/composables/useSearchHistory'
|
||||
import { useSearchSession } from '@/composables/useSearchSession'
|
||||
import { useBookmarks } from '@/composables/useSearchHistory'
|
||||
import { thumbnailsCache, loadThumbnail, filesCache, ensureFiles } from '@/store'
|
||||
import type { HistoryItem } from '@/composables/useSearchHistory'
|
||||
import type { SearchSession, SearchMessage } from '@/composables/useSearchSession'
|
||||
|
||||
interface SourceInfo {
|
||||
tool: string
|
||||
@@ -342,12 +339,12 @@ const showModeMenu = ref(false)
|
||||
const showBookmark = ref(false)
|
||||
const showHistory = ref(false)
|
||||
const isListening = ref(false)
|
||||
const activeHistoryMenu = ref<string | null>(null)
|
||||
const renamingHistory = ref<HistoryItem | null>(null)
|
||||
const activeSessionMenu = ref<string | null>(null)
|
||||
const renamingSession = ref<SearchSession | null>(null)
|
||||
const renameValue = ref('')
|
||||
const renameInputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const { history: historyList, loadHistory, saveToHistory, renameHistory, pinHistory, deleteHistory, restoreFromHistory } = useSearchHistory()
|
||||
const { sessions: sessionList, currentSession, messages: sessionMessages, loading: sessionLoading, error: sessionError, loadSessions, createSession, renameSession, deleteSession, loadMessages, saveMessage, setCurrentSession } = useSearchSession()
|
||||
const { bookmarks: bookmarkList, loadBookmarks, saveBookmark, deleteBookmark } = useBookmarks()
|
||||
|
||||
const markedResults = ref<any[]>([])
|
||||
@@ -578,7 +575,7 @@ function toggleBookmark() {
|
||||
function toggleHistory() {
|
||||
showHistory.value = !showHistory.value
|
||||
showBookmark.value = false
|
||||
if (showHistory.value) loadHistory()
|
||||
if (showHistory.value) loadSessions()
|
||||
}
|
||||
|
||||
function toggleVoice() {
|
||||
@@ -593,7 +590,7 @@ function closeDropdowns(e: MouseEvent) {
|
||||
showHistory.value = false
|
||||
}
|
||||
if (!target.closest('.hs-menu-btn') && !target.closest('.hs-menu')) {
|
||||
activeHistoryMenu.value = null
|
||||
activeSessionMenu.value = null
|
||||
}
|
||||
if (!target.closest('.sel-popup-btn')) {
|
||||
selectionPopup.value.show = false
|
||||
@@ -603,9 +600,20 @@ function closeDropdowns(e: MouseEvent) {
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', closeDropdowns)
|
||||
await ensureFiles()
|
||||
loadHistory()
|
||||
await loadSessions()
|
||||
loadBookmarks()
|
||||
|
||||
// Auto-create default session if none exists
|
||||
if (!sessionList.value.length) {
|
||||
await createSession('Default', 'keyword')
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
// Set first session as current
|
||||
if (sessionList.value.length && !currentSession.value) {
|
||||
setCurrentSession(sessionList.value[0])
|
||||
}
|
||||
|
||||
// Restore session from localStorage
|
||||
try {
|
||||
const saved = localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
@@ -703,59 +711,61 @@ function startNewChat() {
|
||||
showHistory.value = false
|
||||
}
|
||||
|
||||
function toggleHistoryMenu(id: string) {
|
||||
activeHistoryMenu.value = activeHistoryMenu.value === id ? null : id
|
||||
function toggleSessionMenu(id: string) {
|
||||
activeSessionMenu.value = activeSessionMenu.value === id ? null : id
|
||||
}
|
||||
|
||||
async function pinHistoryItem(id: string) {
|
||||
await pinHistory(id, true)
|
||||
activeHistoryMenu.value = null
|
||||
async function handleDeleteSession(id: string) {
|
||||
await deleteSession(id)
|
||||
activeSessionMenu.value = null
|
||||
}
|
||||
|
||||
async function unpinHistoryItem(id: string) {
|
||||
await pinHistory(id, false)
|
||||
activeHistoryMenu.value = null
|
||||
}
|
||||
|
||||
async function deleteHistoryItem(id: string) {
|
||||
await deleteHistory(id)
|
||||
activeHistoryMenu.value = null
|
||||
}
|
||||
|
||||
function startRenameHistory(h: HistoryItem) {
|
||||
renamingHistory.value = h
|
||||
renameValue.value = h.title || h.query
|
||||
activeHistoryMenu.value = null
|
||||
function startRenameSession(s: SearchSession) {
|
||||
renamingSession.value = s
|
||||
renameValue.value = s.title
|
||||
activeSessionMenu.value = null
|
||||
nextTick(() => renameInputEl.value?.focus())
|
||||
}
|
||||
|
||||
async function confirmRename() {
|
||||
if (!renamingHistory.value) return
|
||||
await renameHistory(renamingHistory.value.id, renameValue.value)
|
||||
renamingHistory.value = null
|
||||
if (!renamingSession.value) return
|
||||
await renameSession(renamingSession.value.id, renameValue.value)
|
||||
renamingSession.value = null
|
||||
renameValue.value = ''
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renamingHistory.value = null
|
||||
renamingSession.value = null
|
||||
renameValue.value = ''
|
||||
}
|
||||
|
||||
function restoreHistory(h: HistoryItem) {
|
||||
const restored = restoreFromHistory(h)
|
||||
if (h.mode && modes.some(m => m.value === h.mode)) {
|
||||
mode.value = h.mode!
|
||||
localStorage.setItem('ms_chat_mode_v1', h.mode!)
|
||||
async function handleSelectSession(s: SearchSession) {
|
||||
setCurrentSession(s)
|
||||
showHistory.value = false
|
||||
// Restore messages from session
|
||||
const restoredMessages = sessionMessages.value.map((m: SearchMessage) => {
|
||||
const isUser = m.role === 'user'
|
||||
return {
|
||||
role: m.role,
|
||||
query: isUser ? m.content : undefined,
|
||||
results: m.results ? JSON.parse(m.results) : [],
|
||||
loading: false,
|
||||
message: isUser ? '' : m.content,
|
||||
mode: m.mode
|
||||
}
|
||||
})
|
||||
if (restoredMessages.length) {
|
||||
messages.value = restoredMessages
|
||||
}
|
||||
if (restored) {
|
||||
messages.value = restored
|
||||
query.value = ''
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
|
||||
async function handleNewSession() {
|
||||
const newSession = await createSession('未命名搜尋', mode.value)
|
||||
if (newSession) {
|
||||
setCurrentSession(newSession)
|
||||
messages.value = []
|
||||
showHistory.value = false
|
||||
nextTick(() => scrollToBottom())
|
||||
} else {
|
||||
query.value = h.query
|
||||
showHistory.value = false
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1231,7 +1241,25 @@ async function sendMessage() {
|
||||
if (searchSeq === seq) {
|
||||
scrollToBottom()
|
||||
try {
|
||||
await saveToHistory(q, JSON.stringify(messages.value), mode.value)
|
||||
// Save to session if there's a current session
|
||||
if (currentSession.value) {
|
||||
// Save user message
|
||||
await saveMessage(currentSession.value.id, {
|
||||
role: 'user',
|
||||
content: q,
|
||||
mode: mode.value
|
||||
})
|
||||
// Save assistant message with results
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
await saveMessage(currentSession.value.id, {
|
||||
role: 'assistant',
|
||||
content: lastMsg.message || lastMsg.answer || '',
|
||||
mode: mode.value,
|
||||
results: lastMsg.results
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
nextTick(() => {
|
||||
queryInputEl.value?.focus()
|
||||
@@ -1464,6 +1492,20 @@ function formatMode(mode: string | null): string {
|
||||
return map[mode || ''] || '—'
|
||||
}
|
||||
|
||||
function formatSessionTime(ts: string | null): string {
|
||||
if (!ts) return ''
|
||||
const norm = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts) ? `${ts}Z` : ts
|
||||
const d = new Date(norm)
|
||||
if (isNaN(d.getTime())) return ts
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return 'Just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatHistoryTime(ts: string | null): string {
|
||||
if (!ts) return ''
|
||||
// SQLite datetime('now') writes "YYYY-MM-DD HH:MM:SS" (no TZ) while Rust writes
|
||||
@@ -1840,6 +1882,24 @@ function openSource(src: SourceInfo) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hs-item:hover .hs-menu-btn { visibility: visible; }
|
||||
.hs-item.active { background: var(--primary-color); color: white; }
|
||||
.hs-item.active .hs-mode { background: rgba(255, 255, 255, 0.2); color: white; }
|
||||
.hs-item.active .hs-time { color: rgba(255, 255, 255, 0.8); }
|
||||
.hs-item.active .hs-title { color: white; font-weight: 600; }
|
||||
|
||||
.hs-new-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 4px 0;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hs-new-btn:hover { opacity: 0.9; }
|
||||
.hs-menu-btn:hover { background: var(--border-light); }
|
||||
.hs-menu {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user