From 1997c3eb39cb9371f6a6c4f69574366bf4b2656d Mon Sep 17 00:00:00 2001 From: dernerl Date: Sun, 7 Dec 2025 21:48:38 +0100 Subject: [PATCH 1/4] Phase 1: Extend data model for folder support - Make url optional (nil = folder, String = favorite) - Add parentID for hierarchy (nil = root level) - Add order property for sorting - Add isFolder computed property - Update FormatGenerator to filter folders - Update ContentView to handle optional url - Only export favorites (not folders) for now Part of #4 --- .../ManagedFavsGenerator/ContentView.swift | 10 ++++-- Sources/ManagedFavsGenerator/Favorite.swift | 15 ++++++-- .../FormatGenerator.swift | 34 +++++++++++-------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 5f72369..29593ec 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -227,8 +227,9 @@ struct FavoriteRowView: View { /// Generates the favicon URL using Google's favicon service private var faviconURL: URL? { - guard !favorite.url.isEmpty, - let url = URL(string: favorite.url), + guard let urlString = favorite.url, + !urlString.isEmpty, + let url = URL(string: urlString), let domain = url.host else { return nil } @@ -286,7 +287,10 @@ struct FavoriteRowView: View { .frame(height: 22) AppKitTextField( - text: $favorite.url, + text: Binding( + get: { favorite.url ?? "" }, + set: { favorite.url = $0.isEmpty ? nil : $0 } + ), placeholder: "URL" ) .frame(height: 22) diff --git a/Sources/ManagedFavsGenerator/Favorite.swift b/Sources/ManagedFavsGenerator/Favorite.swift index a766aae..e760dd7 100644 --- a/Sources/ManagedFavsGenerator/Favorite.swift +++ b/Sources/ManagedFavsGenerator/Favorite.swift @@ -1,19 +1,28 @@ import Foundation import SwiftData -/// SwiftData Model für Favoriten +/// SwiftData Model für Favoriten und Ordner // Note: @Model is a SwiftData macro, not a GitHub user mention @Model final class Favorite { @Attribute(.unique) var id: UUID var name: String - var url: String + var url: String? // nil = Folder, String = Favorite + var parentID: UUID? // nil = Root level, UUID = Inside folder + var order: Int // For sorting within same level var createdAt: Date - init(id: UUID = UUID(), name: String = "", url: String = "") { + /// Computed property to check if this is a folder + var isFolder: Bool { + url == nil + } + + init(id: UUID = UUID(), name: String = "", url: String? = "", parentID: UUID? = nil, order: Int = 0) { self.id = id self.name = name self.url = url + self.parentID = parentID + self.order = order self.createdAt = Date() } } diff --git a/Sources/ManagedFavsGenerator/FormatGenerator.swift b/Sources/ManagedFavsGenerator/FormatGenerator.swift index fa5f3a1..d26297f 100644 --- a/Sources/ManagedFavsGenerator/FormatGenerator.swift +++ b/Sources/ManagedFavsGenerator/FormatGenerator.swift @@ -9,12 +9,15 @@ enum FormatGenerator { // First item: toplevel_name items.append(["toplevel_name": toplevelName]) - // Favorites - for favorite in favorites where !favorite.name.isEmpty && !favorite.url.isEmpty { - items.append([ - "url": favorite.url, - "name": favorite.name - ]) + // Only export favorites (not folders) at root level + let rootFavorites = favorites.filter { !$0.isFolder && $0.parentID == nil } + for favorite in rootFavorites where !favorite.name.isEmpty { + if let url = favorite.url, !url.isEmpty { + items.append([ + "url": url, + "name": favorite.name + ]) + } } // Use JSONEncoder with .withoutEscapingSlashes to prevent https:// -> https:\/\/ @@ -46,14 +49,17 @@ enum FormatGenerator { plistLines.append("\t\t\t\(toplevelName.xmlEscaped)") plistLines.append("\t\t") - // Favorites - for favorite in favorites where !favorite.name.isEmpty && !favorite.url.isEmpty { - plistLines.append("\t\t") - plistLines.append("\t\t\tname") - plistLines.append("\t\t\t\(favorite.name.xmlEscaped)") - plistLines.append("\t\t\turl") - plistLines.append("\t\t\t\(favorite.url.xmlEscaped)") - plistLines.append("\t\t") + // Only export favorites (not folders) at root level + let rootFavorites = favorites.filter { !$0.isFolder && $0.parentID == nil } + for favorite in rootFavorites where !favorite.name.isEmpty { + if let url = favorite.url, !url.isEmpty { + plistLines.append("\t\t") + plistLines.append("\t\t\tname") + plistLines.append("\t\t\t\(favorite.name.xmlEscaped)") + plistLines.append("\t\t\turl") + plistLines.append("\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t") + } } plistLines.append("\t") From 71127fa919f6f352d32395023f742934144f78f4 Mon Sep 17 00:00:00 2001 From: dernerl Date: Sun, 7 Dec 2025 21:52:33 +0100 Subject: [PATCH 2/4] Phase 2: Add folder UI with hierarchy display - Create FolderRowView component (yellow folder icon) - Add DisclosureGroup for collapsible folders - Show root level items vs. children - Sort by order property - Filter and display hierarchy correctly - Indent child favorites under folders Part of #4 --- .../ManagedFavsGenerator/ContentView.swift | 58 ++++++++++++++++--- .../ManagedFavsGenerator/FolderRowView.swift | 54 +++++++++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 Sources/ManagedFavsGenerator/FolderRowView.swift diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 29593ec..3d4b85a 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -9,6 +9,16 @@ struct ContentView: View { @State private var viewModel = FavoritesViewModel() @Environment(\.openWindow) private var openWindow + /// Root level items (no parent) + private var rootLevelItems: [Favorite] { + favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } + } + + /// Get children of a folder + private func childrenOf(_ folder: Favorite) -> [Favorite] { + favorites.filter { $0.parentID == folder.id }.sorted { $0.order < $1.order } + } + var body: some View { ZStack { // Hidden helper view um First Responder zu aktivieren @@ -121,16 +131,48 @@ struct ContentView: View { ScrollView { VStack(spacing: 12) { - ForEach(favorites) { favorite in - FavoriteRowView( - favorite: favorite, - onRemove: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.removeFavorite(favorite) + // Root level items (no parent) + ForEach(rootLevelItems) { item in + if item.isFolder { + // Folder with children + DisclosureGroup { + VStack(spacing: 12) { + ForEach(childrenOf(item)) { child in + FavoriteRowView( + favorite: child, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(child) + } + } + ) + .padding(.leading, 16) + .transition(.scale.combined(with: .opacity)) + } } + } label: { + FolderRowView( + folder: item, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(item) + } + } + ) } - ) - .transition(.scale.combined(with: .opacity)) + .disclosureGroupStyle(.automatic) + } else { + // Regular favorite + FavoriteRowView( + favorite: item, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(item) + } + } + ) + .transition(.scale.combined(with: .opacity)) + } } } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: favorites.count) diff --git a/Sources/ManagedFavsGenerator/FolderRowView.swift b/Sources/ManagedFavsGenerator/FolderRowView.swift new file mode 100644 index 0000000..c93b3e6 --- /dev/null +++ b/Sources/ManagedFavsGenerator/FolderRowView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// View für Ordner-Darstellung +struct FolderRowView: View { + @Bindable var folder: Favorite + let onRemove: () -> Void + @State private var isHovering = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + // Folder Icon + Title + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(.yellow) + .frame(width: 20, height: 20) + .imageScale(.medium) + + Label("Folder", systemImage: "folder.fill") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .imageScale(.small) + .labelStyle(.titleOnly) + } + + Spacer() + + Button(action: onRemove) { + Image(systemName: "trash") + .foregroundStyle(.red) + .imageScale(.medium) + } + .buttonStyle(.plain) + .opacity(isHovering ? 1.0 : 0.6) + .help("Delete this folder") + } + + AppKitTextField( + text: $folder.name, + placeholder: "Folder Name" + ) + .frame(height: 22) + } + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: isHovering ? 12 : 8, y: isHovering ? 6 : 4) + .scaleEffect(isHovering ? 1.01 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovering) + .onHover { hovering in + isHovering = hovering + } + } +} From b29ea30445709de6f56d141fe25348b35143064a Mon Sep 17 00:00:00 2001 From: dernerl Date: Sun, 7 Dec 2025 21:54:34 +0100 Subject: [PATCH 3/4] Phase 3: Add folder management (create/delete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add addFolder() method in ViewModel - Add 'Add Folder' button in Toolbar - Keyboard shortcut ⌘⇧N for adding folders - Folder created with url = nil and default name - Support parentID parameter in addFavorite() for future use - Delete works via existing removeFavorite() method Part of #4 --- .../ManagedFavsGenerator/ContentView.swift | 11 +++++++++ .../FavoritesViewModel.swift | 23 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 3d4b85a..58f8f38 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -50,6 +50,17 @@ struct ContentView: View { .keyboardShortcut("n", modifiers: [.command]) .help("Add a new favorite (⌘N)") + // Add Folder + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.addFolder() + } + } label: { + Label("Add Folder", systemImage: "folder.badge.plus") + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .help("Add a new folder (⌘⇧N)") + Divider() // Copy JSON diff --git a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift index a8c598f..f76d015 100644 --- a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift +++ b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift @@ -45,13 +45,13 @@ class FavoritesViewModel { // MARK: - Business Logic - func addFavorite() { + func addFavorite(parentID: UUID? = nil) { guard let modelContext = modelContext else { logger.error("ModelContext nicht verfügbar") return } - let favorite = Favorite() + let favorite = Favorite(parentID: parentID) modelContext.insert(favorite) do { @@ -63,6 +63,25 @@ class FavoritesViewModel { } } + func addFolder() { + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Folder = Favorite with url = nil + let folder = Favorite(name: "New Folder", url: nil) + modelContext.insert(folder) + + do { + try modelContext.save() + logger.info("Ordner hinzugefügt und gespeichert") + } catch { + logger.error("Fehler beim Speichern: \(error.localizedDescription)") + handleError(error) + } + } + func removeFavorite(_ favorite: Favorite) { guard let modelContext = modelContext else { logger.error("ModelContext nicht verfügbar") From 43c0ce3f808d789fba46c98b537f12edfcdfdbd5 Mon Sep 17 00:00:00 2001 From: dernerl Date: Mon, 8 Dec 2025 01:13:21 +0100 Subject: [PATCH 4/4] feat: Add folder support with drag & drop - Add folder hierarchy support (parentID, isFolder, order fields) - Implement folder UI with DisclosureGroup - Add drag & drop for reordering and moving favorites - Generate JSON/Plist with children arrays for folders - Move TopLevel Name to Settings (CMD+,) - Fix Plist format (children before name) - Improve UI consistency for section headers Closes #4 --- .../AppKitTextField.swift | 123 ++++++++++++- .../ManagedFavsGenerator/ContentView.swift | 168 ++++++++++++++---- Sources/ManagedFavsGenerator/Favorite.swift | 2 +- .../FavoritesViewModel.swift | 35 ++++ .../ManagedFavsGenerator/FolderRowView.swift | 11 ++ .../FormatGenerator.swift | 83 +++++++-- .../ManagedFavsGenerator/SettingsView.swift | 23 ++- 7 files changed, 383 insertions(+), 62 deletions(-) diff --git a/Sources/ManagedFavsGenerator/AppKitTextField.swift b/Sources/ManagedFavsGenerator/AppKitTextField.swift index 694310c..2dfa1f4 100644 --- a/Sources/ManagedFavsGenerator/AppKitTextField.swift +++ b/Sources/ManagedFavsGenerator/AppKitTextField.swift @@ -43,6 +43,14 @@ struct AppKitTextField: NSViewRepresentable { // KRITISCH: TextField muss explizit First Responder werden können textField.refusesFirstResponder = false + // WICHTIG: Komplett deaktiviere Drag & Drop für dieses TextField + textField.unregisterDraggedTypes() + + // Disable text field's cell from accepting drops + if let cell = textField.cell as? NSTextFieldCell { + cell.isScrollable = true + } + return textField } @@ -96,15 +104,43 @@ struct AppKitTextField: NSViewRepresentable { /// - Explizites `acceptsFirstResponder = true` /// - Automatische Window-Aktivierung beim Focus-Wechsel /// - Window-Aktivierung beim Hinzufügen zur View-Hierarchie +/// - Blockiert Drag & Drop Operations komplett durch eigenen Field Editor private class KeyboardReceivingTextField: NSTextField { override var acceptsFirstResponder: Bool { true } override var canBecomeKeyView: Bool { true } + // Custom field editor that never accepts drops + private lazy var customFieldEditor: NoDragTextView = { + let textContainer = NSTextContainer() + let editor = NoDragTextView(frame: .zero, textContainer: textContainer) + editor.isFieldEditor = true + editor.isRichText = false + editor.importsGraphics = false + editor.allowsUndo = true + editor.backgroundColor = .clear + editor.drawsBackground = false + return editor + }() + /// Stellt sicher, dass das Window key wird, wenn das TextField First Responder wird override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() + // Get or create the window's field editor and disable drag & drop + if let window = self.window, + let fieldEditor = window.fieldEditor(true, for: self) as? NSTextView { + fieldEditor.unregisterDraggedTypes() + + // Permanently block re-registration + DispatchQueue.main.async { + fieldEditor.unregisterDraggedTypes() + } + + // Schedule periodic unregistration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + fieldEditor.unregisterDraggedTypes() + } + } - // Sicherstellen, dass das Window auch key ist + let result = super.becomeFirstResponder() self.window?.makeKeyAndOrderFront(nil) return result @@ -122,4 +158,87 @@ private class KeyboardReceivingTextField: NSTextField { } } } + + + // MARK: - Drag & Drop Protection + + /// Blockiert alle Drag Operations - kein visuelles Feedback + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] // Reject all drags - no cursor feedback + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] // Reject all drags - no cursor feedback + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false // Never accept drops + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false // Don't even prepare + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + // Do nothing - ignore exit + } + + override func wantsPeriodicDraggingUpdates() -> Bool { + return false // No periodic updates needed + } +} + +/// Custom NSTextView that completely disables drag & drop +private class NoDragTextView: NSTextView { + + override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { + super.init(frame: frameRect, textContainer: container) + setupNoDrag() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupNoDrag() + } + + private func setupNoDrag() { + // Never register any drag types + self.unregisterDraggedTypes() + + // Disable additional drag features + self.allowsImageEditing = false + self.isAutomaticQuoteSubstitutionEnabled = false + self.isAutomaticLinkDetectionEnabled = false + } + + // MARK: - Block all drag operations + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false + } + + override func wantsPeriodicDraggingUpdates() -> Bool { + return false + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + // Do nothing + } + + // Prevent registering drag types + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { + // Do nothing - never register drag types + } } diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 58f8f38..9b9c6c3 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -19,6 +19,29 @@ struct ContentView: View { favorites.filter { $0.parentID == folder.id }.sorted { $0.order < $1.order } } + /// Handle drop operation + private func handleDrop(droppedIds: [String], toParent parentID: UUID?, atIndex index: Int) { + guard let droppedId = droppedIds.first, + let droppedUUID = UUID(uuidString: droppedId), + let favorite = favorites.first(where: { $0.id == droppedUUID }) else { + return + } + + // Don't allow dropping a folder into itself + if let parentID = parentID, parentID == favorite.id { + return + } + + // Don't allow dropping folders into other folders (only 1 level deep) + if favorite.isFolder && parentID != nil { + return + } + + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.moveFavorite(favorite, toParent: parentID, atIndex: index, allFavorites: favorites) + } + } + var body: some View { ZStack { // Hidden helper view um First Responder zu aktivieren @@ -122,56 +145,87 @@ struct ContentView: View { private var inputSection: some View { VStack(alignment: .leading, spacing: 16) { - // Toplevel Name - VStack(alignment: .leading, spacing: 8) { - Text("Toplevel Name") - .font(.headline) - AppKitTextField( - text: $viewModel.toplevelName, - placeholder: "e.g., managedFavs" - ) - .frame(height: 22) - } - - Divider() - // Favorites List - VStack(alignment: .leading, spacing: 8) { - Text("Favorites") - .font(.headline) + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text("Favorites") + .font(.title2) + .fontWeight(.bold) + Text("Add and organize your favorites") + .font(.subheadline) + .foregroundStyle(.secondary) + } ScrollView { VStack(spacing: 12) { + // Drop zone BEFORE first root item (always visible) + Color.clear + .frame(height: 20) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: 0) + return true + } + // Root level items (no parent) - ForEach(rootLevelItems) { item in + ForEach(Array(rootLevelItems.enumerated()), id: \.element.id) { index, item in if item.isFolder { // Folder with children - DisclosureGroup { - VStack(spacing: 12) { - ForEach(childrenOf(item)) { child in - FavoriteRowView( - favorite: child, - onRemove: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.removeFavorite(child) + VStack(spacing: 0) { + DisclosureGroup { + VStack(spacing: 12) { + // Drop zone at START of folder (for first position) + Color.clear + .frame(height: 20) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: 0) + return true + } + + ForEach(Array(childrenOf(item).enumerated()), id: \.element.id) { childIndex, child in + FavoriteRowView( + favorite: child, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(child) + } } + ) + .padding(.leading, 16) + .transition(.scale.combined(with: .opacity)) + .dropDestination(for: String.self) { droppedIds, location in + // Drop AFTER this child + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: childIndex + 1) + return true } - ) - .padding(.leading, 16) - .transition(.scale.combined(with: .opacity)) - } - } - } label: { - FolderRowView( - folder: item, - onRemove: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.removeFavorite(item) + } + + // Drop zone at END of folder (when folder is empty or after last item) + if childrenOf(item).isEmpty { + Color.clear + .frame(height: 40) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: 0) + return true + } } } - ) + } label: { + FolderRowView( + folder: item, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(item) + } + }, + onAddChild: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.addFavorite(parentID: item.id) + } + } + ) + } + .disclosureGroupStyle(.automatic) } - .disclosureGroupStyle(.automatic) } else { // Regular favorite FavoriteRowView( @@ -183,8 +237,21 @@ struct ContentView: View { } ) .transition(.scale.combined(with: .opacity)) + .dropDestination(for: String.self) { droppedIds, location in + // Drop after this favorite + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: index + 1) + return true + } } } + + // Drop zone at end of root level + Color.clear + .frame(height: 40) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: rootLevelItems.count) + return true + } } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: favorites.count) } @@ -277,6 +344,7 @@ struct FavoriteRowView: View { @Bindable var favorite: Favorite let onRemove: () -> Void @State private var isHovering = false + @State private var isDragging = false /// Generates the favicon URL using Google's favicon service private var faviconURL: URL? { @@ -352,10 +420,34 @@ struct FavoriteRowView: View { .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: isHovering ? 12 : 8, y: isHovering ? 6 : 4) .scaleEffect(isHovering ? 1.01 : 1.0) + .opacity(isDragging ? 0.5 : 1.0) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovering) + .animation(.easeInOut(duration: 0.2), value: isDragging) .onHover { hovering in isHovering = hovering } + .draggable(favorite.id.uuidString) { + // Drag preview + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + Text(favorite.name) + .font(.headline) + } + if let url = favorite.url { + Text(url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 8) + .onAppear { isDragging = true } + .onDisappear { isDragging = false } + } } } diff --git a/Sources/ManagedFavsGenerator/Favorite.swift b/Sources/ManagedFavsGenerator/Favorite.swift index e760dd7..35948d2 100644 --- a/Sources/ManagedFavsGenerator/Favorite.swift +++ b/Sources/ManagedFavsGenerator/Favorite.swift @@ -9,7 +9,7 @@ final class Favorite { var name: String var url: String? // nil = Folder, String = Favorite var parentID: UUID? // nil = Root level, UUID = Inside folder - var order: Int // For sorting within same level + var order: Int = 0 // For sorting within same level (default: 0) var createdAt: Date /// Computed property to check if this is a folder diff --git a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift index f76d015..3aa36d7 100644 --- a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift +++ b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift @@ -99,6 +99,41 @@ class FavoritesViewModel { } } + // MARK: - Drag & Drop + + func moveFavorite(_ favorite: Favorite, toParent newParentID: UUID?, atIndex index: Int, allFavorites: [Favorite]) { + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Update parentID + favorite.parentID = newParentID + + // Reorder siblings at target location + let siblings = allFavorites + .filter { $0.parentID == newParentID && $0.id != favorite.id } + .sorted { $0.order < $1.order } + + // Insert at new position + var reorderedSiblings = siblings + let targetIndex = min(index, reorderedSiblings.count) + reorderedSiblings.insert(favorite, at: targetIndex) + + // Update order values + for (idx, item) in reorderedSiblings.enumerated() { + item.order = idx + } + + do { + try modelContext.save() + logger.info("Favorit verschoben: parentID=\(newParentID?.uuidString ?? "root"), order=\(favorite.order)") + } catch { + logger.error("Fehler beim Verschieben: \(error.localizedDescription)") + handleError(error) + } + } + func copyToClipboard(_ text: String) { do { try clipboardService.copyToClipboard(text) diff --git a/Sources/ManagedFavsGenerator/FolderRowView.swift b/Sources/ManagedFavsGenerator/FolderRowView.swift index c93b3e6..783e2e7 100644 --- a/Sources/ManagedFavsGenerator/FolderRowView.swift +++ b/Sources/ManagedFavsGenerator/FolderRowView.swift @@ -4,6 +4,7 @@ import SwiftUI struct FolderRowView: View { @Bindable var folder: Favorite let onRemove: () -> Void + let onAddChild: () -> Void @State private var isHovering = false var body: some View { @@ -26,6 +27,16 @@ struct FolderRowView: View { Spacer() + // Add to Folder Button + Button(action: onAddChild) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.blue) + .imageScale(.medium) + } + .buttonStyle(.plain) + .opacity(isHovering ? 1.0 : 0.6) + .help("Add favorite to this folder") + Button(action: onRemove) { Image(systemName: "trash") .foregroundStyle(.red) diff --git a/Sources/ManagedFavsGenerator/FormatGenerator.swift b/Sources/ManagedFavsGenerator/FormatGenerator.swift index d26297f..8478142 100644 --- a/Sources/ManagedFavsGenerator/FormatGenerator.swift +++ b/Sources/ManagedFavsGenerator/FormatGenerator.swift @@ -4,27 +4,46 @@ enum FormatGenerator { // JSON for GPO (Windows onPrem) and Intune Settings Catalog (Windows) static func generateJSON(toplevelName: String, favorites: [Favorite]) -> String { - var items: [[String: String]] = [] + var items: [[String: Any]] = [] // First item: toplevel_name items.append(["toplevel_name": toplevelName]) - // Only export favorites (not folders) at root level - let rootFavorites = favorites.filter { !$0.isFolder && $0.parentID == nil } - for favorite in rootFavorites where !favorite.name.isEmpty { - if let url = favorite.url, !url.isEmpty { + // Get root level items (no parent) + let rootItems = favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } + + for item in rootItems where !item.name.isEmpty { + if item.isFolder { + // Folder: name + children array + let children = favorites.filter { $0.parentID == item.id }.sorted { $0.order < $1.order } + var childrenArray: [[String: String]] = [] + + for child in children where !child.name.isEmpty { + if let url = child.url, !url.isEmpty { + childrenArray.append([ + "name": child.name, + "url": url + ]) + } + } + items.append([ - "url": url, - "name": favorite.name + "name": item.name, + "children": childrenArray ]) + } else { + // Regular favorite: name + url + if let url = item.url, !url.isEmpty { + items.append([ + "name": item.name, + "url": url + ]) + } } } - // Use JSONEncoder with .withoutEscapingSlashes to prevent https:// -> https:\/\/ - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - - guard let jsonData = try? encoder.encode(items), + // Use JSONSerialization for [String: Any] support + guard let jsonData = try? JSONSerialization.data(withJSONObject: items, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), let jsonString = String(data: jsonData, encoding: .utf8) else { return "[]" } @@ -49,16 +68,42 @@ enum FormatGenerator { plistLines.append("\t\t\t\(toplevelName.xmlEscaped)") plistLines.append("\t\t") - // Only export favorites (not folders) at root level - let rootFavorites = favorites.filter { !$0.isFolder && $0.parentID == nil } - for favorite in rootFavorites where !favorite.name.isEmpty { - if let url = favorite.url, !url.isEmpty { + // Get root level items (no parent) + let rootItems = favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } + + for item in rootItems where !item.name.isEmpty { + if item.isFolder { + // Folder: children array FIRST, then name (Microsoft format) plistLines.append("\t\t") + plistLines.append("\t\t\tchildren") + plistLines.append("\t\t\t") + + let children = favorites.filter { $0.parentID == item.id }.sorted { $0.order < $1.order } + for child in children where !child.name.isEmpty { + if let url = child.url, !url.isEmpty { + plistLines.append("\t\t\t\t") + plistLines.append("\t\t\t\t\tname") + plistLines.append("\t\t\t\t\t\(child.name.xmlEscaped)") + plistLines.append("\t\t\t\t\turl") + plistLines.append("\t\t\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t\t\t") + } + } + + plistLines.append("\t\t\t") plistLines.append("\t\t\tname") - plistLines.append("\t\t\t\(favorite.name.xmlEscaped)") - plistLines.append("\t\t\turl") - plistLines.append("\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t\t\(item.name.xmlEscaped)") plistLines.append("\t\t") + } else { + // Regular favorite: name + url + if let url = item.url, !url.isEmpty { + plistLines.append("\t\t") + plistLines.append("\t\t\tname") + plistLines.append("\t\t\t\(item.name.xmlEscaped)") + plistLines.append("\t\t\turl") + plistLines.append("\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t") + } } } diff --git a/Sources/ManagedFavsGenerator/SettingsView.swift b/Sources/ManagedFavsGenerator/SettingsView.swift index ce3c068..6e57e8a 100644 --- a/Sources/ManagedFavsGenerator/SettingsView.swift +++ b/Sources/ManagedFavsGenerator/SettingsView.swift @@ -2,11 +2,30 @@ import SwiftUI /// Settings View für App-Einstellungen struct SettingsView: View { + @State private var viewModel = FavoritesViewModel() + var body: some View { Form { + Section { + LabeledContent("Toplevel Name") { + TextField("e.g., managedFavs", text: $viewModel.toplevelName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + } + .help("The toplevel name for your managed favorites structure") + + Text("This name appears as the first entry in the exported JSON/Plist configuration.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Configuration") + } + + Divider() + Section { LabeledContent("Version") { - Text("1.0.0") + Text("1.1.0") .foregroundStyle(.secondary) } @@ -19,7 +38,7 @@ struct SettingsView: View { } } .formStyle(.grouped) - .frame(width: 500, height: 200) + .frame(width: 550, height: 300) } }