mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-17 06:30:50 +01:00
use window system
This commit is contained in:
169
src/components/haex/desktop/extension-frame.vue
Normal file
169
src/components/haex/desktop/extension-frame.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
478
src/components/haex/desktop/window.vue
Normal file
478
src/components/haex/desktop/window.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
145
src/components/haex/system/marketplace.vue
Normal file
145
src/components/haex/system/marketplace.vue
Normal 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>
|
||||
96
src/components/haex/system/settings.vue
Normal file
96
src/components/haex/system/settings.vue
Normal 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>
|
||||
@ -98,7 +98,7 @@ const onCreateAsync = async () => {
|
||||
if (vaultId) {
|
||||
initVault()
|
||||
await navigateTo(
|
||||
useLocaleRoute()({ name: 'vaultOverview', params: { vaultId } }),
|
||||
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,7 +150,7 @@ const onOpenDatabase = async () => {
|
||||
|
||||
await navigateTo(
|
||||
localePath({
|
||||
name: 'vaultOverview',
|
||||
name: 'desktop',
|
||||
params: {
|
||||
vaultId,
|
||||
},
|
||||
|
||||
35
src/components/haex/workspace/card.vue
Normal file
35
src/components/haex/workspace/card.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user