diff options
Diffstat (limited to 'Sora/Views')
| -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 |
5 files changed, 313 insertions, 0 deletions
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) + } + } +} |