421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { computed, ref, watch, type Ref } from 'vue'
|
|
|
|
import type { WidgetDefinition, WidgetHeight, WidgetSize, WidgetTab, WidgetTabsState } from '../src/services/widgetsRegistry'
|
|
|
|
const DECK_WIDGET_TYPES = new Set(['deck_cards', 'deck_stats'])
|
|
|
|
export type WidgetRegistryEntry = {
|
|
label?: string
|
|
defaultLayout: WidgetDefinition['layout']
|
|
}
|
|
|
|
export function useWidgetLayoutManager(options: {
|
|
storageKey: string
|
|
widgetsRegistry: Record<string, WidgetRegistryEntry>
|
|
createDefaultTabs: () => WidgetTabsState
|
|
normalizeWidgetTabs: (input: any, fallback: WidgetTabsState) => WidgetTabsState
|
|
createDashboardPreset: (mode: 'quick' | 'standard' | 'pro') => WidgetDefinition[]
|
|
dashboardMode: Ref<'quick' | 'standard' | 'pro'>
|
|
deckEnabled: Ref<boolean>
|
|
hasInitialLoad: Ref<boolean>
|
|
queueSaveRef: Ref<null | ((silent?: boolean) => void)>
|
|
}) {
|
|
const {
|
|
storageKey,
|
|
widgetsRegistry,
|
|
createDefaultTabs,
|
|
normalizeWidgetTabs,
|
|
createDashboardPreset,
|
|
dashboardMode,
|
|
deckEnabled,
|
|
hasInitialLoad,
|
|
queueSaveRef,
|
|
} = options
|
|
|
|
function loadWidgetTabs(): WidgetTabsState {
|
|
return createDefaultTabs()
|
|
}
|
|
|
|
const layoutTabs = ref<WidgetTab[]>(loadWidgetTabs().tabs)
|
|
const defaultTabId = ref(loadWidgetTabs().defaultTabId)
|
|
const activeTabId = ref(defaultTabId.value)
|
|
const widgetsDirty = ref(false)
|
|
const isLayoutEditing = ref(false)
|
|
const newWidgetType = ref('')
|
|
|
|
function persistWidgets() {
|
|
if (hasInitialLoad.value && widgetsDirty.value) {
|
|
queueSaveRef.value?.(false)
|
|
widgetsDirty.value = false
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => hasInitialLoad.value,
|
|
(ready) => {
|
|
if (ready && widgetsDirty.value) {
|
|
persistWidgets()
|
|
}
|
|
},
|
|
)
|
|
|
|
void storageKey
|
|
|
|
const activeTab = computed(() => {
|
|
return layoutTabs.value.find((tab) => tab.id === activeTabId.value) || layoutTabs.value[0]
|
|
})
|
|
|
|
function setActiveTab(id: string) {
|
|
if (layoutTabs.value.some((tab) => tab.id === id)) {
|
|
activeTabId.value = id
|
|
}
|
|
}
|
|
|
|
function setDefaultTab(id: string) {
|
|
if (!layoutTabs.value.some((tab) => tab.id === id)) return
|
|
defaultTabId.value = id
|
|
activeTabId.value = id
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
const widgets = computed<WidgetDefinition[]>(() => {
|
|
const defs = activeTab.value?.widgets || []
|
|
if (!deckEnabled.value) {
|
|
return defs.filter((w) => !DECK_WIDGET_TYPES.has(w.type))
|
|
}
|
|
return defs
|
|
})
|
|
|
|
const availableWidgetTypes = computed(() =>
|
|
Object.keys(widgetsRegistry).map((type) => ({
|
|
type,
|
|
label: widgetsRegistry[type]?.label || type,
|
|
category: widgetsRegistry[type]?.category,
|
|
})),
|
|
)
|
|
|
|
function applyDashboardPreset(mode: 'quick' | 'standard' | 'pro') {
|
|
dashboardMode.value = mode
|
|
const tabId = activeTab.value?.id || 'tab-1'
|
|
const nextTabs = layoutTabs.value.map((tab) =>
|
|
tab.id === tabId ? { ...tab, widgets: createDashboardPreset(mode) } : tab,
|
|
)
|
|
layoutTabs.value = nextTabs
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function updateTabWidgets(tabId: string, updater: (widgets: WidgetDefinition[]) => WidgetDefinition[]) {
|
|
layoutTabs.value = layoutTabs.value.map((tab) =>
|
|
tab.id === tabId ? { ...tab, widgets: updater([...tab.widgets]) } : tab,
|
|
)
|
|
}
|
|
|
|
function updateWidget(id: string, updater: (w: WidgetDefinition) => WidgetDefinition) {
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, (items) =>
|
|
items.map((w) => (w.id === id ? updater({ ...w, layout: { ...w.layout } }) : w)),
|
|
)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function cycleWidth(id: string) {
|
|
const order: WidgetSize[] = ['quarter', 'half', 'full']
|
|
updateWidget(id, (w) => {
|
|
const idx = order.indexOf(w.layout.width as WidgetSize)
|
|
const next = order[(idx + 1) % order.length]
|
|
return { ...w, layout: { ...w.layout, width: next } }
|
|
})
|
|
}
|
|
|
|
function cycleHeight(id: string) {
|
|
const order: WidgetHeight[] = ['s', 'm', 'l', 'xl']
|
|
updateWidget(id, (w) => {
|
|
const idx = order.indexOf(w.layout.height as WidgetHeight)
|
|
const next = order[(idx + 1) % order.length]
|
|
return { ...w, layout: { ...w.layout, height: next } }
|
|
})
|
|
}
|
|
|
|
function moveWidget(id: string, dir: 'up' | 'down') {
|
|
const ordered = [...widgets.value].sort((a, b) => (a.layout.order || 0) - (b.layout.order || 0))
|
|
const idx = ordered.findIndex((w) => w.id === id)
|
|
if (idx < 0) return
|
|
const targetIdx = dir === 'up' ? idx - 1 : idx + 1
|
|
if (targetIdx < 0 || targetIdx >= ordered.length) return
|
|
const currentOrder = ordered[idx].layout.order
|
|
ordered[idx].layout.order = ordered[targetIdx].layout.order
|
|
ordered[targetIdx].layout.order = currentOrder
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, () => ordered)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function removeWidget(id: string) {
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, (items) => items.filter((w) => w.id !== id))
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function nextWidgetOrder(tabId: string) {
|
|
const tab = layoutTabs.value.find((entry) => entry.id === tabId)
|
|
const maxOrder = (tab?.widgets || []).reduce((acc, widget) => Math.max(acc, widget.layout.order || 0), 0)
|
|
return maxOrder + 10
|
|
}
|
|
|
|
function cloneWidgetDefinition(widget: WidgetDefinition, overrides: Partial<WidgetDefinition> = {}): WidgetDefinition {
|
|
return {
|
|
...widget,
|
|
...overrides,
|
|
layout: {
|
|
...widget.layout,
|
|
...(overrides.layout || {}),
|
|
},
|
|
options: widget.options ? JSON.parse(JSON.stringify(widget.options)) : {},
|
|
}
|
|
}
|
|
|
|
function moveWidgetToTab(id: string, targetTabId: string) {
|
|
const sourceTabId = activeTab.value?.id
|
|
if (!sourceTabId || sourceTabId === targetTabId) return false
|
|
const sourceTab = layoutTabs.value.find((tab) => tab.id === sourceTabId)
|
|
const widget = sourceTab?.widgets.find((entry) => entry.id === id)
|
|
if (!widget || !layoutTabs.value.some((tab) => tab.id === targetTabId)) return false
|
|
|
|
const movedWidget = cloneWidgetDefinition(widget, {
|
|
layout: {
|
|
...widget.layout,
|
|
order: nextWidgetOrder(targetTabId),
|
|
},
|
|
})
|
|
|
|
layoutTabs.value = layoutTabs.value.map((tab) => {
|
|
if (tab.id === sourceTabId) {
|
|
return { ...tab, widgets: tab.widgets.filter((entry) => entry.id !== id) }
|
|
}
|
|
if (tab.id === targetTabId) {
|
|
return { ...tab, widgets: [...tab.widgets, movedWidget] }
|
|
}
|
|
return tab
|
|
})
|
|
activeTabId.value = targetTabId
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
return true
|
|
}
|
|
|
|
function duplicateWidgetToTab(id: string, targetTabId: string) {
|
|
const sourceTabId = activeTab.value?.id
|
|
if (!sourceTabId || sourceTabId === targetTabId) return null
|
|
const sourceTab = layoutTabs.value.find((tab) => tab.id === sourceTabId)
|
|
const widget = sourceTab?.widgets.find((entry) => entry.id === id)
|
|
if (!widget || !layoutTabs.value.some((tab) => tab.id === targetTabId)) return null
|
|
|
|
const duplicatedWidget = cloneWidgetDefinition(widget, {
|
|
id: `widget-${widget.type}-${Date.now().toString(36)}`,
|
|
layout: {
|
|
...widget.layout,
|
|
order: nextWidgetOrder(targetTabId),
|
|
},
|
|
})
|
|
|
|
layoutTabs.value = layoutTabs.value.map((tab) =>
|
|
tab.id === targetTabId ? { ...tab, widgets: [...tab.widgets, duplicatedWidget] } : tab,
|
|
)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
return duplicatedWidget.id
|
|
}
|
|
|
|
function addWidget(type: string) {
|
|
const entry = widgetsRegistry[type]
|
|
if (!entry) return
|
|
const maxOrder = widgets.value.reduce((acc, w) => Math.max(acc, w.layout.order || 0), 0)
|
|
const def: WidgetDefinition = {
|
|
id: `widget-${type}-${Date.now()}`,
|
|
type,
|
|
options: {},
|
|
layout: { ...entry.defaultLayout, order: maxOrder + 10 },
|
|
version: 1,
|
|
}
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, (items) => [...items, def])
|
|
newWidgetType.value = ''
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function addWidgetAt(type: string, orderHint?: number) {
|
|
const entry = widgetsRegistry[type]
|
|
if (!entry) return
|
|
const maxOrder = widgets.value.reduce((acc, w) => Math.max(acc, w.layout.order || 0), 0)
|
|
let order = Number.isFinite(orderHint) ? Number(orderHint) : maxOrder + 10
|
|
if (!Number.isFinite(order)) order = maxOrder + 10
|
|
while (widgets.value.some((w) => w.layout.order === order)) {
|
|
order += 0.1
|
|
}
|
|
const def: WidgetDefinition = {
|
|
id: `widget-${type}-${Date.now()}`,
|
|
type,
|
|
options: {},
|
|
layout: { ...entry.defaultLayout, order },
|
|
version: 1,
|
|
}
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, (items) => [...items, def])
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function reorderWidget(id: string, orderHint?: number | null) {
|
|
if (!Number.isFinite(orderHint ?? NaN)) return
|
|
const nextOrder = Number(orderHint)
|
|
const items = widgets.value.map((w) =>
|
|
w.id === id ? { ...w, layout: { ...w.layout, order: nextOrder } } : { ...w },
|
|
)
|
|
items.sort((a, b) => (a.layout.order || 0) - (b.layout.order || 0))
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, () =>
|
|
items.map((w, idx) => ({
|
|
...w,
|
|
layout: { ...w.layout, order: (idx + 1) * 10 },
|
|
})),
|
|
)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function updateWidgetOptions(id: string, key: string, value: any) {
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, (items) =>
|
|
items.map((w) => {
|
|
if (w.id !== id) return w
|
|
const opts = { ...(w.options || {}) }
|
|
if (key.includes('.')) {
|
|
const parts = key.split('.')
|
|
const next = { ...opts }
|
|
let cursor: any = next
|
|
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
const p = parts[i]
|
|
cursor[p] = { ...(cursor[p] || {}) }
|
|
cursor = cursor[p]
|
|
}
|
|
cursor[parts[parts.length - 1]] = value
|
|
return { ...w, options: next }
|
|
}
|
|
if (key === 'scale') {
|
|
opts.scale = value
|
|
if ('textSize' in opts) {
|
|
delete (opts as any).textSize
|
|
}
|
|
return { ...w, options: opts }
|
|
}
|
|
opts[key] = value
|
|
return { ...w, options: opts }
|
|
}),
|
|
)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function resetWidgets() {
|
|
const tabId = activeTab.value?.id
|
|
if (!tabId) return
|
|
updateTabWidgets(tabId, () => createDashboardPreset(dashboardMode.value))
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function addTab(label?: string) {
|
|
const nextId = `tab-${Date.now().toString(36)}`
|
|
const baseLabel = String(label ?? '').trim() || `Tab ${layoutTabs.value.length + 1}`
|
|
const tab: WidgetTab = {
|
|
id: nextId,
|
|
label: baseLabel.slice(0, 48),
|
|
widgets: [],
|
|
}
|
|
layoutTabs.value = [...layoutTabs.value, tab]
|
|
activeTabId.value = tab.id
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function renameTab(id: string, label: string) {
|
|
const nextLabel = String(label || '').trim().slice(0, 48)
|
|
if (!nextLabel) return
|
|
layoutTabs.value = layoutTabs.value.map((tab) =>
|
|
tab.id === id ? { ...tab, label: nextLabel } : tab,
|
|
)
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function removeTab(id: string) {
|
|
if (layoutTabs.value.length <= 1) return
|
|
layoutTabs.value = layoutTabs.value.filter((tab) => tab.id !== id)
|
|
if (defaultTabId.value === id) {
|
|
defaultTabId.value = layoutTabs.value[0]?.id || ''
|
|
}
|
|
if (activeTabId.value === id) {
|
|
activeTabId.value = layoutTabs.value[0]?.id || ''
|
|
}
|
|
widgetsDirty.value = true
|
|
persistWidgets()
|
|
}
|
|
|
|
function setTabsFromPayload(payload: any) {
|
|
const fallback = createDefaultTabs()
|
|
const normalized = normalizeWidgetTabs(payload, fallback)
|
|
layoutTabs.value = normalized.tabs
|
|
defaultTabId.value = normalized.defaultTabId
|
|
const currentActive = activeTabId.value
|
|
if (currentActive && normalized.tabs.some((tab) => tab.id === currentActive)) {
|
|
activeTabId.value = currentActive
|
|
} else {
|
|
activeTabId.value = normalized.defaultTabId
|
|
}
|
|
}
|
|
|
|
return {
|
|
layoutTabs,
|
|
defaultTabId,
|
|
activeTabId,
|
|
widgetsDirty,
|
|
isLayoutEditing,
|
|
newWidgetType,
|
|
widgets,
|
|
availableWidgetTypes,
|
|
persistWidgets,
|
|
applyDashboardPreset,
|
|
updateWidget,
|
|
cycleWidth,
|
|
cycleHeight,
|
|
moveWidget,
|
|
moveWidgetToTab,
|
|
duplicateWidgetToTab,
|
|
removeWidget,
|
|
addWidget,
|
|
addWidgetAt,
|
|
reorderWidget,
|
|
updateWidgetOptions,
|
|
resetWidgets,
|
|
activeTab,
|
|
setActiveTab,
|
|
setDefaultTab,
|
|
addTab,
|
|
renameTab,
|
|
removeTab,
|
|
setTabsFromPayload,
|
|
}
|
|
}
|