summaryrefslogtreecommitdiff
path: root/Sora
diff options
context:
space:
mode:
Diffstat (limited to 'Sora')
-rw-r--r--Sora/Data/Booru/Post/BooruPostFileType.swift2
-rw-r--r--Sora/Data/Settings/SettingsFavoritePost.swift95
-rw-r--r--Sora/Data/Settings/SettingsManager.swift248
-rw-r--r--Sora/Data/Settings/SettingsSyncKey.swift2
-rw-r--r--Sora/Extensions/SettingsFavoritePost+Date.swift5
-rw-r--r--Sora/Views/FavoriteMenuButtonView.swift112
-rw-r--r--Sora/Views/FavoritePostThumbnailView.swift91
-rw-r--r--Sora/Views/FavoritesView.swift567
-rw-r--r--Sora/Views/MainView.swift20
-rw-r--r--Sora/Views/Post/Details/PostDetailsView.swift26
-rw-r--r--Sora/Views/Post/Grid/PostGridFavoriteButtonView.swift45
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))
+}