All checks were successful
Build and Push Docker Image / build (push) Successful in 28m52s
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
/**
|
|
* Composable pour gérer les onglets dynamiques de l'interface unifiée.
|
|
* Onglets renderer auto-générés depuis la liste des renderers online.
|
|
* Onglets server ouverts manuellement via le drawer (fermables).
|
|
*/
|
|
import { reactive, computed, watch, onMounted, type Component } from 'vue'
|
|
import { useMediaQuery } from '@vueuse/core'
|
|
import { Radio, Server } from 'lucide-vue-next'
|
|
import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types'
|
|
|
|
export interface Tab {
|
|
id: string // "renderer-{id}", "server-{id}"
|
|
type: 'renderer' | 'server'
|
|
title: string // Nom affiché (tronqué sur mobile)
|
|
fullTitle: string // Nom complet (pour tooltip)
|
|
icon: Component
|
|
metadata?: {
|
|
rendererId?: string
|
|
serverId?: string
|
|
}
|
|
closeable: boolean // renderer: false (auto-géré), server: true (manuel)
|
|
}
|
|
|
|
interface TabsState {
|
|
tabs: Tab[]
|
|
activeTabId: string
|
|
tabHistory: string[] // Pour back/forward navigation
|
|
}
|
|
|
|
const MAX_TABS = 12 // Augmenté car onglets auto-générés
|
|
const STORAGE_KEY = 'pmo-tabs-state'
|
|
|
|
// État global partagé entre toutes les instances du composable
|
|
const state = reactive<TabsState>({
|
|
tabs: [],
|
|
activeTabId: '',
|
|
tabHistory: [],
|
|
})
|
|
|
|
// Flag pour éviter les boucles de sauvegarde
|
|
let isRestoringFromStorage = false
|
|
|
|
/**
|
|
* Retourne le titre complet sans troncature
|
|
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
|
*/
|
|
function truncateTitle(title: string): string {
|
|
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
|
|
return title
|
|
}
|
|
|
|
/**
|
|
* Sauvegarde l'état dans localStorage
|
|
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
|
*/
|
|
function saveToLocalStorage() {
|
|
if (isRestoringFromStorage) return
|
|
|
|
try {
|
|
const stateToSave = {
|
|
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
|
tabs: state.tabs
|
|
.filter((tab) => tab.type === 'server')
|
|
.map((tab) => ({
|
|
...tab,
|
|
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
|
icon: undefined,
|
|
})),
|
|
activeTabId: state.activeTabId,
|
|
tabHistory: state.tabHistory,
|
|
}
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave))
|
|
} catch (error) {
|
|
console.error('[useTabs] Erreur sauvegarde localStorage:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restaure l'état depuis localStorage
|
|
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
|
*/
|
|
function restoreFromLocalStorage() {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY)
|
|
if (!saved) return
|
|
|
|
isRestoringFromStorage = true
|
|
const savedState = JSON.parse(saved)
|
|
|
|
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
|
const serverTabs = (savedState.tabs || [])
|
|
.filter((tab: Tab) => tab.type === 'server')
|
|
.map((tab: Tab) => ({
|
|
...tab,
|
|
icon: Server,
|
|
closeable: true,
|
|
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
|
|
}))
|
|
|
|
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
|
|
state.tabs.push(...serverTabs)
|
|
|
|
state.activeTabId = savedState.activeTabId || ''
|
|
state.tabHistory = savedState.tabHistory || []
|
|
|
|
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
|
|
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
|
state.activeTabId = ''
|
|
}
|
|
|
|
isRestoringFromStorage = false
|
|
} catch (error) {
|
|
console.error('[useTabs] Erreur restauration localStorage:', error)
|
|
isRestoringFromStorage = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trouve un onglet par son ID
|
|
*/
|
|
function findTab(tabId: string): Tab | undefined {
|
|
return state.tabs.find((t) => t.id === tabId)
|
|
}
|
|
|
|
/**
|
|
* Ouvre un nouvel onglet ou active un onglet existant
|
|
*/
|
|
function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitle?: string }): string {
|
|
// Générer un ID si non fourni
|
|
const tabId =
|
|
newTab.id ||
|
|
(newTab.type === 'renderer'
|
|
? `renderer-${newTab.metadata?.rendererId}`
|
|
: `server-${newTab.metadata?.serverId}`)
|
|
|
|
// Si l'onglet existe déjà, on le sélectionne
|
|
const existingTab = findTab(tabId)
|
|
if (existingTab) {
|
|
switchTab(tabId)
|
|
return tabId
|
|
}
|
|
|
|
// Vérifier la limite max
|
|
if (state.tabs.length >= MAX_TABS) {
|
|
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`)
|
|
return state.activeTabId
|
|
}
|
|
|
|
// Créer le nouvel onglet
|
|
const tab: Tab = {
|
|
id: tabId,
|
|
type: newTab.type,
|
|
title: truncateTitle(newTab.title),
|
|
fullTitle: newTab.fullTitle || newTab.title, // Utiliser fullTitle si fourni, sinon title
|
|
icon: newTab.icon,
|
|
metadata: newTab.metadata,
|
|
closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false
|
|
}
|
|
|
|
state.tabs.push(tab)
|
|
switchTab(tabId)
|
|
|
|
return tabId
|
|
}
|
|
|
|
/**
|
|
* Ferme un onglet (uniquement les onglets server manuels)
|
|
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
|
|
*/
|
|
function closeTab(tabId: string) {
|
|
const tab = findTab(tabId)
|
|
if (!tab) return
|
|
|
|
// Ne fermer que les onglets server (closeable = true)
|
|
// Les renderer tabs sont auto-gérés par syncWithRenderers
|
|
if (!tab.closeable || tab.type === 'renderer') {
|
|
console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)')
|
|
return
|
|
}
|
|
|
|
const tabIndex = state.tabs.findIndex((t) => t.id === tabId)
|
|
if (tabIndex === -1) return
|
|
|
|
// Supprimer l'onglet
|
|
state.tabs.splice(tabIndex, 1)
|
|
|
|
// Supprimer de l'historique
|
|
state.tabHistory = state.tabHistory.filter((id) => id !== tabId)
|
|
|
|
// Si c'était l'onglet actif, basculer vers le précédent dans l'historique
|
|
if (state.activeTabId === tabId) {
|
|
// Chercher le précédent dans l'historique qui existe encore
|
|
const previousTab = state.tabHistory
|
|
.slice()
|
|
.reverse()
|
|
.find((id) => state.tabs.some((t) => t.id === id))
|
|
|
|
if (previousTab) {
|
|
switchTab(previousTab)
|
|
} else {
|
|
// Fallback sur le premier onglet disponible (ou vide)
|
|
const firstTab = state.tabs[0]
|
|
if (firstTab) {
|
|
switchTab(firstTab.id)
|
|
} else {
|
|
state.activeTabId = ''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change l'onglet actif
|
|
*/
|
|
function switchTab(tabId: string) {
|
|
const tab = findTab(tabId)
|
|
if (!tab) {
|
|
console.warn(`[useTabs] Onglet ${tabId} introuvable`)
|
|
return
|
|
}
|
|
|
|
state.activeTabId = tabId
|
|
|
|
// Ajouter à l'historique (en évitant les doublons consécutifs)
|
|
if (state.tabHistory[state.tabHistory.length - 1] !== tabId) {
|
|
state.tabHistory.push(tabId)
|
|
|
|
// Limiter la taille de l'historique
|
|
if (state.tabHistory.length > 20) {
|
|
state.tabHistory.shift()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Onglet suivant (pour swipe gesture)
|
|
*/
|
|
function nextTab() {
|
|
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
|
const nextIndex = (currentIndex + 1) % state.tabs.length
|
|
const nextTabObj = state.tabs[nextIndex]
|
|
if (nextTabObj) switchTab(nextTabObj.id)
|
|
}
|
|
|
|
/**
|
|
* Onglet précédent (pour swipe gesture)
|
|
*/
|
|
function previousTab() {
|
|
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
|
const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1
|
|
const prevTabObj = state.tabs[previousIndex]
|
|
if (prevTabObj) switchTab(prevTabObj.id)
|
|
}
|
|
|
|
/**
|
|
* Synchronise les onglets avec la liste des renderers online
|
|
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
|
|
*/
|
|
function syncWithRenderers(renderers: RendererSummary[]) {
|
|
const onlineRenderers = renderers.filter((r) => r.online)
|
|
|
|
// Extraire les tabs renderer actuels
|
|
const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer')
|
|
|
|
// IDs des renderers online
|
|
const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`))
|
|
|
|
// Supprimer les tabs des renderers qui ne sont plus online
|
|
const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id))
|
|
renderersToRemove.forEach((tab) => {
|
|
const index = state.tabs.findIndex((t) => t.id === tab.id)
|
|
if (index !== -1) state.tabs.splice(index, 1)
|
|
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id)
|
|
})
|
|
|
|
// Ajouter les nouveaux renderers online
|
|
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id))
|
|
onlineRenderers.forEach((renderer) => {
|
|
const tabId = `renderer-${renderer.id}`
|
|
if (!currentRendererIds.has(tabId)) {
|
|
const newTab: Tab = {
|
|
id: tabId,
|
|
type: 'renderer',
|
|
title: truncateTitle(renderer.friendly_name),
|
|
fullTitle: renderer.friendly_name,
|
|
icon: Radio,
|
|
metadata: { rendererId: renderer.id },
|
|
closeable: false, // renderer tabs ne sont pas fermables manuellement
|
|
}
|
|
state.tabs.unshift(newTab) // Ajouter au début
|
|
}
|
|
})
|
|
|
|
// Vérifier que l'onglet actif existe toujours
|
|
if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) {
|
|
// Basculer vers le premier onglet disponible
|
|
const firstTab = state.tabs[0]
|
|
if (firstTab) {
|
|
state.activeTabId = firstTab.id
|
|
} else {
|
|
state.activeTabId = ''
|
|
}
|
|
}
|
|
|
|
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
|
|
if (!state.activeTabId && state.tabs.length > 0) {
|
|
const firstTab = state.tabs[0]
|
|
if (firstTab) {
|
|
state.activeTabId = firstTab.id
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ouvre un onglet server (manuel)
|
|
*/
|
|
function openServer(server: MediaServerSummary | undefined) {
|
|
if (!server) return ''
|
|
|
|
return openTab({
|
|
type: 'server',
|
|
title: server.friendly_name,
|
|
icon: Server,
|
|
metadata: { serverId: server.id },
|
|
closeable: true,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Composable principal
|
|
*/
|
|
export function useTabs() {
|
|
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
|
watch(
|
|
() => [state.tabs, state.activeTabId, state.tabHistory],
|
|
() => {
|
|
saveToLocalStorage()
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
// Restaurer au montage (uniquement les server tabs)
|
|
onMounted(() => {
|
|
// Restaurer seulement si pas déjà fait
|
|
if (state.tabs.filter((t) => t.type === 'server').length === 0) {
|
|
restoreFromLocalStorage()
|
|
}
|
|
})
|
|
|
|
// Détection de la largeur d'écran pour le mode compact
|
|
// Mode compact sur les écrans < 900px (tablettes et mobiles)
|
|
const isNarrowScreen = useMediaQuery('(max-width: 900px)')
|
|
|
|
// Computed properties
|
|
const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null)
|
|
const hasMultipleTabs = computed(() => state.tabs.length > 1)
|
|
const canAddTab = computed(() => state.tabs.length < MAX_TABS)
|
|
const isEmpty = computed(() => state.tabs.length === 0)
|
|
const compactMode = computed(() => isNarrowScreen.value)
|
|
|
|
return {
|
|
// State
|
|
tabs: computed(() => state.tabs),
|
|
activeTabId: computed(() => state.activeTabId),
|
|
activeTab,
|
|
tabHistory: computed(() => state.tabHistory),
|
|
hasMultipleTabs,
|
|
canAddTab,
|
|
isEmpty,
|
|
compactMode,
|
|
|
|
// Actions
|
|
syncWithRenderers, // Nouvelle fonction clé pour auto-sync
|
|
openTab,
|
|
closeTab,
|
|
switchTab,
|
|
nextTab,
|
|
previousTab,
|
|
openServer,
|
|
findTab,
|
|
}
|
|
}
|