mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 22:20:51 +01:00
reorganized window
This commit is contained in:
@ -18,3 +18,8 @@
|
|||||||
:root {
|
:root {
|
||||||
--ui-header-height: 74px;
|
--ui-header-height: 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.swiper-slide {
|
||||||
|
isolation: isolate; /* Für jeden Slide */
|
||||||
|
contain: layout style; /* Enthält den Context, ohne Performance-Hit */
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="desktopEl"
|
ref="desktopEl"
|
||||||
class="w-full h-full relative overflow-hidden"
|
class="w-full h-full relative overflow-hidden isolate"
|
||||||
@click.self.stop="handleDesktopClick"
|
|
||||||
>
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
:modules="[SwiperNavigation]"
|
:modules="[SwiperNavigation]"
|
||||||
@ -25,7 +24,7 @@
|
|||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
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"
|
class="w-full h-full relative isolate"
|
||||||
@click.self.stop="handleDesktopClick"
|
@click.self.stop="handleDesktopClick"
|
||||||
@mousedown.left.self="handleAreaSelectStart"
|
@mousedown.left.self="handleAreaSelectStart"
|
||||||
>
|
>
|
||||||
@ -102,7 +101,7 @@
|
|||||||
class="absolute inset-0 z-[100] bg-transparent group-hover:ring-4 group-hover:ring-purple-500 rounded-xl transition-all"
|
class="absolute inset-0 z-[100] bg-transparent group-hover:ring-4 group-hover:ring-purple-500 rounded-xl transition-all"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HaexDesktopWindow
|
<HaexWindow
|
||||||
:id="window.id"
|
:id="window.id"
|
||||||
:title="window.title"
|
:title="window.title"
|
||||||
:icon="window.icon"
|
:icon="window.icon"
|
||||||
@ -144,11 +143,11 @@
|
|||||||
:extension-id="window.sourceId"
|
:extension-id="window.sourceId"
|
||||||
:window-id="window.id"
|
:window-id="window.id"
|
||||||
/>
|
/>
|
||||||
</HaexDesktopWindow>
|
</HaexWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Normal Mode (non-overview) -->
|
<!-- Normal Mode (non-overview) -->
|
||||||
<HaexDesktopWindow
|
<HaexWindow
|
||||||
:id="window.id"
|
:id="window.id"
|
||||||
:title="window.title"
|
:title="window.title"
|
||||||
:icon="window.icon"
|
:icon="window.icon"
|
||||||
@ -189,7 +188,7 @@
|
|||||||
:extension-id="window.sourceId"
|
:extension-id="window.sourceId"
|
||||||
:window-id="window.id"
|
:window-id="window.id"
|
||||||
/>
|
/>
|
||||||
</HaexDesktopWindow>
|
</HaexWindow>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
@ -471,7 +470,6 @@ const handleDesktopClick = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
desktopStore.clearSelection()
|
desktopStore.clearSelection()
|
||||||
|
|
||||||
isOverviewMode.value = false
|
isOverviewMode.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
ref="windowEl"
|
ref="windowEl"
|
||||||
:style="windowStyle"
|
:style="windowStyle"
|
||||||
:class="[
|
:class="[
|
||||||
'absolute bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden',
|
'absolute backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden',
|
||||||
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600',
|
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600',
|
||||||
'flex flex-col',
|
'flex flex-col',
|
||||||
|
|
||||||
isActive ? 'z-50' : 'z-10',
|
isActive ? 'z-50' : 'z-10',
|
||||||
]"
|
]"
|
||||||
@mousedown="handleActivate"
|
@mousedown="handleActivate"
|
||||||
@ -74,8 +75,8 @@
|
|||||||
<!-- Window Content -->
|
<!-- Window Content -->
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'flex-1 overflow-hidden relative',
|
'flex-1 overflow-auto relative ',
|
||||||
isDragging || isResizing ? 'pointer-events-none' : '',
|
isDragging || isResizing ? 'pointer-events-none select-none' : '',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
@ -84,35 +85,35 @@
|
|||||||
<!-- Resize Handles -->
|
<!-- Resize Handles -->
|
||||||
<template v-if="!isMaximized">
|
<template v-if="!isMaximized">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
class="absolute top-0 left-0 w-2 h-2 cursor-nw-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('nw', $event)"
|
@mousedown.left.stop="handleResizeStart('nw', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
|
class="absolute top-0 right-0 w-2 h-2 cursor-ne-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('ne', $event)"
|
@mousedown.left.stop="handleResizeStart('ne', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
|
class="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('sw', $event)"
|
@mousedown.left.stop="handleResizeStart('sw', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
|
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('se', $event)"
|
@mousedown.left.stop="handleResizeStart('se', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-2 right-2 h-1 cursor-n-resize"
|
class="absolute top-0 left-2 right-2 h-2 cursor-n-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('n', $event)"
|
@mousedown.left.stop="handleResizeStart('n', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 left-2 right-2 h-1 cursor-s-resize"
|
class="absolute bottom-0 left-2 right-2 h-2 cursor-s-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('s', $event)"
|
@mousedown.left.stop="handleResizeStart('s', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-2 bottom-2 w-1 cursor-w-resize"
|
class="absolute left-0 top-2 bottom-2 w-2 cursor-w-resize bg-red-300 overflow-visible"
|
||||||
@mousedown.left.stop="handleResizeStart('w', $event)"
|
@mousedown.left.stop="handleResizeStart('w', $event)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 top-2 bottom-2 w-1 cursor-e-resize"
|
class="absolute right-0 top-2 bottom-2 w-2 cursor-e-resize shrink-0"
|
||||||
@mousedown.left.stop="handleResizeStart('e', $event)"
|
@mousedown.left.stop="handleResizeStart('e', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -466,6 +467,7 @@ useEventListener(window, 'mousemove', (e: MouseEvent) => {
|
|||||||
// Global mouse up handler (for resizing only, dragging handled by useDrag)
|
// Global mouse up handler (for resizing only, dragging handled by useDrag)
|
||||||
useEventListener(window, 'mouseup', () => {
|
useEventListener(window, 'mouseup', () => {
|
||||||
if (isResizing.value) {
|
if (isResizing.value) {
|
||||||
|
globalThis.getSelection()?.removeAllRanges()
|
||||||
isResizing.value = false
|
isResizing.value = false
|
||||||
|
|
||||||
// Snap back to viewport after resize ends
|
// Snap back to viewport after resize ends
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
size="xl"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
|
|||||||
270
src/components/haex/system/developer.vue
Normal file
270
src/components/haex/system/developer.vue
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 mx-auto space-y-6 bg-default/90 backdrop-blur-2xl">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h1 class="text-2xl font-bold">{{ t('title') }}</h1>
|
||||||
|
<p class="text-sm opacity-70">{{ t('description') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Dev Extension Form -->
|
||||||
|
<UCard class="p-4 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">{{ t('add.title') }}</h2>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-medium">{{ t('add.extensionPath') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UiInput
|
||||||
|
v-model="extensionPath"
|
||||||
|
:placeholder="t('add.extensionPathPlaceholder')"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<UiButton
|
||||||
|
:label="t('add.browse')"
|
||||||
|
variant="outline"
|
||||||
|
@click="browseExtensionPathAsync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs opacity-60">{{ t('add.extensionPathHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiButton
|
||||||
|
:label="t('add.loadExtension')"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="!extensionPath"
|
||||||
|
@click="loadDevExtensionAsync"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- List of Dev Extensions -->
|
||||||
|
<div
|
||||||
|
v-if="devExtensions.length > 0"
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold">{{ t('list.title') }}</h2>
|
||||||
|
|
||||||
|
<UCard
|
||||||
|
v-for="ext in devExtensions"
|
||||||
|
:key="ext.id"
|
||||||
|
class="p-4 flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="font-medium">{{ ext.name }}</h3>
|
||||||
|
<UBadge color="info">DEV</UBadge>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm opacity-70">v{{ ext.version }}</p>
|
||||||
|
<p class="text-xs opacity-50">{{ ext.publicKey.slice(0, 16) }}...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UiButton
|
||||||
|
:label="t('list.reload')"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="reloadDevExtensionAsync(ext)"
|
||||||
|
/>
|
||||||
|
<UiButton
|
||||||
|
:label="t('list.remove')"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
color="error"
|
||||||
|
@click="removeDevExtensionAsync(ext)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-center py-8 opacity-50"
|
||||||
|
>
|
||||||
|
{{ t('list.empty') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import type { ExtensionInfoResponse } from '~~/src-tauri/bindings/ExtensionInfoResponse'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { add } = useToast()
|
||||||
|
const { loadExtensionsAsync } = useExtensionsStore()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const extensionPath = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const devExtensions = ref<Array<ExtensionInfoResponse>>([])
|
||||||
|
|
||||||
|
// Load dev extensions on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDevExtensionListAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Browse for extension directory
|
||||||
|
const browseExtensionPathAsync = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: t('add.browseTitle'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
extensionPath.value = selected
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to browse directory:', error)
|
||||||
|
add({
|
||||||
|
description: t('add.errors.browseFailed'),
|
||||||
|
color: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a dev extension
|
||||||
|
const loadDevExtensionAsync = async () => {
|
||||||
|
if (!extensionPath.value) return
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await invoke<string>('load_dev_extension', {
|
||||||
|
extensionPath: extensionPath.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
add({
|
||||||
|
description: t('add.success'),
|
||||||
|
color: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload list
|
||||||
|
await loadDevExtensionListAsync()
|
||||||
|
|
||||||
|
// Reload all extensions in the main extension store so they appear in the launcher
|
||||||
|
await loadExtensionsAsync()
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
extensionPath.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dev extension:', error)
|
||||||
|
add({
|
||||||
|
description: t('add.errors.loadFailed') + error,
|
||||||
|
color: 'error',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all dev extensions (for the list on this page)
|
||||||
|
const loadDevExtensionListAsync = async () => {
|
||||||
|
try {
|
||||||
|
const extensions = await invoke<Array<ExtensionInfoResponse>>(
|
||||||
|
'get_all_dev_extensions',
|
||||||
|
)
|
||||||
|
devExtensions.value = extensions
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dev extensions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload a dev extension (removes and re-adds)
|
||||||
|
const reloadDevExtensionAsync = async (extension: ExtensionInfoResponse) => {
|
||||||
|
try {
|
||||||
|
console.log('reloadDevExtensionAsync', extension)
|
||||||
|
// Get the extension path from somewhere (we need to store this)
|
||||||
|
// For now, just show a message
|
||||||
|
add({
|
||||||
|
description: t('list.reloadInfo'),
|
||||||
|
color: 'info',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reload dev extension:', error)
|
||||||
|
add({
|
||||||
|
description: t('list.errors.reloadFailed') + error,
|
||||||
|
color: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a dev extension
|
||||||
|
const removeDevExtensionAsync = async (extension: ExtensionInfoResponse) => {
|
||||||
|
try {
|
||||||
|
await invoke('remove_dev_extension', {
|
||||||
|
publicKey: extension.publicKey,
|
||||||
|
name: extension.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
add({
|
||||||
|
description: t('list.removeSuccess'),
|
||||||
|
color: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reload list
|
||||||
|
await loadDevExtensionListAsync()
|
||||||
|
|
||||||
|
// Reload all extensions store
|
||||||
|
await loadExtensionsAsync()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove dev extension:', error)
|
||||||
|
add({
|
||||||
|
description: t('list.errors.removeFailed') + error,
|
||||||
|
color: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<i18n lang="yaml">
|
||||||
|
de:
|
||||||
|
title: Entwicklereinstellungen
|
||||||
|
description: Lade Extensions im Entwicklungsmodus für schnelleres Testen mit Hot-Reload.
|
||||||
|
add:
|
||||||
|
title: Dev-Extension hinzufügen
|
||||||
|
extensionPath: Extension-Pfad
|
||||||
|
extensionPathPlaceholder: /pfad/zu/deiner/extension
|
||||||
|
extensionPathHint: Pfad zum Extension-Projekt (enthält haextension/ und haextension.json)
|
||||||
|
browse: Durchsuchen
|
||||||
|
browseTitle: Extension-Verzeichnis auswählen
|
||||||
|
loadExtension: Extension laden
|
||||||
|
success: Dev-Extension erfolgreich geladen
|
||||||
|
errors:
|
||||||
|
browseFailed: Verzeichnis konnte nicht ausgewählt werden
|
||||||
|
loadFailed: Extension konnte nicht geladen werden
|
||||||
|
list:
|
||||||
|
title: Geladene Dev-Extensions
|
||||||
|
empty: Keine Dev-Extensions geladen
|
||||||
|
reload: Neu laden
|
||||||
|
remove: Entfernen
|
||||||
|
reloadInfo: Extension wird beim nächsten Laden automatisch aktualisiert
|
||||||
|
removeSuccess: Dev-Extension erfolgreich entfernt
|
||||||
|
errors:
|
||||||
|
reloadFailed: Extension konnte nicht neu geladen werden
|
||||||
|
removeFailed: Extension konnte nicht entfernt werden
|
||||||
|
|
||||||
|
en:
|
||||||
|
title: Developer Settings
|
||||||
|
description: Load extensions in development mode for faster testing with hot-reload.
|
||||||
|
add:
|
||||||
|
title: Add Dev Extension
|
||||||
|
extensionPath: Extension Path
|
||||||
|
extensionPathPlaceholder: /path/to/your/extension
|
||||||
|
extensionPathHint: Path to your extension project (contains haextension/ and haextension.json)
|
||||||
|
browse: Browse
|
||||||
|
browseTitle: Select Extension Directory
|
||||||
|
loadExtension: Load Extension
|
||||||
|
success: Dev extension loaded successfully
|
||||||
|
errors:
|
||||||
|
browseFailed: Failed to select directory
|
||||||
|
loadFailed: Failed to load extension
|
||||||
|
list:
|
||||||
|
title: Loaded Dev Extensions
|
||||||
|
empty: No dev extensions loaded
|
||||||
|
reload: Reload
|
||||||
|
remove: Remove
|
||||||
|
reloadInfo: Extension will be automatically updated on next load
|
||||||
|
removeSuccess: Dev extension removed successfully
|
||||||
|
errors:
|
||||||
|
reloadFailed: Failed to reload extension
|
||||||
|
removeFailed: Failed to remove extension
|
||||||
|
</i18n>
|
||||||
@ -1,145 +1,606 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Marketplace Header -->
|
<!-- Header with Actions -->
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6"
|
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"
|
||||||
>
|
>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
<div>
|
||||||
Extension Marketplace
|
<h1 class="text-2xl font-bold">
|
||||||
|
{{ t('title') }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Discover and install extensions for HaexHub
|
{{ t('subtitle') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-4"
|
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
|
<UInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="t('search.placeholder')"
|
||||||
icon="i-heroicons-magnifying-glass"
|
icon="i-heroicons-magnifying-glass"
|
||||||
size="lg"
|
class="flex-1"
|
||||||
placeholder="Search extensions..."
|
/>
|
||||||
class="w-full"
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Marketplace Content -->
|
<!-- Empty State -->
|
||||||
<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
|
<div
|
||||||
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 hover:shadow-lg transition-shadow cursor-pointer"
|
v-else
|
||||||
>
|
class="flex flex-col items-center justify-center h-full text-center"
|
||||||
<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
|
<UIcon
|
||||||
name="i-heroicons-puzzle-piece"
|
name="i-heroicons-magnifying-glass"
|
||||||
class="w-6 h-6 text-white"
|
class="w-16 h-16 text-gray-400 mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<div class="flex-1 min-w-0">
|
{{ t('empty.title') }}
|
||||||
<h3
|
|
||||||
class="font-semibold text-gray-900 dark:text-white truncate"
|
|
||||||
>
|
|
||||||
Example Extension
|
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p class="text-gray-500 dark:text-gray-400 mt-2">
|
||||||
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1"
|
{{ t('empty.description') }}
|
||||||
>
|
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Categories -->
|
<HaexExtensionDialogReinstall
|
||||||
<section>
|
v-model:open="openOverwriteDialog"
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
v-model:preview="preview"
|
||||||
Categories
|
@confirm="reinstallExtensionAsync"
|
||||||
</h2>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<UBadge
|
|
||||||
label="Productivity"
|
|
||||||
color="primary"
|
|
||||||
variant="soft"
|
|
||||||
size="lg"
|
|
||||||
/>
|
/>
|
||||||
<UBadge
|
|
||||||
label="Development"
|
<HaexExtensionDialogInstall
|
||||||
color="secondary"
|
v-model:open="showConfirmation"
|
||||||
variant="soft"
|
:preview="preview"
|
||||||
size="lg"
|
@confirm="addExtensionAsync"
|
||||||
/>
|
/>
|
||||||
<UBadge
|
|
||||||
label="Security"
|
<HaexExtensionDialogRemove
|
||||||
color="error"
|
v-model:open="showRemoveDialog"
|
||||||
variant="soft"
|
:extension="extensionToBeRemoved"
|
||||||
size="lg"
|
@confirm="removeExtensionAsync"
|
||||||
/>
|
/>
|
||||||
<UBadge
|
|
||||||
label="Utilities"
|
|
||||||
color="secondary"
|
|
||||||
variant="soft"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Marketplace component - placeholder implementation
|
import type {
|
||||||
|
IHaexHubExtension,
|
||||||
|
IHaexHubExtensionManifest,
|
||||||
|
IMarketplaceExtension,
|
||||||
|
} from '~/types/haexhub'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const extensionStore = useExtensionsStore()
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
'preview.value?.editable_permissions',
|
||||||
|
preview.value?.editable_permissions,
|
||||||
|
)
|
||||||
|
await extensionStore.installAsync(
|
||||||
|
extension.path,
|
||||||
|
preview.value?.editable_permissions,
|
||||||
|
)
|
||||||
|
await extensionStore.loadExtensionsAsync()
|
||||||
|
|
||||||
|
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>
|
</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>
|
||||||
|
|||||||
@ -1,101 +1,132 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
|
<div>
|
||||||
<!-- Settings Header -->
|
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6"
|
class="grid grid-rows-2 sm:grid-cols-2 sm:gap-2 p-2 max-w-2xl w-full h-fit"
|
||||||
>
|
>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Settings</h1>
|
<div class="p-2">{{ t('language') }}</div>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></div>
|
||||||
Manage your HaexHub preferences and configuration
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Content -->
|
<div class="p-2">{{ t('design') }}</div>
|
||||||
<div class="flex-1 overflow-y-auto p-6">
|
<div><UiDropdownTheme @select="onSelectThemeAsync" /></div>
|
||||||
<div class="max-w-2xl space-y-6">
|
|
||||||
<!-- General Section -->
|
<div class="p-2">{{ t('vaultName.label') }}</div>
|
||||||
<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>
|
<div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">Theme</p>
|
<UiInput
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
v-model="currentVaultName"
|
||||||
Choose your preferred theme
|
:placeholder="t('vaultName.label')"
|
||||||
</p>
|
@change="onSetVaultNameAsync"
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
label="Auto"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
|
<div class="p-2">{{ t('notifications.label') }}</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<UiButton
|
||||||
Language
|
:label="t('notifications.requestPermission')"
|
||||||
</p>
|
@click="requestNotificationPermissionAsync"
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Select your language
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
label="English"
|
|
||||||
variant="outline"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Privacy Section -->
|
<div class="p-2">{{ t('deviceName.label') }}</div>
|
||||||
<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>
|
<div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<UiInput
|
||||||
Auto-lock
|
v-model="deviceName"
|
||||||
</p>
|
:placeholder="t('deviceName.label')"
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
@change="onUpdateDeviceNameAsync"
|
||||||
Lock vault after inactivity
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Settings component - placeholder implementation
|
import type { Locale } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t, setLocale } = useI18n()
|
||||||
|
|
||||||
|
const { currentVaultName } = storeToRefs(useVaultStore())
|
||||||
|
const { updateVaultNameAsync, updateLocaleAsync, updateThemeAsync } =
|
||||||
|
useVaultSettingsStore()
|
||||||
|
|
||||||
|
const onSelectLocaleAsync = async (locale: Locale) => {
|
||||||
|
await updateLocaleAsync(locale)
|
||||||
|
await setLocale(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentThemeName } = storeToRefs(useUiStore())
|
||||||
|
|
||||||
|
const onSelectThemeAsync = async (theme: string) => {
|
||||||
|
currentThemeName.value = theme
|
||||||
|
console.log('onSelectThemeAsync', currentThemeName.value)
|
||||||
|
await updateThemeAsync(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { add } = useToast()
|
||||||
|
|
||||||
|
const onSetVaultNameAsync = async () => {
|
||||||
|
try {
|
||||||
|
await updateVaultNameAsync(currentVaultName.value)
|
||||||
|
add({ description: t('vaultName.update.success'), color: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
add({ description: t('vaultName.update.error'), color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { requestNotificationPermissionAsync } = useNotificationStore()
|
||||||
|
|
||||||
|
const { deviceName } = storeToRefs(useDeviceStore())
|
||||||
|
const { updateDeviceNameAsync, readDeviceNameAsync } = useDeviceStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await readDeviceNameAsync()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onUpdateDeviceNameAsync = async () => {
|
||||||
|
const check = vaultDeviceNameSchema.safeParse(deviceName.value)
|
||||||
|
if (!check.success) return
|
||||||
|
try {
|
||||||
|
await updateDeviceNameAsync({ name: deviceName.value })
|
||||||
|
add({ description: t('deviceName.update.success'), color: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
add({ description: t('deviceName.update.error'), color: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<i18n lang="yaml">
|
||||||
|
de:
|
||||||
|
language: Sprache
|
||||||
|
design: Design
|
||||||
|
save: Änderung speichern
|
||||||
|
notifications:
|
||||||
|
label: Benachrichtigungen
|
||||||
|
requestPermission: Benachrichtigung erlauben
|
||||||
|
vaultName:
|
||||||
|
label: Vaultname
|
||||||
|
update:
|
||||||
|
success: Vaultname erfolgreich aktualisiert
|
||||||
|
error: Vaultname konnte nicht aktualisiert werden
|
||||||
|
deviceName:
|
||||||
|
label: Gerätename
|
||||||
|
update:
|
||||||
|
success: Gerätename wurde erfolgreich aktualisiert
|
||||||
|
error: Gerätename konnte nich aktualisiert werden
|
||||||
|
en:
|
||||||
|
language: Language
|
||||||
|
design: Design
|
||||||
|
save: save changes
|
||||||
|
notifications:
|
||||||
|
label: Notifications
|
||||||
|
requestPermission: Grant Permission
|
||||||
|
vaultName:
|
||||||
|
label: Vault Name
|
||||||
|
update:
|
||||||
|
success: Vault Name successfully updated
|
||||||
|
error: Vault name could not be updated
|
||||||
|
deviceName:
|
||||||
|
label: Device name
|
||||||
|
update:
|
||||||
|
success: Device name has been successfully updated
|
||||||
|
error: Device name could not be updated
|
||||||
|
</i18n>
|
||||||
|
|||||||
455
src/components/haex/window/index.vue
Normal file
455
src/components/haex/window/index.vue
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="windowEl"
|
||||||
|
:style="windowStyle"
|
||||||
|
:class="[
|
||||||
|
'absolute bg-default/80 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden isolate',
|
||||||
|
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600 ',
|
||||||
|
'flex flex-col',
|
||||||
|
{ 'select-none': isResizingOrDragging },
|
||||||
|
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-auto relative ',
|
||||||
|
isResizingOrDragging ? 'pointer-events-none' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resize Handles -->
|
||||||
|
<HaexWindowResizeHandles
|
||||||
|
:disabled="isMaximized"
|
||||||
|
@resize-start="handleResizeStart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon?: string | null
|
||||||
|
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 = useTemplateRef('windowEl')
|
||||||
|
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)
|
||||||
|
|
||||||
|
const isResizingOrDragging = computed(
|
||||||
|
() => isResizing.value || isDragging.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
baseStyle.transform = 'translateZ(0)'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 | TouchEvent) => {
|
||||||
|
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) {
|
||||||
|
globalThis.getSelection()?.removeAllRanges()
|
||||||
|
isResizing.value = false
|
||||||
|
|
||||||
|
// Snap back to viewport after resize ends
|
||||||
|
snapToViewport()
|
||||||
|
|
||||||
|
emit('positionChanged', x.value, y.value)
|
||||||
|
emit('sizeChanged', width.value, height.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
61
src/components/haex/window/resizeHandles.vue
Normal file
61
src/components/haex/window/resizeHandles.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="!disabled">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 size-2 cursor-nw-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('nw', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('nw', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 size-2 cursor-ne-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('ne', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('ne', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 size-2 cursor-sw-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('sw', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('sw', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('se', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('se', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-2 right-2 h-2 cursor-n-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('n', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('n', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-2 right-2 h-2 cursor-s-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('s', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('s', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-2 bottom-2 w-2 cursor-w-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('w', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('w', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-2 bottom-2 w-2 cursor-e-resize z-10"
|
||||||
|
@mousedown.left.stop="emitResizeStart('e', $event)"
|
||||||
|
@touchstart.passive.stop="emitResizeStart('e', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Props: Nur Information, ob Handles angezeigt werden sollen
|
||||||
|
defineProps<{
|
||||||
|
disabled?: boolean // True if window is maximized
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Emits: Signalisiert den Start des Resizing mit Richtung und Event
|
||||||
|
const emit = defineEmits<{
|
||||||
|
resizeStart: [direction: string, event: MouseEvent | TouchEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Funktion, um das Event nach oben weiterzuleiten
|
||||||
|
const emitResizeStart = (direction: string, event: MouseEvent | TouchEvent) => {
|
||||||
|
emit('resizeStart', direction, event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -32,14 +32,11 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
:block="isSmallScreen"
|
:block="isSmallScreen"
|
||||||
@click="isOverviewMode = !isOverviewMode"
|
@click="isOverviewMode = !isOverviewMode"
|
||||||
|
icon="i-bi-person-workspace"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-heroicons-squares-2x2" />
|
|
||||||
</template>
|
|
||||||
Workspaces
|
|
||||||
</UButton>
|
</UButton>
|
||||||
<HaexExtensionLauncher :block="isSmallScreen" />
|
<HaexExtensionLauncher :block="isSmallScreen" />
|
||||||
<UiDropdownVault :block="isSmallScreen" />
|
|
||||||
</template>
|
</template>
|
||||||
</UPageHeader>
|
</UPageHeader>
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,18 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
|
|||||||
|
|
||||||
// System Windows Registry
|
// System Windows Registry
|
||||||
const systemWindows: Record<string, SystemWindowDefinition> = {
|
const systemWindows: Record<string, SystemWindowDefinition> = {
|
||||||
|
developer: {
|
||||||
|
id: 'developer',
|
||||||
|
name: 'Developer',
|
||||||
|
icon: 'i-hugeicons-developer',
|
||||||
|
component: defineAsyncComponent(
|
||||||
|
() => import('@/components/haex/system/developer.vue'),
|
||||||
|
) as Component,
|
||||||
|
defaultWidth: 800,
|
||||||
|
defaultHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
singleton: true,
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
@ -169,8 +181,15 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
|
|||||||
const viewportWidth = window.innerWidth
|
const viewportWidth = window.innerWidth
|
||||||
const viewportHeight = window.innerHeight
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
const windowWidth = width > viewportWidth ? viewportWidth : width
|
const windowHeight = Math.min(height, viewportHeight)
|
||||||
const windowHeight = height > viewportHeight ? viewportHeight : height
|
|
||||||
|
// Adjust width proportionally if needed (optional)
|
||||||
|
const aspectRatio = width / height
|
||||||
|
const windowWidth = Math.min(
|
||||||
|
width,
|
||||||
|
viewportWidth,
|
||||||
|
windowHeight * aspectRatio,
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate centered position with cascading offset (only count windows in current workspace)
|
// Calculate centered position with cascading offset (only count windows in current workspace)
|
||||||
const offset = currentWorkspaceWindows.value.length * 30
|
const offset = currentWorkspaceWindows.value.length * 30
|
||||||
|
|||||||
Reference in New Issue
Block a user