summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-02-18 23:39:51 -0800
committerFuwn <[email protected]>2025-02-19 00:00:29 -0800
commit320ce31337ed60cae24a0374fa2d6d79237a6bfe (patch)
treead1e93799efeb8e7a16521e3ab958c911f73f617
parentfeat: Initial commit (diff)
downloadsora-testing-320ce31337ed60cae24a0374fa2d6d79237a6bfe.tar.xz
sora-testing-320ce31337ed60cae24a0374fa2d6d79237a6bfe.zip
feat: Development commit
-rw-r--r--Sora.xcodeproj/project.pbxproj41
-rw-r--r--Sora.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved15
-rw-r--r--Sora/Data/Moebooru/MoebooruManager.swift114
-rw-r--r--Sora/Data/Moebooru/MoebooruPost.swift (renamed from Sora/Data/MoebooruPost.swift)0
-rw-r--r--Sora/Data/Moebooru/MoebooruPostXMLParser.swift42
-rw-r--r--Sora/Data/Moebooru/MoebooruTag.swift9
-rw-r--r--Sora/Data/Moebooru/MoebooruTagXMLParser.swift35
-rw-r--r--Sora/Data/MoebooruXMLParser.swift49
-rw-r--r--Sora/Data/Settings.swift20
-rw-r--r--Sora/Info.plist13
-rw-r--r--Sora/Other/AsyncImageWithPreview.swift163
-rw-r--r--Sora/Sora.entitlements2
-rw-r--r--Sora/SoraApp.swift18
-rw-r--r--Sora/Views/ContentView.swift186
-rw-r--r--Sora/Views/MainView.swift27
-rw-r--r--Sora/Views/Post/PostDetailView.swift75
-rw-r--r--Sora/Views/Post/PostDetailsView.swift83
-rw-r--r--Sora/Views/Post/PostFileType.swift6
-rw-r--r--Sora/Views/Post/PostGridView.swift77
-rw-r--r--Sora/Views/Post/PostThumbnailMode.swift5
-rw-r--r--Sora/Views/Post/PostView.swift20
-rw-r--r--Sora/Views/SearchSuggestionsView.swift21
-rw-r--r--Sora/Views/Settings/SettingsDetailsView.swift31
-rw-r--r--Sora/Views/Settings/SettingsSearchView.swift22
-rw-r--r--Sora/Views/Settings/SettingsThumbnailsView.swift69
-rw-r--r--Sora/Views/SettingsView.swift32
-rw-r--r--Sora/Views/SettingsViewMacOS.swift30
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&apos;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())
+}