reorganized window

This commit is contained in:
2025-10-21 13:49:29 +02:00
parent df97a3cb8b
commit e268947593
11 changed files with 1547 additions and 248 deletions

View File

@ -18,3 +18,8 @@
:root {
--ui-header-height: 74px;
}
.swiper-slide {
isolation: isolate; /* Für jeden Slide */
contain: layout style; /* Enthält den Context, ohne Performance-Hit */
}

View File

@ -1,8 +1,7 @@
<template>
<div
ref="desktopEl"
class="w-full h-full relative overflow-hidden"
@click.self.stop="handleDesktopClick"
class="w-full h-full relative overflow-hidden isolate"
>
<Swiper
:modules="[SwiperNavigation]"
@ -25,7 +24,7 @@
class="w-full h-full"
>
<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"
@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"
/>
<HaexDesktopWindow
<HaexWindow
:id="window.id"
:title="window.title"
:icon="window.icon"
@ -144,11 +143,11 @@
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexDesktopWindow>
</HaexWindow>
</div>
<!-- Normal Mode (non-overview) -->
<HaexDesktopWindow
<HaexWindow
:id="window.id"
:title="window.title"
:icon="window.icon"
@ -189,7 +188,7 @@
:extension-id="window.sourceId"
:window-id="window.id"
/>
</HaexDesktopWindow>
</HaexWindow>
</template>
</div>
</SwiperSlide>
@ -471,7 +470,6 @@ const handleDesktopClick = () => {
}
desktopStore.clearSelection()
isOverviewMode.value = false
}

View File

@ -3,9 +3,10 @@
ref="windowEl"
:style="windowStyle"
: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',
'flex flex-col',
isActive ? 'z-50' : 'z-10',
]"
@mousedown="handleActivate"
@ -74,8 +75,8 @@
<!-- Window Content -->
<div
:class="[
'flex-1 overflow-hidden relative',
isDragging || isResizing ? 'pointer-events-none' : '',
'flex-1 overflow-auto relative ',
isDragging || isResizing ? 'pointer-events-none select-none' : '',
]"
>
<slot />
@ -84,35 +85,35 @@
<!-- Resize Handles -->
<template v-if="!isMaximized">
<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)"
/>
<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)"
/>
<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)"
/>
<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)"
/>
<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)"
/>
<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)"
/>
<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)"
/>
<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)"
/>
</template>
@ -466,6 +467,7 @@ useEventListener(window, 'mousemove', (e: MouseEvent) => {
// 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

View File

@ -5,7 +5,7 @@
color="neutral"
variant="outline"
v-bind="$attrs"
size="xl"
size="lg"
/>
<template #content>

View 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>

View File

@ -1,145 +1,606 @@
<template>
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
<!-- Marketplace Header -->
<div class="flex flex-col h-full">
<!-- Header with Actions -->
<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">
Extension Marketplace
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Discover and install extensions for HaexHub
</p>
</div>
<div>
<h1 class="text-2xl font-bold">
{{ t('title') }}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ t('subtitle') }}
</p>
</div>
<!-- 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>
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"
>
<!-- Marketplace Selector -->
<USelectMenu
v-model="selectedMarketplace"
:items="marketplaces"
value-key="id"
class="w-full sm:w-48"
>
<template #leading>
<UIcon name="i-heroicons-building-storefront" />
</template>
</USelectMenu>
<!-- 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>
<!-- Install from File Button -->
<UiButton
:label="t('extension.installFromFile')"
icon="i-heroicons-arrow-up-tray"
color="neutral"
@click="onSelectExtensionAsync"
/>
</div>
</div>
<!-- Search and Filters -->
<div
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
>
<UInput
v-model="searchQuery"
:placeholder="t('search.placeholder')"
icon="i-heroicons-magnifying-glass"
class="flex-1"
/>
<USelectMenu
v-model="selectedCategory"
:items="categories"
:placeholder="t('filter.category')"
value-key="id"
class="w-full sm:w-48"
>
<template #leading>
<UIcon name="i-heroicons-tag" />
</template>
</USelectMenu>
</div>
<!-- Extensions Grid -->
<div class="flex-1 overflow-auto p-6">
<div
v-if="filteredExtensions.length"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<!-- Marketplace Extension Card -->
<HaexExtensionMarketplaceCard
v-for="ext in filteredExtensions"
:key="ext.id"
:extension="ext"
@install="onInstallFromMarketplace(ext)"
@details="onShowExtensionDetails(ext)"
/>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center h-full text-center"
>
<UIcon
name="i-heroicons-magnifying-glass"
class="w-16 h-16 text-gray-400 mb-4"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('empty.title') }}
</h3>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ t('empty.description') }}
</p>
</div>
</div>
<HaexExtensionDialogReinstall
v-model:open="openOverwriteDialog"
v-model:preview="preview"
@confirm="reinstallExtensionAsync"
/>
<HaexExtensionDialogInstall
v-model:open="showConfirmation"
:preview="preview"
@confirm="addExtensionAsync"
/>
<HaexExtensionDialogRemove
v-model:open="showRemoveDialog"
:extension="extensionToBeRemoved"
@confirm="removeExtensionAsync"
/>
</div>
</template>
<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>
<i18n lang="yaml">
de:
title: Erweiterungen
subtitle: Entdecke und installiere Erweiterungen für HaexHub
extension:
installFromFile: Von Datei installieren
add: Erweiterung hinzufügen
success:
title: '{extension} hinzugefügt'
text: Die Erweiterung wurde erfolgreich hinzugefügt
remove:
success:
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
title: '{extensionName} entfernt'
error:
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
title: 'Fehler beim Entfernen von {extensionName}'
marketplace:
comingSoon: Marketplace-Installation kommt bald!
marketplace:
official: Offizieller Marketplace
community: Community Marketplace
category:
all: Alle
productivity: Produktivität
security: Sicherheit
utilities: Werkzeuge
integration: Integration
search:
placeholder: Erweiterungen durchsuchen...
filter:
category: Kategorie auswählen
empty:
title: Keine Erweiterungen gefunden
description: Versuche einen anderen Suchbegriff oder eine andere Kategorie
en:
title: Extensions
subtitle: Discover and install extensions for HaexHub
extension:
installFromFile: Install from file
add: Add Extension
success:
title: '{extension} added'
text: Extension was added successfully
remove:
success:
text: 'Extension {extensionName} was removed'
title: '{extensionName} removed'
error:
text: "Extension {extensionName} couldn't be removed. \n {error}"
title: 'Exception during uninstall {extensionName}'
marketplace:
comingSoon: Marketplace installation coming soon!
marketplace:
official: Official Marketplace
community: Community Marketplace
category:
all: All
productivity: Productivity
security: Security
utilities: Utilities
integration: Integration
search:
placeholder: Search extensions...
filter:
category: Select category
empty:
title: No extensions found
description: Try a different search term or category
</i18n>

