From 279468eddcd0767ded2d6089664f2c58978a8205 Mon Sep 17 00:00:00 2001 From: haex Date: Tue, 4 Nov 2025 16:04:38 +0100 Subject: [PATCH] Add device management and database-backed desktop settings This update migrates desktop grid settings from localStorage to the database and introduces a comprehensive device management system. Features: - New haex_devices table for device identification and naming - Device-specific settings with foreign key relationships - Preset-based icon sizes (Small, Medium, Large, Extra Large) - Grid positioning improvements to prevent dragging behind PageHeader - Dynamic icon sizing based on database settings Database Changes: - Created haex_devices table with deviceId (UUID) and name columns - Modified haex_settings to include device_id FK and updated unique constraint - Migration 0002_loose_quasimodo.sql for schema changes Technical Improvements: - Replaced arbitrary icon size slider (60-200px) with preset system - Icons use actual measured dimensions for proper grid centering - Settings sync on vault mount for cross-device consistency - Proper bounds checking during icon drag operations Bump version to 0.1.7 --- package.json | 2 +- .../migrations/0002_loose_quasimodo.sql | 13 + .../migrations/meta/0002_snapshot.json | 774 ++++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + src-tauri/database/vault.db | Bin 126976 -> 131072 bytes src/components/haex/desktop/icon.vue | 86 +- src/components/haex/desktop/index.vue | 101 ++- src/components/haex/icon.vue | 65 ++ src/components/haex/system/settings.vue | 80 ++ src/components/haex/window/index.vue | 6 +- src/components/haex/workspace/card.vue | 60 ++ src/components/haex/workspace/drawer.vue | 54 ++ src/database/schemas/haex.ts | 35 +- src/database/tableNames.json | 13 + src/layouts/default.vue | 56 +- src/pages/vault.vue | 2 + src/stores/desktop/index.ts | 99 ++- src/stores/vault/device.ts | 7 +- src/stores/vault/settings.ts | 89 +- 19 files changed, 1421 insertions(+), 128 deletions(-) create mode 100644 src-tauri/database/migrations/0002_loose_quasimodo.sql create mode 100644 src-tauri/database/migrations/meta/0002_snapshot.json create mode 100644 src/components/haex/icon.vue create mode 100644 src/components/haex/workspace/drawer.vue diff --git a/package.json b/package.json index afeeb01..1231e26 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "haex-hub", "private": true, - "version": "0.1.6", + "version": "0.1.7", "type": "module", "scripts": { "build": "nuxt build", diff --git a/src-tauri/database/migrations/0002_loose_quasimodo.sql b/src-tauri/database/migrations/0002_loose_quasimodo.sql new file mode 100644 index 0000000..f8aafe5 --- /dev/null +++ b/src-tauri/database/migrations/0002_loose_quasimodo.sql @@ -0,0 +1,13 @@ +CREATE TABLE `haex_devices` ( + `id` text PRIMARY KEY NOT NULL, + `device_id` text NOT NULL, + `name` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP), + `updated_at` integer, + `haex_timestamp` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `haex_devices_device_id_unique` ON `haex_devices` (`device_id`);--> statement-breakpoint +DROP INDEX `haex_settings_key_type_value_unique`;--> statement-breakpoint +ALTER TABLE `haex_settings` ADD `device_id` text REFERENCES haex_devices(id);--> statement-breakpoint +CREATE UNIQUE INDEX `haex_settings_device_id_key_type_unique` ON `haex_settings` (`device_id`,`key`,`type`); \ No newline at end of file diff --git a/src-tauri/database/migrations/meta/0002_snapshot.json b/src-tauri/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..76743d9 --- /dev/null +++ b/src-tauri/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,774 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3aedf10c-2266-40f4-8549-0ff8b0588853", + "prevId": "10bec43a-4227-483e-b1c1-fd50ae32bb96", + "tables": { + "haex_crdt_configs": { + "name": "haex_crdt_configs", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_crdt_logs": { + "name": "haex_crdt_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "table_name": { + "name": "table_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "row_pks": { + "name": "row_pks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "op_type": { + "name": "op_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_haex_timestamp": { + "name": "idx_haex_timestamp", + "columns": [ + "haex_timestamp" + ], + "isUnique": false + }, + "idx_table_row": { + "name": "idx_table_row", + "columns": [ + "table_name", + "row_pks" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_crdt_snapshots": { + "name": "haex_crdt_snapshots", + "columns": { + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created": { + "name": "created", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epoch_hlc": { + "name": "epoch_hlc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_url": { + "name": "location_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_desktop_items": { + "name": "haex_desktop_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system_window_id": { + "name": "system_window_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position_x": { + "name": "position_x", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "position_y": { + "name": "position_y", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "haex_desktop_items_workspace_id_haex_workspaces_id_fk": { + "name": "haex_desktop_items_workspace_id_haex_workspaces_id_fk", + "tableFrom": "haex_desktop_items", + "tableTo": "haex_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "haex_desktop_items_extension_id_haex_extensions_id_fk": { + "name": "haex_desktop_items_extension_id_haex_extensions_id_fk", + "tableFrom": "haex_desktop_items", + "tableTo": "haex_extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "item_reference": { + "name": "item_reference", + "value": "(\"haex_desktop_items\".\"item_type\" = 'extension' AND \"haex_desktop_items\".\"extension_id\" IS NOT NULL AND \"haex_desktop_items\".\"system_window_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'system' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'file' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL) OR (\"haex_desktop_items\".\"item_type\" = 'folder' AND \"haex_desktop_items\".\"system_window_id\" IS NOT NULL AND \"haex_desktop_items\".\"extension_id\" IS NULL)" + } + } + }, + "haex_devices": { + "name": "haex_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "haex_devices_device_id_unique": { + "name": "haex_devices_device_id_unique", + "columns": [ + "device_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_extension_permissions": { + "name": "haex_extension_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "extension_id": { + "name": "extension_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "constraints": { + "name": "constraints", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'denied'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "haex_extension_permissions_extension_id_resource_type_action_target_unique": { + "name": "haex_extension_permissions_extension_id_resource_type_action_target_unique", + "columns": [ + "extension_id", + "resource_type", + "action", + "target" + ], + "isUnique": true + } + }, + "foreignKeys": { + "haex_extension_permissions_extension_id_haex_extensions_id_fk": { + "name": "haex_extension_permissions_extension_id_haex_extensions_id_fk", + "tableFrom": "haex_extension_permissions", + "tableTo": "haex_extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_extensions": { + "name": "haex_extensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'index.html'" + }, + "homepage": { + "name": "homepage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "single_instance": { + "name": "single_instance", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "haex_extensions_public_key_name_unique": { + "name": "haex_extensions_public_key_name_unique", + "columns": [ + "public_key", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_notifications": { + "name": "haex_notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_settings": { + "name": "haex_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "haex_settings_device_id_key_type_unique": { + "name": "haex_settings_device_id_key_type_unique", + "columns": [ + "device_id", + "key", + "type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "haex_settings_device_id_haex_devices_id_fk": { + "name": "haex_settings_device_id_haex_devices_id_fk", + "tableFrom": "haex_settings", + "tableTo": "haex_devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "haex_workspaces": { + "name": "haex_workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "haex_timestamp": { + "name": "haex_timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "haex_workspaces_position_unique": { + "name": "haex_workspaces_position_unique", + "columns": [ + "position" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src-tauri/database/migrations/meta/_journal.json b/src-tauri/database/migrations/meta/_journal.json index 0099206..c0a785b 100644 --- a/src-tauri/database/migrations/meta/_journal.json +++ b/src-tauri/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1762122405562, "tag": "0001_furry_brother_voodoo", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1762263814375, + "tag": "0002_loose_quasimodo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src-tauri/database/vault.db b/src-tauri/database/vault.db index 1d59aedeef6366f0817b90f577ea57aca6a4a407..7f46bed11bee0993af3b5cf9cfa9ce4607baef4d 100644 GIT binary patch delta 661 zcmZp8z~0cnF+p08g@J)V0f=Eha-xngBg@8wCH!2>{E`g(oB7lDB{vHSaP#|mGqW-@ z^0FpbBqy1enk1PUnVKb88e1l%rJ5L}B$=8Uq^24hnwT3KrkI;pq^72&85<@gCYhQ7 z#eho8OwBEfEX>V~7-#uQUwA$_O@1EGhEELqfB3)if7&dl@Pwb0g_(hox%s>P_V4

A2+Wo$G?SOHOsY(r^YW?^Y6*nX&_!elvS zsd`*06BPXY6cUitCn#tnAl#tYSSH6VuByt|tY4Ctl!Ib2vMw3tAXmo_SA`HqCm&Y~ z1G%OvyD^4M{>c)}!KG;~&nE7zI{lzOqr3L?(&PoX5WqC_Fc)y*}?)z8^ASOM86jm#8HO-nI0abI((%JirmkSEL%4V3N$fCHL*#vi<_G>Hfxt8Cgr5Y$LFP%$7dv_R>YU*7iAY0 zBqpa8V-=Kh4svx2aa9O$bn

- -
() const desktopStore = useDesktopStore() +const { effectiveIconSize } = storeToRefs(desktopStore) const showUninstallDialog = ref(false) const { t } = useI18n() const isSelected = computed(() => desktopStore.isItemSelected(props.id)) +const containerSize = computed(() => effectiveIconSize.value) // Container size +const innerIconSize = computed(() => effectiveIconSize.value * 0.7) // Inner icon is 70% of container const handleClick = (e: MouseEvent) => { // Prevent selection during drag @@ -131,9 +131,40 @@ const isDragging = ref(false) const offsetX = ref(0) const offsetY = ref(0) -// Icon dimensions (approximate) -const iconWidth = 120 // Matches design in template -const iconHeight = 140 +// Track actual icon dimensions dynamically +const { width: iconWidth, height: iconHeight } = useElementSize(draggableEl) + +// Re-center icon position when dimensions are measured +watch([iconWidth, iconHeight], async ([width, height]) => { + if (width > 0 && height > 0) { + console.log('📐 Icon dimensions measured:', { + label: props.label, + width, + height, + currentPosition: { x: x.value, y: y.value }, + gridCellSize: desktopStore.gridCellSize, + }) + + // Re-snap to grid with actual dimensions to ensure proper centering + const snapped = desktopStore.snapToGrid(x.value, y.value, width, height) + + console.log('📍 Snapped position:', { + label: props.label, + oldPosition: { x: x.value, y: y.value }, + newPosition: snapped, + }) + + const oldX = x.value + const oldY = y.value + x.value = snapped.x + y.value = snapped.y + + // Save corrected position to database if it changed + if (oldX !== snapped.x || oldY !== snapped.y) { + emit('positionChanged', props.id, snapped.x, snapped.y) + } + } +}, { once: true }) // Only run once when dimensions are first measured const style = computed(() => ({ position: 'absolute' as const, @@ -146,7 +177,7 @@ const handlePointerDown = (e: PointerEvent) => { if (!draggableEl.value || !draggableEl.value.parentElement) return isDragging.value = true - emit('dragStart', props.id, props.itemType, props.referenceId) + emit('dragStart', props.id, props.itemType, props.referenceId, iconWidth.value, iconHeight.value, x.value, y.value) // Get parent offset to convert from viewport coordinates to parent-relative coordinates const parentRect = draggableEl.value.parentElement.getBoundingClientRect() @@ -165,8 +196,12 @@ const handlePointerMove = (e: PointerEvent) => { const newX = e.clientX - parentRect.left - offsetX.value const newY = e.clientY - parentRect.top - offsetY.value + // Clamp y position to minimum 0 (parent is already below header) x.value = newX - y.value = newY + y.value = Math.max(0, newY) + + // Emit current position during drag + emit('dragging', props.id, x.value, y.value) } const handlePointerUp = (e: PointerEvent) => { @@ -177,10 +212,15 @@ const handlePointerUp = (e: PointerEvent) => { draggableEl.value.releasePointerCapture(e.pointerId) } + // Snap to grid with icon dimensions + const snapped = desktopStore.snapToGrid(x.value, y.value, iconWidth.value, iconHeight.value) + x.value = snapped.x + y.value = snapped.y + // Snap icon to viewport bounds if outside if (viewportSize) { - const maxX = Math.max(0, viewportSize.width.value - iconWidth) - const maxY = Math.max(0, viewportSize.height.value - iconHeight) + const maxX = Math.max(0, viewportSize.width.value - iconWidth.value) + const maxY = Math.max(0, viewportSize.height.value - iconHeight.value) x.value = Math.max(0, Math.min(maxX, x.value)) y.value = Math.max(0, Math.min(maxY, y.value)) } diff --git a/src/components/haex/desktop/index.vue b/src/components/haex/desktop/index.vue index 68dbe8f..8868b5b 100644 --- a/src/components/haex/desktop/index.vue +++ b/src/components/haex/desktop/index.vue @@ -32,13 +32,15 @@ @dragover.prevent="handleDragOver" @drop.prevent="handleDrop($event, workspace.id)" > - +
@@ -79,6 +81,7 @@ class="no-swipe" @position-changed="handlePositionChanged" @drag-start="handleDragStart" + @dragging="handleDragging" @drag-end="handleDragEnd" /> @@ -249,8 +252,6 @@ const { const { getWorkspaceBackgroundStyle, getWorkspaceContextMenuItems } = workspaceStore -const { x: mouseX } = useMouse() - const desktopEl = useTemplateRef('desktopEl') // Track desktop viewport size reactively @@ -284,9 +285,41 @@ const selectionBoxStyle = computed(() => { // Drag state for desktop icons const isDragging = ref(false) -const currentDraggedItemId = ref() -const currentDraggedItemType = ref() -const currentDraggedReferenceId = ref() +const currentDraggedItem = reactive({ + id: '', + itemType: '', + referenceId: '', + width: 0, + height: 0, + x: 0, + y: 0, +}) + +// Track mouse position for showing drop target +const { x: mouseX, y: mouseY } = useMouse() + +const dropTargetZone = computed(() => { + if (!isDragging.value) return null + + // Use the actual icon position during drag, not the mouse position + const iconX = currentDraggedItem.x + const iconY = currentDraggedItem.y + + // Use snapToGrid to get the exact position where the icon will land + const snapped = desktopStore.snapToGrid( + iconX, + iconY, + currentDraggedItem.width || undefined, + currentDraggedItem.height || undefined, + ) + + return { + x: snapped.x, + y: snapped.y, + width: currentDraggedItem.width || desktopStore.gridCellSize, + height: currentDraggedItem.height || desktopStore.gridCellSize, + } +}) // Window drag state for snap zones const isWindowDragging = ref(false) @@ -378,20 +411,43 @@ const handlePositionChanged = async (id: string, x: number, y: number) => { } } -const handleDragStart = (id: string, itemType: string, referenceId: string) => { +const handleDragStart = ( + id: string, + itemType: string, + referenceId: string, + width: number, + height: number, + x: number, + y: number, +) => { isDragging.value = true - currentDraggedItemId.value = id - currentDraggedItemType.value = itemType - currentDraggedReferenceId.value = referenceId + currentDraggedItem.id = id + currentDraggedItem.itemType = itemType + currentDraggedItem.referenceId = referenceId + currentDraggedItem.width = width + currentDraggedItem.height = height + currentDraggedItem.x = x + currentDraggedItem.y = y allowSwipe.value = false // Disable Swiper during icon drag } +const handleDragging = (id: string, x: number, y: number) => { + if (currentDraggedItem.id === id) { + currentDraggedItem.x = x + currentDraggedItem.y = y + } +} + const handleDragEnd = async () => { // Cleanup drag state isDragging.value = false - currentDraggedItemId.value = undefined - currentDraggedItemType.value = undefined - currentDraggedReferenceId.value = undefined + currentDraggedItem.id = '' + currentDraggedItem.itemType = '' + currentDraggedItem.referenceId = '' + currentDraggedItem.width = 0 + currentDraggedItem.height = 0 + currentDraggedItem.x = 0 + currentDraggedItem.y = 0 allowSwipe.value = true // Re-enable Swiper after drag } @@ -426,15 +482,18 @@ const handleDrop = async (event: DragEvent, workspaceId: string) => { const desktopRect = ( event.currentTarget as HTMLElement ).getBoundingClientRect() - const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2) - const y = Math.max(0, event.clientY - desktopRect.top - 32) + const rawX = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2) + const rawY = Math.max(0, event.clientY - desktopRect.top - 32) + + // Snap to grid + const snapped = desktopStore.snapToGrid(rawX, rawY) // Create desktop icon on the specific workspace await desktopStore.addDesktopItemAsync( item.type as DesktopItemType, item.id, - x, - y, + snapped.x, + snapped.y, workspaceId, ) } catch (error) { diff --git a/src/components/haex/icon.vue b/src/components/haex/icon.vue new file mode 100644 index 0000000..14419f1 --- /dev/null +++ b/src/components/haex/icon.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/components/haex/system/settings.vue b/src/components/haex/system/settings.vue index 1f6eac5..a272891 100644 --- a/src/components/haex/system/settings.vue +++ b/src/components/haex/system/settings.vue @@ -47,6 +47,21 @@ />
+ +
+

{{ t('desktopGrid.title') }}

+
+ +
{{ t('desktopGrid.iconSize.label') }}
+
+ +
+
@@ -63,6 +78,7 @@ import { remove, } from '@tauri-apps/plugin-fs' import { appLocalDataDir } from '@tauri-apps/api/path' +import { DesktopIconSizePreset } from '~/stores/vault/settings' const { t, setLocale } = useI18n() @@ -104,8 +120,40 @@ const workspaceStore = useWorkspaceStore() const { currentWorkspace } = storeToRefs(workspaceStore) const { updateWorkspaceBackgroundAsync } = workspaceStore +const desktopStore = useDesktopStore() +const { iconSizePreset } = storeToRefs(desktopStore) +const { syncDesktopIconSizeAsync, updateDesktopIconSizeAsync } = desktopStore + +// Icon size preset options +const iconSizePresetOptions = [ + { + label: t('desktopGrid.iconSize.presets.small'), + value: DesktopIconSizePreset.small, + }, + { + label: t('desktopGrid.iconSize.presets.medium'), + value: DesktopIconSizePreset.medium, + }, + { + label: t('desktopGrid.iconSize.presets.large'), + value: DesktopIconSizePreset.large, + }, + { + label: t('desktopGrid.iconSize.presets.extraLarge'), + value: DesktopIconSizePreset.extraLarge, + }, +] + +// Watch for icon size preset changes and update DB +watch(iconSizePreset, async (newPreset) => { + if (newPreset) { + await updateDesktopIconSizeAsync(newPreset) + } +}) + onMounted(async () => { await readDeviceNameAsync() + await syncDesktopIconSizeAsync() }) const onUpdateDeviceNameAsync = async () => { @@ -295,6 +343,22 @@ de: label: Hintergrund entfernen success: Hintergrund erfolgreich entfernt error: Fehler beim Entfernen des Hintergrunds + desktopGrid: + title: Desktop-Raster + columns: + label: Spalten + unit: Spalten + rows: + label: Zeilen + unit: Zeilen + iconSize: + label: Icon-Größe + presets: + small: Klein + medium: Mittel + large: Groß + extraLarge: Sehr groß + unit: px en: language: Language design: Design @@ -322,4 +386,20 @@ en: label: Remove Background success: Background successfully removed error: Error removing background + desktopGrid: + title: Desktop Grid + columns: + label: Columns + unit: columns + rows: + label: Rows + unit: rows + iconSize: + label: Icon Size + presets: + small: Small + medium: Medium + large: Large + extraLarge: Extra Large + unit: px diff --git a/src/components/haex/window/index.vue b/src/components/haex/window/index.vue index 4ffa3f2..cffa9f2 100644 --- a/src/components/haex/window/index.vue +++ b/src/components/haex/window/index.vue @@ -26,10 +26,10 @@ >
-
diff --git a/src/components/haex/workspace/card.vue b/src/components/haex/workspace/card.vue index f5c8dca..2e3e6db 100644 --- a/src/components/haex/workspace/card.vue +++ b/src/components/haex/workspace/card.vue @@ -25,17 +25,70 @@ /> + + +
+ + + + + + +{{ remainingCount }} + +
+ + +
+ {{ t('noWindows') }} +
+ + +de: + noWindows: Keine Fenster geöffnet +en: + noWindows: No windows open + diff --git a/src/components/haex/workspace/drawer.vue b/src/components/haex/workspace/drawer.vue new file mode 100644 index 0000000..b3f65b9 --- /dev/null +++ b/src/components/haex/workspace/drawer.vue @@ -0,0 +1,54 @@ + + + + + +de: + add: Workspace hinzufügen +en: + add: Add Workspace + diff --git a/src/database/schemas/haex.ts b/src/database/schemas/haex.ts index e6bb163..07c1711 100644 --- a/src/database/schemas/haex.ts +++ b/src/database/schemas/haex.ts @@ -24,17 +24,42 @@ export const withCrdtColumns = < haexTimestamp: text(crdtColumnNames.haexTimestamp), }) +export const haexDevices = sqliteTable( + tableNames.haex.devices.name, + withCrdtColumns({ + id: text(tableNames.haex.devices.columns.id) + .$defaultFn(() => crypto.randomUUID()) + .primaryKey(), + deviceId: text(tableNames.haex.devices.columns.deviceId) + .notNull() + .unique(), + name: text(tableNames.haex.devices.columns.name).notNull(), + createdAt: text(tableNames.haex.devices.columns.createdAt).default( + sql`(CURRENT_TIMESTAMP)`, + ), + updatedAt: integer(tableNames.haex.devices.columns.updatedAt, { + mode: 'timestamp', + }).$onUpdate(() => new Date()), + }), +) +export type InsertHaexDevices = typeof haexDevices.$inferInsert +export type SelectHaexDevices = typeof haexDevices.$inferSelect + export const haexSettings = sqliteTable( tableNames.haex.settings.name, withCrdtColumns({ - id: text() + id: text(tableNames.haex.settings.columns.id) .$defaultFn(() => crypto.randomUUID()) .primaryKey(), - key: text(), - type: text(), - value: text(), + deviceId: text(tableNames.haex.settings.columns.deviceId).references( + (): AnySQLiteColumn => haexDevices.id, + { onDelete: 'cascade' }, + ), + key: text(tableNames.haex.settings.columns.key), + type: text(tableNames.haex.settings.columns.type), + value: text(tableNames.haex.settings.columns.value), }), - (table) => [unique().on(table.key, table.type, table.value)], + (table) => [unique().on(table.deviceId, table.key, table.type)], ) export type InsertHaexSettings = typeof haexSettings.$inferInsert export type SelectHaexSettings = typeof haexSettings.$inferSelect diff --git a/src/database/tableNames.json b/src/database/tableNames.json index 2a77b72..813bbb6 100644 --- a/src/database/tableNames.json +++ b/src/database/tableNames.json @@ -4,6 +4,7 @@ "name": "haex_settings", "columns": { "id": "id", + "deviceId": "device_id", "key": "key", "type": "type", "value": "value", @@ -89,6 +90,18 @@ "haexTimestamp": "haex_timestamp" } }, + "devices": { + "name": "haex_devices", + "columns": { + "id": "id", + "deviceId": "device_id", + "name": "name", + "createdAt": "created_at", + "updatedAt": "updated_at", + + "haexTimestamp": "haex_timestamp" + } + }, "crdt": { "logs": { diff --git a/src/layouts/default.vue b/src/layouts/default.vue index c4f51a6..fa1131a 100644 --- a/src/layouts/default.vue +++ b/src/layouts/default.vue @@ -59,47 +59,7 @@ - - - + @@ -116,15 +76,7 @@ const { showWindowOverview, openWindowsCount } = storeToRefs( useWindowManagerStore(), ) -const workspaceStore = useWorkspaceStore() -const { workspaces, isOverviewMode } = storeToRefs(workspaceStore) - -const handleAddWorkspace = async () => { - const workspace = await workspaceStore.addWorkspaceAsync() - nextTick(() => { - workspaceStore.slideToWorkspace(workspace?.id) - }) -} +const { isOverviewMode } = storeToRefs(useWorkspaceStore()) // Measure header height and store it in UI store const headerEl = useTemplateRef('headerEl') @@ -140,15 +92,11 @@ watch(height, (newHeight) => { de: search: label: Suche - workspaces: label: Workspaces - add: Workspace hinzufügen en: search: label: Search - workspaces: label: Workspaces - add: Add Workspace diff --git a/src/pages/vault.vue b/src/pages/vault.vue index b2007ef..b9c7871 100644 --- a/src/pages/vault.vue +++ b/src/pages/vault.vue @@ -53,6 +53,7 @@ const { addDeviceNameAsync } = useDeviceStore() const { deviceId } = storeToRefs(useDeviceStore()) const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } = useVaultSettingsStore() +const { syncDesktopIconSizeAsync } = useDesktopStore() onMounted(async () => { try { @@ -62,6 +63,7 @@ onMounted(async () => { syncLocaleAsync(), syncThemeAsync(), syncVaultNameAsync(), + syncDesktopIconSizeAsync(), loadExtensionsAsync(), readNotificationsAsync(), ]) diff --git a/src/stores/desktop/index.ts b/src/stores/desktop/index.ts index 1e273b3..2bd0465 100644 --- a/src/stores/desktop/index.ts +++ b/src/stores/desktop/index.ts @@ -1,9 +1,13 @@ import { eq } from 'drizzle-orm' -import { haexDesktopItems } from '~/database/schemas' +import { haexDesktopItems, haexDevices } from '~/database/schemas' import type { InsertHaexDesktopItems, SelectHaexDesktopItems, } from '~/database/schemas' +import { + DesktopIconSizePreset, + iconSizePresetValues, +} from '~/stores/vault/settings' import de from './de.json' import en from './en.json' @@ -20,6 +24,10 @@ export const useDesktopStore = defineStore('desktopStore', () => { const workspaceStore = useWorkspaceStore() const { currentWorkspace } = storeToRefs(workspaceStore) const { $i18n } = useNuxtApp() + const uiStore = useUiStore() + const { isSmallScreen } = storeToRefs(uiStore) + const deviceStore = useDeviceStore() + const settingsStore = useVaultSettingsStore() $i18n.setLocaleMessage('de', { desktop: de, @@ -29,6 +37,86 @@ export const useDesktopStore = defineStore('desktopStore', () => { const desktopItems = ref([]) const selectedItemIds = ref>(new Set()) + // Desktop Grid Settings (stored in DB per device) + const iconSizePreset = ref(DesktopIconSizePreset.medium) + + // Get device internal ID from DB + const getDeviceInternalIdAsync = async () => { + if (!deviceStore.deviceId || !currentVault.value?.drizzle) return undefined + + const device = await currentVault.value.drizzle.query.haexDevices.findFirst({ + where: eq(haexDevices.deviceId, deviceStore.deviceId), + }) + + return device?.id ? device.id : undefined + } + + // Sync icon size from DB + const syncDesktopIconSizeAsync = async () => { + const deviceInternalId = await getDeviceInternalIdAsync() + if (!deviceInternalId) return + + const preset = await settingsStore.syncDesktopIconSizeAsync(deviceInternalId) + iconSizePreset.value = preset + } + + // Update icon size in DB + const updateDesktopIconSizeAsync = async (preset: DesktopIconSizePreset) => { + const deviceInternalId = await getDeviceInternalIdAsync() + if (!deviceInternalId) return + + await settingsStore.updateDesktopIconSizeAsync(deviceInternalId, preset) + iconSizePreset.value = preset + } + + // Reactive grid settings based on screen size + const effectiveGridColumns = computed(() => { + return isSmallScreen.value ? 4 : 8 + }) + + const effectiveGridRows = computed(() => { + return isSmallScreen.value ? 5 : 6 + }) + + const effectiveIconSize = computed(() => { + return iconSizePresetValues[iconSizePreset.value] + }) + + // Calculate grid cell size based on icon size + const gridCellSize = computed(() => { + // Add padding around icon (20px extra for spacing) + return effectiveIconSize.value + 20 + }) + + // Snap position to grid (centers icon in cell) + // iconWidth and iconHeight are optional - if provided, they're used for centering + const snapToGrid = (x: number, y: number, iconWidth?: number, iconHeight?: number) => { + const cellSize = gridCellSize.value + + // Calculate which grid cell the position falls into + const col = Math.floor(x / cellSize) + const row = Math.floor(y / cellSize) + + // Use provided dimensions or fall back to cell size + const actualIconWidth = iconWidth || cellSize + const actualIconHeight = iconHeight || cellSize + + // Center the icon in the cell(s) it occupies + const cellsWide = Math.max(1, Math.ceil(actualIconWidth / cellSize)) + const cellsHigh = Math.max(1, Math.ceil(actualIconHeight / cellSize)) + + const totalWidth = cellsWide * cellSize + const totalHeight = cellsHigh * cellSize + + const paddingX = (totalWidth - actualIconWidth) / 2 + const paddingY = (totalHeight - actualIconHeight) / 2 + + return { + x: col * cellSize + paddingX, + y: row * cellSize + paddingY, + } + } + const loadDesktopItemsAsync = async () => { if (!currentVault.value?.drizzle) { console.error('Kein Vault geöffnet') @@ -347,5 +435,14 @@ export const useDesktopStore = defineStore('desktopStore', () => { toggleSelection, clearSelection, isItemSelected, + // Grid settings + iconSizePreset, + syncDesktopIconSizeAsync, + updateDesktopIconSizeAsync, + effectiveGridColumns, + effectiveGridRows, + effectiveIconSize, + gridCellSize, + snapToGrid, } }) diff --git a/src/stores/vault/device.ts b/src/stores/vault/device.ts index bf4e850..fa9c93c 100644 --- a/src/stores/vault/device.ts +++ b/src/stores/vault/device.ts @@ -45,7 +45,9 @@ export const useDeviceStore = defineStore('vaultDeviceStore', () => { const isKnownDeviceAsync = async () => { const { readDeviceNameAsync } = useVaultSettingsStore() - return !!(await readDeviceNameAsync(deviceId.value)) + const device = await readDeviceNameAsync(deviceId.value) + console.log('device', device) + return !!device } const readDeviceNameAsync = async (id?: string) => { @@ -54,7 +56,8 @@ export const useDeviceStore = defineStore('vaultDeviceStore', () => { if (!_id) return - deviceName.value = (await readDeviceNameAsync(_id))?.value ?? '' + const device = await readDeviceNameAsync(_id) + deviceName.value = device?.name ?? '' return deviceName.value } diff --git a/src/stores/vault/settings.ts b/src/stores/vault/settings.ts index 3d4d93d..6e3b57c 100644 --- a/src/stores/vault/settings.ts +++ b/src/stores/vault/settings.ts @@ -4,14 +4,29 @@ import * as schema from '~/database/schemas/haex' import type { Locale } from 'vue-i18n' export enum VaultSettingsTypeEnum { - deviceName = 'deviceName', settings = 'settings', + system = 'system', } export enum VaultSettingsKeyEnum { locale = 'locale', theme = 'theme', vaultName = 'vaultName', + desktopIconSize = 'desktopIconSize', +} + +export enum DesktopIconSizePreset { + small = 'small', + medium = 'medium', + large = 'large', + extraLarge = 'extra-large', +} + +export const iconSizePresetValues: Record = { + [DesktopIconSizePreset.small]: 60, + [DesktopIconSizePreset.medium]: 80, + [DesktopIconSizePreset.large]: 120, + [DesktopIconSizePreset.extraLarge]: 160, } export const vaultDeviceNameSchema = z.string().min(3).max(255) @@ -118,20 +133,22 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => { .where(eq(schema.haexSettings.key, 'vaultName')) } - const readDeviceNameAsync = async (id?: string) => { + const readDeviceNameAsync = async (deviceId?: string) => { const { currentVault } = useVaultStore() - if (!id) return undefined + if (!deviceId) return undefined - const deviceName = - await currentVault?.drizzle?.query.haexSettings.findFirst({ - where: and( - eq(schema.haexSettings.type, VaultSettingsTypeEnum.deviceName), - eq(schema.haexSettings.key, id), - ), + const device = + await currentVault?.drizzle?.query.haexDevices.findFirst({ + where: eq(schema.haexDevices.deviceId, deviceId), }) - return deviceName?.id ? deviceName : undefined + // Workaround für Drizzle Bug: findFirst gibt manchmal Objekt mit undefined Werten zurück + // https://github.com/drizzle-team/drizzle-orm/issues/3872 + // Prüfe ob das Device wirklich existiert (id muss gesetzt sein, da NOT NULL) + if (!device?.id) return undefined + + return device } const addDeviceNameAsync = async ({ @@ -149,10 +166,9 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => { return } - return currentVault?.drizzle?.insert(schema.haexSettings).values({ - type: VaultSettingsTypeEnum.deviceName, - key: deviceId, - value: deviceName, + return currentVault?.drizzle?.insert(schema.haexDevices).values({ + deviceId, + name: deviceName, }) } @@ -169,14 +185,49 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => { if (!isNameOk.success) return return currentVault?.drizzle - ?.update(schema.haexSettings) + ?.update(schema.haexDevices) .set({ - value: deviceName, + name: deviceName, }) + .where(eq(schema.haexDevices.deviceId, deviceId)) + } + + const syncDesktopIconSizeAsync = async (deviceInternalId: string) => { + const iconSizeRow = + await currentVault.value?.drizzle.query.haexSettings.findFirst({ + where: and( + eq(schema.haexSettings.deviceId, deviceInternalId), + eq(schema.haexSettings.key, VaultSettingsKeyEnum.desktopIconSize), + eq(schema.haexSettings.type, VaultSettingsTypeEnum.system), + ), + }) + + if (!iconSizeRow?.id) { + // Kein Eintrag vorhanden, erstelle einen mit Default (medium) + await currentVault.value?.drizzle.insert(schema.haexSettings).values({ + deviceId: deviceInternalId, + key: VaultSettingsKeyEnum.desktopIconSize, + type: VaultSettingsTypeEnum.system, + value: DesktopIconSizePreset.medium, + }) + return DesktopIconSizePreset.medium + } + + return iconSizeRow.value as DesktopIconSizePreset + } + + const updateDesktopIconSizeAsync = async ( + deviceInternalId: string, + preset: DesktopIconSizePreset, + ) => { + return await currentVault.value?.drizzle + .update(schema.haexSettings) + .set({ value: preset }) .where( and( - eq(schema.haexSettings.key, deviceId), - eq(schema.haexSettings.type, VaultSettingsTypeEnum.deviceName), + eq(schema.haexSettings.deviceId, deviceInternalId), + eq(schema.haexSettings.key, VaultSettingsKeyEnum.desktopIconSize), + eq(schema.haexSettings.type, VaultSettingsTypeEnum.system), ), ) } @@ -191,5 +242,7 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => { updateLocaleAsync, updateThemeAsync, updateVaultNameAsync, + syncDesktopIconSizeAsync, + updateDesktopIconSizeAsync, } })