diff options
Diffstat (limited to 'Sora')
| -rw-r--r-- | Sora/Data/Booru/Post/BooruPostFileType.swift | 2 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsFavoritePost.swift | 95 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsManager.swift | 248 | ||||
| -rw-r--r-- | Sora/Data/Settings/SettingsSyncKey.swift | 2 | ||||
| -rw-r--r-- | Sora/Extensions/SettingsFavoritePost+Date.swift | 5 | ||||
| -rw-r--r-- | Sora/Views/FavoriteMenuButtonView.swift | 112 | ||||
| -rw-r--r-- | Sora/Views/FavoritePostThumbnailView.swift | 91 | ||||
| -rw-r--r-- | Sora/Views/FavoritesView.swift | 567 | ||||
| -rw-r--r-- | Sora/Views/MainView.swift | 20 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/PostDetailsView.swift | 26 | ||||
| -rw-r--r-- | Sora/Views/Post/Grid/PostGridFavoriteButtonView.swift | 45 |
11 files changed, 1198 insertions, 15 deletions
diff --git a/Sora/Data/Booru/Post/BooruPostFileType.swift b/Sora/Data/Booru/Post/BooruPostFileType.swift index f1f98b7..7baa921 100644 --- a/Sora/Data/Booru/Post/BooruPostFileType.swift +++ b/Sora/Data/Booru/Post/BooruPostFileType.swift @@ -1,4 +1,4 @@ -enum BooruPostFileType: String, CaseIterable { +enum BooruPostFileType: String, CaseIterable, Codable { case original = "Original" case preview = "Preview" case sample = "Sample" diff --git a/Sora/Data/Settings/SettingsFavoritePost.swift b/Sora/Data/Settings/SettingsFavoritePost.swift new file mode 100644 index 0000000..f0c5126 --- /dev/null +++ b/Sora/Data/Settings/SettingsFavoritePost.swift @@ -0,0 +1,95 @@ +import Foundation + +struct SettingsFavoritePost: Codable, Identifiable, Hashable { + let id: UUID + let postId: String + let tags: [String] + let createdAt: Date + let provider: BooruProvider + var folder: UUID? + var lastVisit: Date + var visitedCount: Int + let thumbnailUrl: String? + let previewUrl: String? + let fileUrl: String? + let fileType: BooruPostFileType + let rating: BooruRating + let width: Int + let height: Int + let fileSize: Int? + + init( + post: BooruPost, + provider: BooruProvider, + folder: UUID? = nil, + id: UUID = UUID() + ) { + self.createdAt = Date() + self.lastVisit = Date() + self.id = id + self.postId = post.id + self.tags = post.tags + self.provider = provider + self.folder = folder + self.visitedCount = 0 + self.thumbnailUrl = post.previewURL.absoluteString + self.previewUrl = post.previewURL.absoluteString + self.fileUrl = post.fileURL.absoluteString + self.fileType = .preview + self.rating = post.rating + self.width = post.width + self.height = post.height + self.fileSize = nil + } + + enum CodingKeys: String, CodingKey { + // swiftlint:disable:next explicit_enum_raw_value + case id, postId, tags, createdAt, provider, folder, lastVisit, visitedCount, thumbnailUrl, + // swiftlint:disable:next explicit_enum_raw_value + previewUrl, fileUrl, fileType, rating, width, height, fileSize + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.postId = try container.decode(String.self, forKey: .postId) + self.tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() + self.provider = + try container.decodeIfPresent(BooruProvider.self, forKey: .provider) ?? .safebooru + self.folder = try container.decodeIfPresent(UUID.self, forKey: .folder) + self.lastVisit = try container.decodeIfPresent(Date.self, forKey: .lastVisit) ?? Date() + self.visitedCount = try container.decodeIfPresent(Int.self, forKey: .visitedCount) ?? 0 + self.thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl) + self.previewUrl = try container.decodeIfPresent(String.self, forKey: .previewUrl) + self.fileUrl = try container.decodeIfPresent(String.self, forKey: .fileUrl) + self.fileType = + try container.decodeIfPresent(BooruPostFileType.self, forKey: .fileType) ?? .preview + self.rating = try container.decodeIfPresent(BooruRating.self, forKey: .rating) ?? .questionable + self.width = try container.decodeIfPresent(Int.self, forKey: .width) ?? 0 + self.height = try container.decodeIfPresent(Int.self, forKey: .height) ?? 0 + self.fileSize = try container.decodeIfPresent(Int.self, forKey: .fileSize) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(postId, forKey: .postId) + try container.encode(tags, forKey: .tags) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(provider, forKey: .provider) + try container.encodeIfPresent(folder, forKey: .folder) + try container.encode(lastVisit, forKey: .lastVisit) + try container.encode(visitedCount, forKey: .visitedCount) + try container.encodeIfPresent(thumbnailUrl, forKey: .thumbnailUrl) + try container.encodeIfPresent(previewUrl, forKey: .previewUrl) + try container.encodeIfPresent(fileUrl, forKey: .fileUrl) + try container.encode(fileType.rawValue, forKey: .fileType) + try container.encode(rating, forKey: .rating) + try container.encode(width, forKey: .width) + try container.encode(height, forKey: .height) + try container.encodeIfPresent(fileSize, forKey: .fileSize) + } +} diff --git a/Sora/Data/Settings/SettingsManager.swift b/Sora/Data/Settings/SettingsManager.swift index 3fba04e..ffe7057 100644 --- a/Sora/Data/Settings/SettingsManager.swift +++ b/Sora/Data/Settings/SettingsManager.swift @@ -44,6 +44,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l // MARK: - Private Properties private var bookmarksCache: [SettingsBookmark] = [] + private var favoritesCache: [SettingsFavoritePost] = [] private var searchHistoryCache: [BooruSearchQuery] = [] private var blurRatingsCache: [BooruRating] = [] private var displayRatingsCache: [BooruRating] = [] @@ -56,6 +57,9 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l @AppStorage("bookmarks") private var bookmarksData = Data() + @AppStorage("favorites") + private var favoritesData = Data() + @AppStorage("displayRatings") private var displayRatingsData = SettingsManager.encode(BooruRating.allCases) ?? Data() @@ -101,6 +105,29 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l } } + var favorites: [SettingsFavoritePost] { + get { favoritesCache } + + set { + guard !isUpdatingCache else { return } + + isUpdatingCache = true + + defer { isUpdatingCache = false } + + syncableData( + key: "favorites", + localData: $favoritesData, + newValue: newValue + ) { $0.sorted { $0.date > $1.date } } + + favoritesCache = newValue.sorted { $0.date > $1.date } + + pendingSyncKeys.insert(.favorites) + triggerBatchedSync() + } + } + var uniformThumbnailGrid: Bool { get { uniformThumbnailGridCache } @@ -312,6 +339,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l } loadBookmarksCache() + loadFavoritesCache() loadSearchHistoryCache() loadDisplayRatingsCache() loadBlurRatingsCache() @@ -340,6 +368,26 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l triggerBatchedSync() } + func updateFavorites(_ newValue: [SettingsFavoritePost]) async { + guard !isUpdatingCache else { return } + + isUpdatingCache = true + + defer { isUpdatingCache = false } + + syncableData( + key: "favorites", + localData: $favoritesData, + newValue: newValue + ) { $0.sorted { $0.date > $1.date } } + + favoritesCache = newValue.sorted { $0.date > $1.date } + + await backupFavorites() + pendingSyncKeys.insert(.favorites) + triggerBatchedSync() + } + func updateSearchHistory(_ newValue: [BooruSearchQuery]) { guard !isUpdatingCache else { return } @@ -529,6 +577,55 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l }.value } + private func backupFavorites() async { + await Task.detached { + guard + let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first + else { return } + + let backupDirectory = cachesDirectory.appendingPathComponent("favorites_backups") + let fileManager = FileManager.default + + try? fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) + + let timestamp = Int(Date().timeIntervalSince1970) + let backupFile = backupDirectory.appendingPathComponent("favorites_backup_\(timestamp).json") + + if let data = await Self.encode(self.favoritesCache) { + try? data.write(to: backupFile) + } + + if let files = try? fileManager.contentsOfDirectory( + at: backupDirectory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: .skipsHiddenFiles + ) { + let jsonBackups = files.filter { file in + file.lastPathComponent.hasPrefix("favorites_backup_") && file.pathExtension == "json" + } + let sortedBackups = jsonBackups.sorted { firstFile, secondFile in + let firstDate = + (try? firstFile.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) + ?? .distantPast + let secondDate = + (try? secondFile.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate) + ?? .distantPast + + return firstDate > secondDate + } + + if sortedBackups.count > 10 { + for url in sortedBackups[10...] { + try? fileManager.removeItem(at: url) + } + } + } + }.value + } + // swiftlint:disable:next cyclomatic_complexity private func triggerSyncIfNeeded(for key: SettingsSyncKey) { guard enableSync else { return } @@ -626,10 +723,43 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l self.customProvidersData = encoded } } + + case .favorites: + var iCloudFavorites: [SettingsFavoritePost] = [] + + if let iCloudData = NSUbiquitousKeyValueStore.default.data(forKey: "favorites") { + iCloudFavorites = + await Self + .decode([SettingsFavoritePost].self, from: iCloudData) ?? [] + } + + let localFavorites = + await Self.decode([SettingsFavoritePost].self, from: favoritesData) ?? [] + let mergedFavoritesDict = (localFavorites + iCloudFavorites).reduce( + into: [UUID: SettingsFavoritePost]() + ) { dict, favorite in + if let existing = dict[favorite.id] { + if favorite.date > existing.date { + dict[favorite.id] = favorite + } + } else { + dict[favorite.id] = favorite + } + } + let mergedFavorites = Array(mergedFavoritesDict.values).sorted { $0.date > $1.date } + + if let encoded = await Self.encode(mergedFavorites) { + NSUbiquitousKeyValueStore.default.set(encoded, forKey: "favorites") + + await MainActor.run { + self.favoritesData = encoded + } + } } await MainActor.run { self.loadBookmarksCache() + self.loadFavoritesCache() self.loadSearchHistoryCache() self.objectWillChange.send() } @@ -656,6 +786,14 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l ) } + private func loadFavoritesCache() { + loadCache( + from: favoritesData, + sort: { $0.sorted { $0.date > $1.date } }, + assign: { [weak self] in self?.favoritesCache = $0 } + ) + } + private func loadSearchHistoryCache() { loadCache( from: searchHistoryData, @@ -742,6 +880,7 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l func triggerSyncIfNeededForAll() { self.triggerSyncIfNeeded(for: .bookmarks) + self.triggerSyncIfNeeded(for: .favorites) self.triggerSyncIfNeeded(for: .searchHistory) self.triggerSyncIfNeeded(for: .customProviders) } @@ -851,6 +990,115 @@ class SettingsManager: ObservableObject { // swiftlint:disable:this type_body_l triggerBatchedSync() } + // MARK: Favourites Management + func addFavorite(post: BooruPost, provider: BooruProvider, folder: UUID? = nil) { + var updatedFavorites = favorites + + updatedFavorites.append( + SettingsFavoritePost(post: post, provider: provider, folder: folder) + ) + + if let data = Self.encode(updatedFavorites), data.count < 1_000_000 { // 1 MB + Task { + await updateFavorites(updatedFavorites) + } + } else { + debugPrint("SettingsManager.addFavorite: iCloud data limit exceeded") + } + } + + func removeFavorite(at offsets: IndexSet) { + var updated = favorites + + updated.remove(atOffsets: offsets) + + Task { + await updateFavorites(updated) + } + } + + func removeFavorite(withPostId postId: String, provider: BooruProvider) { + let updated = favorites.filter { !($0.postId == postId && $0.provider == provider) } + + Task { + await updateFavorites(updated) + } + } + + func removeFavorite(withID id: UUID) { + let updated = favorites.filter { $0.id != id } + + Task { + await updateFavorites(updated) + } + } + + func isFavorite(postId: String, provider: BooruProvider) -> Bool { + favorites.contains { $0.postId == postId && $0.provider == provider } + } + + func exportFavorites() throws -> Data { + try JSONEncoder().encode(favorites) + } + + func importFavorites(from data: Data) throws { + let importedFavorites = try JSONDecoder().decode([SettingsFavoritePost].self, from: data) + let existingFavoriteIDs = Set(favorites.map(\.id)) + let newFavorites = importedFavorites.filter { !existingFavoriteIDs.contains($0.id) } + var updated = favorites + + updated.append(contentsOf: newFavorites) + + Task { + await updateFavorites(updated) + } + } + + func updateFavoriteFolder(withID id: UUID, folder: UUID?) { + guard let index = favorites.firstIndex(where: { $0.id == id }) else { return } + + var updated = favorites + + updated[index].folder = folder + + Task { + await updateFavorites(updated) + } + + pendingSyncKeys.insert(.favorites) + triggerBatchedSync() + } + + func updateFavoriteLastVisit(withID id: UUID, date: Date = Date()) { + guard let index = favorites.firstIndex(where: { $0.id == id }) else { return } + + var updated = favorites + + updated[index].lastVisit = date + + Task { + await updateFavorites(updated) + } + + pendingSyncKeys.insert(.favorites) + triggerBatchedSync() + } + + func incrementFavoriteVisitCount(withID id: UUID) { + guard let index = favorites.firstIndex(where: { $0.id == id }) else { return } + + var updated = favorites + + updated[index].visitedCount += 1 + + Task { + await updateFavorites(updated) + } + + pendingSyncKeys.insert(.favorites) + triggerBatchedSync() + } + func folderName(forID id: UUID) -> String? { folders.first { $0.id == id }?.name } diff --git a/Sora/Data/Settings/SettingsSyncKey.swift b/Sora/Data/Settings/SettingsSyncKey.swift index 75c6afe..1cfbd1d 100644 --- a/Sora/Data/Settings/SettingsSyncKey.swift +++ b/Sora/Data/Settings/SettingsSyncKey.swift @@ -1,3 +1,3 @@ enum SettingsSyncKey { - case bookmarks, customProviders, searchHistory + case bookmarks, customProviders, favorites, searchHistory } diff --git a/Sora/Extensions/SettingsFavoritePost+Date.swift b/Sora/Extensions/SettingsFavoritePost+Date.swift new file mode 100644 index 0000000..9f21af3 --- /dev/null +++ b/Sora/Extensions/SettingsFavoritePost+Date.swift @@ -0,0 +1,5 @@ +import SwiftUI + +extension SettingsFavoritePost: GenericItem { + var date: Date { createdAt } +} diff --git a/Sora/Views/FavoriteMenuButtonView.swift b/Sora/Views/FavoriteMenuButtonView.swift new file mode 100644 index 0000000..6fee55a --- /dev/null +++ b/Sora/Views/FavoriteMenuButtonView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct FavoriteMenuButtonView: View { + @EnvironmentObject var settings: SettingsManager + @EnvironmentObject var manager: BooruManager + let post: BooruPost + var disableNewCollection = false + @State private var isNewCollectionAlertPresented = false + @State private var newCollectionName = "" + @State private var itemPendingCollectionAssignment: UUID? + @State private var isCollectionErrorAlertPresented = false + + var body: some View { + let isFavorited = settings.isFavorite(postId: post.id, provider: manager.provider) + + Menu { + Button(action: { + if isFavorited { + settings.removeFavorite(withPostId: post.id, provider: manager.provider) + } else { + settings.addFavorite(post: post, provider: manager.provider) + } + }) { + if isFavorited { + Label("Remove from Favorites", systemImage: "heart.fill") + } else { + Label("Add to Favorites", systemImage: "heart") + } + } + + Menu { + ForEach(settings.folders.filter { $0.topLevelName == nil }, id: \.id) { folder in + Button(action: { + let newFavorite = SettingsFavoritePost( + post: post, provider: manager.provider, folder: folder.id + ) + + settings.favorites.append(newFavorite) + }) { + Label(folder.name, systemImage: "folder") + } + .disabled(isFavoritedInFolder(folderId: folder.id)) + } + + let topLevelFolders = settings.folders + .reduce(into: [String: [SettingsFolder]]()) { result, folder in + guard let topLevelName = folder.topLevelName else { return } + + result[topLevelName, default: []].append(folder) + } + + ForEach(topLevelFolders.keys.sorted(), id: \.self) { topLevelName in + Menu { + ForEach(topLevelFolders[topLevelName] ?? [], id: \.id) { folder in + Button(action: { + let newFavorite = SettingsFavoritePost( + post: post, + provider: manager.provider, + folder: folder.id + ) + + settings.favorites.append(newFavorite) + }) { + Text(folder.shortName) + } + .disabled(isFavoritedInFolder(folderId: folder.id)) + } + } label: { + Text(topLevelName) + } + } + + Button(action: { + isNewCollectionAlertPresented = true + }) { + Label("New Collection", systemImage: "plus") + } + .disabled(disableNewCollection) + } label: { + Label("Favorite to Collection", systemImage: "folder.badge.plus") + } + } label: { + if isFavorited { + Label("Favorited", systemImage: "heart.fill") + } else { + Label("Favorite", systemImage: "heart") + } + } + .collectionAlerts( + isNewCollectionAlertPresented: $isNewCollectionAlertPresented, + newCollectionName: $newCollectionName, + isCollectionErrorAlertPresented: $isCollectionErrorAlertPresented + ) { newCollectionName in + let newFolder = SettingsFolder(name: newCollectionName) + + settings.folders.append(newFolder) + + let newFavorite = SettingsFavoritePost( + post: post, provider: manager.provider, folder: newFolder.id + ) + + settings.favorites.append(newFavorite) + } + } + + private func isFavoritedInFolder(folderId: UUID) -> Bool { + settings.favorites.contains { favorite in + favorite.folder == folderId && favorite.postId == post.id + && favorite.provider == manager.provider + } + } +} diff --git a/Sora/Views/FavoritePostThumbnailView.swift b/Sora/Views/FavoritePostThumbnailView.swift new file mode 100644 index 0000000..661f34a --- /dev/null +++ b/Sora/Views/FavoritePostThumbnailView.swift @@ -0,0 +1,91 @@ +import NetworkImage +import SwiftUI + +struct FavoritePostThumbnailView: View { + @EnvironmentObject var settings: SettingsManager + let favorite: SettingsFavoritePost + let onRemove: () -> Void + + private var thumbnailURL: URL? { + switch settings.thumbnailQuality { + case .preview: + favorite.previewUrl.flatMap(URL.init) + + case .sample: + favorite.previewUrl.flatMap(URL.init) + + case .original: + favorite.fileUrl.flatMap(URL.init) + } + } + + @ViewBuilder + private func primaryImageContent(image: Image) -> some View { + let isFiltered = settings.blurRatings.contains(favorite.rating) + + image + .resizable() + .aspectRatio(contentMode: .fit) + .blur(radius: isFiltered ? 8 : 0) + .clipped() + .animation(.default, value: isFiltered) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + @ViewBuilder + private func imageContent(image: Image) -> some View { + if settings.uniformThumbnailGrid { + GeometryReader { proxy in + primaryImageContent(image: image) + .frame(width: proxy.size.width, height: proxy.size.width) + } + .clipped() + .aspectRatio(1, contentMode: .fit) + } else { + primaryImageContent(image: image) + } + } + + var body: some View { + NetworkImage( + url: thumbnailURL, + transaction: Transaction(animation: .default) + ) { image in + imageContent(image: image) + } placeholder: { + PostGridThumbnailPlaceholderView() + } + } +} + +#Preview { + let sampleFavorite = SettingsFavoritePost( + post: BooruPost( + id: "123", + height: 100, + score: "10", + fileURL: URL(string: "https://example.com/file.jpg")!, + parentID: "0", + sampleURL: URL(string: "https://example.com/sample.jpg")!, + sampleWidth: 100, + sampleHeight: 100, + previewURL: URL(string: "https://example.com/preview.jpg")!, + rating: .safe, + tags: ["sample", "test"], + width: 100, + change: nil, + md5: "abc123", + creatorID: "1", + authorID: nil, + createdAt: Date(), + status: "active", + source: "", + previewWidth: 100, + previewHeight: 100 + ), + provider: .yandere + ) + + FavoritePostThumbnailView(favorite: sampleFavorite) { () } + .environmentObject(SettingsManager()) +} diff --git a/Sora/Views/FavoritesView.swift b/Sora/Views/FavoritesView.swift new file mode 100644 index 0000000..27726a7 --- /dev/null +++ b/Sora/Views/FavoritesView.swift @@ -0,0 +1,567 @@ +// swiftlint:disable file_length + +import SwiftUI +import WaterfallGrid + +struct FavoritesView: View { // swiftlint:disable:this type_body_length + @EnvironmentObject var settings: SettingsManager + @EnvironmentObject var manager: BooruManager + @Binding var selectedTab: Int + @State private var searchText: String = "" + @State private var isShowingRemoveAllConfirmation = false + @Binding var isPresented: Bool + @State private var isNewCollectionAlertPresented = false + @State private var newCollectionName = "" + @State private var itemPendingCollectionAssignment: UUID? + @State private var isCollectionErrorAlertPresented = false + @State private var selectedCollectionOption: CollectionPickerOption = .all + @State private var sort: SettingsBookmarkSort = .dateAdded + @State private var isCollectionPickerPresented = false + @State private var isProviderPickerPresented = false + @State private var selectedProvider: BooruProvider? + @State private var navigationPath = NavigationPath() + @State private var isDetailsSheetPresented = false + @State private var selectedFavoriteForDetails: SettingsFavoritePost? + + var filteredFavorites: [SettingsFavoritePost] { + settings.favorites.filter { favorite in + let matchesFolder: Bool = { + switch selectedCollectionOption { + case .all: + return true + + case .uncategorized: + return favorite.folder == nil + + case .folder(let folderId): + return favorite.folder == folderId + } + }() + let matchesSearch = + searchText.isEmpty + || favorite.tags + .joined(separator: " ") + .lowercased() + .contains(searchText.lowercased()) + let matchesProvider = + selectedProvider == nil + || favorite.provider == selectedProvider + + return matchesFolder && matchesSearch && matchesProvider + } + } + + var sortedFilteredFavorites: [SettingsFavoritePost] { + filteredFavorites.sorted { (lhs: SettingsFavoritePost, rhs: SettingsFavoritePost) in + switch sort { + case .dateAdded: + return lhs.date > rhs.date + + case .lastVisited: + return lhs.lastVisit > rhs.lastVisit + + case .visitCount: + return lhs.visitedCount > rhs.visitedCount + } + } + } + + private func isProviderFavorited(_ provider: BooruProvider) -> Bool { + settings.favorites.contains { $0.provider == provider } + } + + private func isCollectionPopulated(_ folder: UUID?) -> Bool { + settings.favorites.contains { $0.folder == folder } + } + + var body: some View { + NavigationStack(path: $navigationPath) { + VStack(spacing: 0) { + if settings.favorites.isEmpty { + ContentUnavailableView( + "No Favorites", + systemImage: "heart", + description: Text("Tap the heart button on a post to add it to favorites.") + ) + } else { + #if os(macOS) + if !settings.folders.isEmpty { + Picker("Sort", selection: $sort) { + ForEach(SettingsBookmarkSort.allCases, id: \.rawValue) { sortMode in + Text(sortMode.rawValue).tag(sortMode) + } + } + .padding() + .pickerStyle(.menu) + + Picker("Collection", selection: $selectedCollectionOption) { + Text(CollectionPickerOption.all.name(settings: settings)) + .tag(CollectionPickerOption.all) + + if settings.favorites.contains(where: { $0.folder == nil }) { + Text(CollectionPickerOption.uncategorized.name(settings: settings)) + .tag(CollectionPickerOption.uncategorized) + } + + ForEach(settings.folders.filter { $0.topLevelName == nil }, id: \.id) { folder in + Text(folder.name).tag(CollectionPickerOption.folder(folder.id)) + } + + let topLevelFolders = settings.folders + .reduce(into: [String: [SettingsFolder]]()) { result, folder in + guard let topLevelName = folder.topLevelName else { return } + + result[topLevelName, default: []].append(folder) + } + + ForEach(topLevelFolders.keys.sorted(), id: \.self) { topLevelName in + Menu { + ForEach(topLevelFolders[topLevelName] ?? [], id: \.id) { folder in + Button(action: { + selectedCollectionOption = .folder(folder.id) + }) { + Text(folder.shortName) + } + } + } label: { + Text(topLevelName) + } + } + } + .padding(.bottom) + .padding(.horizontal) + .pickerStyle(.menu) + + Picker("Provider", selection: $selectedProvider) { + Text("All").tag(nil as BooruProvider?) + + ForEach(BooruProvider.allCases, id: \.rawValue) { provider in + if isProviderFavorited(provider) { + Text(provider.rawValue).tag(provider) + } + } + } + .padding(.bottom) + .padding(.horizontal) + .pickerStyle(.menu) + } + #endif + + ScrollView { + if filteredFavorites.isEmpty + && (!searchText.isEmpty || !(selectedCollectionOption == .all)) + { + ContentUnavailableView( + "No matching favorites found", + systemImage: "heart.slash", + description: Text("Try adjusting your search or collection filter.") + ) + } else { + gridView(columnCount: settings.thumbnailGridColumns) + } + } + .searchable(text: $searchText) + } + } + } + .navigationTitle("Favorites") + .refreshable { settings.syncFromCloud() } + .toolbar { + #if os(macOS) + ToolbarItem { + Button(action: { + settings.syncFromCloud() + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + #endif + + #if os(macOS) + ToolbarItem { + Button( + role: .destructive, + action: { + isShowingRemoveAllConfirmation = true + } + ) { + Label("Remove All", systemImage: "trash") + } + } + #endif + + #if os(iOS) + ToolbarItemGroup(placement: .secondaryAction) { + Menu { + Picker("Sort By", selection: $sort) { + ForEach(SettingsBookmarkSort.allCases, id: \.rawValue) { sortMode in + Text(sortMode.rawValue).tag(sortMode) + } + } + } label: { + Label("Sort By", systemImage: "arrow.up.arrow.down") + } + + Menu { + Picker("Collection", selection: $selectedCollectionOption) { + Text(CollectionPickerOption.all.name(settings: settings)) + .tag(CollectionPickerOption.all) + + if settings.favorites.contains(where: { $0.folder == nil }) { + Text(CollectionPickerOption.uncategorized.name(settings: settings)) + .tag(CollectionPickerOption.uncategorized) + } + + ForEach(settings.folders.filter { $0.topLevelName == nil }, id: \.id) { folder in + Text(folder.name).tag(CollectionPickerOption.folder(folder.id)) + } + + let topLevelFolders = settings.folders + .reduce(into: [String: [SettingsFolder]]()) { result, folder in + guard let topLevelName = folder.topLevelName else { return } + + result[topLevelName, default: []].append(folder) + } + + ForEach(topLevelFolders.keys.sorted(), id: \.self) { topLevelName in + Menu { + ForEach(topLevelFolders[topLevelName] ?? [], id: \.id) { folder in + Button(action: { + selectedCollectionOption = .folder(folder.id) + }) { + Text(folder.shortName) + } + } + } label: { + Text(topLevelName) + } + } + } + } label: { + Label("Collection", systemImage: "folder") + } + + Menu { + Picker("Provider", selection: $selectedProvider) { + Text("All").tag(nil as BooruProvider?) + + ForEach(BooruProvider.allCases, id: \.rawValue) { provider in + Text(provider.rawValue) + .tag(provider) + .selectionDisabled(!isProviderFavorited(provider)) + } + } + } label: { + Label("Provider", systemImage: "globe") + } + + Button( + role: .destructive, + action: { + isShowingRemoveAllConfirmation = true + } + ) { + Label("Delete All", systemImage: "trash") + } + } + #endif + } + .alert( + "Are you sure you want to remove all favorites? This action cannot be undone.", + isPresented: $isShowingRemoveAllConfirmation + ) { + Button("Remove All Favorites") { + settings.favorites.removeAll() + } + + Button("Cancel", role: .cancel) { () } + } + .collectionAlerts( + isNewCollectionAlertPresented: $isNewCollectionAlertPresented, + newCollectionName: $newCollectionName, + isCollectionErrorAlertPresented: $isCollectionErrorAlertPresented + ) { newCollectionName in + let newFolder = SettingsFolder(name: newCollectionName) + + settings.folders.append(newFolder) + + if let id = itemPendingCollectionAssignment { + settings.updateFavoriteFolder(withID: id, folder: newFolder.id) + } + + itemPendingCollectionAssignment = nil + } + .navigationDestination(for: PostWithContext.self) { context in + PostDetailsView( + post: context.post, + navigationPath: $navigationPath, + posts: context.posts, + baseSearchText: context.baseSearchText + ) + } + .sheet(isPresented: $isDetailsSheetPresented) { + if let favorite = selectedFavoriteForDetails { + NavigationView { + VStack(alignment: .leading, spacing: 16) { + Text("Favorite Details") + .font(.title2) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Tags") + .font(.headline) + Text(favorite.tags.joined(separator: ", ").lowercased()) + .font(.body) + .foregroundStyle(Color.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Favorited") + .font(.headline) + Text(favorite.createdAt.formatted()) + .font(.body) + .foregroundStyle(Color.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Provider") + .font(.headline) + Text(favorite.provider.rawValue) + .font(.body) + .foregroundStyle(Color.secondary) + } + + if let folder = favorite.folder, let folderName = settings.folderName(forID: folder) { + VStack(alignment: .leading, spacing: 4) { + Text("Collection") + .font(.headline) + Text(folderName) + .font(.body) + .foregroundStyle(Color.secondary) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text("Visit Count") + .font(.headline) + Text("\(favorite.visitedCount) times") + .font(.body) + .foregroundStyle(Color.secondary) + } + + if favorite.lastVisit != favorite.createdAt { + VStack(alignment: .leading, spacing: 4) { + Text("Last Visited") + .font(.headline) + Text(favorite.lastVisit.formatted()) + .font(.body) + .foregroundStyle(Color.secondary) + } + } + } + + Spacer() + } + .padding() + .navigationTitle("Details") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + isDetailsSheetPresented = false + } + } + #else + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + isDetailsSheetPresented = false + } + } + #endif + } + } + } + } + } + + @ViewBuilder + private func gridView(columnCount: Int) -> some View { + if settings.alternativeThumbnailGrid { + let columnsData = getColumnsData(columnCount: columnCount) + + HStack(alignment: .top) { + ForEach(0..<columnCount, id: \.self) { columnIndex in + LazyVStack { + ForEach(columnsData[columnIndex], id: \.id) { favorite in + favoriteGridContent(favorite: favorite) + .id(favorite.id) + } + } + .transaction { $0.animation = nil } + } + } + #if os(macOS) + .padding(8) + #else + .padding(.horizontal) + #endif + .transition(.opacity) + } else { + WaterfallGrid(sortedFilteredFavorites, id: \.id) { favorite in + favoriteGridContent(favorite: favorite) + .id(favorite.id) + } + .gridStyle(columns: columnCount) + .transaction { $0.animation = nil } + #if os(macOS) + .padding(8) + #else + .padding(.horizontal) + #endif + .transition(.opacity) + } + } + + private func getColumnsData(columnCount: Int) -> [[SettingsFavoritePost]] { + (0..<columnCount).map { columnIndex in + sortedFilteredFavorites.enumerated().compactMap { index, favorite in + index % columnCount == columnIndex ? favorite : nil + } + } + } + + private func favoriteGridContent(favorite: SettingsFavoritePost) -> some View { + NavigationLink( + value: PostWithContext( + post: favorite.toBooruPost(), + posts: sortedFilteredFavorites.map { $0.toBooruPost() }, + baseSearchText: nil + ) + ) { + FavoritePostThumbnailView( + favorite: favorite, + ) { + settings.removeFavorite(withID: favorite.id) + } + } + .buttonStyle(PlainButtonStyle()) + .contextMenu { + Button(action: { + selectedFavoriteForDetails = favorite + isDetailsSheetPresented = true + }) { + Label("Show Details", systemImage: "info.circle") + } + + Button(action: { + manager.searchText += " \(favorite.tags.joined(separator: " "))" + manager.selectedPost = nil + + let localManager = manager + let localSettings = settings + + isPresented.toggle() + + Task(priority: .userInitiated) { + await localManager.performSearch(settings: localSettings) + } + }) { + Label("Add to Search", systemImage: "plus") + } + + Menu { + ForEach(settings.folders.filter { $0.topLevelName == nil }, id: \.id) { folder in + Button(action: { + settings.updateFavoriteFolder(withID: favorite.id, folder: folder.id) + }) { + Label(folder.name, systemImage: "folder") + } + .disabled(favorite.folder == folder.id) + } + + let topLevelFolders = settings.folders + .reduce(into: [String: [SettingsFolder]]()) { result, folder in + guard let topLevelName = folder.topLevelName else { return } + + result[topLevelName, default: []].append(folder) + } + + ForEach(topLevelFolders.keys.sorted(), id: \.self) { topLevelName in + Menu { + ForEach(topLevelFolders[topLevelName] ?? [], id: \.id) { folder in + Button(action: { + settings.updateFavoriteFolder(withID: favorite.id, folder: folder.id) + }) { + Text(folder.shortName) + } + .disabled(favorite.folder == folder.id) + } + } label: { + Text(topLevelName) + } + } + + Button(action: { + itemPendingCollectionAssignment = favorite.id + isNewCollectionAlertPresented = true + }) { + Label("New Collection", systemImage: "plus") + } + } label: { + Label("\(favorite.folder != nil ? "Move" : "Add") to Collection", systemImage: "folder") + } + + if favorite.folder != nil { + Button(action: { + settings.updateFavoriteFolder(withID: favorite.id, folder: nil) + }) { + Label("Remove from Collection", systemImage: "folder.badge.minus") + } + } + + Button { + settings.removeFavorite(withID: favorite.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} + +extension SettingsFavoritePost { + func toBooruPost() -> BooruPost { + BooruPost( + id: postId, + height: height, + score: "0", + fileURL: fileUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, + parentID: "0", + sampleURL: previewUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, + sampleWidth: width, + sampleHeight: height, + previewURL: thumbnailUrl.flatMap(URL.init) ?? URL(string: "https://example.com")!, + rating: rating, + tags: tags, + width: width, + change: nil, + md5: "", + creatorID: "0", + authorID: nil, + createdAt: createdAt, + status: "active", + source: "", + previewWidth: width, + previewHeight: height + ) + } +} + +#Preview { + FavoritesView(selectedTab: .constant(1), isPresented: .constant(false)) + .environmentObject(SettingsManager()) + .environmentObject(BooruManager(.yandere)) +} diff --git a/Sora/Views/MainView.swift b/Sora/Views/MainView.swift index dd73c49..f01a1e6 100644 --- a/Sora/Views/MainView.swift +++ b/Sora/Views/MainView.swift @@ -41,8 +41,14 @@ struct MainView: View { } } + Tab("Favorites", systemImage: "heart", value: 2) { + NavigationStack { + FavoritesView(selectedTab: $selectedTab, isPresented: .constant(false)) + } + } + #if os(macOS) - Tab("Search History", systemImage: "clock.arrow.circlepath", value: 3) { + Tab("Search History", systemImage: "clock.arrow.circlepath", value: 4) { PostGridSearchHistoryView( selectedTab: $selectedTab, isPresented: .constant(false) @@ -51,7 +57,7 @@ struct MainView: View { #endif #if DEBUG || !os(macOS) - Tab("Settings", systemImage: "gear", value: 2) { + Tab("Settings", systemImage: "gear", value: 3) { SettingsView() } #endif @@ -68,6 +74,12 @@ struct MainView: View { .tabItem { Label("Bookmarks", systemImage: "bookmark") } .tag(1) + NavigationStack { + FavoritesView(selectedTab: $selectedTab, isPresented: .constant(false)) + } + .tabItem { Label("Favorites", systemImage: "heart") } + .tag(2) + #if os(macOS) NavigationStack { PostGridSearchHistoryView( @@ -76,13 +88,13 @@ struct MainView: View { ) } .tabItem { Label("Search History", systemImage: "clock.arrow.circlepath") } - .tag(3) + .tag(4) #endif #if DEBUG || !os(macOS) SettingsView() .tabItem { Label("Settings", systemImage: "gear") } - .tag(2) + .tag(3) #endif } } diff --git a/Sora/Views/Post/Details/PostDetailsView.swift b/Sora/Views/Post/Details/PostDetailsView.swift index 4a7daf3..bca956a 100644 --- a/Sora/Views/Post/Details/PostDetailsView.swift +++ b/Sora/Views/Post/Details/PostDetailsView.swift @@ -14,13 +14,13 @@ struct PostDetailsView: View { private var imageURL: URL? { switch settings.detailViewQuality { case .preview: - post.previewURL + currentPost.previewURL case .sample: - post.sampleURL + currentPost.sampleURL case .original: - post.fileURL + currentPost.fileURL } } @@ -43,6 +43,10 @@ struct PostDetailsView: View { return sourcePosts.filter { settings.displayRatings.contains($0.rating) } } + private var currentPost: BooruPost { + manager.selectedPost ?? post + } + var body: some View { VStack(spacing: 0) { #if os(macOS) @@ -50,20 +54,20 @@ struct PostDetailsView: View { url: imageURL, loadingStage: $loadingStage, finalLoadingState: .loaded, - post: post + post: currentPost ) { PostDetailsImageView( - url: post.previewURL, + url: currentPost.previewURL, loadingStage: $loadingStage ) - .id(post.previewURL) + .id(currentPost.previewURL) } .id(imageURL) #else PostDetailsCarouselView( posts: filteredPosts, loadingStage: $loadingStage, - focusedPost: post + focusedPost: currentPost ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) #endif @@ -71,7 +75,7 @@ struct PostDetailsView: View { if settings.displayDetailsInformationBar { VStack(spacing: 5) { HStack { - Text(post.createdAt.formatted()) + Text(currentPost.createdAt.formatted()) .frame(maxWidth: .infinity, alignment: .leading) Group { @@ -119,6 +123,10 @@ struct PostDetailsView: View { } } + ToolbarItem { + PostGridFavoriteButtonView(post: currentPost) + } + #if os(macOS) if settings.enableShareShortcut { ToolbarItem { @@ -146,7 +154,7 @@ struct PostDetailsView: View { PostDetailsTagsView( isPresented: $isTagsSheetPresented, navigationPath: $navigationPath, - tags: post.tags, + tags: currentPost.tags, isNestedContext: posts != nil, baseSearchText: baseSearchText ) diff --git a/Sora/Views/Post/Grid/PostGridFavoriteButtonView.swift b/Sora/Views/Post/Grid/PostGridFavoriteButtonView.swift new file mode 100644 index 0000000..bd6b6f9 --- /dev/null +++ b/Sora/Views/Post/Grid/PostGridFavoriteButtonView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct PostGridFavoriteButtonView: View { + @EnvironmentObject private var manager: BooruManager + @EnvironmentObject private var settings: SettingsManager + let post: BooruPost + + var isFavorited: Bool { + settings.isFavorite(postId: post.id, provider: manager.provider) + } + + var body: some View { + FavoriteMenuButtonView(post: post) + } +} + +#Preview { + let samplePost = BooruPost( + id: "123", + height: 100, + score: "10", + fileURL: URL(string: "https://example.com/file.jpg")!, + parentID: "0", + sampleURL: URL(string: "https://example.com/sample.jpg")!, + sampleWidth: 100, + sampleHeight: 100, + previewURL: URL(string: "https://example.com/preview.jpg")!, + rating: .safe, + tags: ["sample", "test"], + width: 100, + change: nil, + md5: "abc123", + creatorID: "1", + authorID: nil, + createdAt: Date(), + status: "active", + source: "", + previewWidth: 100, + previewHeight: 100 + ) + + PostGridFavoriteButtonView(post: samplePost) + .environmentObject(SettingsManager()) + .environmentObject(BooruManager(.yandere)) +} |