mirror of
https://github.com/cranci1/Sora.git
synced 2026-07-26 14:32:21 +00:00
684 lines
28 KiB
Swift
684 lines
28 KiB
Swift
//
|
|
// LibraryView.swift
|
|
// Sora
|
|
//
|
|
// Created by Francesco on 05/01/25.
|
|
//
|
|
|
|
import UIKit
|
|
import NukeUI
|
|
import SwiftUI
|
|
|
|
struct LibraryView: View {
|
|
@EnvironmentObject private var libraryManager: LibraryManager
|
|
@EnvironmentObject private var moduleManager: ModuleManager
|
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
|
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
|
@AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = {
|
|
try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"])
|
|
}()
|
|
@AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = {
|
|
try! JSONEncoder().encode([String]())
|
|
}()
|
|
|
|
@Environment(\.verticalSizeClass) var verticalSizeClass
|
|
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
|
|
|
@State private var continueWatchingItems: [ContinueWatchingItem] = []
|
|
@State private var continueReadingItems: [ContinueReadingItem] = []
|
|
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
|
|
@State private var selectedTab: Int = 0
|
|
@State private var isActive: Bool = false
|
|
|
|
private var librarySectionsOrder: [String] {
|
|
(try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"]
|
|
}
|
|
|
|
private var disabledLibrarySections: [String] {
|
|
(try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? []
|
|
}
|
|
|
|
private let columns = [
|
|
GridItem(.adaptive(minimum: 150), spacing: 12)
|
|
]
|
|
|
|
private var columnsCount: Int {
|
|
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
|
return verticalSizeClass == .compact ? 3 : 2
|
|
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
|
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
|
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
|
} else {
|
|
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
|
}
|
|
}
|
|
|
|
private var cellWidth: CGFloat {
|
|
let keyWindow = UIApplication.shared.connectedScenes
|
|
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
|
|
.first
|
|
let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero
|
|
let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right
|
|
let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1)
|
|
let availableWidth = safeWidth - totalSpacing
|
|
return availableWidth / CGFloat(columnsCount)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ZStack {
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text(LocalizedStringKey("Library"))
|
|
.font(.largeTitle)
|
|
.fontWeight(.bold)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 20)
|
|
|
|
ForEach(librarySectionsOrder, id: \.self) { section in
|
|
if !disabledLibrarySections.contains(section) {
|
|
switch section {
|
|
case "continueWatching":
|
|
continueWatchingSection
|
|
case "continueReading":
|
|
continueReadingSection
|
|
case "collections":
|
|
collectionsSection
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer().frame(height: 100)
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
.scrollViewBottomPadding()
|
|
.deviceScaled()
|
|
.onAppear {
|
|
isActive = true
|
|
fetchContinueWatching()
|
|
fetchContinueReading()
|
|
|
|
let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive")
|
|
let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive")
|
|
if !isMediaInfoActive && !isReaderActive {
|
|
NotificationCenter.default.post(name: .showTabBar, object: nil)
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
|
let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive")
|
|
let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive")
|
|
if !isMediaInfoActive && !isReaderActive {
|
|
NotificationCenter.default.post(name: .showTabBar, object: nil)
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
isActive = false
|
|
}
|
|
.onChange(of: scenePhase) { newPhase in
|
|
if newPhase == .active {
|
|
fetchContinueWatching()
|
|
fetchContinueReading()
|
|
|
|
let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive")
|
|
let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive")
|
|
if !isMediaInfoActive && !isReaderActive {
|
|
NotificationCenter.default.post(name: .showTabBar, object: nil)
|
|
}
|
|
}
|
|
}
|
|
.onReceive(Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()) { _ in
|
|
let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive")
|
|
let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive")
|
|
if isActive && !isMediaInfoActive && !isReaderActive {
|
|
NotificationCenter.default.post(name: .showTabBar, object: nil)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
|
isActive = true
|
|
let isMediaInfoActive = UserDefaults.standard.bool(forKey: "isMediaInfoActive")
|
|
let isReaderActive = UserDefaults.standard.bool(forKey: "isReaderActive")
|
|
if !isMediaInfoActive && !isReaderActive {
|
|
NotificationCenter.default.post(name: .showTabBar, object: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationViewStyle(StackNavigationViewStyle())
|
|
|
|
}
|
|
|
|
// MARK: - Section Views
|
|
|
|
private var continueWatchingSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "play.fill")
|
|
.font(.subheadline)
|
|
Text(LocalizedStringKey("Continue Watching"))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
NavigationLink(destination: AllWatchingView()) {
|
|
Text(LocalizedStringKey("View All"))
|
|
.font(.subheadline)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.gray.opacity(0.2))
|
|
.cornerRadius(15)
|
|
.gradientOutline()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 10)
|
|
|
|
if continueWatchingItems.isEmpty {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "play.circle")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.secondary)
|
|
Text(LocalizedStringKey("Nothing to Continue Watching"))
|
|
.font(.headline)
|
|
Text(LocalizedStringKey("Your 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)
|
|
})
|
|
}
|
|
|
|
Spacer().frame(height: 20)
|
|
}
|
|
}
|
|
|
|
private var continueReadingSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "book.fill")
|
|
.font(.subheadline)
|
|
Text(LocalizedStringKey("Continue Reading"))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
NavigationLink(destination: AllReadingView()) {
|
|
Text(LocalizedStringKey("View All"))
|
|
.font(.subheadline)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.gray.opacity(0.2))
|
|
.cornerRadius(15)
|
|
.gradientOutline()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 10)
|
|
|
|
if continueReadingItems.isEmpty {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "book.closed")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.secondary)
|
|
Text(LocalizedStringKey("Nothing to Continue Reading"))
|
|
.font(.headline)
|
|
Text(LocalizedStringKey("Your recently read novels will appear here"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
ContinueReadingSection(items: $continueReadingItems, markAsRead: {
|
|
item in
|
|
markContinueReadingItemAsRead(item: item)
|
|
}, removeItem: {
|
|
item in
|
|
removeContinueReadingItem(item: item)
|
|
})
|
|
}
|
|
|
|
Spacer().frame(height: 20)
|
|
}
|
|
}
|
|
|
|
private var collectionsSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "folder.fill")
|
|
.font(.subheadline)
|
|
Text(LocalizedStringKey("Collections"))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
NavigationLink(destination: BookmarksDetailView()) {
|
|
Text(LocalizedStringKey("View All"))
|
|
.font(.subheadline)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.gray.opacity(0.2))
|
|
.cornerRadius(15)
|
|
.gradientOutline()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 10)
|
|
|
|
BookmarksSection()
|
|
|
|
Spacer().frame(height: 20)
|
|
}
|
|
}
|
|
|
|
private func fetchContinueWatching() {
|
|
continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
|
|
}
|
|
|
|
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
|
|
let key = "lastPlayedTime_\(item.fullUrl)"
|
|
let totalKey = "totalTime_\(item.fullUrl)"
|
|
UserDefaults.standard.set(99999999.0, forKey: key)
|
|
UserDefaults.standard.set(99999999.0, forKey: totalKey)
|
|
ContinueWatchingManager.shared.remove(item: item)
|
|
continueWatchingItems.removeAll {
|
|
$0.id == item.id
|
|
}
|
|
}
|
|
|
|
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
|
|
ContinueWatchingManager.shared.remove(item: item)
|
|
continueWatchingItems.removeAll {
|
|
$0.id == item.id
|
|
}
|
|
}
|
|
|
|
private func fetchContinueReading() {
|
|
continueReadingItems = ContinueReadingManager.shared.fetchItems()
|
|
Logger.shared.log("Fetched \(continueReadingItems.count) continue reading items", type: "Debug")
|
|
|
|
if !continueReadingItems.isEmpty {
|
|
for (index, item) in continueReadingItems.enumerated() {
|
|
Logger.shared.log("Reading item \(index): \(item.mediaTitle), chapter \(item.chapterNumber), progress \(item.progress)", type: "Debug")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func markContinueReadingItemAsRead(item: ContinueReadingItem) {
|
|
UserDefaults.standard.set(1.0, forKey: "readingProgress_\(item.href)")
|
|
ContinueReadingManager.shared.updateProgress(for: item.href, progress: 1.0)
|
|
fetchContinueReading()
|
|
}
|
|
|
|
private func removeContinueReadingItem(item: ContinueReadingItem) {
|
|
ContinueReadingManager.shared.remove(item: item)
|
|
continueReadingItems.removeAll {
|
|
$0.id == item.id
|
|
}
|
|
}
|
|
|
|
private func updateOrientation() {
|
|
DispatchQueue.main.async {
|
|
isLandscape = UIDevice.current.orientation.isLandscape
|
|
}
|
|
}
|
|
|
|
private func determineColumns() -> Int {
|
|
if UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact {
|
|
return verticalSizeClass == .compact ? 3 : 2
|
|
} else if UIDevice.current.userInterfaceIdiom == .pad {
|
|
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
|
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
|
|
} else {
|
|
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContinueWatchingSection: View {
|
|
@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)
|
|
})
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.frame(height: 157.03)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContinueWatchingCell: View {
|
|
let item: ContinueWatchingItem
|
|
var markAsWatched: () -> Void
|
|
var removeItem: () -> Void
|
|
|
|
@State private var currentProgress: Double = 0.0
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
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,
|
|
episodeTitle: item.episodeTitle ?? "",
|
|
seasonNumber: item.seasonNumber ?? 1,
|
|
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) {
|
|
LazyImage(url: URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl)) { state in
|
|
if let uiImage = state.imageContainer?.image {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.aspectRatio(16/9, contentMode: .fill)
|
|
.frame(width: 280, height: 157.03)
|
|
.cornerRadius(10)
|
|
.clipped()
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.gray.opacity(0.3))
|
|
.frame(width: 280, height: 157.03)
|
|
.redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
.overlay(
|
|
ZStack {
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Spacer()
|
|
Text(item.mediaTitle)
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
|
|
HStack {
|
|
Text(episodeLabel(for: item))
|
|
.font(.subheadline)
|
|
.foregroundColor(.white.opacity(0.9))
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
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(
|
|
LazyImage(url: URL(string: item.module.metadata.iconUrl)) { state in
|
|
if let uiImage = state.imageContainer?.image {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
.frame(width: 32, height: 32)
|
|
.clipShape(Circle())
|
|
} else {
|
|
Circle()
|
|
.fill(Color.gray.opacity(0.3))
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
}
|
|
)
|
|
.padding(8)
|
|
}
|
|
},
|
|
alignment: .topLeading
|
|
)
|
|
}
|
|
.frame(width: 280, height: 157.03)
|
|
}
|
|
.contextMenu {
|
|
Button(action: {
|
|
markAsWatched()
|
|
}) {
|
|
Label("Mark as Watched", systemImage: "checkmark.circle")
|
|
}
|
|
Button(role: .destructive, action: {
|
|
removeItem()
|
|
}) {
|
|
Label("Remove Item", systemImage: "trash")
|
|
}
|
|
}
|
|
.onAppear {
|
|
updateProgress()
|
|
}
|
|
.onChange(of: scenePhase) { newPhase in
|
|
if newPhase == .active {
|
|
updateProgress()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateProgress() {
|
|
let lastPlayed = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
|
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
|
|
|
let ratio: Double
|
|
if totalTime > 0 {
|
|
ratio = min(max(lastPlayed / totalTime, 0), 1)
|
|
} else {
|
|
ratio = min(max(item.progress, 0), 1)
|
|
}
|
|
currentProgress = ratio
|
|
|
|
if ratio >= 0.9 {
|
|
removeItem()
|
|
} else {
|
|
var updated = item
|
|
updated.progress = ratio
|
|
ContinueWatchingManager.shared.save(item: updated)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func episodeLabel(for item: ContinueWatchingItem) -> String {
|
|
let hasTitle = !(item.episodeTitle?.isEmpty ?? true)
|
|
let isSingleSeason = (item.seasonNumber ?? 1) <= 1
|
|
let episodePart = "E\(item.episodeNumber)"
|
|
let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)"
|
|
let colon = hasTitle ? ":" : ""
|
|
let title = item.episodeTitle ?? ""
|
|
let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined()
|
|
return hasTitle ? "\(main)\(colon) \(title)" : main
|
|
}
|
|
|
|
struct RoundedCorner: Shape {
|
|
var radius: CGFloat = .infinity
|
|
var corners: UIRectCorner = .allCorners
|
|
|
|
func path( in rect: CGRect) -> Path {
|
|
let path = UIBezierPath(
|
|
roundedRect: rect,
|
|
byRoundingCorners: corners,
|
|
cornerRadii: CGSize(width: radius, height: radius)
|
|
)
|
|
return Path(path.cgPath)
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct BookmarksSection: View {
|
|
@EnvironmentObject private var libraryManager: LibraryManager
|
|
@EnvironmentObject private var moduleManager: ModuleManager
|
|
@State private var isShowingRenamePrompt: Bool = false
|
|
@State private var collectionToRename: BookmarkCollection? = nil
|
|
@State private var renameCollectionName: String = ""
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if libraryManager.collections.isEmpty {
|
|
EmptyBookmarksView()
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 12) {
|
|
ForEach(Array(libraryManager.collections.prefix(5))) { collection in
|
|
NavigationLink(destination: CollectionDetailView(collection: collection)) {
|
|
BookmarkCollectionGridCell(collection: collection, width: 162, height: 162)
|
|
}
|
|
.contextMenu {
|
|
Button("Rename") {
|
|
collectionToRename = collection
|
|
renameCollectionName = collection.name
|
|
isShowingRenamePrompt = true
|
|
}
|
|
Button(role: .destructive) {
|
|
libraryManager.deleteCollection(id: collection.id)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.frame(height: 220)
|
|
}
|
|
}
|
|
}
|
|
.alert("Rename Collection", isPresented: $isShowingRenamePrompt, presenting: collectionToRename) { collection in
|
|
TextField("Collection Name", text: $renameCollectionName)
|
|
Button("Cancel", role: .cancel) {
|
|
collectionToRename = nil
|
|
renameCollectionName = ""
|
|
}
|
|
Button("Rename") {
|
|
if !renameCollectionName.isEmpty {
|
|
libraryManager.renameCollection(id: collection.id, newName: renameCollectionName)
|
|
}
|
|
collectionToRename = nil
|
|
renameCollectionName = ""
|
|
}
|
|
} message: { _ in EmptyView() }
|
|
}
|
|
}
|
|
|
|
struct EmptyBookmarksView: View {
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "folder")
|
|
.font(.largeTitle)
|
|
.foregroundColor(.secondary)
|
|
Text("No Collections")
|
|
.font(.headline)
|
|
Text("Create a collection to organize your bookmarks")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|