diff options
| author | Fuwn <[email protected]> | 2025-02-17 19:59:40 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-02-17 19:59:40 -0800 |
| commit | 4b03f632491b2f988f1acf07a257cc23ef4f395b (patch) | |
| tree | b9f5093fdfbdf93d19266463a608d892776803b5 /Sora | |
| download | sora-testing-4b03f632491b2f988f1acf07a257cc23ef4f395b.tar.xz sora-testing-4b03f632491b2f988f1acf07a257cc23ef4f395b.zip | |
feat: Initial commit
Diffstat (limited to 'Sora')
| -rw-r--r-- | Sora/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | Sora/Assets.xcassets/AppIcon.appiconset/Contents.json | 85 | ||||
| -rw-r--r-- | Sora/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Sora/Data/MoebooruPost.swift | 16 | ||||
| -rw-r--r-- | Sora/Data/MoebooruXMLParser.swift | 49 | ||||
| -rw-r--r-- | Sora/Preview Content/Preview Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | Sora/Sora.entitlements | 12 | ||||
| -rw-r--r-- | Sora/SoraApp.swift | 10 | ||||
| -rw-r--r-- | Sora/Views/ContentView.swift | 198 | ||||
| -rw-r--r-- | Sora/Views/Post/PostDetailView.swift | 75 | ||||
| -rw-r--r-- | Sora/Views/Post/PostLoadingStage.swift | 5 | ||||
| -rw-r--r-- | Sora/Views/Post/PostThumbnailMode.swift | 5 | ||||
| -rw-r--r-- | Sora/Views/Post/PostView.swift | 30 |
13 files changed, 508 insertions, 0 deletions
diff --git a/Sora/Assets.xcassets/AccentColor.colorset/Contents.json b/Sora/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Sora/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/Contents.json b/Sora/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sora/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Data/MoebooruPost.swift b/Sora/Data/MoebooruPost.swift new file mode 100644 index 0000000..df332ec --- /dev/null +++ b/Sora/Data/MoebooruPost.swift @@ -0,0 +1,16 @@ +import Foundation + +struct MoebooruPost: Identifiable, Hashable { + let id: Int + let tags: [String] + let createdAt: Date + let author: String + let source: URL? + let score: Int + let fileURL: URL? + let previewURL: URL? + let sampleURL: URL? + let jpegURL: URL? + let width: Int + let height: Int +} diff --git a/Sora/Data/MoebooruXMLParser.swift b/Sora/Data/MoebooruXMLParser.swift new file mode 100644 index 0000000..1053299 --- /dev/null +++ b/Sora/Data/MoebooruXMLParser.swift @@ -0,0 +1,49 @@ +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/Preview Content/Preview Assets.xcassets/Contents.json b/Sora/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sora/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements new file mode 100644 index 0000000..625af03 --- /dev/null +++ b/Sora/Sora.entitlements @@ -0,0 +1,12 @@ +<?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>com.apple.security.app-sandbox</key> + <true/> + <key>com.apple.security.files.user-selected.read-only</key> + <true/> + <key>com.apple.security.network.client</key> + <true/> +</dict> +</plist> diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift new file mode 100644 index 0000000..1fab837 --- /dev/null +++ b/Sora/SoraApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct SoraApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Sora/Views/ContentView.swift b/Sora/Views/ContentView.swift new file mode 100644 index 0000000..38516ef --- /dev/null +++ b/Sora/Views/ContentView.swift @@ -0,0 +1,198 @@ +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)), + ] + } + + 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") + } + } + } + } detail: { + if let post = selectedPost { + PostDetailView(post: post) + } else { + 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") + } + } + } + } + #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() +} diff --git a/Sora/Views/Post/PostDetailView.swift b/Sora/Views/Post/PostDetailView.swift new file mode 100644 index 0000000..799339c --- /dev/null +++ b/Sora/Views/Post/PostDetailView.swift @@ -0,0 +1,75 @@ +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/PostLoadingStage.swift b/Sora/Views/Post/PostLoadingStage.swift new file mode 100644 index 0000000..4a8c7f3 --- /dev/null +++ b/Sora/Views/Post/PostLoadingStage.swift @@ -0,0 +1,5 @@ +enum PostLoadingState { + case loadingPreview + case loadingFile + case loaded +} diff --git a/Sora/Views/Post/PostThumbnailMode.swift b/Sora/Views/Post/PostThumbnailMode.swift new file mode 100644 index 0000000..1e6c3b3 --- /dev/null +++ b/Sora/Views/Post/PostThumbnailMode.swift @@ -0,0 +1,5 @@ +enum PostThumbnailMode { + case preview + case sample + case file +} diff --git a/Sora/Views/Post/PostView.swift b/Sora/Views/Post/PostView.swift new file mode 100644 index 0000000..97e311c --- /dev/null +++ b/Sora/Views/Post/PostView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct PostView: View { + let post: MoebooruPost + var softLimit: CGFloat + var thumbnailMode: PostThumbnailMode = .preview + private var thumbnailURL: URL? { + switch thumbnailMode { + case .preview: + return post.previewURL + case .sample: + return post.sampleURL + case .file: + return post.fileURL + } + } + + var body: some View { + VStack { + AsyncImage(url: thumbnailURL) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + } + .frame(width: softLimit, height: softLimit) + } + } +} |