Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 160 additions & 5 deletions src/components/ExternalFeaturesDiscoveryModal.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
<template>
<GlassModal :is-visible="isVisible" position="center">
<GlassModal :is-visible="isVisible" position="center" no-close-on-outside-click @outside-click="requestCloseModal">
<div class="features-modal p-4 max-w-[95vw]">
<div class="flex justify-center items-center mb-2">
<h2 class="text-xl font-semibold">BlueOS Extension Features</h2>
</div>
<div class="fixed top-1 right-1">
<v-btn icon="mdi-close" size="small" variant="text" class="text-lg" @click="closeModal"></v-btn>
<v-btn icon="mdi-close" size="small" variant="text" class="text-lg" @click="requestCloseModal"></v-btn>
</div>

<v-tabs v-model="activeTab" class="mb-4">
<v-tab value="actions">Actions</v-tab>
<v-tab value="joystick-suggestions">Joystick Mappings</v-tab>
<v-tab value="actions" :class="{ 'tab-blink': activeTab !== 'actions' && hasPendingActions }">Actions</v-tab>
<v-tab
value="joystick-suggestions"
:class="{ 'tab-blink': activeTab !== 'joystick-suggestions' && hasPendingJoystickSuggestions }"
>
Joystick Mappings
</v-tab>
</v-tabs>

<v-tabs-window v-model="activeTab">
Expand All @@ -25,6 +30,11 @@
<p class="opacity-70">No actions available from extensions.</p>
</div>

<div v-else-if="filteredActions.length === 0" class="text-center py-6">
<v-icon size="40" class="mb-2 opacity-50">mdi-check-circle-outline</v-icon>
<p class="opacity-70 max-w-[70%] mx-auto">No new actions — all extension actions have been reviewed.</p>
</div>

<!-- New Actions -->
<div v-if="filteredActions.length > 0" class="mb-4">
<div class="flex items-center gap-2 mb-2">
Expand Down Expand Up @@ -206,6 +216,13 @@
</div>

<div v-else class="actions-container">
<div v-if="filteredJoystickSuggestionsByExtension.length === 0" class="text-center py-6">
<v-icon size="40" class="mb-2 opacity-50">mdi-check-circle-outline</v-icon>
<p class="opacity-70 max-w-[70%] mx-auto">
No new suggestions — all joystick mapping suggestions have been reviewed.
</p>
</div>

<!-- New Suggestions Section -->
<div v-if="filteredJoystickSuggestionsByExtension.length > 0" class="mb-8">
<div class="flex items-center gap-2 mb-4">
Expand Down Expand Up @@ -692,6 +709,70 @@
</v-card-actions>
</v-card>
</v-dialog>

<!-- Close Confirmation Dialog -->
<v-dialog v-model="closeConfirmationDialog" max-width="500px">
<v-card class="rounded-lg" :style="interfaceStore.globalGlassMenuStyles">
<v-card-title class="text-center pt-4 pb-0">
<div class="flex items-center justify-center gap-2">
<v-icon color="warning" size="24">mdi-alert</v-icon>
<h2 class="text-xl font-semibold">Pending Extension Features</h2>
</div>
</v-card-title>
<v-btn
icon="mdi-close"
size="small"
variant="text"
class="absolute top-2 right-2 text-lg"
@click="closeConfirmationDialog = false"
></v-btn>

<v-card-text class="px-6 pb-4">
<p class="text-center text-sm text-gray-300 mb-4">
There are still extension features pending your decision. Please accept or ignore each suggestion from
your BlueOS extensions. Otherwise, this dialog will open automatically again the next time you start
Cockpit.
</p>

<div class="max-h-[260px] overflow-y-auto pr-1 space-y-3">
<div v-if="filteredActions.length > 0">
<div class="flex items-center gap-2 mb-1">
<v-icon size="16">mdi-lightning-bolt-outline</v-icon>
<h3 class="text-sm font-semibold">Pending actions ({{ filteredActions.length }})</h3>
</div>
<ul class="list-disc list-inside text-xs text-gray-300 space-y-0.5">
<li v-for="action in filteredActions" :key="action.id">
{{ action.name }} <span class="opacity-60">— from {{ action.extensionName }}</span>
</li>
</ul>
</div>

