From fddf940b952faabf0052d34f99ce16e499fa7075 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:41:11 +0200 Subject: [PATCH] idk i changed layout constants --- Sora/Views/LibraryView/LibraryView.swift | 720 +++++++++++------------ 1 file changed, 353 insertions(+), 367 deletions(-) diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 77289b7..6a97b7e 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -12,38 +12,35 @@ import UIKit struct LibraryView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - + @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - + @Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass @State private var selectedBookmark: LibraryItem? = nil @State private var isDetailActive: Bool = false - + @State private var continueWatchingItems: [ContinueWatchingItem] = [] @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape @State private var selectedTab: Int = 0 - + private let columns = [ GridItem(.adaptive(minimum: 150), spacing: 12) ] - + private var columnsCount: Int { - // Stage Manager Detection if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact { return verticalSizeClass == .compact ? 3 : 2 } else if UIDevice.current.userInterfaceIdiom == .pad { - // Normal iPad layout let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait } else { - // iPhone layout return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } - + private var cellWidth: CGFloat { let keyWindow = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } @@ -54,114 +51,114 @@ struct LibraryView: View { let availableWidth = safeWidth - totalSpacing return availableWidth / CGFloat(columnsCount) } - + var body: some View { NavigationView { ZStack { ScrollView { VStack(alignment: .leading, spacing: 20) { - Text("Library") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.horizontal, 20) - .padding(.top, 20) - HStack { - HStack(spacing: 4) { - Image(systemName: "play.fill") - .font(.subheadline) - Text("Continue Watching") - .font(.title3) - .fontWeight(.semibold) - } - - Spacer() - - NavigationLink(destination: AllWatchingView()) { - Text("View All") - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - } + Text("Library") + .font(.largeTitle) + .fontWeight(.bold) .padding(.horizontal, 20) - - if continueWatchingItems.isEmpty { - VStack(spacing: 8) { - Image(systemName: "play.circle") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("No items to continue watching.") - .font(.headline) - Text("Recently watched content will appear here.") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .frame(maxWidth: .infinity) - } else { - ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { - item in - markContinueWatchingItemAsWatched(item: item) - }, removeItem: { - item in - removeContinueWatchingItem(item: item) - }) + .padding(.top, 20) + HStack { + HStack(spacing: 4) { + Image(systemName: "play.fill") + .font(.subheadline) + Text("Continue Watching") + .font(.title3) + .fontWeight(.semibold) } - - HStack { - HStack(spacing: 4) { - Image(systemName: "bookmark.fill") - .font(.subheadline) - Text("Bookmarks") - .font(.title3) - .fontWeight(.semibold) - } - - Spacer() - - NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) { - Text("View All") - .font(.subheadline) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - } - .padding(.horizontal, 20) - - - BookmarksSection( - selectedBookmark: $selectedBookmark, - isDetailActive: $isDetailActive - ) - - Spacer().frame(height: 100) - - NavigationLink( - destination: Group { - if let bookmark = selectedBookmark, - let module = moduleManager.modules.first(where: { - $0.id.uuidString == bookmark.moduleId - }) { - MediaInfoView(title: bookmark.title, - imageUrl: bookmark.imageUrl, - href: bookmark.href, - module: module) - } else { - Text("No Data Available") - } - }, - isActive: $isDetailActive - ) { - EmptyView() + + Spacer() + + NavigationLink(destination: AllWatchingView()) { + Text("View All") + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() } } - .padding(.bottom, 20) + .padding(.horizontal, 20) + + if continueWatchingItems.isEmpty { + VStack(spacing: 8) { + Image(systemName: "play.circle") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No items to continue watching.") + .font(.headline) + Text("Recently watched content will appear here.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) + } else { + ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { + item in + markContinueWatchingItemAsWatched(item: item) + }, removeItem: { + item in + removeContinueWatchingItem(item: item) + }) + } + + HStack { + HStack(spacing: 4) { + Image(systemName: "bookmark.fill") + .font(.subheadline) + Text("Bookmarks") + .font(.title3) + .fontWeight(.semibold) + } + + Spacer() + + NavigationLink(destination: BookmarksDetailView(bookmarks: $libraryManager.bookmarks)) { + Text("View All") + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + } + .padding(.horizontal, 20) + + + BookmarksSection( + selectedBookmark: $selectedBookmark, + isDetailActive: $isDetailActive + ) + + Spacer().frame(height: 100) + + NavigationLink( + destination: Group { + if let bookmark = selectedBookmark, + let module = moduleManager.modules.first(where: { + $0.id.uuidString == bookmark.moduleId + }) { + MediaInfoView(title: bookmark.title, + imageUrl: bookmark.imageUrl, + href: bookmark.href, + module: module) + } else { + Text("No Data Available") + } + }, + isActive: $isDetailActive + ) { + EmptyView() + } + } + .padding(.bottom, 20) } .scrollViewBottomPadding() .deviceScaled() @@ -172,11 +169,11 @@ struct LibraryView: View { } .navigationViewStyle(StackNavigationViewStyle()) } - + private func fetchContinueWatching() { continueWatchingItems = ContinueWatchingManager.shared.fetchItems() } - + private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) { let key = "lastPlayedTime_\(item.fullUrl)" let totalKey = "totalTime_\(item.fullUrl)" @@ -187,54 +184,50 @@ struct LibraryView: View { $0.id == item.id } } - + private func removeContinueWatchingItem(item: ContinueWatchingItem) { ContinueWatchingManager.shared.remove(item: item) continueWatchingItems.removeAll { $0.id == item.id } } - + private func updateOrientation() { DispatchQueue.main.async { isLandscape = UIDevice.current.orientation.isLandscape } } - + private func determineColumns() -> Int { - // Stage Manager Detection if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact { return verticalSizeClass == .compact ? 3 : 2 } else if UIDevice.current.userInterfaceIdiom == .pad { - // Normal iPad layout let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait } else { - // iPhone layout return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } } struct ContinueWatchingSection: View { - @Binding - var items: [ContinueWatchingItem] + @Binding var items: [ContinueWatchingItem] var markAsWatched: (ContinueWatchingItem) -> Void var removeItem: (ContinueWatchingItem) -> Void - + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { - ForEach(Array(items.reversed().prefix(5))) { - item in - ContinueWatchingCell(item: item, markAsWatched: { - markAsWatched(item) - }, removeItem: { - removeItem(item) - }) - } + ForEach(Array(items.reversed().prefix(5))) { item in + ContinueWatchingCell(item: item, markAsWatched: { + markAsWatched(item) + }, removeItem: { + removeItem(item) + }) } - .padding(.horizontal, 20) + } + .padding(.horizontal, 20) + .frame(height: 157.03) } } } @@ -243,160 +236,158 @@ struct ContinueWatchingCell: View { let item: ContinueWatchingItem var markAsWatched: () -> Void var removeItem: () -> Void - + @State private var currentProgress: Double = 0.0 - + var body: some View { Button(action: { - if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" { - let videoPlayerViewController = VideoPlayerViewController(module: item.module) - videoPlayerViewController.streamUrl = item.streamUrl - videoPlayerViewController.fullUrl = item.fullUrl - videoPlayerViewController.episodeImageUrl = item.imageUrl - videoPlayerViewController.episodeNumber = item.episodeNumber - videoPlayerViewController.mediaTitle = item.mediaTitle - videoPlayerViewController.subtitles = item.subtitles ?? "" - videoPlayerViewController.aniListID = item.aniListID ?? 0 - videoPlayerViewController.modalPresentationStyle = .fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) - } - } else { - let customMediaPlayer = CustomMediaPlayerViewController( - module: item.module, - urlString: item.streamUrl, - fullUrl: item.fullUrl, - title: item.mediaTitle, - episodeNumber: item.episodeNumber, - onWatchNext: { }, - subtitlesURL: item.subtitles, - aniListID: item.aniListID ?? 0, - totalEpisodes: item.totalEpisodes, - episodeImageUrl: item.imageUrl, - headers: item.headers ?? nil - ) - customMediaPlayer.modalPresentationStyle = .fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) - } + if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" { + let videoPlayerViewController = VideoPlayerViewController(module: item.module) + videoPlayerViewController.streamUrl = item.streamUrl + videoPlayerViewController.fullUrl = item.fullUrl + videoPlayerViewController.episodeImageUrl = item.imageUrl + videoPlayerViewController.episodeNumber = item.episodeNumber + videoPlayerViewController.mediaTitle = item.mediaTitle + videoPlayerViewController.subtitles = item.subtitles ?? "" + videoPlayerViewController.aniListID = item.aniListID ?? 0 + videoPlayerViewController.modalPresentationStyle = .fullScreen + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) } - }) { - ZStack(alignment: .bottomLeading) { - KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl)) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 280, height: 157.03) - .shimmering() - } - .resizable() - .aspectRatio(contentMode: .fill) + } else { + let customMediaPlayer = CustomMediaPlayerViewController( + module: item.module, + urlString: item.streamUrl, + fullUrl: item.fullUrl, + title: item.mediaTitle, + episodeNumber: item.episodeNumber, + onWatchNext: { }, + subtitlesURL: item.subtitles, + aniListID: item.aniListID ?? 0, + totalEpisodes: item.totalEpisodes, + episodeImageUrl: item.imageUrl, + headers: item.headers ?? nil + ) + customMediaPlayer.modalPresentationStyle = .fullScreen + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) + } + } + }) { + ZStack(alignment: .bottomLeading) { + KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) .frame(width: 280, height: 157.03) - .cornerRadius(10) - .clipped() - .overlay( - ZStack { - ProgressiveBlurView() - .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) - - VStack(alignment: .leading, spacing: 4) { - Spacer() - Text(item.mediaTitle) - .font(.headline) - .foregroundColor(.white) - .lineLimit(1) - - HStack { - Text("Episode \(item.episodeNumber)") - .font(.subheadline) - .foregroundColor(.white.opacity(0.9)) - - Spacer() - - Text("\(Int(item.progress * 100))% seen") - .font(.caption) - .foregroundColor(.white.opacity(0.9)) - } - } - .padding(10) - .background( - LinearGradient( - colors: [ - .black.opacity(0.7), - .black.opacity(0.0) - ], - startPoint: .bottom, - endPoint: .top - ) - .clipped() - .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) - .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1) - ) - }, - alignment: .bottom - ) - .overlay( - ZStack { - if item.streamUrl.hasPrefix("file://") { - Image(systemName: "arrow.down.app.fill") - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(.white) - .background(Color.black.cornerRadius(6)) - .padding(8) - } else { - Circle() - .fill(Color.black.opacity(0.5)) - .frame(width: 28, height: 28) - .overlay( - KFImage(URL(string: item.module.metadata.iconUrl)) - .resizable() - .scaledToFill() - .frame(width: 32, height: 32) - .clipShape(Circle()) - ) - .padding(8) - } - }, - alignment: .topLeading - ) + .shimmering() } + .resizable() + .aspectRatio(16/9, contentMode: .fill) .frame(width: 280, height: 157.03) + .cornerRadius(10) + .clipped() + .overlay( + ZStack { + ProgressiveBlurView() + .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) + + VStack(alignment: .leading, spacing: 4) { + Spacer() + Text(item.mediaTitle) + .font(.headline) + .foregroundColor(.white) + .lineLimit(1) + + HStack { + Text("Episode \(item.episodeNumber)") + .font(.subheadline) + .foregroundColor(.white.opacity(0.9)) + + Spacer() + + Text("\(Int(item.progress * 100))% seen") + .font(.caption) + .foregroundColor(.white.opacity(0.9)) + } + } + .padding(10) + .background( + LinearGradient( + colors: [ + .black.opacity(0.7), + .black.opacity(0.0) + ], + startPoint: .bottom, + endPoint: .top + ) + .clipped() + .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) + .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 1) + ) + }, + alignment: .bottom + ) + .overlay( + ZStack { + if item.streamUrl.hasPrefix("file://") { + Image(systemName: "arrow.down.app.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(.white) + .background(Color.black.cornerRadius(6)) + .padding(8) + } else { + Circle() + .fill(Color.black.opacity(0.5)) + .frame(width: 28, height: 28) + .overlay( + KFImage(URL(string: item.module.metadata.iconUrl)) + .resizable() + .scaledToFill() + .frame(width: 32, height: 32) + .clipShape(Circle()) + ) + .padding(8) + } + }, + alignment: .topLeading + ) } - .contextMenu { - Button(action: { - markAsWatched() - }) { - Label("Mark as Watched", systemImage: "checkmark.circle") - } - Button(role: .destructive, action: { - removeItem() - }) { - Label("Remove Item", systemImage: "trash") - } + .frame(width: 280, height: 157.03) + } + .contextMenu { + Button(action: { + markAsWatched() + }) { + Label("Mark as Watched", systemImage: "checkmark.circle") } - .onAppear { + Button(role: .destructive, action: { + removeItem() + }) { + Label("Remove Item", systemImage: "trash") + } + } + .onAppear { + updateProgress() + } + .onReceive(NotificationCenter.default.publisher( + for: UIApplication.didBecomeActiveNotification)) { + _ in updateProgress() } - .onReceive(NotificationCenter.default.publisher( - for: UIApplication.didBecomeActiveNotification)) { - _ in - updateProgress() - } } - + private func updateProgress() { - // grab the true playback times let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)") let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") - // compute a clean 0…1 ratio let ratio: Double if totalTime > 0 { ratio = min(max(lastPlayed / totalTime, 0), 1) @@ -406,10 +397,8 @@ struct ContinueWatchingCell: View { currentProgress = ratio if ratio >= 0.9 { - // >90% watched? drop it immediately removeItem() } else { - // otherwise persist the latest progress var updated = item updated.progress = ratio ContinueWatchingManager.shared.save(item: updated) @@ -421,7 +410,7 @@ struct ContinueWatchingCell: View { struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - + func path( in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, @@ -436,21 +425,21 @@ extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } - + func gradientOutline() -> some View { self.background( RoundedRectangle(cornerRadius: 15) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.25), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) ) } } @@ -458,10 +447,10 @@ extension View { struct BookmarksSection: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - + @Binding var selectedBookmark: LibraryItem? @Binding var isDetailActive: Bool - + var body: some View { VStack(alignment: .leading, spacing: 16) { if libraryManager.bookmarks.isEmpty { @@ -479,45 +468,44 @@ struct BookmarksSection: View { struct EmptyBookmarksView: View { var body: some View { VStack(spacing: 8) { - Image(systemName: "magazine") - .font(.largeTitle) - .foregroundColor(.secondary) - Text("You have no items saved.") - .font(.headline) - Text("Bookmark items for an easier access later.") - .font(.caption) - .foregroundColor(.secondary) - } - .padding() - .frame(maxWidth: .infinity) + Image(systemName: "magazine") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("You have no items saved.") + .font(.headline) + Text("Bookmark items for an easier access later.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity) } } struct BookmarksGridView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - + @Binding var selectedBookmark: LibraryItem? @Binding var isDetailActive: Bool - - private - var recentBookmarks: [LibraryItem] { + + private var recentBookmarks: [LibraryItem] { Array(libraryManager.bookmarks.prefix(5)) } - + var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { - ForEach(recentBookmarks) { - item in - BookmarkItemView( - item: item, - selectedBookmark: $selectedBookmark, - isDetailActive: $isDetailActive - ) - } + ForEach(recentBookmarks) { item in + BookmarkItemView( + item: item, + selectedBookmark: $selectedBookmark, + isDetailActive: $isDetailActive + ) } - .padding(.horizontal, 20) + } + .padding(.horizontal, 20) + .frame(height: 243) } } } @@ -525,79 +513,77 @@ struct BookmarksGridView: View { struct BookmarkItemView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager - + let item: LibraryItem @Binding var selectedBookmark: LibraryItem? @Binding var isDetailActive: Bool - + var body: some View { - if let module = moduleManager.modules.first(where: { - $0.id.uuidString == item.moduleId - }) { + if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) { Button(action: { - selectedBookmark = item - isDetailActive = true - }) { - ZStack { - KFImage(URL(string: item.imageUrl)) - .placeholder { - RoundedRectangle(cornerRadius: 12) - .fill(Color.gray.opacity(0.3)) - .aspectRatio(2 / 3, contentMode: .fit) - .shimmering() - } - .resizable() - .aspectRatio(0.72, contentMode: .fill) - .frame(width: 162, height: 243) - .cornerRadius(12) - .clipped() - .overlay( - ZStack { - Circle() - .fill(Color.black.opacity(0.5)) - .frame(width: 28, height: 28) - .overlay( - KFImage(URL(string: module.metadata.iconUrl)) + selectedBookmark = item + isDetailActive = true + }) { + ZStack { + KFImage(URL(string: item.imageUrl)) + .placeholder { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.3)) + .aspectRatio(2 / 3, contentMode: .fit) + .shimmering() + } + .resizable() + .aspectRatio(0.72, contentMode: .fill) + .frame(width: 162, height: 243) + .cornerRadius(12) + .clipped() + .overlay( + ZStack { + Circle() + .fill(Color.black.opacity(0.5)) + .frame(width: 28, height: 28) + .overlay( + KFImage(URL(string: module.metadata.iconUrl)) .resizable() .scaledToFill() .frame(width: 32, height: 32) .clipShape(Circle()) - ) - } - .padding(8), - alignment: .topLeading - ) - - VStack { - Spacer() - Text(item.title) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(2) - .foregroundColor(.white) - .padding(12) - .background( - LinearGradient( - colors: [ - .black.opacity(0.7), - .black.opacity(0.0) - ], - startPoint: .bottom, - endPoint: .top ) - .shadow(color: .black, radius: 4, x: 0, y: 2) + } + .padding(8), + alignment: .topLeading + ) + + VStack { + Spacer() + Text(item.title) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(2) + .foregroundColor(.white) + .padding(12) + .background( + LinearGradient( + colors: [ + .black.opacity(0.7), + .black.opacity(0.0) + ], + startPoint: .bottom, + endPoint: .top ) - } - .frame(width: 162) - } - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .contextMenu { - Button(role: .destructive, action: { - libraryManager.removeBookmark(item: item) - }) { - Label("Remove from Bookmarks", systemImage: "trash") + .shadow(color: .black, radius: 4, x: 0, y: 2) + ) } } + .frame(width: 162, height: 243) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .contextMenu { + Button(role: .destructive, action: { + libraryManager.removeBookmark(item: item) + }) { + Label("Remove from Bookmarks", systemImage: "trash") + } + } } } }