View File

@ -1,101 +1,132 @@
<template>
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
<!-- 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>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage your HaexHub preferences and configuration
</p>
</div>
<div class="p-2">{{ t('language') }}</div>
<div><UiDropdownLocale @select="onSelectLocaleAsync" /></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>
<div class="p-2">{{ t('design') }}</div>
<div><UiDropdownTheme @select="onSelectThemeAsync" /></div>
<!-- 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>
</div>
</div>
</section>
<div class="p-2">{{ t('vaultName.label') }}</div>
<div>
<UiInput
v-model="currentVaultName"
:placeholder="t('vaultName.label')"
@change="onSetVaultNameAsync"
/>
</div>
<!-- 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 class="p-2">{{ t('notifications.label') }}</div>
<div>
<UiButton
:label="t('notifications.requestPermission')"
@click="requestNotificationPermissionAsync"
/>
</div>
<div class="p-2">{{ t('deviceName.label') }}</div>
<div>
<UiInput
v-model="deviceName"
:placeholder="t('deviceName.label')"
@change="onUpdateDeviceNameAsync"
/>
</div>
</div>
</div>
</template>
<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>
<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>

View 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>

View 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>

View File

@ -32,14 +32,11 @@
variant="outline"
:block="isSmallScreen"
@click="isOverviewMode = !isOverviewMode"
icon="i-bi-person-workspace"
size="lg"
>
<template #leading>
<UIcon name="i-heroicons-squares-2x2" />
</template>
Workspaces
</UButton>
<HaexExtensionLauncher :block="isSmallScreen" />
<UiDropdownVault :block="isSmallScreen" />
</template>
</UPageHeader>

View File

@ -41,6 +41,18 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
// System Windows Registry
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: {
id: 'settings',
name: 'Settings',
@ -169,8 +181,15 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const windowWidth = width > viewportWidth ? viewportWidth : width
const windowHeight = height > viewportHeight ? viewportHeight : height
const windowHeight = Math.min(height, viewportHeight)
// 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)
const offset = currentWorkspaceWindows.value.length * 30