Skip to content

Commit eac7f03

Browse files
committed
Unify Sidebar view implementation across Library and Folders views
1 parent 8d4f5e7 commit eac7f03

9 files changed

Lines changed: 815 additions & 655 deletions

Views/Components/SidebarView.swift

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Views/Components/SidebarView.swift
2+
3+
import SwiftUI
4+
5+
// MARK: - Sidebar Item Protocol
6+
protocol SidebarItem: Identifiable, Equatable {
7+
var id: UUID { get }
8+
var title: String { get }
9+
var subtitle: String? { get }
10+
var icon: String? { get }
11+
var count: Int? { get }
12+
}
13+
14+
// MARK: - Sidebar View
15+
struct SidebarView<Item: SidebarItem>: View {
16+
let items: [Item]
17+
@Binding var selectedItem: Item?
18+
let onItemTap: (Item) -> Void
19+
let contextMenuItems: ((Item) -> [ContextMenuItem])?
20+
21+
// Header configuration
22+
let headerTitle: String?
23+
let headerControls: AnyView?
24+
25+
// Customization
26+
let showIcon: Bool
27+
let iconColor: Color
28+
let showCount: Bool
29+
30+
@State private var hoveredItemID: UUID?
31+
32+
init(
33+
items: [Item],
34+
selectedItem: Binding<Item?>,
35+
onItemTap: @escaping (Item) -> Void,
36+
contextMenuItems: ((Item) -> [ContextMenuItem])? = nil,
37+
headerTitle: String? = nil,
38+
headerControls: AnyView? = nil,
39+
showIcon: Bool = true,
40+
iconColor: Color = .secondary,
41+
showCount: Bool = false
42+
) {
43+
self.items = items
44+
self._selectedItem = selectedItem
45+
self.onItemTap = onItemTap
46+
self.contextMenuItems = contextMenuItems
47+
self.headerTitle = headerTitle
48+
self.headerControls = headerControls
49+
self.showIcon = showIcon
50+
self.iconColor = iconColor
51+
self.showCount = showCount
52+
}
53+
54+
var body: some View {
55+
VStack(spacing: 0) {
56+
// Header
57+
if headerTitle != nil || headerControls != nil {
58+
sidebarHeader
59+
.background(.clear)
60+
Divider()
61+
.background(Color(NSColor.separatorColor))
62+
}
63+
64+
// Items list
65+
if items.isEmpty {
66+
emptyView
67+
} else {
68+
itemsList
69+
}
70+
}
71+
.background(.clear)
72+
}
73+
74+
// MARK: - Header
75+
76+
private var sidebarHeader: some View {
77+
HStack {
78+
if let title = headerTitle {
79+
Text(title)
80+
.headerTitleStyle()
81+
}
82+
83+
Spacer()
84+
85+
if let controls = headerControls {
86+
controls
87+
}
88+
}
89+
.padding(.horizontal, 16)
90+
.padding(.vertical, 8)
91+
}
92+
93+
// MARK: - Items List
94+
95+
private var itemsList: some View {
96+
ScrollView {
97+
LazyVStack(spacing: 1) {
98+
ForEach(items) { item in
99+
SidebarItemRow(
100+
item: item,
101+
isSelected: selectedItem?.id == item.id,
102+
isHovered: hoveredItemID == item.id,
103+
showIcon: showIcon,
104+
iconColor: iconColor,
105+
showCount: showCount,
106+
onTap: {
107+
selectedItem = item
108+
onItemTap(item)
109+
},
110+
onHover: { isHovered in
111+
hoveredItemID = isHovered ? item.id : nil
112+
}
113+
)
114+
.contextMenu {
115+
if let menuItems = contextMenuItems?(item) {
116+
ForEach(menuItems, id: \.id) { menuItem in
117+
contextMenuItem(menuItem)
118+
}
119+
}
120+
}
121+
}
122+
}
123+
.padding(.horizontal, 8)
124+
.padding(.vertical, 4)
125+
}
126+
}
127+
128+
// MARK: - Empty View
129+
130+
private var emptyView: some View {
131+
VStack(spacing: 16) {
132+
Image(systemName: "tray")
133+
.font(.system(size: 32))
134+
.foregroundColor(.gray)
135+
136+
Text("No Items")
137+
.font(.subheadline)
138+
.foregroundColor(.secondary)
139+
}
140+
.frame(maxWidth: .infinity, maxHeight: .infinity)
141+
.padding()
142+
.background(Color(NSColor.windowBackgroundColor))
143+
}
144+
145+
// MARK: - Context Menu Helper
146+
147+
@ViewBuilder
148+
private func contextMenuItem(_ item: ContextMenuItem) -> some View {
149+
switch item {
150+
case .button(let title, let role, let action):
151+
Button(title, role: role, action: action)
152+
case .menu(let title, let items):
153+
Menu(title) {
154+
ForEach(items, id: \.id) { subItem in
155+
if case .button(let subTitle, let subRole, let subAction) = subItem {
156+
Button(subTitle, role: subRole, action: subAction)
157+
}
158+
}
159+
}
160+
case .divider:
161+
Divider()
162+
}
163+
}
164+
}
165+
166+
// MARK: - Sidebar Item Row
167+
private struct SidebarItemRow<Item: SidebarItem>: View {
168+
let item: Item
169+
let isSelected: Bool
170+
let isHovered: Bool
171+
let showIcon: Bool
172+
let iconColor: Color
173+
let showCount: Bool
174+
let onTap: () -> Void
175+
let onHover: (Bool) -> Void
176+
177+
var body: some View {
178+
HStack(spacing: 10) {
179+
// Icon
180+
if showIcon, let icon = item.icon {
181+
Image(systemName: icon)
182+
.foregroundColor(isSelected ? .white : iconColor)
183+
.font(.system(size: 16))
184+
.frame(width: 16, height: 16)
185+
}
186+
187+
// Content
188+
VStack(alignment: .leading, spacing: 1) {
189+
Text(item.title)
190+
.font(.system(size: 13, weight: isSelected ? .medium : .regular))
191+
.lineLimit(1)
192+
.foregroundColor(isSelected ? .white : .primary)
193+
194+
if let subtitle = item.subtitle {
195+
Text(subtitle)
196+
.font(.system(size: 11))
197+
.foregroundColor(isSelected ? .white.opacity(0.8) : .secondary)
198+
.lineLimit(1)
199+
}
200+
}
201+
202+
Spacer(minLength: 0)
203+
204+
// Count badge
205+
if showCount, let count = item.count, count > 0 {
206+
Text("\(count)")
207+
.font(.system(size: 11, weight: .medium))
208+
.foregroundColor(isSelected ? .accentColor : .secondary)
209+
.padding(.horizontal, 6)
210+
.padding(.vertical, 2)
211+
.background(
212+
Capsule()
213+
.fill(isSelected ? .white : Color.secondary.opacity(0.15))
214+
)
215+
}
216+
}
217+
.padding(.horizontal, 12)
218+
.padding(.vertical, 6)
219+
.frame(maxWidth: .infinity, alignment: .leading)
220+
.background(
221+
RoundedRectangle(cornerRadius: 6)
222+
.fill(backgroundColor)
223+
)
224+
.contentShape(RoundedRectangle(cornerRadius: 6))
225+
.onTapGesture {
226+
onTap()
227+
}
228+
.onHover { hovering in
229+
onHover(hovering)
230+
}
231+
}
232+
233+
private var backgroundColor: Color {
234+
if isSelected {
235+
return Color.accentColor
236+
} else if isHovered {
237+
return Color.primary.opacity(0.06)
238+
} else {
239+
return Color.clear
240+
}
241+
}
242+
}
243+
244+
// MARK: - Concrete Item Types
245+
246+
// Library Filter Item
247+
struct LibrarySidebarItem: SidebarItem {
248+
let id = UUID()
249+
let title: String
250+
let subtitle: String?
251+
let icon: String?
252+
let count: Int?
253+
let filterType: LibraryFilterType
254+
let filterName: String
255+
256+
init(filterItem: LibraryFilterItem) {
257+
self.title = filterItem.name
258+
self.subtitle = nil
259+
self.icon = "person.fill"
260+
self.count = filterItem.count
261+
self.filterType = filterItem.filterType
262+
self.filterName = filterItem.name
263+
}
264+
265+
// Special "All" item
266+
init(allItemFor filterType: LibraryFilterType, count: Int) {
267+
self.title = "All \(filterType.rawValue)"
268+
self.subtitle = nil
269+
self.icon = "person.2.fill"
270+
self.count = count
271+
self.filterType = filterType
272+
self.filterName = ""
273+
}
274+
}
275+
276+
// Folder Item
277+
struct FolderSidebarItem: SidebarItem {
278+
let id = UUID()
279+
let title: String
280+
let subtitle: String?
281+
let icon: String?
282+
let count: Int?
283+
let folder: Folder
284+
285+
init(folder: Folder, trackCount: Int) {
286+
self.title = folder.name
287+
self.subtitle = "\(trackCount) tracks"
288+
self.icon = "folder.fill"
289+
self.count = nil
290+
self.folder = folder
291+
}
292+
}
293+
294+
// MARK: - Convenience Extensions
295+
296+
extension SidebarView where Item == LibrarySidebarItem {
297+
init(
298+
filterItems: [LibraryFilterItem],
299+
filterType: LibraryFilterType,
300+
totalTracksCount: Int,
301+
selectedItem: Binding<LibrarySidebarItem?>,
302+
onItemTap: @escaping (LibrarySidebarItem) -> Void
303+
) {
304+
// Create items including "All" item
305+
var items: [LibrarySidebarItem] = []
306+
307+
// Add "All" item
308+
let allItem = LibrarySidebarItem(allItemFor: filterType, count: totalTracksCount)
309+
items.append(allItem)
310+
311+
// Add filter items
312+
items.append(contentsOf: filterItems.map { LibrarySidebarItem(filterItem: $0) })
313+
314+
self.init(
315+
items: items,
316+
selectedItem: selectedItem,
317+
onItemTap: onItemTap,
318+
showIcon: true,
319+
iconColor: .secondary,
320+
showCount: true
321+
)
322+
}
323+
}
324+
325+
extension SidebarView where Item == FolderSidebarItem {
326+
init(
327+
folders: [Folder],
328+
trackCounts: [Int64: Int],
329+
selectedItem: Binding<FolderSidebarItem?>,
330+
onItemTap: @escaping (FolderSidebarItem) -> Void,
331+
contextMenuItems: @escaping (FolderSidebarItem) -> [ContextMenuItem],
332+
headerControls: AnyView? = nil
333+
) {
334+
let items = folders.map { folder in
335+
FolderSidebarItem(
336+
folder: folder,
337+
trackCount: trackCounts[folder.id ?? -1] ?? 0
338+
)
339+
}
340+
341+
self.init(
342+
items: items,
343+
selectedItem: selectedItem,
344+
onItemTap: onItemTap,
345+
contextMenuItems: contextMenuItems,
346+
headerControls: headerControls,
347+
showIcon: true,
348+
iconColor: .secondary,
349+
showCount: false
350+
)
351+
}
352+
}

0 commit comments

Comments
 (0)