summaryrefslogtreecommitdiff
path: root/Sora
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-02-17 19:59:40 -0800
committerFuwn <[email protected]>2025-02-17 19:59:40 -0800
commit4b03f632491b2f988f1acf07a257cc23ef4f395b (patch)
treeb9f5093fdfbdf93d19266463a608d892776803b5 /Sora
downloadsora-testing-4b03f632491b2f988f1acf07a257cc23ef4f395b.tar.xz
sora-testing-4b03f632491b2f988f1acf07a257cc23ef4f395b.zip
feat: Initial commit
Diffstat (limited to 'Sora')
-rw-r--r--Sora/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--Sora/Assets.xcassets/AppIcon.appiconset/Contents.json85
-rw-r--r--Sora/Assets.xcassets/Contents.json6
-rw-r--r--Sora/Data/MoebooruPost.swift16
-rw-r--r--Sora/Data/MoebooruXMLParser.swift49
-rw-r--r--Sora/Preview Content/Preview Assets.xcassets/Contents.json6
-rw-r--r--Sora/Sora.entitlements12
-rw-r--r--Sora/SoraApp.swift10
-rw-r--r--Sora/Views/ContentView.swift198
-rw-r--r--Sora/Views/Post/PostDetailView.swift75
-rw-r--r--Sora/Views/Post/PostLoadingStage.swift5
-rw-r--r--Sora/Views/Post/PostThumbnailMode.swift5
-rw-r--r--Sora/Views/Post/PostView.swift30
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)
+ }
+ }
+}