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
This commit is contained in:
2025-11-04 16:04:38 +01:00
parent cffb129e4f
commit 279468eddc
19 changed files with 1421 additions and 128 deletions

View File

@ -1,7 +1,7 @@
{ {
"name": "haex-hub", "name": "haex-hub",
"private": true, "private": true,
"version": "0.1.6", "version": "0.1.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",

View File

@ -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`);

View File

@ -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": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1762122405562, "when": 1762122405562,
"tag": "0001_furry_brother_voodoo", "tag": "0001_furry_brother_voodoo",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1762263814375,
"tag": "0002_loose_quasimodo",
"breakpoints": true
} }
] ]
} }

Binary file not shown.

View File

@ -24,29 +24,25 @@
<div class="flex flex-col items-center gap-2 p-3 group"> <div class="flex flex-col items-center gap-2 p-3 group">
<div <div
:class="[ :class="[
'w-20 h-20 flex items-center justify-center rounded-2xl transition-all duration-200 ease-out', 'flex items-center justify-center rounded-2xl transition-all duration-200 ease-out',
'backdrop-blur-sm border', 'backdrop-blur-sm border',
isSelected isSelected
? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105' ? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105'
: 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105', : 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105',
]" ]"
:style="{ width: `${containerSize}px`, height: `${containerSize}px` }"
> >
<img <HaexIcon
v-if="icon" :name="icon || 'i-heroicons-puzzle-piece-solid'"
:src="icon"
:alt="label"
class="w-14 h-14 object-contain transition-transform duration-200"
:class="{ 'scale-110': isSelected }"
/>
<UIcon
v-else
name="i-heroicons-puzzle-piece-solid"
:class="[ :class="[
'w-14 h-14 transition-all duration-200', 'object-contain transition-all duration-200',
isSelected isSelected && 'scale-110',
? 'text-blue-500 dark:text-blue-400 scale-110' !icon &&
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400', (isSelected
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400'),
]" ]"
:style="{ width: `${innerIconSize}px`, height: `${innerIconSize}px` }"
/> />
</div> </div>
<span <span
@ -79,15 +75,19 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
positionChanged: [id: string, x: number, y: number] positionChanged: [id: string, x: number, y: number]
dragStart: [id: string, itemType: string, referenceId: string] dragStart: [id: string, itemType: string, referenceId: string, width: number, height: number, x: number, y: number]
dragging: [id: string, x: number, y: number]
dragEnd: [] dragEnd: []
}>() }>()
const desktopStore = useDesktopStore() const desktopStore = useDesktopStore()
const { effectiveIconSize } = storeToRefs(desktopStore)
const showUninstallDialog = ref(false) const showUninstallDialog = ref(false)
const { t } = useI18n() const { t } = useI18n()
const isSelected = computed(() => desktopStore.isItemSelected(props.id)) 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) => { const handleClick = (e: MouseEvent) => {
// Prevent selection during drag // Prevent selection during drag
@ -131,9 +131,40 @@ const isDragging = ref(false)
const offsetX = ref(0) const offsetX = ref(0)
const offsetY = ref(0) const offsetY = ref(0)
// Icon dimensions (approximate) // Track actual icon dimensions dynamically
const iconWidth = 120 // Matches design in template const { width: iconWidth, height: iconHeight } = useElementSize(draggableEl)
const iconHeight = 140
// 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(() => ({ const style = computed(() => ({
position: 'absolute' as const, position: 'absolute' as const,
@ -146,7 +177,7 @@ const handlePointerDown = (e: PointerEvent) => {
if (!draggableEl.value || !draggableEl.value.parentElement) return if (!draggableEl.value || !draggableEl.value.parentElement) return
isDragging.value = true 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 // Get parent offset to convert from viewport coordinates to parent-relative coordinates
const parentRect = draggableEl.value.parentElement.getBoundingClientRect() const parentRect = draggableEl.value.parentElement.getBoundingClientRect()
@ -165,8 +196,12 @@ const handlePointerMove = (e: PointerEvent) => {
const newX = e.clientX - parentRect.left - offsetX.value const newX = e.clientX - parentRect.left - offsetX.value
const newY = e.clientY - parentRect.top - offsetY.value const newY = e.clientY - parentRect.top - offsetY.value
// Clamp y position to minimum 0 (parent is already below header)
x.value = newX 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) => { const handlePointerUp = (e: PointerEvent) => {
@ -177,10 +212,15 @@ const handlePointerUp = (e: PointerEvent) => {
draggableEl.value.releasePointerCapture(e.pointerId) 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 // Snap icon to viewport bounds if outside
if (viewportSize) { if (viewportSize) {
const maxX = Math.max(0, viewportSize.width.value - iconWidth) const maxX = Math.max(0, viewportSize.width.value - iconWidth.value)
const maxY = Math.max(0, viewportSize.height.value - iconHeight) const maxY = Math.max(0, viewportSize.height.value - iconHeight.value)
x.value = Math.max(0, Math.min(maxX, x.value)) x.value = Math.max(0, Math.min(maxX, x.value))
y.value = Math.max(0, Math.min(maxY, y.value)) y.value = Math.max(0, Math.min(maxY, y.value))
} }

View File

@ -32,13 +32,15 @@
@dragover.prevent="handleDragOver" @dragover.prevent="handleDragOver"
@drop.prevent="handleDrop($event, workspace.id)" @drop.prevent="handleDrop($event, workspace.id)"
> >
<!-- Grid Pattern Background --> <!-- Drop Target Zone (visible during drag) -->
<div <div
class="absolute inset-0 pointer-events-none opacity-30" v-if="dropTargetZone"
class="absolute border-2 border-blue-500 bg-blue-500/10 rounded-lg pointer-events-none z-10 transition-all duration-75"
:style="{ :style="{
backgroundImage: left: `${dropTargetZone.x}px`,
'linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)', top: `${dropTargetZone.y}px`,
backgroundSize: '32px 32px', width: `${dropTargetZone.width}px`,
height: `${dropTargetZone.height}px`,
}" }"
/> />
@ -79,6 +81,7 @@
class="no-swipe" class="no-swipe"
@position-changed="handlePositionChanged" @position-changed="handlePositionChanged"
@drag-start="handleDragStart" @drag-start="handleDragStart"
@dragging="handleDragging"
@drag-end="handleDragEnd" @drag-end="handleDragEnd"
/> />
@ -249,8 +252,6 @@ const {
const { getWorkspaceBackgroundStyle, getWorkspaceContextMenuItems } = const { getWorkspaceBackgroundStyle, getWorkspaceContextMenuItems } =
workspaceStore workspaceStore
const { x: mouseX } = useMouse()
const desktopEl = useTemplateRef('desktopEl') const desktopEl = useTemplateRef('desktopEl')
// Track desktop viewport size reactively // Track desktop viewport size reactively
@ -284,9 +285,41 @@ const selectionBoxStyle = computed(() => {
// Drag state for desktop icons // Drag state for desktop icons
const isDragging = ref(false) const isDragging = ref(false)
const currentDraggedItemId = ref<string>() const currentDraggedItem = reactive({
const currentDraggedItemType = ref<string>() id: '',
const currentDraggedReferenceId = ref<string>() 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 // Window drag state for snap zones
const isWindowDragging = ref(false) 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 isDragging.value = true
currentDraggedItemId.value = id currentDraggedItem.id = id
currentDraggedItemType.value = itemType currentDraggedItem.itemType = itemType
currentDraggedReferenceId.value = referenceId currentDraggedItem.referenceId = referenceId
currentDraggedItem.width = width
currentDraggedItem.height = height
currentDraggedItem.x = x
currentDraggedItem.y = y
allowSwipe.value = false // Disable Swiper during icon drag 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 () => { const handleDragEnd = async () => {
// Cleanup drag state // Cleanup drag state
isDragging.value = false isDragging.value = false
currentDraggedItemId.value = undefined currentDraggedItem.id = ''
currentDraggedItemType.value = undefined currentDraggedItem.itemType = ''
currentDraggedReferenceId.value = undefined currentDraggedItem.referenceId = ''
currentDraggedItem.width = 0
currentDraggedItem.height = 0
currentDraggedItem.x = 0
currentDraggedItem.y = 0
allowSwipe.value = true // Re-enable Swiper after drag allowSwipe.value = true // Re-enable Swiper after drag
} }
@ -426,15 +482,18 @@ const handleDrop = async (event: DragEvent, workspaceId: string) => {
const desktopRect = ( const desktopRect = (
event.currentTarget as HTMLElement event.currentTarget as HTMLElement
).getBoundingClientRect() ).getBoundingClientRect()
const x = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2) const rawX = Math.max(0, event.clientX - desktopRect.left - 32) // Center icon (64px / 2)
const y = Math.max(0, event.clientY - desktopRect.top - 32) 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 // Create desktop icon on the specific workspace
await desktopStore.addDesktopItemAsync( await desktopStore.addDesktopItemAsync(
item.type as DesktopItemType, item.type as DesktopItemType,
item.id, item.id,
x, snapped.x,
y, snapped.y,
workspaceId, workspaceId,
) )
} catch (error) { } catch (error) {

View File

@ -0,0 +1,65 @@
<template>
<div class="inline-flex">
<UTooltip :text="tooltip">
<!-- Bundled Icon (iconify) -->
<UIcon
v-if="isBundledIcon"
:name="name"
v-bind="$attrs"
/>
<!-- External Image (Extension icon) -->
<img
v-else
:src="imageUrl"
v-bind="$attrs"
@error="handleImageError"
/>
</UTooltip>
</div>
</template>
<script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
name: string
tooltip?: string
}>()
// Check if it's a bundled icon (no file extension)
const isBundledIcon = computed(() => {
return !props.name.match(/\.(png|jpg|jpeg|svg|gif|webp|ico)$/i)
})
// Convert file path to Tauri URL for images
const imageUrl = ref('')
const showFallback = ref(false)
// Default fallback icon
const FALLBACK_ICON = 'i-heroicons-puzzle-piece-solid'
watchEffect(() => {
if (!isBundledIcon.value && !showFallback.value) {
// Convert local file path to Tauri asset URL
imageUrl.value = convertFileSrc(props.name)
}
})
const handleImageError = () => {
console.warn(`Failed to load icon: ${props.name}`)
showFallback.value = true
}
// Use fallback icon if image failed to load
const name = computed(() => {
if (showFallback.value) {
return FALLBACK_ICON
}
return props.name
})
</script>

View File

@ -47,6 +47,21 @@
/> />
</div> </div>
<!-- Desktop Grid Settings -->
<div
class="col-span-2 mt-4 border-t border-gray-200 dark:border-gray-700 pt-4"
>
<h3 class="text-lg font-semibold mb-4">{{ t('desktopGrid.title') }}</h3>
</div>
<div class="p-2">{{ t('desktopGrid.iconSize.label') }}</div>
<div>
<USelect
v-model="iconSizePreset"
:items="iconSizePresetOptions"
/>
</div>
<div class="h-full" /> <div class="h-full" />
</div> </div>
</div> </div>
@ -63,6 +78,7 @@ import {
remove, remove,
} from '@tauri-apps/plugin-fs' } from '@tauri-apps/plugin-fs'
import { appLocalDataDir } from '@tauri-apps/api/path' import { appLocalDataDir } from '@tauri-apps/api/path'
import { DesktopIconSizePreset } from '~/stores/vault/settings'
const { t, setLocale } = useI18n() const { t, setLocale } = useI18n()
@ -104,8 +120,40 @@ const workspaceStore = useWorkspaceStore()
const { currentWorkspace } = storeToRefs(workspaceStore) const { currentWorkspace } = storeToRefs(workspaceStore)
const { updateWorkspaceBackgroundAsync } = 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 () => { onMounted(async () => {
await readDeviceNameAsync() await readDeviceNameAsync()
await syncDesktopIconSizeAsync()
}) })
const onUpdateDeviceNameAsync = async () => { const onUpdateDeviceNameAsync = async () => {
@ -295,6 +343,22 @@ de:
label: Hintergrund entfernen label: Hintergrund entfernen
success: Hintergrund erfolgreich entfernt success: Hintergrund erfolgreich entfernt
error: Fehler beim Entfernen des Hintergrunds 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: en:
language: Language language: Language
design: Design design: Design
@ -322,4 +386,20 @@ en:
label: Remove Background label: Remove Background
success: Background successfully removed success: Background successfully removed
error: Error removing background 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
</i18n> </i18n>

View File

@ -26,10 +26,10 @@
> >
<!-- Left: Icon --> <!-- Left: Icon -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img <HaexIcon
v-if="icon" v-if="icon"
:src="icon" :name="icon"
:alt="title" :tooltip="title"
class="w-5 h-5 object-contain shrink-0" class="w-5 h-5 object-contain shrink-0"
/> />
</div> </div>

View File

@ -25,17 +25,70 @@
/> />
</div> </div>
</template> </template>
<!-- Window Icons Preview -->
<div
v-if="workspaceWindows.length > 0"
class="flex flex-wrap gap-2 items-center"
>
<!-- Show first 8 window icons -->
<HaexIcon
v-for="window in visibleWindows"
:key="window.id"
:name="window.icon || 'i-heroicons-window'"
:tooltip="window.title"
class="size-6 opacity-70"
/>
<!-- Show remaining count badge if more than 8 windows -->
<UBadge
v-if="remainingCount > 0"
color="neutral"
variant="subtle"
size="sm"
>
+{{ remainingCount }}
</UBadge>
</div>
<!-- Empty state when no windows -->
<div
v-else
class="text-sm text-gray-400 dark:text-gray-600 italic"
>
{{ t('noWindows') }}
</div>
</UCard> </UCard>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<{ workspace: IWorkspace }>() const props = defineProps<{ workspace: IWorkspace }>()
const { t } = useI18n()
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const windowManager = useWindowManagerStore() const windowManager = useWindowManagerStore()
const { currentWorkspace } = storeToRefs(workspaceStore) const { currentWorkspace } = storeToRefs(workspaceStore)
// Get all windows for this workspace
const workspaceWindows = computed(() => {
return windowManager.windows.filter(
(window) => window.workspaceId === props.workspace.id,
)
})
// Limit to 8 visible icons
const MAX_VISIBLE_ICONS = 8
const visibleWindows = computed(() => {
return workspaceWindows.value.slice(0, MAX_VISIBLE_ICONS)
})
// Count remaining windows
const remainingCount = computed(() => {
const remaining = workspaceWindows.value.length - MAX_VISIBLE_ICONS
return remaining > 0 ? remaining : 0
})
const cardEl = useTemplateRef('cardEl') const cardEl = useTemplateRef('cardEl')
const isDragOver = ref(false) const isDragOver = ref(false)
@ -96,3 +149,10 @@ watch(
}, },
) )
</script> </script>
<i18n lang="yaml">
de:
noWindows: Keine Fenster geöffnet
en:
noWindows: No windows open
</i18n>

View File

@ -0,0 +1,54 @@
<template>
<UDrawer
v-model:open="isOverviewMode"
direction="left"
:overlay="false"
:modal="false"
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="py-8 pl-8 pr-4 h-full overflow-y-auto">
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
icon="i-heroicons-plus"
:label="t('add')"
@click="handleAddWorkspaceAsync"
/>
</div>
</template>
</UDrawer>
</template>
<script setup lang="ts">
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const { workspaces, isOverviewMode } = storeToRefs(workspaceStore)
const handleAddWorkspaceAsync = async () => {
const workspace = await workspaceStore.addWorkspaceAsync()
nextTick(() => {
workspaceStore.slideToWorkspace(workspace?.id)
})
}
</script>
<i18n lang="yaml">
de:
add: Workspace hinzufügen
en:
add: Add Workspace
</i18n>

View File

@ -24,17 +24,42 @@ export const withCrdtColumns = <
haexTimestamp: text(crdtColumnNames.haexTimestamp), 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( export const haexSettings = sqliteTable(
tableNames.haex.settings.name, tableNames.haex.settings.name,
withCrdtColumns({ withCrdtColumns({
id: text() id: text(tableNames.haex.settings.columns.id)
.$defaultFn(() => crypto.randomUUID()) .$defaultFn(() => crypto.randomUUID())
.primaryKey(), .primaryKey(),
key: text(), deviceId: text(tableNames.haex.settings.columns.deviceId).references(
type: text(), (): AnySQLiteColumn => haexDevices.id,
value: text(), { 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 InsertHaexSettings = typeof haexSettings.$inferInsert
export type SelectHaexSettings = typeof haexSettings.$inferSelect export type SelectHaexSettings = typeof haexSettings.$inferSelect

View File

@ -4,6 +4,7 @@
"name": "haex_settings", "name": "haex_settings",
"columns": { "columns": {
"id": "id", "id": "id",
"deviceId": "device_id",
"key": "key", "key": "key",
"type": "type", "type": "type",
"value": "value", "value": "value",
@ -89,6 +90,18 @@
"haexTimestamp": "haex_timestamp" "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": { "crdt": {
"logs": { "logs": {

View File

@ -59,47 +59,7 @@
</main> </main>
<!-- Workspace Drawer --> <!-- Workspace Drawer -->
<UDrawer <HaexWorkspaceDrawer />
v-model:open="isOverviewMode"
direction="left"
:dismissible="false"
:overlay="false"
:modal="false"
title="Workspaces"
description="Workspaces"
>
<template #content>
<div class="p-6 h-full overflow-y-auto">
<UButton
block
trailing-icon="mdi-close"
class="text-2xl font-bold ext-gray-900 dark:text-white mb-4"
@click="isOverviewMode = false"
>
Workspaces
</UButton>
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
v-for="workspace in workspaces"
:key="workspace.id"
:workspace
/>
</div>
<!-- Add New Workspace Button -->
<UButton
block
variant="outline"
class="mt-6"
@click="handleAddWorkspace"
icon="i-heroicons-plus"
:label="t('workspaces.add')"
/>
</div>
</template>
</UDrawer>
</div> </div>
</template> </template>
@ -116,15 +76,7 @@ const { showWindowOverview, openWindowsCount } = storeToRefs(
useWindowManagerStore(), useWindowManagerStore(),
) )
const workspaceStore = useWorkspaceStore() const { isOverviewMode } = storeToRefs(useWorkspaceStore())
const { workspaces, isOverviewMode } = storeToRefs(workspaceStore)
const handleAddWorkspace = async () => {
const workspace = await workspaceStore.addWorkspaceAsync()
nextTick(() => {
workspaceStore.slideToWorkspace(workspace?.id)
})
}
// Measure header height and store it in UI store // Measure header height and store it in UI store
const headerEl = useTemplateRef('headerEl') const headerEl = useTemplateRef('headerEl')
@ -140,15 +92,11 @@ watch(height, (newHeight) => {
de: de:
search: search:
label: Suche label: Suche
workspaces: workspaces:
label: Workspaces label: Workspaces
add: Workspace hinzufügen
en: en:
search: search:
label: Search label: Search
workspaces: workspaces:
label: Workspaces label: Workspaces
add: Add Workspace
</i18n> </i18n>

View File

@ -53,6 +53,7 @@ const { addDeviceNameAsync } = useDeviceStore()
const { deviceId } = storeToRefs(useDeviceStore()) const { deviceId } = storeToRefs(useDeviceStore())
const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } = const { syncLocaleAsync, syncThemeAsync, syncVaultNameAsync } =
useVaultSettingsStore() useVaultSettingsStore()
const { syncDesktopIconSizeAsync } = useDesktopStore()
onMounted(async () => { onMounted(async () => {
try { try {
@ -62,6 +63,7 @@ onMounted(async () => {
syncLocaleAsync(), syncLocaleAsync(),
syncThemeAsync(), syncThemeAsync(),
syncVaultNameAsync(), syncVaultNameAsync(),
syncDesktopIconSizeAsync(),
loadExtensionsAsync(), loadExtensionsAsync(),
readNotificationsAsync(), readNotificationsAsync(),
]) ])

View File

@ -1,9 +1,13 @@
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { haexDesktopItems } from '~/database/schemas' import { haexDesktopItems, haexDevices } from '~/database/schemas'
import type { import type {
InsertHaexDesktopItems, InsertHaexDesktopItems,
SelectHaexDesktopItems, SelectHaexDesktopItems,
} from '~/database/schemas' } from '~/database/schemas'
import {
DesktopIconSizePreset,
iconSizePresetValues,
} from '~/stores/vault/settings'
import de from './de.json' import de from './de.json'
import en from './en.json' import en from './en.json'
@ -20,6 +24,10 @@ export const useDesktopStore = defineStore('desktopStore', () => {
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()
const { currentWorkspace } = storeToRefs(workspaceStore) const { currentWorkspace } = storeToRefs(workspaceStore)
const { $i18n } = useNuxtApp() const { $i18n } = useNuxtApp()
const uiStore = useUiStore()
const { isSmallScreen } = storeToRefs(uiStore)
const deviceStore = useDeviceStore()
const settingsStore = useVaultSettingsStore()
$i18n.setLocaleMessage('de', { $i18n.setLocaleMessage('de', {
desktop: de, desktop: de,
@ -29,6 +37,86 @@ export const useDesktopStore = defineStore('desktopStore', () => {
const desktopItems = ref<IDesktopItem[]>([]) const desktopItems = ref<IDesktopItem[]>([])
const selectedItemIds = ref<Set<string>>(new Set()) const selectedItemIds = ref<Set<string>>(new Set())
// Desktop Grid Settings (stored in DB per device)
const iconSizePreset = ref<DesktopIconSizePreset>(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 () => { const loadDesktopItemsAsync = async () => {
if (!currentVault.value?.drizzle) { if (!currentVault.value?.drizzle) {
console.error('Kein Vault geöffnet') console.error('Kein Vault geöffnet')
@ -347,5 +435,14 @@ export const useDesktopStore = defineStore('desktopStore', () => {
toggleSelection, toggleSelection,
clearSelection, clearSelection,
isItemSelected, isItemSelected,
// Grid settings
iconSizePreset,
syncDesktopIconSizeAsync,
updateDesktopIconSizeAsync,
effectiveGridColumns,
effectiveGridRows,
effectiveIconSize,
gridCellSize,
snapToGrid,
} }
}) })

View File

@ -45,7 +45,9 @@ export const useDeviceStore = defineStore('vaultDeviceStore', () => {
const isKnownDeviceAsync = async () => { const isKnownDeviceAsync = async () => {
const { readDeviceNameAsync } = useVaultSettingsStore() 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) => { const readDeviceNameAsync = async (id?: string) => {
@ -54,7 +56,8 @@ export const useDeviceStore = defineStore('vaultDeviceStore', () => {
if (!_id) return if (!_id) return
deviceName.value = (await readDeviceNameAsync(_id))?.value ?? '' const device = await readDeviceNameAsync(_id)
deviceName.value = device?.name ?? ''
return deviceName.value return deviceName.value
} }

View File

@ -4,14 +4,29 @@ import * as schema from '~/database/schemas/haex'
import type { Locale } from 'vue-i18n' import type { Locale } from 'vue-i18n'
export enum VaultSettingsTypeEnum { export enum VaultSettingsTypeEnum {
deviceName = 'deviceName',
settings = 'settings', settings = 'settings',
system = 'system',
} }
export enum VaultSettingsKeyEnum { export enum VaultSettingsKeyEnum {
locale = 'locale', locale = 'locale',
theme = 'theme', theme = 'theme',
vaultName = 'vaultName', vaultName = 'vaultName',
desktopIconSize = 'desktopIconSize',
}
export enum DesktopIconSizePreset {
small = 'small',
medium = 'medium',
large = 'large',
extraLarge = 'extra-large',
}
export const iconSizePresetValues: Record<DesktopIconSizePreset, number> = {
[DesktopIconSizePreset.small]: 60,
[DesktopIconSizePreset.medium]: 80,
[DesktopIconSizePreset.large]: 120,
[DesktopIconSizePreset.extraLarge]: 160,
} }
export const vaultDeviceNameSchema = z.string().min(3).max(255) export const vaultDeviceNameSchema = z.string().min(3).max(255)
@ -118,20 +133,22 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
.where(eq(schema.haexSettings.key, 'vaultName')) .where(eq(schema.haexSettings.key, 'vaultName'))
} }
const readDeviceNameAsync = async (id?: string) => { const readDeviceNameAsync = async (deviceId?: string) => {
const { currentVault } = useVaultStore() const { currentVault } = useVaultStore()
if (!id) return undefined if (!deviceId) return undefined
const deviceName = const device =
await currentVault?.drizzle?.query.haexSettings.findFirst({ await currentVault?.drizzle?.query.haexDevices.findFirst({
where: and( where: eq(schema.haexDevices.deviceId, deviceId),
eq(schema.haexSettings.type, VaultSettingsTypeEnum.deviceName),
eq(schema.haexSettings.key, id),
),
}) })
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 ({ const addDeviceNameAsync = async ({
@ -149,10 +166,9 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
return return
} }
return currentVault?.drizzle?.insert(schema.haexSettings).values({ return currentVault?.drizzle?.insert(schema.haexDevices).values({
type: VaultSettingsTypeEnum.deviceName, deviceId,
key: deviceId, name: deviceName,
value: deviceName,
}) })
} }
@ -169,14 +185,49 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
if (!isNameOk.success) return if (!isNameOk.success) return
return currentVault?.drizzle return currentVault?.drizzle
?.update(schema.haexSettings) ?.update(schema.haexDevices)
.set({ .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( .where(
and( and(
eq(schema.haexSettings.key, deviceId), eq(schema.haexSettings.deviceId, deviceInternalId),
eq(schema.haexSettings.type, VaultSettingsTypeEnum.deviceName), eq(schema.haexSettings.key, VaultSettingsKeyEnum.desktopIconSize),
eq(schema.haexSettings.type, VaultSettingsTypeEnum.system),
), ),
) )
} }
@ -191,5 +242,7 @@ export const useVaultSettingsStore = defineStore('vaultSettingsStore', () => {
updateLocaleAsync, updateLocaleAsync,
updateThemeAsync, updateThemeAsync,
updateVaultNameAsync, updateVaultNameAsync,
syncDesktopIconSizeAsync,
updateDesktopIconSizeAsync,
} }
}) })