<div v-if="pendingJoystickSuggestions.length > 0">
<div class="flex items-center gap-2 mb-1">
<v-icon size="16">mdi-gamepad-variant-outline</v-icon>
<h3 class="text-sm font-semibold">
Pending joystick mappings ({{ pendingJoystickSuggestions.length }})
</h3>
</div>
<ul class="list-disc list-inside text-xs text-gray-300 space-y-0.5">
<li v-for="item in pendingJoystickSuggestions" :key="item.id">
{{ item.actionName }} <span class="opacity-60">— from {{ item.extensionName }}</span>
</li>
</ul>
</div>
</div>
</v-card-text>

<div class="flex justify-center w-full px-6 pb-2">
<v-divider style="border-color: #ffffff14"></v-divider>
</div>

<v-card-actions class="px-6 pb-4 justify-space-between">
<v-btn variant="text" @click="confirmCloseModal">Close anyway</v-btn>
<v-btn @click="closeConfirmationDialog = false">Keep reviewing</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</GlassModal>
</template>
Expand Down Expand Up @@ -1095,11 +1176,35 @@ const ignoredJoystickSuggestionsByExtension = computed(() => {
.filter((ext) => ext.suggestionGroups.length > 0)
})

/**
* Whether there are new actions pending user action
*/
const hasPendingActions = computed(() => filteredActions.value.length > 0)

/**
* Whether there are new joystick suggestions pending user action
*/
const hasPendingJoystickSuggestions = computed(() => filteredJoystickSuggestionsByExtension.value.length > 0)

/**
* Whether there are new extension features that still need user action
*/
const hasPendingBlueOSFeatures = computed(() => {
return filteredActions.value.length > 0 || filteredJoystickSuggestionsByExtension.value.length > 0
return hasPendingActions.value || hasPendingJoystickSuggestions.value
})

/**
* Flat list of pending joystick suggestions with their extension names
*/
const pendingJoystickSuggestions = computed((): JoystickSuggestionWithExtensionName[] => {
return filteredJoystickSuggestionsByExtension.value.flatMap((ext) =>
ext.suggestionGroups.flatMap((group) =>
group.buttonMappingSuggestions.map((suggestion) => ({
...suggestion,
extensionName: ext.extensionName,
}))
)
)
})

/**
Expand Down Expand Up @@ -1437,6 +1542,11 @@ const restoreIgnoredSuggestion = (suggestion: JoystickMapSuggestion): void => {
})
}

/**
* Controls visibility of the close confirmation dialog
*/
const closeConfirmationDialog = ref(false)

/**
* Close the modal
*/
Expand All @@ -1445,6 +1555,25 @@ const closeModal = (): void => {
emit('close')
}

/**
* Request to close the modal. If there are still pending items to decide upon, ask the user to confirm first.
*/
const requestCloseModal = (): void => {
if (hasPendingBlueOSFeatures.value) {
closeConfirmationDialog.value = true
return
}
closeModal()
}

/**
* Confirm closing the modal from the confirmation dialog
*/
const confirmCloseModal = (): void => {
closeConfirmationDialog.value = false
closeModal()
}

/**
* Check for available actions from BlueOS.
*/
Expand Down Expand Up @@ -1523,6 +1652,32 @@ watch(activeTab, () => {
transition: width 0.2s ease;
}

.tab-blink {
position: relative;
}

.tab-blink::before {
content: '';
position: absolute;
inset: 0;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-color: #ffffff22;
animation: tab-blink 1.2s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}

@keyframes tab-blink {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}

.features-modal:has(.v-expansion-panels) {
width: 760px;
}
Expand Down
8 changes: 7 additions & 1 deletion src/components/GlassModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const props = defineProps<{
* If true, modal will not close by pressing 'esc' or by an outside click.
*/
isPersistent?: boolean
/**
* If true, the modal will not emit `outside-click` when the user clicks outside of it.
* Useful when the modal hosts Vuetify dialogs that are teleported to the document body,
* whose clicks would otherwise be interpreted as outside clicks of the modal.
*/
noCloseOnOutsideClick?: boolean
/**
* The overflow property of the modal.
*/
Expand Down Expand Up @@ -177,7 +183,7 @@ const closeModal = (): void => {
}

onClickOutside(modal, () => {
if (!isPersistent.value) {
if (!isPersistent.value && !props.noCloseOnOutsideClick) {
closeModal()
}
if (!isAlwaysOnTop.value) {
Expand Down
Loading