diff options
| author | Fuwn <[email protected]> | 2025-02-18 23:39:51 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-02-19 00:00:29 -0800 |
| commit | 320ce31337ed60cae24a0374fa2d6d79237a6bfe (patch) | |
| tree | ad1e93799efeb8e7a16521e3ab958c911f73f617 | |
| parent | feat: Initial commit (diff) | |
| download | sora-testing-320ce31337ed60cae24a0374fa2d6d79237a6bfe.tar.xz sora-testing-320ce31337ed60cae24a0374fa2d6d79237a6bfe.zip | |
feat: Development commit
27 files changed, 893 insertions, 312 deletions
diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index 99ec5f5..b01bf8c 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -6,13 +6,30 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 4CA3DE752D65B5990033035D /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA3DE742D65B5990033035D /* WaterfallGrid */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 4CD342762D6341D900565E6F /* Sora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sora.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4C6771012D64ADEA00596BBC /* Exceptions for "Sora" folder in "Sora" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 4CD342752D6341D900565E6F /* Sora */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 4CD342782D6341D900565E6F /* Sora */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4C6771012D64ADEA00596BBC /* Exceptions for "Sora" folder in "Sora" target */, + ); path = Sora; sourceTree = "<group>"; }; @@ -23,6 +40,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4CA3DE752D65B5990033035D /* WaterfallGrid in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,6 +83,7 @@ ); name = Sora; packageProductDependencies = ( + 4CA3DE742D65B5990033035D /* WaterfallGrid */, ); productName = Sora; productReference = 4CD342762D6341D900565E6F /* Sora.app */; @@ -95,6 +114,7 @@ mainGroup = 4CD3426D2D6341D900565E6F; minimizedProjectReferenceProxies = 1; packageReferences = ( + 4CA3DE732D65B5990033035D /* XCRemoteSwiftPackageReference "WaterfallGrid" */, ); preferredProjectObjectVersion = 77; productRefGroup = 4CD342772D6341D900565E6F /* Products */; @@ -254,6 +274,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sora/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -293,6 +314,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Sora/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -341,6 +363,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 4CA3DE732D65B5990033035D /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4CA3DE742D65B5990033035D /* WaterfallGrid */ = { + isa = XCSwiftPackageProductDependency; + package = 4CA3DE732D65B5990033035D /* XCRemoteSwiftPackageReference "WaterfallGrid" */; + productName = WaterfallGrid; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 4CD3426E2D6341D900565E6F /* Project object */; } diff --git a/Sora.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sora.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..30341aa --- /dev/null +++ b/Sora.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "8646e25e180920a065232acf8b4ca4c4fdf4a57ffbb51e24fcf155613761b82d", + "pins" : [ + { + "identity" : "waterfallgrid", + "kind" : "remoteSourceControl", + "location" : "https://github.com/paololeonardi/WaterfallGrid", + "state" : { + "revision" : "c7c08652c3540adf8e48409c351879b4caea7e89", + "version" : "1.1.0" + } + } + ], + "version" : 3 +} diff --git a/Sora/Data/Moebooru/MoebooruManager.swift b/Sora/Data/Moebooru/MoebooruManager.swift new file mode 100644 index 0000000..fd8d337 --- /dev/null +++ b/Sora/Data/Moebooru/MoebooruManager.swift @@ -0,0 +1,114 @@ +import SwiftUI + +@MainActor +class MoebooruManager: ObservableObject { + @Published var posts: [MoebooruPost] = [] + @Published var allTags: [MoebooruTag] = [] + @Published var isLoading: Bool = false + @Published var currentPage: Int = 1 + @Published var searchText = "" + @Published var endOfData: Bool = false + #if os(macOS) + @Published var selectedPost: MoebooruPost? + #endif + private var currentTask: Task<Void, Never>? + var tags: [String] { + if searchText.isEmpty { + return [] + } + + return searchText + .split(separator: " ") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + init() { + fetchAllTags() + } + + func fetchPosts(page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false) async { + guard !isLoading else { return } + + currentTask?.cancel() + + currentTask = Task { + isLoading = true + + defer { isLoading = false } + + if replace { + self.posts = [] + self.currentPage = 1 + } + + guard let url = URL(string: "https://yande.re/post.xml?page=\(page)&limit=\(limit)&tags=\(tags.joined(separator: "+"))") else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + if Task.isCancelled { return } + + DispatchQueue.main.async { + let newPosts = Array(Set(MoebooruPostXMLParser().parse(data: data))).sorted { $0.id > $1.id } + + if newPosts == [] { + self.endOfData = true + } else { + self.posts += newPosts + } + } + } catch { + if (error as? URLError)?.code != .cancelled { + #if DEBUG + print(error) + #endif + } + } + } + } + + func performSearch() { + Task { + await fetchPosts( + page: 1, + tags: tags, + replace: true + ) + } + } + + func loadNextPage() { + guard !isLoading else { return } + + Task { + await fetchPosts(page: currentPage + 1, tags: tags) + + DispatchQueue.main.async { + self.currentPage += 1 + } + } + } + + func fetchAllTags(limit: Int = 100_000) { + Task { + guard let url = URL(string: "https://yande.re/tag.xml?limit=\(limit)") else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + + if Task.isCancelled { return } + + DispatchQueue.main.async { + self.allTags = (MoebooruTagXMLParser().parse(data: data)).sorted { $0.count > $1.count } + } + } catch { + if (error as? URLError)?.code != .cancelled { + #if DEBUG + print(error) + #endif + } + } + } + } +} diff --git a/Sora/Data/MoebooruPost.swift b/Sora/Data/Moebooru/MoebooruPost.swift index df332ec..df332ec 100644 --- a/Sora/Data/MoebooruPost.swift +++ b/Sora/Data/Moebooru/MoebooruPost.swift diff --git a/Sora/Data/Moebooru/MoebooruPostXMLParser.swift b/Sora/Data/Moebooru/MoebooruPostXMLParser.swift new file mode 100644 index 0000000..418a9c6 --- /dev/null +++ b/Sora/Data/Moebooru/MoebooruPostXMLParser.swift @@ -0,0 +1,42 @@ +import Foundation + +class MoebooruPostXMLParser: NSObject, XMLParserDelegate { + private var posts: [MoebooruPost] = [] + + func parse(data: Data) -> [MoebooruPost] { + let parser = XMLParser(data: data) + + parser.delegate = self + parser.parse() + + return posts + } + + func parser(_: XMLParser, didStartElement elementName: String, + namespaceURI _: String?, qualifiedName _: String?, + attributes attributeDict: [String: String]) + { + if elementName == "post", + let id = Int(attributeDict["id"] ?? ""), + let createdAtTimestamp = TimeInterval(attributeDict["created_at"] ?? ""), + let score = Int(attributeDict["score"] ?? ""), + let width = Int(attributeDict["width"] ?? ""), + let height = Int(attributeDict["height"] ?? "") + { + posts.append(MoebooruPost( + id: id, + tags: attributeDict["tags"]?.components(separatedBy: " ") ?? [], + createdAt: Date(timeIntervalSince1970: createdAtTimestamp), + author: attributeDict["author"] ?? "", + source: URL(string: attributeDict["source"] ?? ""), + score: score, + fileURL: URL(string: attributeDict["file_url"] ?? ""), + previewURL: URL(string: attributeDict["preview_url"] ?? ""), + sampleURL: URL(string: attributeDict["sample_url"] ?? ""), + jpegURL: URL(string: attributeDict["jpeg_url"] ?? ""), + width: width, + height: height + )) + } + } +} diff --git a/Sora/Data/Moebooru/MoebooruTag.swift b/Sora/Data/Moebooru/MoebooruTag.swift new file mode 100644 index 0000000..cc783f0 --- /dev/null +++ b/Sora/Data/Moebooru/MoebooruTag.swift @@ -0,0 +1,9 @@ +import Foundation + +struct MoebooruTag: Identifiable, Hashable { + let id: Int + let name: String + let count: Int + let type: Int + let ambiguous: Bool +} diff --git a/Sora/Data/Moebooru/MoebooruTagXMLParser.swift b/Sora/Data/Moebooru/MoebooruTagXMLParser.swift new file mode 100644 index 0000000..7ea2e8e --- /dev/null +++ b/Sora/Data/Moebooru/MoebooruTagXMLParser.swift @@ -0,0 +1,35 @@ +import Foundation + +class MoebooruTagXMLParser: NSObject, XMLParserDelegate { + private var tags: [MoebooruTag] = [] + + func parse(data: Data) -> [MoebooruTag] { + let parser = XMLParser(data: data) + + parser.delegate = self + parser.parse() + + return tags + } + + func parser(_: XMLParser, didStartElement elementName: String, + namespaceURI _: String?, qualifiedName _: String?, + attributes attributeDict: [String: String]) + { + if elementName == "tag" { + if let id = Int(attributeDict["id"] ?? ""), + let count = Int(attributeDict["count"] ?? ""), + let type = Int(attributeDict["type"] ?? ""), + let ambiguous = Bool(attributeDict["ambiguous"] ?? "false") + { + tags.append(MoebooruTag( + id: id, + name: attributeDict["name"] ?? "", + count: count, + type: type, + ambiguous: ambiguous + )) + } + } + } +} diff --git a/Sora/Data/MoebooruXMLParser.swift b/Sora/Data/MoebooruXMLParser.swift deleted file mode 100644 index 1053299..0000000 --- a/Sora/Data/MoebooruXMLParser.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation - -class MoebooruXMLParser: NSObject, XMLParserDelegate { - private var posts: [MoebooruPost] = [] - private var currentAttributes: [String: String] = [:] - private var currentPost: MoebooruPost? - - func parse(data: Data) -> [MoebooruPost] { - let parser = XMLParser(data: data) - - parser.delegate = self - parser.parse() - - return posts - } - - func parser(_: XMLParser, didStartElement elementName: String, - namespaceURI _: String?, qualifiedName _: String?, - attributes attributeDict: [String: String]) - { - if elementName == "post" { - currentAttributes = attributeDict - - if let id = Int(attributeDict["id"] ?? ""), - let createdAtTimestamp = TimeInterval(attributeDict["created_at"] ?? "") - { - if let score = Int(attributeDict["score"] ?? ""), - let width = Int(attributeDict["width"] ?? ""), - let height = Int(attributeDict["height"] ?? "") - { - posts.append(MoebooruPost( - id: id, - tags: attributeDict["tags"]?.components(separatedBy: " ") ?? [], - createdAt: Date(timeIntervalSince1970: createdAtTimestamp), - author: attributeDict["author"] ?? "", - source: URL(string: attributeDict["source"] ?? ""), - score: score, - fileURL: URL(string: attributeDict["file_url"] ?? ""), - previewURL: URL(string: attributeDict["preview_url"] ?? ""), - sampleURL: URL(string: attributeDict["sample_url"] ?? ""), - jpegURL: URL(string: attributeDict["jpeg_url"] ?? ""), - width: width, - height: height - )) - } - } - } - } -} diff --git a/Sora/Data/Settings.swift b/Sora/Data/Settings.swift new file mode 100644 index 0000000..4f1554e --- /dev/null +++ b/Sora/Data/Settings.swift @@ -0,0 +1,20 @@ +import SwiftUI + +class Settings: ObservableObject { + @AppStorage("softLimit") var softLimit: Int = 100 + @AppStorage("largerThumbnails") var largerThumbnails: Bool = false + #if DEBUG + @AppStorage("detailViewType") var detailViewType: PostFileType = .compressed + #else + @AppStorage("detailViewType") var detailViewType: PostFileType = .original + #endif + @AppStorage("thumbnailType") var thumbnailType: PostFileType = .preview + @AppStorage("searchSuggestions") var searchSuggestions: Bool = false + @AppStorage("columns") var columns: Int = 2 + let minSoftLimit: Int = 100 + let maxSoftLimit: Int = 10000 + + func softLimitAsCGFloat() -> CGFloat { + max(CGFloat(softLimit), CGFloat(minSoftLimit)) + } +} diff --git a/Sora/Info.plist b/Sora/Info.plist new file mode 100644 index 0000000..679be16 --- /dev/null +++ b/Sora/Info.plist @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSPhotoLibraryAddUsageDescription</key> + <string>Sora must be able to access permissions to write photos to the user's photo library to use the image-saving feature.</string> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <true/> + </dict> +</dict> +</plist> diff --git a/Sora/Other/AsyncImageWithPreview.swift b/Sora/Other/AsyncImageWithPreview.swift new file mode 100644 index 0000000..8d93ee5 --- /dev/null +++ b/Sora/Other/AsyncImageWithPreview.swift @@ -0,0 +1,163 @@ +import SwiftUI + +struct AsyncImageWithPreview<Placeholder: View>: View { + var url: URL? + @Binding var loadingState: PostLoadingState + var finalLoadingState: PostLoadingState + var postURL: URL? + let placeholder: () -> Placeholder + @State private var currentScale: CGFloat = 1.0 + @State private var finalScale: CGFloat = 1.0 + @State private var currentOffset: CGSize = .zero + @State private var finalOffset: CGSize = .zero + + init( + url: URL?, + loadingStage: Binding<PostLoadingState>, + finalLoadingState: PostLoadingState = .loadingFile, + postURL: URL? = nil, + @ViewBuilder placeholder: @escaping () -> Placeholder = { + GeometryReader { geometry in + ProgressView() + .frame(width: geometry.size.width, height: geometry.size.height) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .padding() + } + } + ) { + self.url = url + _loadingState = loadingStage + self.finalLoadingState = finalLoadingState + self.postURL = postURL + self.placeholder = placeholder + } + + var body: some View { + GeometryReader { geometry in + AsyncImage(url: url) { image in + image + .resizable() + .scaledToFit() + .onAppear { + self.loadingState = finalLoadingState + } + .scaleEffect(finalScale * currentScale) + .offset(x: finalOffset.width + currentOffset.width, + y: finalOffset.height + currentOffset.height) + .frame(width: geometry.size.width, height: geometry.size.height) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + .gesture( + DragGesture() + .onChanged { value in + let translation = value.translation + let newOffset = CGSize( + width: finalOffset.width + translation.width, + height: finalOffset.height + translation.height + ) + let scale = finalScale * currentScale + let imageWidth = geometry.size.width * scale + let imageHeight = geometry.size.height * scale + let maxX = max((imageWidth - geometry.size.width) / 2, 0) + let maxY = max((imageHeight - geometry.size.height) / 2, 0) + let clampedX = min(max(newOffset.width, -maxX), maxX) + let clampedY = min(max(newOffset.height, -maxY), maxY) + + currentOffset = CGSize( + width: clampedX - finalOffset.width, + height: clampedY - finalOffset.height + ) + } + .onEnded { value in + let translation = value.translation + var newOffset = CGSize( + width: finalOffset.width + translation.width, + height: finalOffset.height + translation.height + ) + let scale = finalScale * currentScale + let imageWidth = geometry.size.width * scale + let imageHeight = geometry.size.height * scale + let maxX = max((imageWidth - geometry.size.width) / 2, 0) + let maxY = max((imageHeight - geometry.size.height) / 2, 0) + + newOffset.width = min(max(newOffset.width, -maxX), maxX) + newOffset.height = min(max(newOffset.height, -maxY), maxY) + finalOffset = newOffset + currentOffset = .zero + } + ) + .simultaneousGesture( + MagnificationGesture() + .onChanged { value in + currentScale = value + } + .onEnded { _ in + finalScale *= currentScale + currentScale = 1.0 + + let scale = finalScale + let imageWidth = geometry.size.width * scale + let imageHeight = geometry.size.height * scale + let maxX = max((imageWidth - geometry.size.width) / 2, 0) + let maxY = max((imageHeight - geometry.size.height) / 2, 0) + + finalOffset.width = min(max(finalOffset.width, -maxX), maxX) + finalOffset.height = min(max(finalOffset.height, -maxY), maxY) + } + ) + .highPriorityGesture( + TapGesture(count: 2) + .onEnded { + withAnimation { + finalScale = 1.0 + currentScale = 1.0 + finalOffset = .zero + currentOffset = .zero + } + } + ) + .contextMenu { + #if os(iOS) + Button { + guard let url = url else { return } + + URLSession.shared.dataTask(with: url) { data, _, _ in + guard let data = data, let uiImage = UIImage(data: data) else { return } + + UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil) + }.resume() + } label: { + Label("Save Image", systemImage: "square.and.arrow.down") + } + #endif + + #if os(iOS) + Button { + let activityViewController = UIActivityViewController(activityItems: [url ?? URL(string: "")!], applicationActivities: nil) + + UIApplication.shared.windows.first?.rootViewController?.present(activityViewController, animated: true) + } label: { + Label("Share Image", systemImage: "square.and.arrow.up") + } + #endif + + if let url = postURL { + Button { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } label: { + Label("Open in Safari", systemImage: "safari") + } + } + } + } placeholder: { + placeholder() + .onAppear { + self.loadingState = .loadingPreview + } + } + } + } +} diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements index 625af03..bcc27ba 100644 --- a/Sora/Sora.entitlements +++ b/Sora/Sora.entitlements @@ -8,5 +8,7 @@ <true/> <key>com.apple.security.network.client</key> <true/> + <key>com.apple.security.personal-information.photos-library</key> + <true/> </dict> </plist> diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 1fab837..0d59fcd 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -2,9 +2,25 @@ import SwiftUI @main struct SoraApp: App { + @StateObject private var settings = Settings() + var body: some Scene { WindowGroup { - ContentView() + MainView() + .environmentObject(settings) } + + #if os(macOS) + SwiftUI.Settings { + SettingsViewMacOS() + .environmentObject(settings) + } + .defaultSize(width: 400, height: 400) + #endif } } + +#Preview { + MainView() + .environmentObject(Settings()) +} diff --git a/Sora/Views/ContentView.swift b/Sora/Views/ContentView.swift index 38516ef..9ec56a4 100644 --- a/Sora/Views/ContentView.swift +++ b/Sora/Views/ContentView.swift @@ -1,198 +1,32 @@ import SwiftUI struct ContentView: View { - @State private var posts: [MoebooruPost] = [] - #if os(macOS) - @State private var selectedPost: YanderePost? - #endif - @State private var searchQuery = "" - @State private var currentPage = 1 - @State private var isLoading: Bool = false - @State private var largerThumbnails: Bool = false - var tags: [String] { - if searchQuery.isEmpty { - return [] - } - - return searchQuery - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - @State var softLimit: Int = 100 - var softLimitCGFloat: CGFloat { - CGFloat(softLimit) - } - - var columns: [GridItem] { - return [ - GridItem(.adaptive(minimum: softLimitCGFloat)), - ] - } + @EnvironmentObject var settings: Settings + @StateObject private var manager = MoebooruManager() var body: some View { #if os(macOS) NavigationSplitView { - ScrollView { - if self.posts.isEmpty { - ProgressView() - .frame(width: softLimitCGFloat, height: softLimitCGFloat) - } - - LazyVGrid(columns: columns) { - ForEach(posts, id: \.id) { post in - Button { - selectedPost = post - } label: { - PostView(post: post, softLimit: softLimitCGFloat) - .frame(maxWidth: .infinity) - } - .buttonStyle(PlainButtonStyle()) - - if post == posts.last { - ProgressView() - .onAppear(perform: loadNextPage) - .padding() - } - } - } - } - .searchable(text: $searchQuery, prompt: "Tags") - .onSubmit(of: .search, performSearch) - .toolbar { - ToolbarItem { - Button(action: { - Task { - await fetchPosts(page: 1, tags: tags, replace: true) - } - }) { - Label("Refresh", systemImage: "arrow.clockwise") - } - } - } + PostGridView( + manager: manager + ) } detail: { - if let post = selectedPost { - PostDetailView(post: post) + if let post = manager.selectedPost { + PostDetailsView(post: post) } else { - Text("Select a post") + Text("Select a post.") .foregroundColor(.secondary) } } - .task { - await fetchPosts(page: currentPage) - } #else NavigationStack { - ScrollView { - if self.posts.isEmpty { - ProgressView() - .frame(width: softLimitCGFloat, height: softLimitCGFloat) - } - - LazyVGrid(columns: columns, spacing: 10) { - ForEach(posts, id: \.id) { post in - NavigationLink(value: post) { - PostView( - post: post, - softLimit: softLimitCGFloat, - thumbnailMode: self.largerThumbnails ? .sample : .preview - ) - .frame(maxWidth: .infinity) - } - - if post == posts.last { - ProgressView() - .onAppear(perform: loadNextPage) - .padding() - } - } - } - } - .searchable(text: $searchQuery, prompt: "Tags") - .onSubmit(of: .search, performSearch) - .navigationDestination(for: MoebooruPost.self) { post in - PostDetailView(post: post) - } - .task { - await fetchPosts(page: currentPage) - } - .toolbar { - ToolbarItem { - TextField( - softLimit.description, - value: $softLimit, - format: .number - ) - } - - ToolbarItem { - Toggle("Full size thumbnails", isOn: $largerThumbnails) - } - - ToolbarItem { - Button(action: { - Task { - await fetchPosts(page: 1, tags: tags, replace: true) - } - }) { - Label("Refresh", systemImage: "arrow.clockwise") - } - } - } + PostGridView(manager: manager) } #endif } - - func fetchPosts(page: Int = 1, limit: Int = 100, tags: [String] = [], replace: Bool = false) async { - guard !isLoading else { return } - - isLoading = true - - defer { isLoading = false } - - guard let url = URL(string: "https://yande.re/post.xml?page=\(page)&limit=\(limit)&tags=\(tags.joined(separator: "+"))") else { return } - do { - let (data, _) = try await URLSession.shared.data(from: url) - - DispatchQueue.main.async { - if replace { - self.posts = [] - self.currentPage = 1 - } - - self.posts += Array(Set(MoebooruXMLParser().parse(data: data))).sorted { $0.id > $1.id } - } - } catch { - #if DEBUG - print(error) - #endif - } - } - - func performSearch() { - Task { - await fetchPosts( - page: 1, - tags: tags, - replace: true - ) - } - } - - func loadNextPage() { - guard !isLoading else { return } - - Task { - await fetchPosts(page: currentPage + 1, tags: tags) - - DispatchQueue.main.async { - currentPage += 1 - } - } - } } #Preview { ContentView() + .environmentObject(Settings()) } diff --git a/Sora/Views/MainView.swift b/Sora/Views/MainView.swift new file mode 100644 index 0000000..c657540 --- /dev/null +++ b/Sora/Views/MainView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct MainView: View { + @EnvironmentObject var settings: Settings + + var body: some View { + #if os(macOS) + ContentView() + .environmentObject(settings) + #else + TabView { + ContentView() + .tabItem { + Label("Posts", systemImage: "rectangle.stack") + } + + NavigationStack { + SettingsView() + } + .tabItem { + Label("Settings", systemImage: "gear") + } + } + .environmentObject(settings) + #endif + } +} diff --git a/Sora/Views/Post/PostDetailView.swift b/Sora/Views/Post/PostDetailView.swift deleted file mode 100644 index 799339c..0000000 --- a/Sora/Views/Post/PostDetailView.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI - -struct PostDetailView: View { - let post: MoebooruPost - @State var loadingStage: PostLoadingState = .loadingPreview - - var body: some View { - VStack { - Link(destination: URL(string: "https://yande.re/post/show/\(post.id)")!) { - AsyncImage(url: post.sampleURL) { image in - image - .resizable() - .scaledToFit() - .onAppear { - self.loadingStage = .loaded - } - } placeholder: { - AsyncImage(url: post.previewURL) { image in - image - .resizable() - .scaledToFit() - .onAppear { - self.loadingStage = .loadingFile - } - } placeholder: { - ProgressView() - .progressViewStyle(LinearProgressViewStyle()) - .padding() - .onAppear { - self.loadingStage = .loadingPreview - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .id(post.fileURL) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .id(post.fileURL) - } - .buttonStyle(PlainButtonStyle()) - - Spacer() - - VStack(spacing: 5) { - HStack { - Text(post.tags.joined(separator: ", ")) - } - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - Text(post.createdAt.formatted()) - .frame(maxWidth: .infinity, alignment: .leading) - - Group { - switch loadingStage { - case .loadingPreview: - Text("Loading preview …") - case .loadingFile: - Text("Loading file …") - case .loaded: - EmptyView() - } - } - .padding(.trailing, 5) - } - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 10) - .padding(.vertical, 10 / 1.33) - .textSelection(.enabled) - .font(.footnote) - .background(.opacity(0.1)) - } - } -} diff --git a/Sora/Views/Post/PostDetailsView.swift b/Sora/Views/Post/PostDetailsView.swift new file mode 100644 index 0000000..8f8f1de --- /dev/null +++ b/Sora/Views/Post/PostDetailsView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct PostDetailsView: View { + @EnvironmentObject var settings: Settings + let post: MoebooruPost + @State var loadingStage: PostLoadingState = .loadingPreview + private var imageURL: URL? { + switch settings.detailViewType { + case .preview: + return post.previewURL + case .sample: + return post.sampleURL + case .original: + return post.fileURL + case .compressed: + return post.jpegURL + } + } + + var body: some View { + VStack(spacing: 0) { + AsyncImageWithPreview( + url: imageURL, + loadingStage: $loadingStage, + finalLoadingState: .loaded, + postURL: URL(string: "https://yande.re/post/show/\(post.id)")! + ) { + AsyncImageWithPreview( + url: post.previewURL, + loadingStage: $loadingStage + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .id(post.previewURL) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .id(imageURL) + .padding(0) + .zIndex(0) + + VStack(spacing: 5) { + HStack { + Text(post.tags.joined(separator: ", ")) + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Text(post.createdAt.formatted()) + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + switch loadingStage { + case .loadingPreview: + Text("Loading preview …") + case .loadingFile: + Text("Loading \(settings.detailViewType.rawValue) …") + case .loaded: + EmptyView() + } + } + .padding(.trailing, 5) + } + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 10 / 1.33) + .textSelection(.enabled) + .font(.footnote) + #if os(iOS) + .background(.ultraThinMaterial) + #else + .background(.opacity(0.1)) + #endif + .zIndex(1) + } + .navigationTitle("Details") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + #endif + } +} diff --git a/Sora/Views/Post/PostFileType.swift b/Sora/Views/Post/PostFileType.swift new file mode 100644 index 0000000..a7b3ca6 --- /dev/null +++ b/Sora/Views/Post/PostFileType.swift @@ -0,0 +1,6 @@ +enum PostFileType: String, CaseIterable { + case original + case sample + case compressed + case preview +} diff --git a/Sora/Views/Post/PostGridView.swift b/Sora/Views/Post/PostGridView.swift new file mode 100644 index 0000000..4c43eeb --- /dev/null +++ b/Sora/Views/Post/PostGridView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import WaterfallGrid + +struct PostGridView: View { + @EnvironmentObject var settings: Settings + @ObservedObject var manager: MoebooruManager + + var body: some View { + ScrollViewReader { _ in + ScrollView { + if manager.posts.isEmpty { + ProgressView() + .frame( + width: settings.softLimitAsCGFloat(), + height: settings.softLimitAsCGFloat() + ) + } + + WaterfallGrid(manager.posts, id: \.id) { post in + Group { + #if os(macOS) + Button { + manager.selectedPost = post + } label: { + PostView(post: post, manager: manager) + } + .buttonStyle(PlainButtonStyle()) + #else + NavigationLink(value: post) { + PostView(post: post, manager: manager) + } + #endif + } + } + .gridStyle(columns: settings.columns, spacing: 0) + } + .searchable(text: $manager.searchText, prompt: "Tags") + .searchSuggestions { + if settings.searchSuggestions { + SearchSuggestionsView( + tags: manager.allTags, + searchText: $manager.searchText + ) + } + } + .onSubmit(of: .search, manager.performSearch) + .task { + if manager.posts.isEmpty { + await manager.fetchPosts(page: manager.currentPage) + } + } + .navigationDestination(for: MoebooruPost.self) { post in + PostDetailsView(post: post) + } + #if os(macOS) + .toolbar { + ToolbarItem { + Button(action: { + Task { + await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) + } + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + } + } + #endif + .navigationTitle("Posts") + .refreshable { + await manager.fetchPosts(page: 1, tags: manager.tags, replace: true) + } + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + } +} diff --git a/Sora/Views/Post/PostThumbnailMode.swift b/Sora/Views/Post/PostThumbnailMode.swift deleted file mode 100644 index 1e6c3b3..0000000 --- a/Sora/Views/Post/PostThumbnailMode.swift +++ /dev/null @@ -1,5 +0,0 @@ -enum PostThumbnailMode { - case preview - case sample - case file -} diff --git a/Sora/Views/Post/PostView.swift b/Sora/Views/Post/PostView.swift index 97e311c..d070aac 100644 --- a/Sora/Views/Post/PostView.swift +++ b/Sora/Views/Post/PostView.swift @@ -1,17 +1,19 @@ import SwiftUI struct PostView: View { + @EnvironmentObject var settings: Settings let post: MoebooruPost - var softLimit: CGFloat - var thumbnailMode: PostThumbnailMode = .preview + @ObservedObject var manager: MoebooruManager private var thumbnailURL: URL? { - switch thumbnailMode { + switch settings.thumbnailType { case .preview: return post.previewURL case .sample: return post.sampleURL - case .file: + case .original: return post.fileURL + case .compressed: + return post.jpegURL } } @@ -20,11 +22,17 @@ struct PostView: View { AsyncImage(url: thumbnailURL) { image in image .resizable() - .scaledToFit() + .aspectRatio(contentMode: .fit) + .onScrollVisibilityChange { visible in + if post == manager.posts.last && !manager.endOfData && visible { + Task { + manager.loadNextPage() + } + } + } } placeholder: { ProgressView() } - .frame(width: softLimit, height: softLimit) } } } diff --git a/Sora/Views/SearchSuggestionsView.swift b/Sora/Views/SearchSuggestionsView.swift new file mode 100644 index 0000000..cae8d43 --- /dev/null +++ b/Sora/Views/SearchSuggestionsView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct SearchSuggestionsView: View { + var tags: [MoebooruTag] + @Binding var searchText: String + var lastSearchTag: String { + String(searchText.split(separator: " ").last ?? "") + } + + var body: some View { + ForEach( + tags.filter { $0.name.lowercased().contains(lastSearchTag) + }) { suggestion in + Button { + searchText.replaceSubrange(searchText.range(of: lastSearchTag)!, with: suggestion.name) + } label: { + Text(suggestion.name) + } + } + } +} diff --git a/Sora/Views/Settings/SettingsDetailsView.swift b/Sora/Views/Settings/SettingsDetailsView.swift new file mode 100644 index 0000000..29b9e76 --- /dev/null +++ b/Sora/Views/Settings/SettingsDetailsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct SettingsDetailsView: View { + @EnvironmentObject var settings: Settings + + var body: some View { + #if os(macOS) + Text("Details") + .font(.headline) + + HStack { + Text("Detail View Type") + + Spacer() + + Picker("", selection: $settings.detailViewType) { + ForEach(PostFileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + .frame(width: 150) + } + #else + Picker("Detail View Type", selection: $settings.detailViewType) { + ForEach(PostFileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + #endif + } +} diff --git a/Sora/Views/Settings/SettingsSearchView.swift b/Sora/Views/Settings/SettingsSearchView.swift new file mode 100644 index 0000000..c1f1baa --- /dev/null +++ b/Sora/Views/Settings/SettingsSearchView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct SettingsSearchView: View { + @EnvironmentObject var settings: Settings + + var body: some View { + #if os(macOS) + Text("Search") + .font(.headline) + + HStack { + Text("Suggest Search Tags") + + Spacer() + + Toggle("", isOn: $settings.searchSuggestions) + } + #else + Toggle("Suggest Search Tags", isOn: $settings.searchSuggestions) + #endif + } +} diff --git a/Sora/Views/Settings/SettingsThumbnailsView.swift b/Sora/Views/Settings/SettingsThumbnailsView.swift new file mode 100644 index 0000000..552ef0c --- /dev/null +++ b/Sora/Views/Settings/SettingsThumbnailsView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct SettingsThumbnailsView: View { + @EnvironmentObject var settings: Settings + + var body: some View { + #if os(macOS) + Text("Thumbnails") + .font(.headline) + + HStack { + Text("Thumbnail Size") + + Spacer() + + TextField("", value: $settings.softLimit, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + } + #else + Stepper( + "Thumbnail Size (\(settings.softLimit))", + value: $settings.softLimit, + in: 100 ... 10000, + step: 10 + ) + #endif + + #if os(macOS) + HStack { + Text("Thumbnail Type") + + Spacer() + + Picker("", selection: $settings.thumbnailType) { + ForEach(PostFileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + .frame(width: 150) + } + #else + Picker("Thumbnail Type", selection: $settings.thumbnailType) { + ForEach(PostFileType.allCases, id: \.self) { type in + Text(type.rawValue.capitalized).tag(type) + } + } + #endif + + #if os(macOS) + HStack { + Text("Thumbnail Columns") + + Spacer() + + Picker("", selection: $settings.columns) { + ForEach(1 ... 10, id: \.self) { i in Text("\(i)") } + } + .frame(width: 75) + } + #else + Stepper( + "Thumbnail Columns (\(settings.columns))", + value: $settings.columns, + in: 1 ... 10 + ) + #endif + } +} diff --git a/Sora/Views/SettingsView.swift b/Sora/Views/SettingsView.swift new file mode 100644 index 0000000..105b5b6 --- /dev/null +++ b/Sora/Views/SettingsView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var settings: Settings + + var body: some View { + NavigationStack { + Form { + Section(header: Text("Thumbnails")) { + SettingsThumbnailsView() + } + + Section(header: Text("Details")) { + SettingsDetailsView() + } + + Section(header: Text("Search")) { + SettingsSearchView() + } + } + } + .navigationTitle("Settings") + #if os(macOS) + .padding() + #endif + } +} + +#Preview { + SettingsView() + .environmentObject(Settings()) +} diff --git a/Sora/Views/SettingsViewMacOS.swift b/Sora/Views/SettingsViewMacOS.swift new file mode 100644 index 0000000..a981a59 --- /dev/null +++ b/Sora/Views/SettingsViewMacOS.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct SettingsViewMacOS: View { + @EnvironmentObject var settings: Settings + @State private var selection: Int = 0 + + var body: some View { + NavigationStack { + VStack(alignment: .leading) { + SettingsThumbnailsView() + + Spacer() + .frame(height: 25) + + SettingsDetailsView() + + Spacer() + .frame(height: 25) + + SettingsSearchView() + } + .padding() + } + } +} + +#Preview { + SettingsViewMacOS() + .environmentObject(Settings()) +} |