use window system

This commit is contained in:
2025-10-20 19:14:05 +02:00
parent a291619f63
commit 2b8f1781f3
51 changed files with 6687 additions and 2070 deletions

View File

@ -2,7 +2,7 @@ export default defineAppConfig({
ui: {
colors: {
primary: 'sky',
secondary: 'purple',
secondary: 'fuchsia',
},
},
})

View File

@ -1,8 +1,10 @@
<template>
<UApp :locale="locales[locale]">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<div data-vaul-drawer-wrapper>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</UApp>
</template>

View File

@ -0,0 +1,169 @@
<template>
<div class="w-full h-full relative">
<!-- Error overlay for dev extensions when server is not reachable -->
<div
v-if="extension?.devServerUrl && hasError"
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center p-8"
>
<div class="max-w-md space-y-4 text-center">
<UIcon
name="i-heroicons-exclamation-circle"
class="w-16 h-16 mx-auto text-yellow-500"
/>
<h3 class="text-lg font-semibold">Dev Server Not Reachable</h3>
<p class="text-sm opacity-70">
The dev server at {{ extension.devServerUrl }} is not reachable.
</p>
<div
class="bg-gray-100 dark:bg-gray-800 p-4 rounded text-left text-xs font-mono"
>
<p class="opacity-70 mb-2">To start the dev server:</p>
<code class="block">cd /path/to/extension</code>
<code class="block">npm run dev</code>
</div>
<UButton
label="Retry"
@click="retryLoad"
/>
</div>
</div>
<!-- Loading Spinner -->
<div
v-if="isLoading"
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center"
>
<div class="flex flex-col items-center gap-4">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
></div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Loading extension...
</p>
</div>
</div>
<iframe
ref="iframeRef"
:class="[
'w-full h-full border-0 transition-all duration-1000 ease-out',
isLoading ? 'opacity-0 scale-0' : 'opacity-100 scale-100',
]"
:src="extensionUrl"
:sandbox="sandboxAttributes"
allow="autoplay; speaker-selection; encrypted-media;"
@load="handleIframeLoad"
@error="hasError = true"
/>
</div>
</template>
<script setup lang="ts">
import {
EXTENSION_PROTOCOL_PREFIX,
EXTENSION_PROTOCOL_NAME,
} from '~/config/constants'
const props = defineProps<{
extensionId: string
windowId: string
}>()
const extensionsStore = useExtensionsStore()
const { platform } = useDeviceStore()
const iframeRef = useTemplateRef('iframeRef')
const hasError = ref(false)
const isLoading = ref(true)
// Convert windowId to ref for reactive tracking
const windowIdRef = toRef(props, 'windowId')
const extension = computed(() => {
return extensionsStore.availableExtensions.find(
(ext) => ext.id === props.extensionId,
)
})
const handleIframeLoad = () => {
// Delay the fade-in slightly to allow window animation to mostly complete
setTimeout(() => {
isLoading.value = false
}, 200)
}
const sandboxDefault = ['allow-scripts'] as const
const sandboxAttributes = computed(() => {
return extension.value?.devServerUrl
? [...sandboxDefault, 'allow-same-origin'].join(' ')
: sandboxDefault.join(' ')
})
// Generate extension URL
const extensionUrl = computed(() => {
if (!extension.value) return ''
const { publicKey, name, version, devServerUrl } = extension.value
const assetPath = 'index.html'
if (!publicKey || !name || !version) {
console.error('Missing required extension fields')
return ''
}
// If dev server URL is provided, load directly from dev server
if (devServerUrl) {
const cleanUrl = devServerUrl.replace(/\/$/, '')
const cleanPath = assetPath.replace(/^\//, '')
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
}
const extensionInfo = {
name,
publicKey,
version,
}
const encodedInfo = btoa(JSON.stringify(extensionInfo))
if (platform === 'android' || platform === 'windows') {
// Android: Tauri uses http://{scheme}.localhost format
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
} else {
// Desktop: Use custom protocol with base64 as host
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
}
})
const retryLoad = () => {
hasError.value = false
if (iframeRef.value) {
//iframeRef.value.src = iframeRef.value.src // Force reload
}
}
// Initialize extension message handler to set up context
useExtensionMessageHandler(iframeRef, extension, windowIdRef)
// Additional explicit registration on mount to ensure iframe is registered
onMounted(() => {
// Wait for iframe to be ready
if (iframeRef.value && extension.value) {
console.log(
'[ExtensionFrame] Manually registering iframe on mount',
extension.value.name,
'windowId:',
props.windowId,
)
registerExtensionIFrame(iframeRef.value, extension.value, props.windowId)
}
})
// Explicit cleanup before unmount
onBeforeUnmount(() => {
if (iframeRef.value) {
console.log('[ExtensionFrame] Unregistering iframe on unmount')
unregisterExtensionIFrame(iframeRef.value)
}
})
</script>

View File

@ -1,29 +1,69 @@
<template>
<UContextMenu :items="contextMenuItems">
<div
ref="draggableEl"
:style="style"
class="select-none cursor-grab active:cursor-grabbing"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@dblclick="handleOpen"
>
<div class="flex flex-col items-center gap-1 p-2">
<div
class="w-16 h-16 flex items-center justify-center bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg hover:shadow-xl transition-shadow"
>
<img v-if="icon" :src="icon" :alt="label" class="w-12 h-12 object-contain" />
<Icon v-else name="i-heroicons-puzzle-piece-solid" class="w-12 h-12 text-gray-500" />
<div>
<UiDialogConfirm
v-model:open="showUninstallDialog"
:title="t('confirmUninstall.title')"
:description="t('confirmUninstall.message', { name: label })"
:confirm-label="t('confirmUninstall.confirm')"
:abort-label="t('confirmUninstall.cancel')"
confirm-icon="i-heroicons-trash"
@confirm="handleConfirmUninstall"
/>
<UContextMenu :items="contextMenuItems">
<div
ref="draggableEl"
:style="style"
class="select-none cursor-grab active:cursor-grabbing"
@pointerdown.left="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@click.left="handleClick"
@dblclick="handleDoubleClick"
>
<div class="flex flex-col items-center gap-2 p-3 group">
<div
:class="[
'w-20 h-20 flex items-center justify-center rounded-2xl transition-all duration-200 ease-out',
'backdrop-blur-sm border',
isSelected
? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105'
: 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105',
]"
>
<img
v-if="icon"
:src="icon"
:alt="label"
class="w-14 h-14 object-contain transition-transform duration-200"
:class="{ 'scale-110': isSelected }"
/>
<UIcon
v-else
name="i-heroicons-puzzle-piece-solid"
:class="[
'w-14 h-14 transition-all duration-200',
isSelected
? 'text-blue-500 dark:text-blue-400 scale-110'
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400',
]"
/>
</div>
<span
:class="[
'text-xs text-center max-w-24 truncate px-3 py-1.5 rounded-lg transition-all duration-200',
'backdrop-blur-sm',
isSelected
? 'bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-gray-100 font-medium shadow-md'
: 'bg-white/70 dark:bg-gray-800/70 text-gray-700 dark:text-gray-300 group-hover:bg-white/85 dark:group-hover:bg-gray-800/85',
]"
>
{{ label }}
</span>
</div>
<span
class="text-xs text-center max-w-20 truncate bg-white/80 dark:bg-gray-800/80 px-2 py-1 rounded shadow"
>
{{ label }}
</span>
</div>
</div>
</UContextMenu>
</UContextMenu>
</div>
</template>
<script setup lang="ts">
@ -39,24 +79,51 @@ const props = defineProps<{
const emit = defineEmits<{
positionChanged: [id: string, x: number, y: number]
open: [itemType: string, referenceId: string]
uninstall: [itemType: string, referenceId: string]
dragStart: [id: string, itemType: string, referenceId: string]
dragEnd: []
}>()
const desktopStore = useDesktopStore()
const showUninstallDialog = ref(false)
const { t } = useI18n()
const isSelected = computed(() => desktopStore.isItemSelected(props.id))
const handleClick = (e: MouseEvent) => {
// Prevent selection during drag
if (isDragging.value) return
desktopStore.toggleSelection(props.id, e.ctrlKey || e.metaKey)
}
const handleUninstallClick = () => {
showUninstallDialog.value = true
}
const handleConfirmUninstall = async () => {
showUninstallDialog.value = false
await desktopStore.uninstallDesktopItem(
props.id,
props.itemType,
props.referenceId,
)
}
const contextMenuItems = computed(() =>
desktopStore.getContextMenuItems(
props.id,
props.itemType,
props.referenceId,
handleOpen,
handleUninstall,
handleUninstallClick,
),
)
// Inject viewport size from parent desktop
const viewportSize = inject<{
width: Ref<number>
height: Ref<number>
}>('viewportSize')
const draggableEl = ref<HTMLElement>()
const x = ref(props.initialX)
const y = ref(props.initialY)
@ -64,6 +131,10 @@ const isDragging = ref(false)
const offsetX = ref(0)
const offsetY = ref(0)
// Icon dimensions (approximate)
const iconWidth = 120 // Matches design in template
const iconHeight = 140
const style = computed(() => ({
position: 'absolute' as const,
left: `${x.value}px`,
@ -105,15 +176,52 @@ const handlePointerUp = (e: PointerEvent) => {
if (draggableEl.value) {
draggableEl.value.releasePointerCapture(e.pointerId)
}
// Snap icon to viewport bounds if outside
if (viewportSize) {
const maxX = Math.max(0, viewportSize.width.value - iconWidth)
const maxY = Math.max(0, viewportSize.height.value - iconHeight)
x.value = Math.max(0, Math.min(maxX, x.value))
y.value = Math.max(0, Math.min(maxY, y.value))
}
emit('dragEnd')
emit('positionChanged', props.id, x.value, y.value)
}
const handleOpen = () => {
emit('open', props.itemType, props.referenceId)
}
const handleUninstall = () => {
emit('uninstall', props.itemType, props.referenceId)
const handleDoubleClick = () => {
// Get icon position and size for animation
if (draggableEl.value) {
const rect = draggableEl.value.getBoundingClientRect()
const sourcePosition = {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
}
desktopStore.openDesktopItem(
props.itemType,
props.referenceId,
sourcePosition,
)
} else {
desktopStore.openDesktopItem(props.itemType, props.referenceId)
}
}
</script>
<i18n lang="yaml">
de:
confirmUninstall:
title: Erweiterung deinstallieren
message: Möchten Sie die Erweiterung '{name}' wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.
confirm: Deinstallieren
cancel: Abbrechen
en:
confirmUninstall:
title: Uninstall Extension
message: Do you really want to uninstall the extension '{name}'? This action cannot be undone.
confirm: Uninstall
cancel: Cancel
</i18n>

View File

@ -1,175 +1,415 @@
<template>
<div
class="w-full h-full relative overflow-hidden bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-900 dark:to-gray-800"
ref="desktopEl"
class="w-full h-full relative overflow-hidden"
@click.self.stop="handleDesktopClick"
>
<!-- Dropzones (only visible during drag) -->
<Transition name="slide-down">
<div
v-if="isDragging"
class="absolute top-0 left-0 right-0 flex gap-2 p-4 z-50"
<Swiper
:modules="[SwiperNavigation]"
:slides-per-view="1"
:space-between="0"
:initial-slide="currentWorkspaceIndex"
:speed="300"
:touch-angle="45"
:threshold="10"
:no-swiping="true"
no-swiping-class="no-swipe"
:allow-touch-move="allowSwipe"
class="w-full h-full"
@swiper="onSwiperInit"
@slide-change="onSlideChange"
>
<SwiperSlide
v-for="workspace in workspaces"
:key="workspace.id"
class="w-full h-full"
>
<!-- Remove from Desktop Dropzone -->
<div
ref="removeDropzoneEl"
class="flex-1 h-20 flex items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-all"
:class="
isOverRemoveZone
? 'bg-orange-500/20 border-orange-500 dark:bg-orange-400/20 dark:border-orange-400'
: 'border-orange-500/50 dark:border-orange-400/50'
"
class="w-full h-full relative bg-gradient-to-br from-gray-50 via-gray-100 to-gray-200 dark:from-gray-900 dark:via-gray-800 dark:to-gray-700"
@click.self.stop="handleDesktopClick"
@mousedown.left.self="handleAreaSelectStart"
>
<Icon
name="i-heroicons-x-mark"
class="w-6 h-6"
:class="
isOverRemoveZone
? 'text-orange-700 dark:text-orange-300'
: 'text-orange-600 dark:text-orange-400'
"
<!-- Grid Pattern Background -->
<div
class="absolute inset-0 pointer-events-none opacity-30"
:style="{
backgroundImage:
'linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}"
/>
<span
class="font-semibold"
:class="
isOverRemoveZone
? 'text-orange-700 dark:text-orange-300'
: 'text-orange-600 dark:text-orange-400'
"
>
Von Desktop entfernen
</span>
</div>
<!-- Uninstall Dropzone -->
<div
ref="uninstallDropzoneEl"
class="flex-1 h-20 flex items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-all"
:class="
isOverUninstallZone
? 'bg-red-500/20 border-red-500 dark:bg-red-400/20 dark:border-red-400'
: 'border-red-500/50 dark:border-red-400/50'
"
>
<Icon
name="i-heroicons-trash"
class="w-6 h-6"
:class="
isOverUninstallZone
? 'text-red-700 dark:text-red-300'
: 'text-red-600 dark:text-red-400'
"
<!-- Snap Dropzones (only visible when window drag near edge) -->
<Transition name="fade">
<div
v-if="showLeftSnapZone"
class="absolute left-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40"
/>
</Transition>
<Transition name="fade">
<div
v-if="showRightSnapZone"
class="absolute right-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40"
/>
</Transition>
<!-- Area Selection Box -->
<div
v-if="isAreaSelecting"
class="absolute bg-blue-500/20 border-2 border-blue-500 pointer-events-none z-30"
:style="selectionBoxStyle"
/>
<span
class="font-semibold"
:class="
isOverUninstallZone
? 'text-red-700 dark:text-red-300'
: 'text-red-600 dark:text-red-400'
"
>
Deinstallieren
</span>
</div>
</div>
</Transition>
<HaexDesktopIcon
v-for="item in desktopItemIcons"
:key="item.id"
:id="item.id"
:item-type="item.itemType"
:reference-id="item.referenceId"
:initial-x="item.positionX"
:initial-y="item.positionY"
:label="item.label"
:icon="item.icon"
@position-changed="handlePositionChanged"
@open="handleOpen"
@drag-start="handleDragStart"
@drag-end="handleDragEnd"
@uninstall="handleUninstall"
/>
<!-- Icons for this workspace -->
<HaexDesktopIcon
v-for="item in getWorkspaceIcons(workspace.id)"
:id="item.id"
:key="item.id"
:item-type="item.itemType"
:reference-id="item.referenceId"
:initial-x="item.positionX"
:initial-y="item.positionY"
:label="item.label"
:icon="item.icon"
class="no-swipe"
@position-changed="handlePositionChanged"
@drag-start="handleDragStart"
@drag-end="handleDragEnd"
/>
<!-- Windows for this workspace -->
<template
v-for="(window, index) in getWorkspaceWindows(workspace.id)"
:key="window.id"
>
<!-- Wrapper for Overview Mode Click/Drag -->
<div
v-if="false"
:style="
getOverviewWindowGridStyle(
index,
getWorkspaceWindows(workspace.id).length,
)
"
class="absolute cursor-pointer group"
:draggable="true"
@dragstart="handleOverviewWindowDragStart($event, window.id)"
@dragend="handleOverviewWindowDragEnd"
@click="handleOverviewWindowClick(window.id)"
>
<!-- Overlay for click/drag events (prevents interaction with window content) -->
<div
class="absolute inset-0 z-[100] bg-transparent group-hover:ring-4 group-hover:ring-purple-500 rounded-xl transition-all"
/>
<HaexDesktopWindow
:id="window.id"
:title="window.title"
:icon="window.icon"
:initial-x="window.x"
:initial-y="window.y"
:initial-width="window.width"
:initial-height="window.height"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe pointer-events-none"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
{{ window }}
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexDesktopWindow>
</div>
<!-- Normal Mode (non-overview) -->
<HaexDesktopWindow
:id="window.id"
:title="window.title"
:icon="window.icon"
:initial-x="window.x"
:initial-y="window.y"
:initial-width="window.width"
:initial-height="window.height"
:is-active="windowManager.isWindowActive(window.id)"
:source-x="window.sourceX"
:source-y="window.sourceY"
:source-width="window.sourceWidth"
:source-height="window.sourceHeight"
:is-opening="window.isOpening"
:is-closing="window.isClosing"
class="no-swipe"
@close="windowManager.closeWindow(window.id)"
@minimize="windowManager.minimizeWindow(window.id)"
@activate="windowManager.activateWindow(window.id)"
@position-changed="
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
"
@size-changed="
(width, height) =>
windowManager.updateWindowSize(window.id, width, height)
"
@drag-start="handleWindowDragStart(window.id)"
@drag-end="handleWindowDragEnd"
>
<!-- System Window: Render Vue Component -->
<component
:is="getSystemWindowComponent(window.sourceId)"
v-if="window.type === 'system'"
/>
<!-- Extension Window: Render iFrame -->
<HaexDesktopExtensionFrame
v-else
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexDesktopWindow>
</template>
</div>
</SwiperSlide>
</Swiper>
<!-- Workspace Drawer -->
<UDrawer
v-model:open="isOverviewMode"
direction="left"
:dismissible="false"
:overlay="false"
:modal="false"
should-scale-background
set-background-color-on-scale
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="p-6 h-full overflow-y-auto">
<UButton
block
trailing-icon="mdi-close"
class="text-2xl font-bold ext-gray-900 dark:text-white mb-4"
@click="isOverviewMode = false"
>
Workspaces
</UButton>
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
>
<template #leading>
<UIcon name="i-heroicons-plus" />
</template>
New Workspace
</UButton>
</div>
</template>
</UDrawer>
</div>
</template>
<script setup lang="ts">
import { Swiper, SwiperSlide } from 'swiper/vue'
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'
import { eq } from 'drizzle-orm'
import { haexDesktopItems } from '~~/src-tauri/database/schemas'
const SwiperNavigation = Navigation
const desktopStore = useDesktopStore()
const extensionsStore = useExtensionsStore()
const router = useRouter()
const windowManager = useWindowManagerStore()
const workspaceStore = useWorkspaceStore()
const { currentVault } = storeToRefs(useVaultStore())
const { desktopItems } = storeToRefs(desktopStore)
const { availableExtensions } = storeToRefs(extensionsStore)
const {
currentWorkspace,
currentWorkspaceIndex,
workspaces,
swiperInstance,
allowSwipe,
isOverviewMode,
} = storeToRefs(workspaceStore)
// Drag state
// Swiper instance
// Control Swiper touch behavior (disable during icon/window drag)
// Mouse position tracking
const { x: mouseX } = useMouse()
// Desktop element ref
const desktopEl = useTemplateRef('desktopEl')
// Track desktop viewport size reactively
const { width: viewportWidth, height: viewportHeight } =
useElementSize(desktopEl)
// Provide viewport size to child windows
provide('viewportSize', {
width: viewportWidth,
height: viewportHeight,
})
// Area selection state
const isAreaSelecting = ref(false)
const selectionStart = ref({ x: 0, y: 0 })
const selectionEnd = ref({ x: 0, y: 0 })
const selectionBoxStyle = computed(() => {
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
return {
left: `${x1}px`,
top: `${y1}px`,
width: `${x2 - x1}px`,
height: `${y2 - y1}px`,
}
})
// Drag state for desktop icons
const isDragging = ref(false)
const currentDraggedItemId = ref<string>()
const currentDraggedItemType = ref<string>()
const currentDraggedReferenceId = ref<string>()
// Window drag state for snap zones
const isWindowDragging = ref(false)
const currentDraggingWindowId = ref<string | null>(null)
const snapEdgeThreshold = 50 // pixels from edge to show snap zone
// Computed visibility for snap zones (uses mouseX from above)
const showLeftSnapZone = computed(() => {
return isWindowDragging.value && mouseX.value <= snapEdgeThreshold
})
const showRightSnapZone = computed(() => {
if (!isWindowDragging.value) return false
const viewportWidth = window.innerWidth
return mouseX.value >= viewportWidth - snapEdgeThreshold
})
// Dropzone refs
const removeDropzoneEl = ref<HTMLElement>()
const uninstallDropzoneEl = ref<HTMLElement>()
/* const removeDropzoneEl = ref<HTMLElement>()
const uninstallDropzoneEl = ref<HTMLElement>() */
// Setup dropzones with VueUse
const { isOverDropZone: isOverRemoveZone } = useDropZone(removeDropzoneEl, {
/* const { isOverDropZone: isOverRemoveZone } = useDropZone(removeDropzoneEl, {
onDrop: () => {
if (currentDraggedItemId.value) {
handleRemoveFromDesktop(currentDraggedItemId.value)
}
},
})
}) */
const { isOverDropZone: isOverUninstallZone } = useDropZone(uninstallDropzoneEl, {
/* const { isOverDropZone: isOverUninstallZone } = useDropZone(uninstallDropzoneEl, {
onDrop: () => {
if (currentDraggedItemType.value && currentDraggedReferenceId.value) {
handleUninstall(currentDraggedItemType.value, currentDraggedReferenceId.value)
}
},
})
}) */
interface DesktopItemIcon extends IDesktopItem {
label: string
icon?: string
// Get icons for a specific workspace
const getWorkspaceIcons = (workspaceId: string) => {
return desktopItems.value
.filter((item) => item.workspaceId === workspaceId)
.map((item) => {
if (item.itemType === 'extension') {
const extension = availableExtensions.value.find(
(ext) => ext.id === item.referenceId,
)
return {
...item,
label: extension?.name || 'Unknown',
icon: extension?.icon || '',
}
}
if (item.itemType === 'file') {
// Für später: file handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
if (item.itemType === 'folder') {
// Für später: folder handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
return {
...item,
label: item.referenceId,
icon: undefined,
}
})
}
const desktopItemIcons = computed<DesktopItemIcon[]>(() => {
return desktopItems.value.map((item) => {
if (item.itemType === 'extension') {
const extension = availableExtensions.value.find(
(ext) => ext.id === item.referenceId,
)
// Get windows for a specific workspace
const getWorkspaceWindows = (workspaceId: string) => {
return windowManager.windows.filter(
(w) => w.workspaceId === workspaceId && !w.isMinimized,
)
}
return {
...item,
label: extension?.name || 'Unknown',
icon: extension?.icon || '',
}
}
if (item.itemType === 'file') {
// Für später: file handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
if (item.itemType === 'folder') {
// Für später: folder handling
return {
...item,
label: item.referenceId,
icon: undefined,
}
}
return {
...item,
label: item.referenceId,
icon: undefined,
}
})
})
// Get Vue Component for system window
const getSystemWindowComponent = (sourceId: string) => {
const systemWindow = windowManager.getSystemWindow(sourceId)
return systemWindow?.component
}
const handlePositionChanged = async (id: string, x: number, y: number) => {
try {
@ -179,55 +419,335 @@ const handlePositionChanged = async (id: string, x: number, y: number) => {
}
}
const localePath = useLocalePath()
const handleOpen = (itemType: string, referenceId: string) => {
if (itemType === 'extension') {
router.push(
localePath({
name: 'extension',
params: { extensionId: referenceId },
})
)
}
// Für später: file und folder handling
}
const handleDragStart = (id: string, itemType: string, referenceId: string) => {
isDragging.value = true
currentDraggedItemId.value = id
currentDraggedItemType.value = itemType
currentDraggedReferenceId.value = referenceId
allowSwipe.value = false // Disable Swiper during icon drag
}
const handleDragEnd = () => {
const handleDragEnd = async () => {
// Cleanup drag state
isDragging.value = false
currentDraggedItemId.value = undefined
currentDraggedItemType.value = undefined
currentDraggedReferenceId.value = undefined
allowSwipe.value = true // Re-enable Swiper after drag
}
const handleUninstall = async (itemType: string, referenceId: string) => {
if (itemType === 'extension') {
try {
const extension = availableExtensions.value.find((ext) => ext.id === referenceId)
if (extension) {
await extensionsStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version,
)
// Reload extensions after uninstall
await extensionsStore.loadExtensionsAsync()
}
} catch (error) {
console.error('Fehler beim Deinstallieren:', error)
}
// Move desktop item to different workspace
const moveItemToWorkspace = async (
itemId: string,
targetWorkspaceId: string,
) => {
const item = desktopItems.value.find((i) => i.id === itemId)
if (!item) return
try {
if (!currentVault.value?.drizzle) return
await currentVault.value.drizzle
.update(haexDesktopItems)
.set({ workspaceId: targetWorkspaceId })
.where(eq(haexDesktopItems.id, itemId))
// Update local state
item.workspaceId = targetWorkspaceId
} catch (error) {
console.error('Fehler beim Verschieben des Items:', error)
}
// Für später: file und folder handling
}
const handleDesktopClick = () => {
// Only clear selection if it was a simple click, not an area selection
// Check if we just finished an area selection (box size > threshold)
const boxWidth = Math.abs(selectionEnd.value.x - selectionStart.value.x)
const boxHeight = Math.abs(selectionEnd.value.y - selectionStart.value.y)
// If box is larger than 5px in any direction, it was an area select, not a click
if (boxWidth > 5 || boxHeight > 5) {
return
}
desktopStore.clearSelection()
isOverviewMode.value = false
}
const handleWindowDragStart = (windowId: string) => {
isWindowDragging.value = true
currentDraggingWindowId.value = windowId
allowSwipe.value = false // Disable Swiper during window drag
}
const handleWindowDragEnd = async () => {
// Window handles snapping itself, we just need to cleanup state
isWindowDragging.value = false
currentDraggingWindowId.value = null
allowSwipe.value = true // Re-enable Swiper after drag
}
// Move window to different workspace
const moveWindowToWorkspace = async (
windowId: string,
targetWorkspaceId: string,
) => {
const window = windowManager.windows.find((w) => w.id === windowId)
if (!window) return
// Update window's workspaceId
window.workspaceId = targetWorkspaceId
}
// Area selection handlers
const handleAreaSelectStart = (e: MouseEvent) => {
if (!desktopEl.value) return
const rect = desktopEl.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
isAreaSelecting.value = true
selectionStart.value = { x, y }
selectionEnd.value = { x, y }
// Clear current selection
desktopStore.clearSelection()
}
// Track mouse movement for area selection
useEventListener(window, 'mousemove', (e: MouseEvent) => {
if (isAreaSelecting.value && desktopEl.value) {
const rect = desktopEl.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
selectionEnd.value = { x, y }
// Find all items within selection box
selectItemsInBox()
}
})
// End area selection
useEventListener(window, 'mouseup', () => {
if (isAreaSelecting.value) {
isAreaSelecting.value = false
// Reset selection coordinates after a short delay
// This allows handleDesktopClick to still check the box size
setTimeout(() => {
selectionStart.value = { x: 0, y: 0 }
selectionEnd.value = { x: 0, y: 0 }
}, 100)
}
})
const selectItemsInBox = () => {
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
desktopStore.clearSelection()
desktopItems.value.forEach((item) => {
// Check if item position is within selection box
const itemX = item.positionX + 60 // Icon center (approx)
const itemY = item.positionY + 60
if (itemX >= x1 && itemX <= x2 && itemY >= y1 && itemY <= y2) {
desktopStore.toggleSelection(item.id, true) // true = add to selection
}
})
}
// Swiper event handlers
const onSwiperInit = (swiper: SwiperType) => {
swiperInstance.value = swiper
}
const onSlideChange = (swiper: SwiperType) => {
workspaceStore.switchToWorkspace(swiper.activeIndex)
}
// Workspace control handlers
const handleAddWorkspace = async () => {
await workspaceStore.addWorkspaceAsync()
// Swiper will auto-slide to new workspace because we switch in addWorkspaceAsync
nextTick(() => {
if (swiperInstance.value) {
swiperInstance.value.slideTo(workspaces.value.length - 1)
}
})
}
const handleSwitchToWorkspace = (index: number) => {
if (swiperInstance.value) {
swiperInstance.value.slideTo(index)
}
}
const handleRemoveWorkspace = async () => {
if (!currentWorkspace.value || workspaces.value.length <= 1) return
const currentIndex = currentWorkspaceIndex.value
await workspaceStore.removeWorkspaceAsync(currentWorkspace.value.id)
// Slide to adjusted index
nextTick(() => {
if (swiperInstance.value) {
const newIndex = Math.min(currentIndex, workspaces.value.length - 1)
swiperInstance.value.slideTo(newIndex)
}
})
}
// Drawer handlers
const handleSwitchToWorkspaceFromDrawer = (index: number) => {
handleSwitchToWorkspace(index)
// Close drawer after switch
isOverviewMode.value = false
}
const handleDropWindowOnWorkspace = async (
event: DragEvent,
targetWorkspaceId: string,
) => {
// Get the window ID from drag data (will be set when we implement window dragging)
const windowId = event.dataTransfer?.getData('windowId')
if (windowId) {
await moveWindowToWorkspace(windowId, targetWorkspaceId)
}
}
// Overview Mode: Calculate grid positions and scale for windows
const getOverviewWindowGridStyle = (index: number, totalWindows: number) => {
if (!viewportWidth.value || !viewportHeight.value) {
return {}
}
// Determine grid layout based on number of windows
let cols = 1
let rows = 1
if (totalWindows === 1) {
cols = 1
rows = 1
} else if (totalWindows === 2) {
cols = 2
rows = 1
} else if (totalWindows <= 4) {
cols = 2
rows = 2
} else if (totalWindows <= 6) {
cols = 3
rows = 2
} else if (totalWindows <= 9) {
cols = 3
rows = 3
} else {
cols = 4
rows = Math.ceil(totalWindows / 4)
}
// Calculate grid cell position
const col = index % cols
const row = Math.floor(index / cols)
// Padding and gap
const padding = 40 // px from viewport edges
const gap = 30 // px between windows
// Available space
const availableWidth = viewportWidth.value - padding * 2 - gap * (cols - 1)
const availableHeight = viewportHeight.value - padding * 2 - gap * (rows - 1)
// Cell dimensions
const cellWidth = availableWidth / cols
const cellHeight = availableHeight / rows
// Window aspect ratio (assume 16:9 or use actual window dimensions)
const windowAspectRatio = 16 / 9
// Calculate scale to fit window in cell
const targetWidth = cellWidth
const targetHeight = cellHeight
const targetAspect = targetWidth / targetHeight
let scale = 0.25 // Default scale
let scaledWidth = 800 * scale
let scaledHeight = 600 * scale
if (targetAspect > windowAspectRatio) {
// Cell is wider than window aspect ratio - fit by height
scaledHeight = Math.min(targetHeight, 600 * 0.4)
scale = scaledHeight / 600
scaledWidth = 800 * scale
} else {
// Cell is taller than window aspect ratio - fit by width
scaledWidth = Math.min(targetWidth, 800 * 0.4)
scale = scaledWidth / 800
scaledHeight = 600 * scale
}
// Calculate position to center window in cell
const cellX = padding + col * (cellWidth + gap)
const cellY = padding + row * (cellHeight + gap)
// Center window in cell
const x = cellX + (cellWidth - scaledWidth) / 2
const y = cellY + (cellHeight - scaledHeight) / 2
return {
transform: `scale(${scale})`,
transformOrigin: 'top left',
left: `${x / scale}px`,
top: `${y / scale}px`,
width: '800px',
height: '600px',
zIndex: 91,
transition: 'all 0.3s ease',
}
}
// Overview Mode handlers
const handleOverviewWindowClick = (windowId: string) => {
// Activate the window
windowManager.activateWindow(windowId)
// Close overview mode
isOverviewMode.value = false
}
const handleOverviewWindowDragStart = (event: DragEvent, windowId: string) => {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('windowId', windowId)
}
}
const handleOverviewWindowDragEnd = () => {
// Cleanup after drag
}
// Disable Swiper in overview mode
watch(isOverviewMode, (newValue) => {
allowSwipe.value = !newValue
})
// Watch for workspace changes to reload desktop items
watch(currentWorkspace, async () => {
if (currentWorkspace.value) {
await desktopStore.loadDesktopItemsAsync()
}
})
onMounted(async () => {
// Load workspaces first
await workspaceStore.loadWorkspacesAsync()
// Then load desktop items for current workspace
await desktopStore.loadDesktopItemsAsync()
})
</script>
@ -247,4 +767,14 @@ onMounted(async () => {
opacity: 0;
transform: translateY(-100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,478 @@
<template>
<div
ref="windowEl"
:style="windowStyle"
:class="[
'absolute bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden',
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600',
'flex flex-col',
isActive ? 'z-50' : 'z-10',
]"
@mousedown="handleActivate"
>
<!-- Window Titlebar -->
<div
ref="titlebarEl"
class="grid grid-cols-3 items-center px-3 py-1 bg-white/80 dark:bg-gray-800/80 border-b border-gray-200/50 dark:border-gray-700/50 cursor-move select-none touch-none"
@dblclick="handleMaximize"
>
<!-- Left: Icon -->
<div class="flex items-center gap-2">
<img
v-if="icon"
:src="icon"
:alt="title"
class="w-5 h-5 object-contain flex-shrink-0"
/>
</div>
<!-- Center: Title -->
<div class="flex items-center justify-center">
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-full"
>
{{ title }}
</span>
</div>
<!-- Right: Window Controls -->
<div class="flex items-center gap-1 justify-end">
<button
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
@click.stop="handleMinimize"
>
<UIcon
name="i-heroicons-minus"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/>
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
@click.stop="handleMaximize"
>
<UIcon
:name="
isMaximized
? 'i-heroicons-arrows-pointing-in'
: 'i-heroicons-arrows-pointing-out'
"
class="w-4 h-4 text-gray-600 dark:text-gray-400"
/>
</button>
<button
class="w-8 h-8 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 flex items-center justify-center transition-colors group"
@click.stop="handleClose"
>
<UIcon
name="i-heroicons-x-mark"
class="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-red-600 dark:group-hover:text-red-400"
/>
</button>
</div>
</div>
<!-- Window Content -->
<div
:class="[
'flex-1 overflow-hidden relative',
isDragging || isResizing ? 'pointer-events-none' : '',
]"
>
<slot />
</div>
<!-- Resize Handles -->
<template v-if="!isMaximized">
<div
class="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
@mousedown.left.stop="handleResizeStart('nw', $event)"
/>
<div
class="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
@mousedown.left.stop="handleResizeStart('ne', $event)"
/>
<div
class="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
@mousedown.left.stop="handleResizeStart('sw', $event)"
/>
<div
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
@mousedown.left.stop="handleResizeStart('se', $event)"
/>
<div
class="absolute top-0 left-2 right-2 h-1 cursor-n-resize"
@mousedown.left.stop="handleResizeStart('n', $event)"
/>
<div
class="absolute bottom-0 left-2 right-2 h-1 cursor-s-resize"
@mousedown.left.stop="handleResizeStart('s', $event)"
/>
<div
class="absolute left-0 top-2 bottom-2 w-1 cursor-w-resize"
@mousedown.left.stop="handleResizeStart('w', $event)"
/>
<div
class="absolute right-0 top-2 bottom-2 w-1 cursor-e-resize"
@mousedown.left.stop="handleResizeStart('e', $event)"
/>
</template>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
id: string
title: string
icon?: string
initialX?: number
initialY?: number
initialWidth?: number
initialHeight?: number
isActive?: boolean
sourceX?: number
sourceY?: number
sourceWidth?: number
sourceHeight?: number
isOpening?: boolean
isClosing?: boolean
}>()
const emit = defineEmits<{
close: []
minimize: []
activate: []
positionChanged: [x: number, y: number]
sizeChanged: [width: number, height: number]
dragStart: []
dragEnd: []
}>()
const windowEl = ref<HTMLElement>()
const titlebarEl = useTemplateRef('titlebarEl')
// Inject viewport size from parent desktop
const viewportSize = inject<{
width: Ref<number>
height: Ref<number>
}>('viewportSize')
// Window state
const x = ref(props.initialX ?? 100)
const y = ref(props.initialY ?? 100)
const width = ref(props.initialWidth ?? 800)
const height = ref(props.initialHeight ?? 600)
const isMaximized = ref(false) // Don't start maximized
// Store initial position/size for restore
const preMaximizeState = ref({
x: props.initialX ?? 100,
y: props.initialY ?? 100,
width: props.initialWidth ?? 800,
height: props.initialHeight ?? 600,
})
// Dragging state
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
// Resizing state
const isResizing = ref(false)
const resizeDirection = ref<string>('')
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartWidth = ref(0)
const resizeStartHeight = ref(0)
const resizeStartPosX = ref(0)
const resizeStartPosY = ref(0)
// Snap settings
const snapEdgeThreshold = 50 // pixels from edge to trigger snap
const { x: mouseX } = useMouse()
// Setup drag with useDrag composable (supports mouse + touch)
useDrag(
({ movement: [mx, my], first, last }) => {
if (isMaximized.value) return
if (first) {
// Drag started - save initial position
isDragging.value = true
dragStartX.value = x.value
dragStartY.value = y.value
emit('dragStart')
return // Don't update position on first event
}
if (last) {
// Drag ended - apply snapping
isDragging.value = false
const viewportBounds = getViewportBounds()
if (viewportBounds) {
const viewportWidth = viewportBounds.width
const viewportHeight = viewportBounds.height
if (mouseX.value <= snapEdgeThreshold) {
// Snap to left half
x.value = 0
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else if (mouseX.value >= viewportWidth - snapEdgeThreshold) {
// Snap to right half
x.value = viewportWidth / 2
y.value = 0
width.value = viewportWidth / 2
height.value = viewportHeight
isMaximized.value = false
} else {
// Normal snap back to viewport
snapToViewport()
}
}
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
emit('dragEnd')
return
}
// Dragging (not first, not last)
const newX = dragStartX.value + mx
const newY = dragStartY.value + my
// Apply constraints during drag
const constrained = constrainToViewportDuringDrag(newX, newY)
x.value = constrained.x
y.value = constrained.y
},
{
domTarget: titlebarEl,
eventOptions: { passive: false },
pointer: { touch: true },
drag: {
threshold: 10, // 10px threshold prevents accidental drags and improves performance
filterTaps: true, // Filter out taps (clicks) vs drags
delay: 0, // No delay for immediate response
},
},
)
const windowStyle = computed(() => {
const baseStyle: Record<string, string> = {}
// Opening animation: start from icon position
if (
props.isOpening &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Closing animation: shrink to icon position
else if (
props.isClosing &&
props.sourceX !== undefined &&
props.sourceY !== undefined
) {
baseStyle.left = `${props.sourceX}px`
baseStyle.top = `${props.sourceY}px`
baseStyle.width = `${props.sourceWidth || 100}px`
baseStyle.height = `${props.sourceHeight || 100}px`
baseStyle.opacity = '0'
baseStyle.transform = 'scale(0.3)'
}
// Normal state
else if (isMaximized.value) {
baseStyle.left = '0px'
baseStyle.top = '0px'
baseStyle.width = '100%'
baseStyle.height = '100%'
baseStyle.borderRadius = '0'
baseStyle.opacity = '1'
baseStyle.transform = 'scale(1)'
} else {
baseStyle.left = `${x.value}px`
baseStyle.top = `${y.value}px`
baseStyle.width = `${width.value}px`
baseStyle.height = `${height.value}px`
baseStyle.opacity = '1'
baseStyle.transform = 'scale(1)'
}
// Performance optimization: hint browser about transforms
if (isDragging.value || isResizing.value) {
baseStyle.willChange = 'transform, width, height'
}
return baseStyle
})
const getViewportBounds = () => {
// Use reactive viewport size from parent if available
if (viewportSize) {
return {
width: viewportSize.width.value,
height: viewportSize.height.value,
}
}
// Fallback to parent element measurement
if (!windowEl.value?.parentElement) return null
const parent = windowEl.value.parentElement
return {
width: parent.clientWidth,
height: parent.clientHeight,
}
}
const constrainToViewportDuringDrag = (newX: number, newY: number) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = width.value
const windowHeight = height.value
// Allow max 1/3 of window to go outside viewport during drag
const maxOffscreenX = windowWidth / 3
const maxOffscreenY = windowHeight / 3
const maxX = bounds.width - windowWidth + maxOffscreenX
const minX = -maxOffscreenX
const maxY = bounds.height - windowHeight + maxOffscreenY
const minY = -maxOffscreenY
const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const constrainToViewportFully = (
newX: number,
newY: number,
newWidth?: number,
newHeight?: number,
) => {
const bounds = getViewportBounds()
if (!bounds) return { x: newX, y: newY }
const windowWidth = newWidth ?? width.value
const windowHeight = newHeight ?? height.value
// Keep entire window within viewport
const maxX = bounds.width - windowWidth
const minX = 0
const maxY = bounds.height - windowHeight
const minY = 0
const constrainedX = Math.max(minX, Math.min(maxX, newX))
const constrainedY = Math.max(minY, Math.min(maxY, newY))
return { x: constrainedX, y: constrainedY }
}
const snapToViewport = () => {
const bounds = getViewportBounds()
if (!bounds) return
const constrained = constrainToViewportFully(x.value, y.value)
x.value = constrained.x
y.value = constrained.y
}
const handleActivate = () => {
emit('activate')
}
const handleClose = () => {
emit('close')
}
const handleMinimize = () => {
emit('minimize')
}
const handleMaximize = () => {
if (isMaximized.value) {
// Restore
x.value = preMaximizeState.value.x
y.value = preMaximizeState.value.y
width.value = preMaximizeState.value.width
height.value = preMaximizeState.value.height
isMaximized.value = false
} else {
// Maximize
preMaximizeState.value = {
x: x.value,
y: y.value,
width: width.value,
height: height.value,
}
isMaximized.value = true
}
}
// Window resizing
const handleResizeStart = (direction: string, e: MouseEvent) => {
isResizing.value = true
resizeDirection.value = direction
resizeStartX.value = e.clientX
resizeStartY.value = e.clientY
resizeStartWidth.value = width.value
resizeStartHeight.value = height.value
resizeStartPosX.value = x.value
resizeStartPosY.value = y.value
}
// Global mouse move handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mousemove', (e: MouseEvent) => {
if (isResizing.value) {
const deltaX = e.clientX - resizeStartX.value
const deltaY = e.clientY - resizeStartY.value
const dir = resizeDirection.value
// Handle width changes
if (dir.includes('e')) {
width.value = Math.max(300, resizeStartWidth.value + deltaX)
} else if (dir.includes('w')) {
const newWidth = Math.max(300, resizeStartWidth.value - deltaX)
const widthDiff = resizeStartWidth.value - newWidth
x.value = resizeStartPosX.value + widthDiff
width.value = newWidth
}
// Handle height changes
if (dir.includes('s')) {
height.value = Math.max(200, resizeStartHeight.value + deltaY)
} else if (dir.includes('n')) {
const newHeight = Math.max(200, resizeStartHeight.value - deltaY)
const heightDiff = resizeStartHeight.value - newHeight
y.value = resizeStartPosY.value + heightDiff
height.value = newHeight
}
}
})
// Global mouse up handler (for resizing only, dragging handled by useDrag)
useEventListener(window, 'mouseup', () => {
if (isResizing.value) {
isResizing.value = false
// Snap back to viewport after resize ends
snapToViewport()
emit('positionChanged', x.value, y.value)
emit('sizeChanged', width.value, height.value)
}
})
</script>

View File

@ -10,10 +10,10 @@
<template #content>
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll">
<!-- Enabled Extensions -->
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
<UiButton
v-for="extension in enabledExtensions"
:key="extension.id"
v-for="item in launcherItems"
:key="item.id"
square
size="xl"
variant="ghost"
@ -22,10 +22,10 @@
leadingIcon: 'size-10',
label: 'w-full',
}"
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
:label="extension.name"
:tooltip="extension.name"
@click="openExtension(extension.id)"
:icon="item.icon"
:label="item.name"
:tooltip="item.name"
@click="openItem(item)"
/>
<!-- Disabled Extensions (grayed out) -->
@ -45,30 +45,16 @@
:label="extension.name"
:tooltip="`${extension.name} (${t('disabled')})`"
/>
<!-- Marketplace Button (always at the end) -->
<UiButton
square
size="xl"
variant="soft"
color="primary"
:ui="{
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
leadingIcon: 'size-10',
label: 'w-full',
}"
icon="i-heroicons-plus-circle"
:label="t('marketplace')"
:tooltip="t('marketplace')"
@click="openMarketplace"
/>
</ul>
</template>
</UPopover>
</template>
<script setup lang="ts">
import type { SystemWindowDefinition } from '@/stores/desktop/windowManager'
const extensionStore = useExtensionsStore()
const windowManagerStore = useWindowManagerStore()
const router = useRouter()
const route = useRoute()
const localePath = useLocalePath()
@ -76,38 +62,78 @@ const { t } = useI18n()
const open = ref(false)
// Enabled extensions first
const enabledExtensions = computed(() => {
return extensionStore.availableExtensions.filter((ext) => ext.enabled)
// Unified launcher item type
interface LauncherItem {
id: string
name: string
icon: string
type: 'system' | 'extension'
}
// Combine system windows and enabled extensions, sorted alphabetically
const launcherItems = computed(() => {
const items: LauncherItem[] = []
// Add system windows
const systemWindows = windowManagerStore.getAllSystemWindows()
systemWindows.forEach((sysWin: SystemWindowDefinition) => {
items.push({
id: sysWin.id,
name: sysWin.name,
icon: sysWin.icon,
type: 'system',
})
})
// Add enabled extensions
const enabledExtensions = extensionStore.availableExtensions.filter(
(ext) => ext.enabled,
)
enabledExtensions.forEach((ext) => {
items.push({
id: ext.id,
name: ext.name,
icon: ext.icon || 'i-heroicons-puzzle-piece-solid',
type: 'extension',
})
})
// Sort alphabetically by name
return items.sort((a, b) => a.name.localeCompare(b.name))
})
// Disabled extensions last
// Disabled extensions (shown grayed out at the end)
const disabledExtensions = computed(() => {
return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
})
const openExtension = (extensionId: string) => {
router.push(
localePath({
name: 'haexExtension',
params: {
vaultId: route.params.vaultId,
extensionId,
},
}),
)
open.value = false
}
const { currentWorkspace } = storeToRefs(useWorkspaceStore())
// Open launcher item (system window or extension)
const openItem = async (item: LauncherItem) => {
// Check if we're on the desktop page
const isOnDesktop = route.name === 'desktop'
const openMarketplace = () => {
router.push(
localePath({
name: 'extensionOverview',
params: {
vaultId: route.params.vaultId,
},
}),
console.log('currentWorkspace', currentWorkspace.value)
if (!isOnDesktop) {
// Navigate to desktop first
await router.push(
localePath({
name: 'desktop',
}),
)
// Wait for navigation and DOM update
await nextTick()
}
// Open the window with correct type and sourceId
windowManagerStore.openWindow(
item.type, // 'system' or 'extension'
item.id, // systemWindowId or extensionId
item.name,
item.icon,
)
open.value = false
}
</script>

View File

@ -0,0 +1,145 @@
<template>
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
<!-- Marketplace Header -->
<div
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6"
>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Extension Marketplace
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Discover and install extensions for HaexHub
</p>
</div>
<!-- Search Bar -->
<div
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-4"
>
<UInput
icon="i-heroicons-magnifying-glass"
size="lg"
placeholder="Search extensions..."
class="w-full"
/>
</div>
<!-- Marketplace Content -->
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-4xl space-y-6">
<!-- Featured Extensions -->
<section>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Featured Extensions
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Example Extension Card -->
<div
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 hover:shadow-lg transition-shadow cursor-pointer"
>
<div class="flex items-start gap-4">
<div
class="w-12 h-12 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0"
>
<UIcon
name="i-heroicons-puzzle-piece"
class="w-6 h-6 text-white"
/>
</div>
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-gray-900 dark:text-white truncate"
>
Example Extension
</h3>
<p
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1"
>
A powerful extension for HaexHub
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-gray-500 dark:text-gray-500"
>v1.0.0</span
>
<span class="text-xs text-gray-500 dark:text-gray-500"
></span
>
<span class="text-xs text-gray-500 dark:text-gray-500"
>1.2k downloads</span
>
</div>
</div>
<UButton
label="Install"
size="sm"
color="primary"
/>
</div>
</div>
<!-- Placeholder for more extensions -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 opacity-50">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 rounded-lg bg-gray-400 flex items-center justify-center flex-shrink-0"
>
<UIcon
name="i-heroicons-puzzle-piece"
class="w-6 h-6 text-white"
/>
</div>
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-gray-900 dark:text-white truncate"
>
More extensions coming soon...
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Check back later for more extensions
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Categories -->
<section>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Categories
</h2>
<div class="flex flex-wrap gap-2">
<UBadge
label="Productivity"
color="primary"
variant="soft"
size="lg"
/>
<UBadge
label="Development"
color="secondary"
variant="soft"
size="lg"
/>
<UBadge
label="Security"
color="error"
variant="soft"
size="lg"
/>
<UBadge
label="Utilities"
color="secondary"
variant="soft"
size="lg"
/>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Marketplace component - placeholder implementation
</script>

View File

@ -0,0 +1,96 @@
<template>
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
<!-- Settings Header -->
<div class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Settings
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage your HaexHub preferences and configuration
</p>
</div>
<!-- Settings Content -->
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-2xl space-y-6">
<!-- General Section -->
<section>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
General
</h2>
<div class="space-y-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">
Theme
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Choose your preferred theme
</p>
</div>
<UButton
label="Auto"
variant="outline"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">
Language
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Select your language
</p>
</div>
<UButton
label="English"
variant="outline"
/>
</div>
</div>
</section>
<!-- Privacy Section -->
<section>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Privacy & Security
</h2>
<div class="space-y-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900 dark:text-white">
Auto-lock
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
Lock vault after inactivity
</p>
</div>
<UToggle />
</div>
</div>
</section>
<!-- About Section -->
<section>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
About
</h2>
<div class="space-y-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Version</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">0.1.0</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600 dark:text-gray-400">Platform</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">Tauri + Vue</span>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// Settings component - placeholder implementation
</script>

View File

@ -98,7 +98,7 @@ const onCreateAsync = async () => {
if (vaultId) {
initVault()
await navigateTo(
useLocaleRoute()({ name: 'vaultOverview', params: { vaultId } }),
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
)
}
}

View File

@ -150,7 +150,7 @@ const onOpenDatabase = async () => {
await navigateTo(
localePath({
name: 'vaultOverview',
name: 'desktop',
params: {
vaultId,
},

View File

@ -0,0 +1,35 @@
<template>
<UCard
class="cursor-pointer transition-all h-32 w-72 shrink-0 group duration-500"
:class="[
workspace.position === currentWorkspaceIndex
? 'ring-2 ring-secondary bg-secondary/10'
: 'hover:ring-2 hover:ring-gray-300',
]"
@click="workspaceStore.slideToWorkspace(workspace.position)"
>
<template #header>
<div class="flex justify-between">
<h3 class="font-semibold text-gray-900 dark:text-white text-lg">
{{ workspace.name }}
</h3>
<UButton
v-if="workspaceStore.workspaces.length > 1"
icon="mdi-close"
variant="ghost"
class="group-hover:opacity-100 opacity-0 transition-opacity duration-300"
@click.stop="workspaceStore.closeWorkspaceAsync(workspace.id)"
/>
</div>
</template>
</UCard>
</template>
<script setup lang="ts">
defineProps<{ workspace: IWorkspace }>()
const workspaceStore = useWorkspaceStore()
const { currentWorkspaceIndex } = storeToRefs(workspaceStore)
</script>

View File

@ -4,23 +4,15 @@
:title
:description
>
<slot>
<!-- <UiButton
color="primary"
variant="outline"
icon="mdi:menu"
:ui="{
base: '',
}"
/> -->
</slot>
<template #title>
<slot name="title" />
</template>
<template #body>
<slot name="body" />
<template
v-for="(_, name) in $slots"
:key="name"
#[name]="slotData"
>
<slot
:name="name"
v-bind="slotData"
/>
</template>
<template #footer>
@ -38,7 +30,7 @@
:label="confirmLabel || t('confirm')"
block
color="primary"
varaint="solid"
variant="solid"
@click="$emit('confirm')"
/>
</div>

View File

@ -16,11 +16,15 @@ interface ExtensionRequest {
// Globaler Handler - nur einmal registriert
let globalHandlerRegistered = false
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
// Map event.source (WindowProxy) to extension for sandbox-compatible matching
const sourceRegistry = new Map<Window, IHaexHubExtension>()
// Reverse map: extension ID to Window for broadcasting
const extensionToWindowMap = new Map<string, Window>()
interface ExtensionInstance {
extension: IHaexHubExtension
windowId: string
}
const iframeRegistry = new Map<HTMLIFrameElement, ExtensionInstance>()
// Map event.source (WindowProxy) to extension instance for sandbox-compatible matching
const sourceRegistry = new Map<Window, ExtensionInstance>()
// Reverse map: window ID to Window for broadcasting (supports multiple windows per extension)
const windowIdToWindowMap = new Map<string, Window>()
// Store context values that need to be accessed outside setup
let contextGetters: {
@ -40,15 +44,25 @@ const registerGlobalMessageHandler = () => {
const request = event.data as ExtensionRequest
// Find extension by decoding event.origin (works with sandboxed iframes)
// Find extension instance by decoding event.origin (works with sandboxed iframes)
// Origin formats:
// - Desktop: haex-extension://<base64>
// - Android: http://haex-extension.localhost (need to check request URL for base64)
let extension: IHaexHubExtension | undefined
let instance: ExtensionInstance | undefined
// Debug: Find which extension sent this message
let sourceInfo = 'unknown source'
for (const [iframe, inst] of iframeRegistry.entries()) {
if (iframe.contentWindow === event.source) {
sourceInfo = `${inst.extension.name} (${inst.windowId})`
break
}
}
console.log(
'[ExtensionHandler] Received message from origin:',
event.origin,
'[ExtensionHandler] Received message from:',
sourceInfo,
'Method:',
request.method,
)
// Try to decode extension info from origin
@ -74,10 +88,10 @@ const registerGlobalMessageHandler = () => {
if (iframeRegistry.size === 1) {
const entry = Array.from(iframeRegistry.entries())[0]
if (entry) {
const [_, ext] = entry
extension = ext
sourceRegistry.set(event.source as Window, ext)
extensionToWindowMap.set(ext.id, event.source as Window)
const [_, inst] = entry
instance = inst
sourceRegistry.set(event.source as Window, inst)
windowIdToWindowMap.set(inst.windowId, event.source as Window)
}
}
}
@ -91,16 +105,16 @@ const registerGlobalMessageHandler = () => {
}
// Find matching extension in registry
for (const [_, ext] of iframeRegistry.entries()) {
for (const [_, inst] of iframeRegistry.entries()) {
if (
ext.name === decodedInfo.name &&
ext.publicKey === decodedInfo.publicKey &&
ext.version === decodedInfo.version
inst.extension.name === decodedInfo.name &&
inst.extension.publicKey === decodedInfo.publicKey &&
inst.extension.version === decodedInfo.version
) {
extension = ext
instance = inst
// Register for future lookups
sourceRegistry.set(event.source as Window, ext)
extensionToWindowMap.set(ext.id, event.source as Window)
sourceRegistry.set(event.source as Window, inst)
windowIdToWindowMap.set(inst.windowId, event.source as Window)
break
}
}
@ -110,31 +124,36 @@ const registerGlobalMessageHandler = () => {
}
}
// Fallback: Try to find extension by event.source (for localhost origin or legacy)
if (!extension) {
extension = sourceRegistry.get(event.source as Window)
// Fallback: Try to find extension instance by event.source (for localhost origin or legacy)
if (!instance) {
instance = sourceRegistry.get(event.source as Window)
// If not registered yet, register on first message from this source
if (!extension && iframeRegistry.size === 1) {
// If we only have one iframe, assume this message is from it
const entry = Array.from(iframeRegistry.entries())[0]
if (entry) {
const [_, ext] = entry
const windowSource = event.source as Window
sourceRegistry.set(windowSource, ext)
extensionToWindowMap.set(ext.id, windowSource)
extension = ext
// If not registered yet, find by matching iframe.contentWindow to event.source
if (!instance) {
for (const [iframe, inst] of iframeRegistry.entries()) {
if (iframe.contentWindow === event.source) {
instance = inst
// Register for future lookups
sourceRegistry.set(event.source as Window, inst)
windowIdToWindowMap.set(inst.windowId, event.source as Window)
console.log(
'[ExtensionHandler] Registered instance via contentWindow match:',
inst.windowId,
)
break
}
}
} else if (extension && !extensionToWindowMap.has(extension.id)) {
} else if (instance && !windowIdToWindowMap.has(instance.windowId)) {
// Also register in reverse map for broadcasting
extensionToWindowMap.set(extension.id, event.source as Window)
windowIdToWindowMap.set(instance.windowId, event.source as Window)
}
}
if (!extension) {
if (!instance) {
console.warn(
'[ExtensionHandler] Could not identify extension for message:',
event.origin,
'[ExtensionHandler] Could not identify extension instance from event.source.',
'Registered iframes:',
iframeRegistry.size,
)
return // Message ist nicht von einem registrierten IFrame
}
@ -148,19 +167,19 @@ const registerGlobalMessageHandler = () => {
let result: unknown
if (request.method.startsWith('extension.')) {
result = await handleExtensionMethodAsync(request, extension)
result = await handleExtensionMethodAsync(request, instance.extension)
} else if (request.method.startsWith('db.')) {
result = await handleDatabaseMethodAsync(request, extension)
result = await handleDatabaseMethodAsync(request, instance.extension)
} else if (request.method.startsWith('fs.')) {
result = await handleFilesystemMethodAsync(request, extension)
result = await handleFilesystemMethodAsync(request, instance.extension)
} else if (request.method.startsWith('http.')) {
result = await handleHttpMethodAsync(request, extension)
result = await handleHttpMethodAsync(request, instance.extension)
} else if (request.method.startsWith('permissions.')) {
result = await handlePermissionsMethodAsync(request, extension)
result = await handlePermissionsMethodAsync(request, instance.extension)
} else if (request.method.startsWith('context.')) {
result = await handleContextMethodAsync(request)
} else if (request.method.startsWith('storage.')) {
result = await handleStorageMethodAsync(request, extension)
result = await handleStorageMethodAsync(request, instance)
} else {
throw new Error(`Unknown method: ${request.method}`)
}
@ -203,6 +222,7 @@ const registerGlobalMessageHandler = () => {
export const useExtensionMessageHandler = (
iframeRef: Ref<HTMLIFrameElement | undefined | null>,
extension: ComputedRef<IHaexHubExtension | undefined | null>,
windowId: Ref<string>,
) => {
// Initialize context getters (can use composables here because we're in setup)
const { currentTheme } = storeToRefs(useUiStore())
@ -223,13 +243,26 @@ export const useExtensionMessageHandler = (
// Registriere dieses IFrame
watchEffect(() => {
if (iframeRef.value && extension.value) {
iframeRegistry.set(iframeRef.value, extension.value)
iframeRegistry.set(iframeRef.value, {
extension: extension.value,
windowId: windowId.value,
})
}
})
// Cleanup beim Unmount
onUnmounted(() => {
if (iframeRef.value) {
const instance = iframeRegistry.get(iframeRef.value)
if (instance) {
// Remove from all maps
windowIdToWindowMap.delete(instance.windowId)
for (const [source, inst] of sourceRegistry.entries()) {
if (inst.windowId === instance.windowId) {
sourceRegistry.delete(source)
}
}
}
iframeRegistry.delete(iframeRef.value)
}
})
@ -239,6 +272,7 @@ export const useExtensionMessageHandler = (
export const registerExtensionIFrame = (
iframe: HTMLIFrameElement,
extension: IHaexHubExtension,
windowId: string,
) => {
// Stelle sicher, dass der globale Handler registriert ist
registerGlobalMessageHandler()
@ -250,28 +284,48 @@ export const registerExtensionIFrame = (
)
}
iframeRegistry.set(iframe, extension)
iframeRegistry.set(iframe, { extension, windowId })
}
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
// Also remove from source registry
const ext = iframeRegistry.get(iframe)
if (ext) {
// Find and remove all sources pointing to this extension
for (const [source, extension] of sourceRegistry.entries()) {
if (extension === ext) {
// Also remove from source registry and instance map
const instance = iframeRegistry.get(iframe)
if (instance) {
// Find and remove all sources pointing to this instance
for (const [source, inst] of sourceRegistry.entries()) {
if (inst.windowId === instance.windowId) {
sourceRegistry.delete(source)
}
}
// Remove from extension-to-window map
extensionToWindowMap.delete(ext.id)
// Remove from instance-to-window map
windowIdToWindowMap.delete(instance.windowId)
}
iframeRegistry.delete(iframe)
}
// Export function to get Window for an extension (for broadcasting)
// Export function to get Window for a specific instance (for broadcasting)
export const getInstanceWindow = (windowId: string): Window | undefined => {
return windowIdToWindowMap.get(windowId)
}
// Get all windows for an extension (all instances)
export const getAllInstanceWindows = (extensionId: string): Window[] => {
const windows: Window[] = []
for (const [_, instance] of iframeRegistry.entries()) {
if (instance.extension.id === extensionId) {
const win = windowIdToWindowMap.get(instance.windowId)
if (win) {
windows.push(win)
}
}
}
return windows
}
// Deprecated - kept for backwards compatibility
export const getExtensionWindow = (extensionId: string): Window | undefined => {
return extensionToWindowMap.get(extensionId)
// Return first window for this extension
return getAllInstanceWindows(extensionId)[0]
}
// ==========================================
@ -436,11 +490,12 @@ async function handleContextMethodAsync(request: ExtensionRequest) {
async function handleStorageMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
instance: ExtensionInstance,
) {
const storageKey = `ext_${extension.id}_`
// Storage is now per-window, not per-extension
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
console.log(
`[HaexHub Storage] ${request.method} for extension ${extension.id}`,
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
)
switch (request.method) {
@ -463,7 +518,7 @@ async function handleStorageMethodAsync(
}
case 'storage.clear': {
// Remove only extension-specific keys
// Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey),
)
@ -472,7 +527,7 @@ async function handleStorageMethodAsync(
}
case 'storage.keys': {
// Return only extension-specific keys (without prefix)
// Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey))
.map((k) => k.substring(storageKey.length))

View File

@ -17,7 +17,7 @@
<NuxtLinkLocale
class="link text-base-content link-neutral text-xl font-semibold no-underline flex items-center"
:to="{ name: 'vaultOverview' }"
:to="{ name: 'desktop' }"
>
<UiTextGradient class="text-nowrap">
{{ currentVaultName }}
@ -27,6 +27,17 @@
</template>
<template #links>
<UButton
color="neutral"
variant="outline"
:block="isSmallScreen"
@click="isOverviewMode = !isOverviewMode"
>
<template #leading>
<UIcon name="i-heroicons-squares-2x2" />
</template>
Workspaces
</UButton>
<HaexExtensionLauncher :block="isSmallScreen" />
<UiDropdownVault :block="isSmallScreen" />
</template>
@ -42,6 +53,8 @@
const { currentVaultName } = storeToRefs(useVaultStore())
const { isSmallScreen } = storeToRefs(useUiStore())
const { isOverviewMode } = storeToRefs(useWorkspaceStore())
</script>
<i18n lang="yaml">

View File

@ -1,459 +0,0 @@
<template>
<div class="h-screen w-screen flex flex-col">
<!-- Tab Bar -->
<div
class="flex gap-2 bg-base-200 overflow-x-auto border-b border-base-300 flex-shrink-0"
>
<UButton
v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id"
:class="[
'gap-2',
tabsStore.activeTabId === tab.extension.id ? 'primary' : 'neutral',
]"
@click="tabsStore.setActiveTab(tab.extension.id)"
>
{{ tab.extension.name }}
<template #trailing>
<div
class="ml-1 hover:text-error"
@click.stop="tabsStore.closeTab(tab.extension.id)"
>
<Icon
name="mdi:close"
size="16"
/>
</div>
</template>
</UButton>
<!-- Console Tab -->
<UButton
:class="['gap-2', showConsole ? 'primary' : 'neutral']"
@click="showConsole = !showConsole"
>
<Icon
name="mdi:console"
size="16"
/>
Console
<UBadge
v-if="visibleLogs.length > 0"
size="xs"
color="primary"
>
{{ visibleLogs.length }}
</UBadge>
</UButton>
</div>
<!-- IFrame Container -->
<div class="flex-1 relative min-h-0">
<!-- Extension IFrames -->
<div
v-for="tab in tabsStore.sortedTabs"
:key="tab.extension.id"
:style="{ display: tab.isVisible && !showConsole ? 'block' : 'none' }"
class="absolute inset-0"
>
<!-- Error overlay for dev extensions when server is not reachable -->
<div
v-if="tab.extension.devServerUrl && iframe.errors[tab.extension.id]"
class="absolute inset-0 bg-base-100 flex items-center justify-center p-8"
>
<div class="max-w-md space-y-4 text-center">
<Icon
name="mdi:alert-circle-outline"
size="64"
class="mx-auto text-warning"
/>
<h3 class="text-lg font-semibold">
{{ t('devServer.notReachable.title') }}
</h3>
<p class="text-sm opacity-70">
{{
t('devServer.notReachable.description', {
url: tab.extension.devServerUrl,
})
}}
</p>
<div class="bg-base-200 p-4 rounded text-left text-xs font-mono">
<p class="opacity-70 mb-2">
{{ t('devServer.notReachable.howToStart') }}
</p>
<code class="block">cd /path/to/extension</code>
<code class="block">npm run dev</code>
</div>
<UButton
:label="t('devServer.notReachable.retry')"
@click="retryLoadIFrame(tab.extension.id)"
/>
</div>
</div>
<iframe
:ref="
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
"
class="w-full h-full border-0"
:src="
getExtensionUrl(
tab.extension.publicKey,
tab.extension.name,
tab.extension.version,
'index.html',
tab.extension.devServerUrl ?? undefined,
)
"
:sandbox="iframe.sandboxAttributes(tab.extension.devServerUrl)"
allow="autoplay; speaker-selection; encrypted-media;"
@error="onIFrameError(tab.extension.id)"
/>
</div>
<!-- Console View -->
<div
v-if="showConsole"
class="absolute inset-0 bg-base-100 flex flex-col"
>
<!-- Console Header -->
<div
class="p-2 border-b border-base-300 flex justify-between items-center"
>
<h3 class="font-semibold">Console Output</h3>
<UButton
size="xs"
color="neutral"
variant="ghost"
@click="$clearConsoleLogs()"
>
Clear
</UButton>
</div>
<!-- Console Logs -->
<div class="flex-1 overflow-y-auto p-2 font-mono text-sm">
<!-- Info banner if logs are limited -->
<div
v-if="consoleLogs.length > maxVisibleLogs"
class="mb-2 p-2 bg-warning/10 border border-warning/30 rounded text-xs"
>
Showing last {{ maxVisibleLogs }} of {{ consoleLogs.length }} logs
</div>
<!-- Simple log list instead of accordion for better performance -->
<div
v-if="visibleLogs.length > 0"
class="space-y-1"
>
<div
v-for="(log, index) in visibleLogs"
:key="index"
class="border-b border-base-200 pb-2"
>
<!-- Log header with timestamp and level -->
<div class="flex justify-between items-center mb-1">
<span class="text-xs opacity-60">
[{{ log.timestamp }}] [{{ log.level.toUpperCase() }}]
</span>
<UButton
size="xs"
color="neutral"
variant="ghost"
icon="i-heroicons-clipboard-document"
@click="copyToClipboard(log.message)"
/>
</div>
<!-- Log message -->
<pre
:class="[
'text-xs whitespace-pre-wrap break-all',
log.level === 'error' ? 'text-error' : '',
log.level === 'warn' ? 'text-warning' : '',
log.level === 'info' ? 'text-info' : '',
log.level === 'debug' ? 'text-base-content/70' : '',
]"
>{{ log.message }}</pre
>
</div>
</div>
<div
v-if="visibleLogs.length === 0"
class="text-center text-base-content/50 py-8"
>
No console messages yet
</div>
</div>
</div>
<!-- Loading State -->
<div
v-if="tabsStore.tabCount === 0"
class="absolute inset-0 flex items-center justify-center"
>
<p>{{ t('loading') }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
EXTENSION_PROTOCOL_PREFIX,
EXTENSION_PROTOCOL_NAME,
} from '~/config/constants'
definePageMeta({
name: 'extension',
})
const { t } = useI18n()
const tabsStore = useExtensionTabsStore()
// Track iframe errors (for dev mode)
//const iframeErrors = ref<Record<string, boolean>>({})
const sandboxDefault = [
'allow-scripts',
'allow-storage-access-by-user-activation',
'allow-forms',
] as const
const iframe = reactive<{
errors: Record<string, boolean>
sandboxAttributes: (devUrl?: string | null) => string
}>({
errors: {},
sandboxAttributes: (devUrl) => {
return devUrl
? [...sandboxDefault, 'allow-same-origin'].join(' ')
: sandboxDefault.join(' ')
},
})
const { platform } = useDeviceStore()
// Generate extension URL (uses cached platform)
const getExtensionUrl = (
publicKey: string,
name: string,
version: string,
assetPath: string = 'index.html',
devServerUrl?: string,
) => {
if (!publicKey || !name || !version) {
console.error('Missing required extension fields')
return ''
}
// If dev server URL is provided, load directly from dev server
if (devServerUrl) {
const cleanUrl = devServerUrl.replace(/\/$/, '')
const cleanPath = assetPath.replace(/^\//, '')
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
}
const extensionInfo = {
name,
publicKey,
version,
}
const encodedInfo = btoa(JSON.stringify(extensionInfo))
if (platform === 'android' || platform === 'windows') {
// Android: Tauri uses http://{scheme}.localhost format
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
} else {
// Desktop: Use custom protocol with base64 as host
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
}
}
// Console logging - use global logs from plugin
const { $consoleLogs, $clearConsoleLogs } = useNuxtApp()
const showConsole = ref(false)
const maxVisibleLogs = ref(100) // Limit for performance on mobile
const consoleLogs = $consoleLogs as Ref<
Array<{
timestamp: string
level: 'log' | 'info' | 'warn' | 'error' | 'debug'
message: string
}>
>
// Only show last N logs for performance
const visibleLogs = computed(() => {
return consoleLogs.value.slice(-maxVisibleLogs.value)
})
// Extension aus Route öffnen
//const extensionId = computed(() => route.params.extensionId as string)
const { currentExtensionId } = storeToRefs(useExtensionsStore())
watchEffect(() => {
if (currentExtensionId.value) {
tabsStore.openTab(currentExtensionId.value)
}
})
// Setup global message handler EINMAL im Setup-Kontext
// Dies registriert den globalen Event Listener
const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
const dummyExtensionRef = computed(() => null)
useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
// Track which iframes have been registered to prevent duplicate registrations
const registeredIFrames = new WeakSet<HTMLIFrameElement>()
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
if (!el) return
// Prevent duplicate registration (Vue calls ref functions on every render)
if (registeredIFrames.has(el)) {
return
}
console.log('[Vue Debug] ========== registerIFrame called ==========')
console.log('[Vue Debug] Extension ID:', extensionId)
console.log('[Vue Debug] Element:', 'HTMLIFrameElement')
// Mark as registered
registeredIFrames.add(el)
// Registriere IFrame im Store
tabsStore.registerIFrame(extensionId, el)
// Registriere IFrame im globalen Message Handler Registry
const tab = tabsStore.openTabs.get(extensionId)
if (tab?.extension) {
console.log(
'[Vue Debug] Registering iframe in message handler for:',
tab.extension.name,
)
registerExtensionIFrame(el, tab.extension)
console.log('[Vue Debug] Registration complete!')
} else {
console.error('[Vue Debug] ❌ No tab found for extension ID:', extensionId)
}
console.log('[Vue Debug] ========================================')
}
// Listen for console messages from extensions (via postMessage)
const handleExtensionConsole = (event: MessageEvent) => {
if (event.data?.type === 'console.forward') {
const { timestamp, level, message } = event.data.data
consoleLogs.value.push({
timestamp,
level,
message: `[Extension] ${message}`,
})
// Limit to last 1000 logs
if (consoleLogs.value.length > 1000) {
consoleLogs.value = consoleLogs.value.slice(-1000)
}
}
}
onMounted(() => {
window.addEventListener('message', handleExtensionConsole)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleExtensionConsole)
// Unregister all iframes when the page unmounts
tabsStore.openTabs.forEach((tab) => {
if (tab.iframe) {
unregisterExtensionIFrame(tab.iframe)
}
})
})
// Cleanup wenn Tabs geschlossen werden
watch(
() => tabsStore.openTabs,
(newTabs, oldTabs) => {
if (oldTabs) {
// Finde gelöschte Tabs
oldTabs.forEach((tab, id) => {
if (!newTabs.has(id) && tab.iframe) {
unregisterExtensionIFrame(tab.iframe)
}
})
}
},
{ deep: true },
)
// Context Changes an alle Tabs broadcasten
const { currentTheme } = storeToRefs(useUiStore())
const { locale } = useI18n()
watch([currentTheme, locale], () => {
tabsStore.broadcastToAllTabs({
type: 'context.changed',
data: {
context: {
theme: currentTheme.value || 'system',
locale: locale.value,
platform:
window.innerWidth < 768
? 'mobile'
: window.innerWidth < 1024
? 'tablet'
: 'desktop',
},
},
timestamp: Date.now(),
})
})
// Copy to clipboard function
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
// Optional: Show success toast
console.log('Copied to clipboard')
} catch (err) {
console.error('Failed to copy:', err)
}
}
// Handle iframe errors (e.g., dev server not running)
const onIFrameError = (extensionId: string) => {
iframe.errors[extensionId] = true
}
// Retry loading iframe (clears error and reloads)
const retryLoadIFrame = (extensionId: string) => {
iframe.errors[extensionId] = false
// Reload the iframe by updating the tab
const tab = tabsStore.openTabs.get(extensionId)
if (tab?.iframe) {
tab.iframe.src = tab.iframe.src // Force reload
}
}
</script>
<i18n lang="yaml">
de:
loading: Erweiterung wird geladen
devServer:
notReachable:
title: Dev-Server nicht erreichbar
description: Der Dev-Server unter {url} ist nicht erreichbar.
howToStart: 'So starten Sie den Dev-Server:'
retry: Erneut versuchen
en:
loading: Extension is loading
devServer:
notReachable:
title: Dev Server Not Reachable
description: The dev server at {url} is not reachable.
howToStart: 'To start the dev server:'
retry: Retry
</i18n>

View File

@ -1,599 +0,0 @@
<template>
<div class="flex flex-col h-full">
<!-- Header with Actions -->
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
>
<div>
<h1 class="text-2xl font-bold">
{{ t('title') }}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ t('subtitle') }}
</p>
</div>
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"
>
<!-- Marketplace Selector -->
<USelectMenu
v-model="selectedMarketplace"
:items="marketplaces"
value-key="id"
class="w-full sm:w-48"
>
<template #leading>
<UIcon name="i-heroicons-building-storefront" />
</template>
</USelectMenu>
<!-- Install from File Button -->
<UiButton
:label="t('extension.installFromFile')"
icon="i-heroicons-arrow-up-tray"
color="neutral"
@click="onSelectExtensionAsync"
/>
</div>
</div>
<!-- Search and Filters -->
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
>
<UInput
v-model="searchQuery"
:placeholder="t('search.placeholder')"
icon="i-heroicons-magnifying-glass"
class="flex-1"
/>
<USelectMenu
v-model="selectedCategory"
:items="categories"
:placeholder="t('filter.category')"
value-key="id"
class="w-full sm:w-48"
>
<template #leading>
<UIcon name="i-heroicons-tag" />
</template>
</USelectMenu>
</div>
<!-- Extensions Grid -->
<div class="flex-1 overflow-auto p-6">
<div
v-if="filteredExtensions.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard
v-for="ext in filteredExtensions"
:key="ext.id"
:extension="ext"
@install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)"
/>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center h-full text-center"
>
<UIcon
name="i-heroicons-magnifying-glass"
class="w-16 h-16 text-gray-400 mb-4"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('empty.title') }}
</h3>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ t('empty.description') }}
</p>
</div>
</div>
<HaexExtensionDialogReinstall
v-model:open="openOverwriteDialog"
v-model:preview="preview"
@confirm="reinstallExtensionAsync"
/>
<HaexExtensionDialogInstall
v-model:open="showConfirmation"
:preview="preview"
@confirm="(addToDesktop) => addExtensionAsync(addToDesktop)"
/>
<HaexExtensionDialogRemove
v-model:open="showRemoveDialog"
:extension="extensionToBeRemoved"
@confirm="removeExtensionAsync"
/>
</div>
</template>
<script setup lang="ts">
import type {
IHaexHubExtension,
IHaexHubExtensionManifest,
IMarketplaceExtension,
} from '~/types/haexhub'
import { open } from '@tauri-apps/plugin-dialog'
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
definePageMeta({
name: 'extensionOverview',
})
const { t } = useI18n()
const extensionStore = useExtensionsStore()
const desktopStore = useDesktopStore()
const showConfirmation = ref(false)
const openOverwriteDialog = ref(false)
const extension = reactive<{
manifest: IHaexHubExtensionManifest | null | undefined
path: string | null
}>({
manifest: null,
path: '',
})
/* const loadExtensionManifestAsync = async () => {
try {
extension.path = await open({ directory: true, recursive: true })
if (!extension.path) return
const manifestFile = JSON.parse(
await readTextFile(await join(extension.path, 'manifest.json')),
)
if (!extensionStore.checkManifest(manifestFile))
throw new Error(`Manifest fehlerhaft ${JSON.stringify(manifestFile)}`)
return manifestFile
} catch (error) {
console.error('Fehler loadExtensionManifestAsync:', error)
add({ color: 'error', description: JSON.stringify(error) })
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
}
} */
const { add } = useToast()
const { addNotificationAsync } = useNotificationStore()
const preview = ref<ExtensionPreview>()
// Marketplace State
const selectedMarketplace = ref('official')
const searchQuery = ref('')
const selectedCategory = ref('all')
// Marketplaces (später von API laden)
const marketplaces = [
{
id: 'official',
label: t('marketplace.official'),
icon: 'i-heroicons-building-storefront',
},
{
id: 'community',
label: t('marketplace.community'),
icon: 'i-heroicons-users',
},
]
// Categories
const categories = computed(() => [
{ id: 'all', label: t('category.all') },
{ id: 'productivity', label: t('category.productivity') },
{ id: 'security', label: t('category.security') },
{ id: 'utilities', label: t('category.utilities') },
{ id: 'integration', label: t('category.integration') },
])
// Dummy Marketplace Extensions (später von API laden)
const marketplaceExtensions = ref<IMarketplaceExtension[]>([
{
id: 'haex-passy',
name: 'HaexPassDummy',
version: '1.0.0',
author: 'HaexHub Team',
public_key: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2',
description:
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
icon: 'i-heroicons-lock-closed',
homepage: null,
downloads: 15420,
rating: 4.8,
verified: true,
tags: ['security', 'password', 'productivity'],
category: 'security',
downloadUrl: '/extensions/haex-pass-1.0.0.haextension',
isInstalled: false,
},
{
id: 'haex-notes',
name: 'HaexNotes',
version: '2.1.0',
author: 'HaexHub Team',
public_key: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3',
description:
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
icon: 'i-heroicons-document-text',
homepage: null,
downloads: 8930,
rating: 4.5,
verified: true,
tags: ['productivity', 'notes', 'markdown'],
category: 'productivity',
downloadUrl: '/extensions/haex-notes-2.1.0.haextension',
isInstalled: false,
},
{
id: 'haex-backup',
name: 'HaexBackup',
version: '1.5.2',
author: 'Community',
public_key: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4',
description:
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
icon: 'i-heroicons-cloud-arrow-up',
homepage: null,
downloads: 5240,
rating: 4.6,
verified: false,
tags: ['backup', 'cloud', 'utilities'],
category: 'utilities',
downloadUrl: '/extensions/haex-backup-1.5.2.haextension',
isInstalled: false,
},
{
id: 'haex-calendar',
name: 'HaexCalendar',
version: '3.0.1',
author: 'HaexHub Team',
public_key: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5',
description:
'Integrierter Kalender mit Event-Management und Synchronisation.',
icon: 'i-heroicons-calendar',
homepage: null,
downloads: 12100,
rating: 4.7,
verified: true,
tags: ['productivity', 'calendar', 'events'],
category: 'productivity',
downloadUrl: '/extensions/haex-calendar-3.0.1.haextension',
isInstalled: false,
},
{
id: 'haex-2fa',
name: 'Haex2FA',
version: '1.2.0',
author: 'Security Team',
public_key: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6',
description:
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
icon: 'i-heroicons-shield-check',
homepage: null,
downloads: 7800,
rating: 4.9,
verified: true,
tags: ['security', '2fa', 'authentication'],
category: 'security',
downloadUrl: '/extensions/haex-2fa-1.2.0.haextension',
isInstalled: false,
},
{
id: 'haex-github',
name: 'GitHub Integration',
version: '1.0.5',
author: 'Community',
public_key: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7',
description:
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
icon: 'i-heroicons-code-bracket',
homepage: null,
downloads: 4120,
rating: 4.3,
verified: false,
tags: ['integration', 'github', 'development'],
category: 'integration',
downloadUrl: '/extensions/haex-github-1.0.5.haextension',
isInstalled: false,
},
])
// Mark marketplace extensions as installed if they exist in availableExtensions
const allExtensions = computed((): IMarketplaceExtension[] => {
return marketplaceExtensions.value.map((ext) => {
// Extensions are uniquely identified by public_key + name
const installedExt = extensionStore.availableExtensions.find((installed) => {
return installed.publicKey === ext.publicKey && installed.name === ext.name
})
if (installedExt) {
return {
...ext,
isInstalled: true,
// Show installed version if it differs from marketplace version
installedVersion: installedExt.version !== ext.version ? installedExt.version : undefined,
}
}
return {
...ext,
isInstalled: false,
installedVersion: undefined,
}
})
})
// Filtered Extensions
const filteredExtensions = computed(() => {
return allExtensions.value.filter((ext) => {
const matchesSearch =
!searchQuery.value ||
ext.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesCategory =
selectedCategory.value === 'all' ||
ext.category === selectedCategory.value
return matchesSearch && matchesCategory
})
})
// Install from marketplace
const onInstallFromMarketplace = async (ext: unknown) => {
console.log('Install from marketplace:', ext)
// TODO: Download extension from marketplace and install
add({ color: 'info', description: t('extension.marketplace.comingSoon') })
}
// Show extension details
const onShowExtensionDetails = (ext: unknown) => {
console.log('Show details:', ext)
// TODO: Show extension details modal
}
const onSelectExtensionAsync = async () => {
try {
extension.path = await open({ directory: false, recursive: true })
if (!extension.path) return
preview.value = await extensionStore.previewManifestAsync(extension.path)
if (!preview.value?.manifest) return
// Check if already installed using public_key + name
const isAlreadyInstalled = extensionStore.availableExtensions.some(
(ext) =>
ext.publicKey === preview.value!.manifest.public_key &&
ext.name === preview.value!.manifest.name
)
if (isAlreadyInstalled) {
openOverwriteDialog.value = true
} else {
showConfirmation.value = true
}
} catch (error) {
add({ color: 'error', description: JSON.stringify(error) })
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
}
}
const addExtensionAsync = async (addToDesktop: boolean = false) => {
try {
console.log(
'preview.value?.editable_permissions',
preview.value?.editable_permissions,
)
const extensionId = await extensionStore.installAsync(
extension.path,
preview.value?.editable_permissions,
)
await extensionStore.loadExtensionsAsync()
// Add to desktop if requested
if (addToDesktop && extensionId) {
await desktopStore.addDesktopItemAsync('extension', extensionId, 50, 50)
}
add({
color: 'success',
title: t('extension.success.title', {
extension: extension.manifest?.name,
}),
description: t('extension.success.text'),
})
await addNotificationAsync({
text: t('extension.success.text'),
type: 'success',
title: t('extension.success.title', {
extension: extension.manifest?.name,
}),
})
} catch (error) {
console.error('Fehler addExtensionAsync:', error)
add({ color: 'error', description: JSON.stringify(error) })
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
}
}
const reinstallExtensionAsync = async () => {
try {
if (!preview.value?.manifest) return
// Find the installed extension to get its current version
const installedExt = extensionStore.availableExtensions.find(
(ext) =>
ext.publicKey === preview.value!.manifest.public_key &&
ext.name === preview.value!.manifest.name
)
if (installedExt) {
// Remove old extension first
await extensionStore.removeExtensionAsync(
installedExt.publicKey,
installedExt.name,
installedExt.version
)
}
// Then install new version
await addExtensionAsync()
} catch (error) {
console.error('Fehler reinstallExtensionAsync:', error)
add({ color: 'error', description: JSON.stringify(error) })
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
}
}
const extensionToBeRemoved = ref<IHaexHubExtension>()
const showRemoveDialog = ref(false)
// Load extensions on mount
onMounted(async () => {
try {
await extensionStore.loadExtensionsAsync()
console.log('Loaded extensions:', extensionStore.availableExtensions)
} catch (error) {
console.error('Failed to load extensions:', error)
add({ color: 'error', description: 'Failed to load installed extensions' })
}
})
/* const onShowRemoveDialog = (extension: IHaexHubExtension) => {
extensionToBeRemoved.value = extension
showRemoveDialog.value = true
} */
const removeExtensionAsync = async () => {
if (!extensionToBeRemoved.value?.publicKey || !extensionToBeRemoved.value?.name || !extensionToBeRemoved.value?.version) {
add({
color: 'error',
description: 'Erweiterung kann nicht gelöscht werden',
})
return
}
try {
await extensionStore.removeExtensionAsync(
extensionToBeRemoved.value.publicKey,
extensionToBeRemoved.value.name,
extensionToBeRemoved.value.version,
)
await extensionStore.loadExtensionsAsync()
add({
color: 'success',
title: t('extension.remove.success.title', {
extensionName: extensionToBeRemoved.value.name,
}),
description: t('extension.remove.success.text', {
extensionName: extensionToBeRemoved.value.name,
}),
})
await addNotificationAsync({
text: t('extension.remove.success.text', {
extensionName: extensionToBeRemoved.value.name,
}),
type: 'success',
title: t('extension.remove.success.title', {
extensionName: extensionToBeRemoved.value.name,
}),
})
} catch (error) {
add({
color: 'error',
title: t('extension.remove.error.title'),
description: t('extension.remove.error.text', {
error: JSON.stringify(error),
}),
})
await addNotificationAsync({
type: 'error',
title: t('extension.remove.error.title'),
text: t('extension.remove.error.text', { error: JSON.stringify(error) }),
})
}
}
</script>
<i18n lang="yaml">
de:
title: Erweiterungen
subtitle: Entdecke und installiere Erweiterungen für HaexHub
extension:
installFromFile: Von Datei installieren
add: Erweiterung hinzufügen
success:
title: '{extension} hinzugefügt'
text: Die Erweiterung wurde erfolgreich hinzugefügt
remove:
success:
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
title: '{extensionName} entfernt'
error:
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
title: 'Fehler beim Entfernen von {extensionName}'
marketplace:
comingSoon: Marketplace-Installation kommt bald!
marketplace:
official: Offizieller Marketplace
community: Community Marketplace
category:
all: Alle
productivity: Produktivität
security: Sicherheit
utilities: Werkzeuge
integration: Integration
search:
placeholder: Erweiterungen durchsuchen...
filter:
category: Kategorie auswählen
empty:
title: Keine Erweiterungen gefunden
description: Versuche einen anderen Suchbegriff oder eine andere Kategorie
en:
title: Extensions
subtitle: Discover and install extensions for HaexHub
extension:
installFromFile: Install from file
add: Add Extension
success:
title: '{extension} added'
text: Extension was added successfully
remove:
success:
text: 'Extension {extensionName} was removed'
title: '{extensionName} removed'
error:
text: "Extension {extensionName} couldn't be removed. \n {error}"
title: 'Exception during uninstall {extensionName}'
marketplace:
comingSoon: Marketplace installation coming soon!
marketplace:
official: Official Marketplace
community: Community Marketplace
category:
all: All
productivity: Productivity
security: Security
utilities: Utilities
integration: Integration
search:
placeholder: Search extensions...
filter:
category: Select category
empty:
title: No extensions found
description: Try a different search term or category
</i18n>

View File

@ -6,6 +6,6 @@
<script setup lang="ts">
definePageMeta({
name: 'vaultOverview',
name: 'desktop',
})
</script>

View File

@ -0,0 +1,13 @@
{
"contextMenu": {
"open": "Öffnen",
"removeFromDesktop": "Von Desktop entfernen",
"uninstall": "Deinstallieren"
},
"confirmUninstall": {
"title": "Erweiterung deinstallieren",
"message": "Möchten Sie die Erweiterung '{name}' wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.",
"confirm": "Deinstallieren",
"cancel": "Abbrechen"
}
}

View File

@ -0,0 +1,13 @@
{
"contextMenu": {
"open": "Open",
"removeFromDesktop": "Remove from Desktop",
"uninstall": "Uninstall"
},
"confirmUninstall": {
"title": "Uninstall Extension",
"message": "Do you really want to uninstall the extension '{name}'? This action cannot be undone.",
"confirm": "Uninstall",
"cancel": "Cancel"
}
}

View File

@ -4,6 +4,8 @@ import type {
InsertHaexDesktopItems,
SelectHaexDesktopItems,
} from '~~/src-tauri/database/schemas'
import de from './de.json'
import en from './en.json'
export type DesktopItemType = 'extension' | 'file' | 'folder'
@ -14,8 +16,17 @@ export interface IDesktopItem extends SelectHaexDesktopItems {
export const useDesktopStore = defineStore('desktopStore', () => {
const { currentVault } = storeToRefs(useVaultStore())
const workspaceStore = useWorkspaceStore()
const { currentWorkspace } = storeToRefs(workspaceStore)
const { $i18n } = useNuxtApp()
$i18n.setLocaleMessage('de', {
desktop: de,
})
$i18n.setLocaleMessage('en', { desktop: en })
const desktopItems = ref<IDesktopItem[]>([])
const selectedItemIds = ref<Set<string>>(new Set())
const loadDesktopItemsAsync = async () => {
if (!currentVault.value?.drizzle) {
@ -23,10 +34,16 @@ export const useDesktopStore = defineStore('desktopStore', () => {
return
}
if (!currentWorkspace.value) {
console.error('Kein Workspace aktiv')
return
}
try {
const items = await currentVault.value.drizzle
.select()
.from(haexDesktopItems)
.where(eq(haexDesktopItems.workspaceId, currentWorkspace.value.id))
desktopItems.value = items
} catch (error) {
@ -45,8 +62,13 @@ export const useDesktopStore = defineStore('desktopStore', () => {
throw new Error('Kein Vault geöffnet')
}
if (!currentWorkspace.value) {
throw new Error('Kein Workspace aktiv')
}
try {
const newItem: InsertHaexDesktopItems = {
workspaceId: currentWorkspace.value.id,
itemType: itemType,
referenceId: referenceId,
positionX: positionX,
@ -100,6 +122,7 @@ export const useDesktopStore = defineStore('desktopStore', () => {
}
const removeDesktopItemAsync = async (id: string) => {
console.log('removeDesktopItemAsync', id)
if (!currentVault.value?.drizzle) {
throw new Error('Kein Vault geöffnet')
}
@ -126,33 +149,128 @@ export const useDesktopStore = defineStore('desktopStore', () => {
)
}
const openDesktopItem = (
itemType: DesktopItemType,
referenceId: string,
sourcePosition?: { x: number; y: number; width: number; height: number },
) => {
if (itemType === 'extension') {
const windowManager = useWindowManagerStore()
const extensionsStore = useExtensionsStore()
const extension = extensionsStore.availableExtensions.find(
(ext) => ext.id === referenceId,
)
if (extension) {
windowManager.openWindow(
'extension',
extension.id,
extension.name,
extension.icon || undefined,
undefined, // Use default viewport-aware width
undefined, // Use default viewport-aware height
sourcePosition,
)
}
}
// Für später: file und folder handling
}
const uninstallDesktopItem = async (
id: string,
itemType: DesktopItemType,
referenceId: string,
) => {
if (itemType === 'extension') {
try {
const extensionsStore = useExtensionsStore()
const extension = extensionsStore.availableExtensions.find(
(ext) => ext.id === referenceId,
)
if (!extension) {
console.error('Extension nicht gefunden')
return
}
// Uninstall the extension
await extensionsStore.removeExtensionAsync(
extension.publicKey,
extension.name,
extension.version,
)
// Reload extensions after uninstall
await extensionsStore.loadExtensionsAsync()
// Remove desktop item
await removeDesktopItemAsync(id)
} catch (error) {
console.error('Fehler beim Deinstallieren:', error)
}
}
// Für später: file und folder handling
}
const toggleSelection = (id: string, ctrlKey: boolean = false) => {
if (ctrlKey) {
// Mit Ctrl: Toggle einzelnes Element
if (selectedItemIds.value.has(id)) {
selectedItemIds.value.delete(id)
} else {
selectedItemIds.value.add(id)
}
} else {
// Ohne Ctrl: Nur dieses Element auswählen
selectedItemIds.value.clear()
selectedItemIds.value.add(id)
}
}
const clearSelection = () => {
selectedItemIds.value.clear()
}
const isItemSelected = (id: string) => {
return selectedItemIds.value.has(id)
}
const selectedItems = computed(() => {
return desktopItems.value.filter((item) =>
selectedItemIds.value.has(item.id),
)
})
const getContextMenuItems = (
id: string,
itemType: DesktopItemType,
referenceId: string,
onOpen: () => void,
onUninstall: () => void,
) => {
const handleOpen = () => {
openDesktopItem(itemType, referenceId)
}
return [
[
{
label: 'Öffnen',
label: $i18n.t('desktop.contextMenu.open'),
icon: 'i-heroicons-arrow-top-right-on-square',
click: onOpen,
onSelect: handleOpen,
},
],
[
{
label: 'Von Desktop entfernen',
label: $i18n.t('desktop.contextMenu.removeFromDesktop'),
icon: 'i-heroicons-x-mark',
click: async () => {
onSelect: async () => {
await removeDesktopItemAsync(id)
},
},
{
label: 'Deinstallieren',
label: $i18n.t('desktop.contextMenu.uninstall'),
icon: 'i-heroicons-trash',
click: onUninstall,
onSelect: onUninstall,
},
],
]
@ -160,11 +278,18 @@ export const useDesktopStore = defineStore('desktopStore', () => {
return {
desktopItems,
selectedItemIds,
selectedItems,
loadDesktopItemsAsync,
addDesktopItemAsync,
updateDesktopItemPositionAsync,
removeDesktopItemAsync,
getDesktopItemByReference,
getContextMenuItems,
openDesktopItem,
uninstallDesktopItem,
toggleSelection,
clearSelection,
isItemSelected,
}
})

View File

@ -0,0 +1,312 @@
import { defineAsyncComponent, type Component } from 'vue'
export interface IWindow {
id: string
workspaceId: string // Window belongs to a specific workspace
type: 'system' | 'extension'
sourceId: string // extensionId or systemWindowId (depends on type)
title: string
icon?: string
x: number
y: number
width: number
height: number
isMinimized: boolean
zIndex: number
// Animation source position (icon position)
sourceX?: number
sourceY?: number
sourceWidth?: number
sourceHeight?: number
// Animation state
isOpening?: boolean
isClosing?: boolean
}
export interface SystemWindowDefinition {
id: string
name: string
icon: string
component: Component
defaultWidth: number
defaultHeight: number
resizable?: boolean
singleton?: boolean // Nur eine Instanz erlaubt?
}
export const useWindowManagerStore = defineStore('windowManager', () => {
const workspaceStore = useWorkspaceStore()
const { currentWorkspace, workspaces } = storeToRefs(workspaceStore)
const windows = ref<IWindow[]>([])
const activeWindowId = ref<string | null>(null)
const nextZIndex = ref(100)
// System Windows Registry
const systemWindows: Record<string, SystemWindowDefinition> = {
settings: {
id: 'settings',
name: 'Settings',
icon: 'i-mdi-cog',
component: defineAsyncComponent(
() => import('@/components/haex/system/settings.vue'),
) as Component,
defaultWidth: 800,
defaultHeight: 600,
resizable: true,
singleton: true,
},
marketplace: {
id: 'marketplace',
name: 'Marketplace',
icon: 'i-mdi-store',
component: defineAsyncComponent(
() => import('@/components/haex/system/marketplace.vue'),
),
defaultWidth: 1000,
defaultHeight: 700,
resizable: true,
singleton: false,
},
}
const getSystemWindow = (id: string): SystemWindowDefinition | undefined => {
return systemWindows[id]
}
const getAllSystemWindows = (): SystemWindowDefinition[] => {
return Object.values(systemWindows)
}
// Window animation settings
const windowAnimationDuration = ref(600) // in milliseconds (matches Tailwind duration-600)
// Get windows for current workspace only
const currentWorkspaceWindows = computed(() => {
if (!currentWorkspace.value) return []
return windows.value.filter(
(w) => w.workspaceId === currentWorkspace.value?.id,
)
})
const windowsByWorkspaceId = (workspaceId: string) =>
computed(() =>
windows.value.filter((window) => window.workspaceId === workspaceId),
)
const moveWindowsToWorkspace = (
fromWorkspaceId: string,
toWorkspaceId: string,
) => {
const windowsFrom = windowsByWorkspaceId(fromWorkspaceId)
windowsFrom.value.forEach((window) => (window.workspaceId = toWorkspaceId))
}
const openWindow = (
type: 'system' | 'extension',
sourceId: string,
workspaceId: string,
title?: string,
icon?: string,
width?: number,
height?: number,
sourcePosition?: { x: number; y: number; width: number; height: number },
) => {
const workspace = workspaces.value.find((w) => w.id === workspaceId)
if (!workspace) {
console.error('Cannot open window: No active workspace')
return
}
// System Window specific handling
if (type === 'system') {
const systemWindowDef = getSystemWindow(sourceId)
if (!systemWindowDef) {
console.error(`System window '${sourceId}' not found in registry`)
return
}
// Singleton check: If already open, activate existing window
if (systemWindowDef.singleton) {
const existingWindow = windows.value.find(
(w) => w.type === 'system' && w.sourceId === sourceId,
)
if (existingWindow) {
activateWindow(existingWindow.id)
return existingWindow.id
}
}
// Use system window defaults
title = title ?? systemWindowDef.name
icon = icon ?? systemWindowDef.icon
width = width ?? systemWindowDef.defaultWidth
height = height ?? systemWindowDef.defaultHeight
}
// Create new window
const windowId = crypto.randomUUID()
// Calculate viewport-aware size
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const isMobile = viewportWidth < 768 // Tailwind md breakpoint
// Default size based on viewport
const defaultWidth = isMobile ? Math.floor(viewportWidth * 0.95) : 800
const defaultHeight = isMobile ? Math.floor(viewportHeight * 0.85) : 600
const windowWidth = width ?? defaultWidth
const windowHeight = height ?? defaultHeight
// Calculate centered position with cascading offset (only count windows in current workspace)
const offset = currentWorkspaceWindows.value.length * 30
const centerX = Math.max(0, (viewportWidth - windowWidth) / 2)
const centerY = Math.max(0, (viewportHeight - windowHeight) / 2)
const x = Math.min(centerX + offset, viewportWidth - windowWidth)
const y = Math.min(centerY + offset, viewportHeight - windowHeight)
const newWindow: IWindow = {
id: windowId,
workspaceId: workspace.id,
type,
sourceId,
title: title!,
icon,
x,
y,
width: windowWidth,
height: windowHeight,
isMinimized: false,
zIndex: nextZIndex.value++,
sourceX: sourcePosition?.x,
sourceY: sourcePosition?.y,
sourceWidth: sourcePosition?.width,
sourceHeight: sourcePosition?.height,
isOpening: true,
isClosing: false,
}
windows.value.push(newWindow)
activeWindowId.value = windowId
// Remove opening flag after animation
setTimeout(() => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.isOpening = false
}
}, windowAnimationDuration.value)
return windowId
}
/*****************************************************************************************************
* TODO: Momentan werden die Fenster einfach nur geschlossen.
* In Zukunft sollte aber vorher ein close event an die Erweiterungen via postMessage geschickt werden,
* so dass die Erweiterungen darauf reagieren können, um eventuell ungespeicherte Daten zu sichern
*****************************************************************************************************/
const closeWindow = (windowId: string) => {
const window = windows.value.find((w) => w.id === windowId)
if (!window) return
// Start closing animation
window.isClosing = true
// Remove window after animation completes
setTimeout(() => {
const index = windows.value.findIndex((w) => w.id === windowId)
if (index !== -1) {
windows.value.splice(index, 1)
// If closed window was active, activate the topmost window
if (activeWindowId.value === windowId) {
if (windows.value.length > 0) {
const topWindow = windows.value.reduce((max, w) =>
w.zIndex > max.zIndex ? w : max,
)
activeWindowId.value = topWindow.id
} else {
activeWindowId.value = null
}
}
}
}, windowAnimationDuration.value)
}
const minimizeWindow = (windowId: string) => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.isMinimized = true
}
}
const restoreWindow = (windowId: string) => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.isMinimized = false
activateWindow(windowId)
}
}
const activateWindow = (windowId: string) => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.zIndex = nextZIndex.value++
activeWindowId.value = windowId
}
}
const updateWindowPosition = (windowId: string, x: number, y: number) => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.x = x
window.y = y
}
}
const updateWindowSize = (
windowId: string,
width: number,
height: number,
) => {
const window = windows.value.find((w) => w.id === windowId)
if (window) {
window.width = width
window.height = height
}
}
const isWindowActive = (windowId: string) => {
return activeWindowId.value === windowId
}
const getVisibleWindows = computed(() => {
return currentWorkspaceWindows.value.filter((w) => !w.isMinimized)
})
const getMinimizedWindows = computed(() => {
return currentWorkspaceWindows.value.filter((w) => w.isMinimized)
})
return {
activateWindow,
activeWindowId,
closeWindow,
currentWorkspaceWindows,
getAllSystemWindows,
getMinimizedWindows,
getSystemWindow,
getVisibleWindows,
isWindowActive,
minimizeWindow,
moveWindowsToWorkspace,
openWindow,
restoreWindow,
updateWindowPosition,
updateWindowSize,
windowAnimationDuration,
windows,
windowsByWorkspaceId,
}
})

View File

@ -0,0 +1,189 @@
import { asc, eq } from 'drizzle-orm'
import {
haexWorkspaces,
type InsertHaexWorkspaces,
type SelectHaexWorkspaces,
} from '~~/src-tauri/database/schemas'
import type { Swiper } from 'swiper/types'
export type IWorkspace = SelectHaexWorkspaces
export const useWorkspaceStore = defineStore('workspaceStore', () => {
const vaultStore = useVaultStore()
const windowStore = useWindowManagerStore()
const { currentVault } = storeToRefs(vaultStore)
const swiperInstance = ref<Swiper | null>(null)
const allowSwipe = ref(true)
// Workspace Overview Mode (GNOME-style)
const isOverviewMode = ref(false)
const workspaces = ref<IWorkspace[]>([])
const currentWorkspaceIndex = ref(0)
// Load workspaces from database
const loadWorkspacesAsync = async () => {
if (!currentVault.value?.drizzle) {
console.error('Kein Vault geöffnet')
return
}
try {
const items = await currentVault.value.drizzle
.select()
.from(haexWorkspaces)
.orderBy(asc(haexWorkspaces.position))
workspaces.value = items
// Create default workspace if none exist
if (items.length === 0) {
await addWorkspaceAsync('Workspace 1')
}
} catch (error) {
console.error('Fehler beim Laden der Workspaces:', error)
throw error
}
}
const currentWorkspace = computed(() => {
return workspaces.value[currentWorkspaceIndex.value]
})
const addWorkspaceAsync = async (name?: string) => {
if (!currentVault.value?.drizzle) {
throw new Error('Kein Vault geöffnet')
}
try {
const newIndex = workspaces.value.length + 1
const newWorkspace: InsertHaexWorkspaces = {
name: name || `Workspace ${newIndex}`,
position: workspaces.value.length,
}
const result = await currentVault.value.drizzle
.insert(haexWorkspaces)
.values(newWorkspace)
.returning()
if (result.length > 0 && result[0]) {
workspaces.value.push(result[0])
currentWorkspaceIndex.value = workspaces.value.length - 1
return result[0]
}
} catch (error) {
console.error('Fehler beim Hinzufügen des Workspace:', error)
throw error
}
}
const closeWorkspaceAsync = async (workspaceId: string) => {
const openWindows = windowStore.windowsByWorkspaceId(workspaceId)
for (const window of openWindows.value) {
windowStore.closeWindow(window.id)
}
return await removeWorkspaceAsync(workspaceId)
}
const removeWorkspaceAsync = async (workspaceId: string) => {
// Don't allow removing the last workspace
if (workspaces.value.length <= 1) return
if (!currentVault.value?.drizzle) {
throw new Error('Kein Vault geöffnet')
}
const index = workspaces.value.findIndex((ws) => ws.id === workspaceId)
if (index === -1) return
try {
await currentVault.value.drizzle
.delete(haexWorkspaces)
.where(eq(haexWorkspaces.id, workspaceId))
workspaces.value.splice(index, 1)
// Adjust current index if needed
if (currentWorkspaceIndex.value >= workspaces.value.length) {
currentWorkspaceIndex.value = workspaces.value.length - 1
}
} catch (error) {
console.error('Fehler beim Entfernen des Workspace:', error)
throw error
}
}
const switchToWorkspace = (index: number) => {
if (index >= 0 && index < workspaces.value.length) {
currentWorkspaceIndex.value = index
}
}
const switchToNext = () => {
if (currentWorkspaceIndex.value < workspaces.value.length - 1) {
currentWorkspaceIndex.value++
}
}
const switchToPrevious = () => {
if (currentWorkspaceIndex.value > 0) {
currentWorkspaceIndex.value--
}
}
const renameWorkspaceAsync = async (workspaceId: string, newName: string) => {
if (!currentVault.value?.drizzle) {
throw new Error('Kein Vault geöffnet')
}
try {
const result = await currentVault.value.drizzle
.update(haexWorkspaces)
.set({ name: newName })
.where(eq(haexWorkspaces.id, workspaceId))
.returning()
if (result.length > 0 && result[0]) {
const index = workspaces.value.findIndex((ws) => ws.id === workspaceId)
if (index !== -1) {
workspaces.value[index] = result[0]
}
}
} catch (error) {
console.error('Fehler beim Umbenennen des Workspace:', error)
throw error
}
}
const slideToWorkspace = (index: number) => {
if (swiperInstance.value) {
swiperInstance.value.slideTo(index)
}
isOverviewMode.value = false
}
return {
addWorkspaceAsync,
allowSwipe,
closeWorkspaceAsync,
currentWorkspace,
currentWorkspaceIndex,
isOverviewMode,
slideToWorkspace,
loadWorkspacesAsync,
removeWorkspaceAsync,
renameWorkspaceAsync,
swiperInstance,
switchToNext,
switchToPrevious,
switchToWorkspace,
workspaces,
}
})

View File

@ -31,26 +31,12 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
)
})
/* const { addNotificationAsync } = useNotificationStore() */
/* const extensionLinks = computed<ISidebarItem[]>(() =>
availableExtensions.value
.filter((extension) => extension.enabled && extension.installed)
.map((extension) => ({
icon: extension.icon ?? '',
id: extension.id,
name: extension.name ?? '',
tooltip: extension.name ?? '',
to: { name: 'haexExtension', params: { extensionId: extension.id } },
})),
) */
const isActive = (id: string) =>
/* const isActive = (id: string) =>
computed(
() =>
currentRoute.value.name === 'extension' &&
currentRoute.value.params.extensionId === id,
)
) */
const extensionEntry = computed(() => {
if (
@ -65,7 +51,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
currentExtension.value.name,
currentExtension.value.version,
'index.html',
currentExtension.value.devServerUrl ?? undefined
currentExtension.value.devServerUrl ?? undefined,
)
})
@ -286,7 +272,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
currentExtensionId,
extensionEntry,
installAsync,
isActive,
//isActive,
isExtensionInstalledAsync,
loadExtensionsAsync,
previewManifestAsync,

View File

@ -92,12 +92,20 @@ export const useVaultStore = defineStore('vaultStore', () => {
delete openVaults.value?.[currentVaultId.value]
}
const existsVault = () => {
if (!currentVault.value?.drizzle) {
console.error('Kein Vault geöffnet')
return
}
}
return {
closeAsync,
createAsync,
currentVault,
currentVaultId,
currentVaultName,
existsVault,
openAsync,
openVaults,
}
@ -118,6 +126,11 @@ const isSelectQuery = (sql: string) => {
return selectRegex.test(sql)
}
const hasReturning = (sql: string) => {
const returningRegex = /\bRETURNING\b/i
return returningRegex.test(sql)
}
const drizzleCallback = (async (
sql: string,
params: unknown[],
@ -125,18 +138,33 @@ const drizzleCallback = (async (
) => {
let rows: unknown[] = []
console.log('drizzleCallback', method, sql, params)
if (isSelectQuery(sql)) {
// SELECT statements
rows = await invoke<unknown[]>('sql_select', { sql, params }).catch((e) => {
console.error('SQL select Error:', e, sql, params)
return []
})
} else if (hasReturning(sql)) {
// INSERT/UPDATE/DELETE with RETURNING → use query
rows = await invoke<unknown[]>('sql_query_with_crdt', {
sql,
params,
}).catch((e) => {
console.error('SQL query with CRDT Error:', e, sql, params)
return []
})
} else {
rows = await invoke<unknown[]>('sql_execute', { sql, params }).catch(
(e) => {
console.error('SQL execute Error:', e, sql, params)
return []
},
)
// INSERT/UPDATE/DELETE without RETURNING → use execute
await invoke<unknown[]>('sql_execute_with_crdt', {
sql,
params,
}).catch((e) => {
console.error('SQL execute with CRDT Error:', e, sql, params, rows)
return []
})
return { rows: undefined }
}
if (method === 'get') {