Toolbar is now view-provided via useToolbar composable
Each view declares its toolbar groups and connection via provideToolbar(). AppToolbar injects and renders only what the active view needs. - AgentsView: quad-view + themes + panels + chat connection - TestsView: themes + test SSE connection (replaces raw EventSource) - ViewerView: themes only - Home/Login: no provide → toolbar hidden useConnection() manages any SSE endpoint: connect, reconnect, state. Tenant feature gating follows naturally — routes that don't exist contribute no toolbar config. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4abbe86963
commit
409d873aa0
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="isLoggedIn" class="app-toolbar">
|
<div v-if="isLoggedIn && toolbar" class="app-toolbar">
|
||||||
|
|
||||||
<!-- Group: Connection + Takeover -->
|
<!-- Group: Connection + Takeover (only if view provides a connection) -->
|
||||||
<div class="toolbar-group">
|
<div v-if="conn" class="toolbar-group">
|
||||||
<button class="toolbar-btn" :class="{ active: connActive }" @click="toggleDropdown('conn')" :title="connLabel || 'Connection'">
|
<button class="toolbar-btn" :class="{ active: connActive }" @click="toggleDropdown('conn')" :title="connLabel || 'Connection'">
|
||||||
<WifiIcon class="w-6 h-6" />
|
<WifiIcon class="w-6 h-6" />
|
||||||
<span v-if="connLabel" class="toolbar-btn-label">{{ connLabel }}</span>
|
<span v-if="connLabel" class="toolbar-btn-label">{{ connLabel }}</span>
|
||||||
@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
<div class="toolbar-spacer" />
|
<div class="toolbar-spacer" />
|
||||||
|
|
||||||
<!-- Group: Quad view (pane toggles) -->
|
<!-- Group: Quad view — pane toggles (nyx only) -->
|
||||||
<div class="toolbar-group">
|
<div v-if="hasGroup('quad-view')" class="toolbar-group">
|
||||||
<button class="toolbar-btn" :class="{ active: isPaneOpen('chat') }" @click="togglePane('chat')" title="Chat">
|
<button class="toolbar-btn" :class="{ active: isPaneOpen('chat') }" @click="togglePane('chat')" title="Chat">
|
||||||
<ChatBubbleLeftIcon class="w-6 h-6" />
|
<ChatBubbleLeftIcon class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -31,28 +31,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group: Themes -->
|
<!-- Group: Themes -->
|
||||||
<div class="toolbar-group">
|
<div v-if="hasGroup('themes')" class="toolbar-group">
|
||||||
<button
|
<button v-for="t in THEMES" :key="t" class="toolbar-btn" :class="{ active: theme === t }" @click="setTheme(t)" :title="THEME_NAMES[t]">
|
||||||
v-for="t in THEMES"
|
|
||||||
:key="t"
|
|
||||||
class="toolbar-btn"
|
|
||||||
:class="{ active: theme === t }"
|
|
||||||
@click="setTheme(t)"
|
|
||||||
:title="THEME_NAMES[t]"
|
|
||||||
>
|
|
||||||
<component :is="THEME_ICONS[t]" class="w-6 h-6" />
|
<component :is="THEME_ICONS[t]" class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group: Panel config + version -->
|
<!-- Group: Debug panels + version (nyx only) -->
|
||||||
<div class="toolbar-group">
|
<div v-if="hasGroup('panels')" class="toolbar-group">
|
||||||
<button
|
<button
|
||||||
v-for="panel in availablePanels"
|
v-for="panel in availablePanels" :key="panel.id"
|
||||||
:key="panel.id"
|
class="toolbar-btn" :class="{ active: isPanelOpen(panel.id) }"
|
||||||
class="toolbar-btn"
|
@click="togglePanelBtn(panel.id)" :title="panel.label"
|
||||||
:class="{ active: isPanelOpen(panel.id) }"
|
|
||||||
@click="togglePanelBtn(panel.id)"
|
|
||||||
:title="panel.label"
|
|
||||||
>
|
>
|
||||||
<component :is="panelIcon(panel.id)" class="w-6 h-6" />
|
<component :is="panelIcon(panel.id)" class="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -66,10 +56,9 @@
|
|||||||
|
|
||||||
<div v-if="openDropdown === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
|
<div v-if="openDropdown === 'conn'" class="toolbar-panel" style="right: auto; left: 0;">
|
||||||
<div class="toolbar-panel-header">Connection</div>
|
<div class="toolbar-panel-header">Connection</div>
|
||||||
<div class="toolbar-panel-row"><span>HTTP</span><span>{{ chatStore.connectionState }}</span></div>
|
<template v-for="(val, key) in connDetails" :key="key">
|
||||||
<div class="toolbar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
<div class="toolbar-panel-row"><span>{{ key }}</span><span>{{ val }}</span></div>
|
||||||
<div class="toolbar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
</template>
|
||||||
<div class="toolbar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="openDropdown === 'takeover'" class="toolbar-panel" style="right: 0;">
|
<div v-if="openDropdown === 'takeover'" class="toolbar-panel" style="right: 0;">
|
||||||
@ -94,43 +83,34 @@ defineOptions({ name: 'AppToolbar' });
|
|||||||
import { ref, computed, type Component } from 'vue';
|
import { ref, computed, type Component } from 'vue';
|
||||||
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid';
|
import { WifiIcon, SignalIcon, QueueListIcon, CpuChipIcon, EyeIcon, CircleStackIcon, ChatBubbleLeftIcon, RectangleGroupIcon, FolderIcon, SparklesIcon } from '@heroicons/vue/20/solid';
|
||||||
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
|
import { THEME_ICONS, THEME_NAMES, useTheme, type Theme } from '../composables/useTheme';
|
||||||
import { ws, auth, agents, takeover } from '../store';
|
import { ws, auth, takeover } from '../store';
|
||||||
import { useChatStore } from '../store/chat';
|
import { useChatStore } from '../store/chat';
|
||||||
import { useUI } from '../composables/ui';
|
import { useUI } from '../composables/ui';
|
||||||
import { usePanels, type PanelId, type PaneId } from '../composables/usePanels';
|
import { usePanels, type PanelId } from '../composables/usePanels';
|
||||||
|
import { injectToolbar } from '../composables/useToolbar';
|
||||||
|
|
||||||
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
|
const THEMES: Theme[] = ['loop42', 'titan', 'eras'];
|
||||||
|
|
||||||
const { isLoggedIn } = auth;
|
const { isLoggedIn } = auth;
|
||||||
const { selectedAgent, selectedMode } = agents;
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
// Panels + panes
|
|
||||||
const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels();
|
const { availablePanels, togglePanel: togglePanelBtn, isPanelOpen, togglePane, isPaneOpen } = usePanels();
|
||||||
|
|
||||||
|
// Toolbar config — injected from active view
|
||||||
|
const toolbar = injectToolbar();
|
||||||
|
const hasGroup = (g: string) => toolbar?.groups.includes(g as any) ?? false;
|
||||||
|
const conn = computed(() => toolbar?.connection);
|
||||||
|
const connActive = computed(() => conn.value?.connected.value ?? false);
|
||||||
|
const connLabel = computed(() => conn.value?.label.value ?? '');
|
||||||
|
const connDetails = computed(() => conn.value?.details?.() ?? {});
|
||||||
|
|
||||||
const PANEL_ICONS: Record<PanelId, Component> = {
|
const PANEL_ICONS: Record<PanelId, Component> = {
|
||||||
graph: CircleStackIcon,
|
graph: CircleStackIcon,
|
||||||
trace: QueueListIcon,
|
trace: QueueListIcon,
|
||||||
nodes: CpuChipIcon,
|
nodes: CpuChipIcon,
|
||||||
awareness: EyeIcon,
|
awareness: EyeIcon,
|
||||||
};
|
};
|
||||||
|
function panelIcon(id: PanelId): Component { return PANEL_ICONS[id] || CpuChipIcon; }
|
||||||
function panelIcon(id: PanelId): Component {
|
|
||||||
return PANEL_ICONS[id] || CpuChipIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection
|
|
||||||
const connLabel = computed(() => {
|
|
||||||
switch (chatStore.connectionState) {
|
|
||||||
case 'CONNECTING': return 'Connecting...';
|
|
||||||
case 'LOADING_HISTORY': return 'Loading...';
|
|
||||||
case 'SWITCHING': return 'Switching...';
|
|
||||||
case 'SYNCED': return 'Connected';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const connActive = computed(() => chatStore.connectionState === 'SYNCED');
|
|
||||||
|
|
||||||
// Takeover
|
// Takeover
|
||||||
const takeoverToken = takeover.token;
|
const takeoverToken = takeover.token;
|
||||||
@ -138,20 +118,15 @@ const captureActive = takeover.capture.isActive;
|
|||||||
const tokenCopied = ref(false);
|
const tokenCopied = ref(false);
|
||||||
|
|
||||||
async function toggleCapture() {
|
async function toggleCapture() {
|
||||||
if (captureActive.value) {
|
if (captureActive.value) takeover.capture.disable();
|
||||||
takeover.capture.disable();
|
else await takeover.capture.enable();
|
||||||
} else {
|
|
||||||
await takeover.capture.enable();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function copyToken() {
|
function copyToken() {
|
||||||
if (!takeoverToken.value) return;
|
if (!takeoverToken.value) return;
|
||||||
navigator.clipboard.writeText(takeoverToken.value);
|
navigator.clipboard.writeText(takeoverToken.value);
|
||||||
tokenCopied.value = true;
|
tokenCopied.value = true;
|
||||||
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeAndClose() {
|
function revokeAndClose() {
|
||||||
takeover.revoke();
|
takeover.revoke();
|
||||||
openDropdown.value = null;
|
openDropdown.value = null;
|
||||||
@ -162,18 +137,11 @@ const { version } = useUI(ws.status);
|
|||||||
const versionShort = version.split('-')[0];
|
const versionShort = version.split('-')[0];
|
||||||
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`);
|
const versionFull = computed(() => `${envLabel} | fe: ${version} | be: ${chatStore.beVersion || '...'}`);
|
||||||
const versionCopied = ref(false);
|
|
||||||
|
|
||||||
function copyVersionDetails() {
|
function copyVersionDetails() {
|
||||||
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
|
navigator.clipboard.writeText(`env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`);
|
||||||
navigator.clipboard.writeText(details);
|
|
||||||
versionCopied.value = true;
|
|
||||||
setTimeout(() => { versionCopied.value = false; }, 2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar dropdown (conn/takeover panels — distinct from debug panel toggles)
|
|
||||||
const openDropdown = ref<'conn' | 'takeover' | null>(null);
|
const openDropdown = ref<'conn' | 'takeover' | null>(null);
|
||||||
|
|
||||||
function toggleDropdown(panel: 'conn' | 'takeover') {
|
function toggleDropdown(panel: 'conn' | 'takeover') {
|
||||||
openDropdown.value = openDropdown.value === panel ? null : panel;
|
openDropdown.value = openDropdown.value === panel ? null : panel;
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/composables/useToolbar.ts
Normal file
80
src/composables/useToolbar.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Toolbar configuration — views provide their toolbar needs, AppToolbar injects.
|
||||||
|
*
|
||||||
|
* Each view declares:
|
||||||
|
* - which groups to show (quad-view, themes, panels)
|
||||||
|
* - which connection to surface (optional)
|
||||||
|
*
|
||||||
|
* AppToolbar renders only what the active view declares.
|
||||||
|
* Views that don't provide → toolbar shows nothing (login, home).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { inject, provide, onMounted, onUnmounted, ref, type InjectionKey, type Ref } from 'vue';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface ToolbarConnection {
|
||||||
|
connected: Ref<boolean>;
|
||||||
|
label: Ref<string>; // shown when not idle (e.g. "Connecting...")
|
||||||
|
details?: () => Record<string, string>; // for dropdown panel rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolbarGroup = 'quad-view' | 'themes' | 'panels';
|
||||||
|
|
||||||
|
export interface ToolbarConfig {
|
||||||
|
connection?: ToolbarConnection;
|
||||||
|
groups: ToolbarGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Injection key ---
|
||||||
|
|
||||||
|
const TOOLBAR_KEY: InjectionKey<ToolbarConfig> = Symbol('toolbar');
|
||||||
|
|
||||||
|
export function provideToolbar(config: ToolbarConfig) {
|
||||||
|
provide(TOOLBAR_KEY, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectToolbar(): ToolbarConfig | undefined {
|
||||||
|
return inject(TOOLBAR_KEY, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- useConnection: manages an SSE endpoint, returns connection state ---
|
||||||
|
|
||||||
|
export function useConnection(
|
||||||
|
url: string,
|
||||||
|
onMessage?: (data: any) => void,
|
||||||
|
): ToolbarConnection {
|
||||||
|
const connected = ref(false);
|
||||||
|
const label = ref('Connecting...');
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
es?.close();
|
||||||
|
es = new EventSource(url);
|
||||||
|
es.onopen = () => {
|
||||||
|
connected.value = true;
|
||||||
|
label.value = '';
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
connected.value = false;
|
||||||
|
label.value = 'Disconnected';
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
retryTimer = setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
if (!onMessage) return;
|
||||||
|
try { onMessage(JSON.parse(e.data)); } catch {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(connect);
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
es?.close();
|
||||||
|
es = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { connected, label };
|
||||||
|
}
|
||||||
@ -75,7 +75,9 @@ defineOptions({ name: 'AgentsView' });
|
|||||||
import { ref, watch, computed } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { ws, auth, agents } from '../store';
|
import { ws, auth, agents } from '../store';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
import { usePanels } from '../composables/usePanels';
|
import { usePanels } from '../composables/usePanels';
|
||||||
|
import { provideToolbar } from '../composables/useToolbar';
|
||||||
import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
import { LockClosedIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||||
import ChatPane from '../components/ChatPane.vue';
|
import ChatPane from '../components/ChatPane.vue';
|
||||||
import ContentLayout from '../components/ContentLayout.vue';
|
import ContentLayout from '../components/ContentLayout.vue';
|
||||||
@ -96,6 +98,29 @@ const { connected, send: wsSend } = ws;
|
|||||||
const { isLoggedIn } = auth;
|
const { isLoggedIn } = auth;
|
||||||
const { selectedAgent, selectedMode, filteredAgents, defaultAgent, allAgents } = agents;
|
const { selectedAgent, selectedMode, filteredAgents, defaultAgent, allAgents } = agents;
|
||||||
|
|
||||||
|
// Toolbar — wrap chatStore state into ToolbarConnection shape
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
provideToolbar({
|
||||||
|
groups: ['quad-view', 'themes', 'panels'],
|
||||||
|
connection: {
|
||||||
|
connected: computed(() => chatStore.connectionState === 'SYNCED'),
|
||||||
|
label: computed(() => {
|
||||||
|
switch (chatStore.connectionState) {
|
||||||
|
case 'CONNECTING': return 'Connecting...';
|
||||||
|
case 'LOADING_HISTORY': return 'Loading...';
|
||||||
|
case 'SWITCHING': return 'Switching...';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
details: () => ({
|
||||||
|
HTTP: chatStore.connectionState,
|
||||||
|
Channel: chatStore.channelState,
|
||||||
|
Agent: selectedAgent.value || 'none',
|
||||||
|
Mode: selectedMode.value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Agent picker — disabled for now (auto-select default agent)
|
// Agent picker — disabled for now (auto-select default agent)
|
||||||
// TODO: re-enable when multi-agent UX is designed
|
// TODO: re-enable when multi-agent UX is designed
|
||||||
const showPicker = computed(() => false);
|
const showPicker = computed(() => false);
|
||||||
|
|||||||
@ -57,9 +57,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { getApiBase } from '../utils/apiBase';
|
import { getApiBase } from '../utils/apiBase';
|
||||||
import { relativeTime } from '../utils/relativeTime';
|
import { relativeTime } from '../utils/relativeTime';
|
||||||
|
import { provideToolbar, useConnection } from '../composables/useToolbar';
|
||||||
|
|
||||||
interface TestStats {
|
interface TestStats {
|
||||||
runs: number;
|
runs: number;
|
||||||
@ -87,7 +88,10 @@ const runId = ref('');
|
|||||||
const startedAt = ref('');
|
const startedAt = ref('');
|
||||||
const updatedAt = ref('');
|
const updatedAt = ref('');
|
||||||
const expanded = ref<Set<string>>(new Set());
|
const expanded = ref<Set<string>>(new Set());
|
||||||
let eventSource: EventSource | null = null;
|
|
||||||
|
// SSE connection — managed by useConnection, state surfaced in toolbar
|
||||||
|
const testConn = useConnection(`${getApiBase()}/api/test-results`, handleResult);
|
||||||
|
provideToolbar({ groups: ['themes'], connection: testConn });
|
||||||
|
|
||||||
|
|
||||||
const displayResults = computed(() =>
|
const displayResults = computed(() =>
|
||||||
@ -133,16 +137,13 @@ function handleResult(data: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const base = getApiBase();
|
// Fetch latest snapshot on load (SSE delivers live updates via useConnection)
|
||||||
|
fetch(`${getApiBase()}/api/test-results/latest`)
|
||||||
// First fetch snapshot
|
|
||||||
fetch(`${base}/api/test-results/latest`)
|
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.run_id) runId.value = data.run_id;
|
if (data.run_id) runId.value = data.run_id;
|
||||||
if (data.results) {
|
if (data.results) {
|
||||||
results.value = data.results;
|
results.value = data.results;
|
||||||
// Extract timestamps from results
|
|
||||||
const timestamps = data.results.map((r: any) => r.ts).filter(Boolean).sort();
|
const timestamps = data.results.map((r: any) => r.ts).filter(Boolean).sort();
|
||||||
if (timestamps.length) {
|
if (timestamps.length) {
|
||||||
startedAt.value = timestamps[0];
|
startedAt.value = timestamps[0];
|
||||||
@ -151,20 +152,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Then connect SSE for live updates
|
|
||||||
eventSource = new EventSource(`${base}/api/test-results`);
|
|
||||||
eventSource.onmessage = (e) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.run_id) handleResult(data);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
eventSource?.close();
|
|
||||||
eventSource = null;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -85,8 +85,10 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
|||||||
import { scrollbarOptions } from '../composables/useScrollbar';
|
import { scrollbarOptions } from '../composables/useScrollbar';
|
||||||
import { FolderOpenIcon, FolderIcon, DocumentIcon, ArrowDownTrayIcon } from '@heroicons/vue/20/solid';
|
import { FolderOpenIcon, FolderIcon, DocumentIcon, ArrowDownTrayIcon } from '@heroicons/vue/20/solid';
|
||||||
import { useViewer } from '../composables/useViewer';
|
import { useViewer } from '../composables/useViewer';
|
||||||
|
import { provideToolbar } from '../composables/useToolbar';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
provideToolbar({ groups: ['themes'] });
|
||||||
import { getApiBase } from '../utils/apiBase';
|
import { getApiBase } from '../utils/apiBase';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
Reference in New Issue
Block a user