Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Django Files/API/Files.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,13 @@ public struct DFFile: Codable, Hashable, Equatable, Identifiable {
public var raw: String
public var date: String
public var albums: [Int]
public var tags: [String]
public var exif: [String: AnyCodable]?
public var meta: [String: AnyCodable]?

// Skip nested JSON structures
enum CodingKeys: String, CodingKey {
case id, user, size, mime, name, info, expr, view, maxv, password, `private`, avatar, userName, userUsername, url, thumb, raw, date, albums, exif, meta
case id, user, size, mime, name, info, expr, view, maxv, password, `private`, avatar, userName, userUsername, url, thumb, raw, date, albums, tags, exif, meta
}

public init(from decoder: Decoder) throws {
Expand All @@ -57,7 +58,8 @@ public struct DFFile: Codable, Hashable, Equatable, Identifiable {
raw = try container.decode(String.self, forKey: .raw)
date = try container.decode(String.self, forKey: .date)
albums = try container.decode([Int].self, forKey: .albums)

tags = (try? container.decode([String].self, forKey: .tags)) ?? []

// Decode exif and meta as dynamic JSON objects
if let exifContainer = try? container.decode([String: AnyCodable].self, forKey: .exif) {
exif = exifContainer
Expand Down Expand Up @@ -93,6 +95,7 @@ public struct DFFile: Codable, Hashable, Equatable, Identifiable {
try container.encode(raw, forKey: .raw)
try container.encode(date, forKey: .date)
try container.encode(albums, forKey: .albums)
try container.encode(tags, forKey: .tags)
try container.encode(exif, forKey: .exif)
try container.encode(meta, forKey: .meta)
}
Expand Down
2 changes: 1 addition & 1 deletion Django Files/Views/Lists/FileList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ struct FileListView: View {
isSelectMode = true
selectedFileIDs = []
} label: {
Label("Bulk Select", systemImage: "checklist")
Label("Select", systemImage: "checklist")
}
.disabled(filteredFiles.isEmpty)
}
Expand Down
136 changes: 110 additions & 26 deletions Django Files/Views/Lists/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@

import SwiftUI

enum SearchScope: String, CaseIterable {
case files = "Files"
case albums = "Albums"
case shorts = "Shorts"
case streams = "Streams"
}

struct SearchView: View {
let server: Binding<DjangoFilesSession?>
@Binding var searchQuery: String
@Binding var scope: SearchScope

@State private var searchTask: Task<Void, Never>? = nil

@State private var files: [DFFile] = []
@State private var albums: [DFAlbum] = []
@State private var shorts: [DFShort] = []
@State private var streams: [DFStream] = []

@State private var isLoading = false
@State private var searchTask: Task<Void, Never>? = nil
@State private var selectedFile: DFFile? = nil
@State private var showingPreview = false
@State private var showFileInfo = false
Expand All @@ -22,28 +35,13 @@ struct SearchView: View {

var body: some View {
List {
ForEach(files) { file in
Button {
selectedFile = file
showingPreview = true
} label: {
FileRowView(file: .constant(file), serverURL: serverURL)
}
.buttonStyle(.plain)
}
listContent
}
.listStyle(.plain)
.overlay { overlay }
.navigationTitle("Search")
.onChange(of: searchQuery) { _, newQuery in
searchTask?.cancel()
guard !newQuery.isEmpty else { files = []; return }
searchTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await fetchFiles(query: newQuery)
}
}
.onChange(of: scope) { _, _ in triggerSearch() }
.onChange(of: searchQuery) { _, _ in triggerSearch() }
.fullScreenCover(isPresented: $showingPreview) {
if let file = selectedFile,
let index = files.firstIndex(where: { $0.id == file.id }) {
Expand All @@ -62,30 +60,116 @@ struct SearchView: View {
}
}

private func matchingTag(for file: DFFile) -> String? {
guard !searchQuery.isEmpty else { return nil }
let q = searchQuery.lowercased()
return file.tags.first { $0.lowercased().contains(q) }
}

@ViewBuilder
private var listContent: some View {
switch scope {
case .files:
ForEach(files) { file in
Button {
selectedFile = file
showingPreview = true
} label: {
VStack(alignment: .leading, spacing: 4) {
FileRowView(file: .constant(file), serverURL: serverURL)
if let tag = matchingTag(for: file) {
Text(tag)
.font(.caption2.weight(.medium))
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.foregroundStyle(.white)
.background(Color.blue, in: Capsule())
.padding(.leading, 72)
}
}
}
.buttonStyle(.plain)
}
case .albums:
ForEach(albums) { album in
AlbumRowView(album: album, session: server.wrappedValue)
}
case .shorts:
ForEach(shorts) { short in
ShortRow(short: short)
}
case .streams:
ForEach(streams) { stream in
StreamRow(stream: stream)
}
}
}

@ViewBuilder
private var overlay: some View {
if searchQuery.isEmpty {
ContentUnavailableView(
"Search Files",
"Search \(scope.rawValue)",
systemImage: "magnifyingglass",
description: Text("Search by filename, type, or content")
description: Text("Search by name or content")
)
} else if files.isEmpty && !isLoading {
} else if !isLoading && currentResults == 0 {
ContentUnavailableView.search(text: searchQuery)
}
}

private var currentResults: Int {
switch scope {
case .files: return files.count
case .albums: return albums.count
case .shorts: return shorts.count
case .streams: return streams.count
}
}

private func triggerSearch() {
searchTask?.cancel()
guard !searchQuery.isEmpty else {
files = []; albums = []; shorts = []; streams = []
return
}
searchTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await fetchResults(query: searchQuery, scope: scope)
}
}

@MainActor
private func fetchFiles(query: String) async {
private func fetchResults(query: String, scope: SearchScope) async {
guard let serverInstance = server.wrappedValue,
let url = URL(string: serverInstance.url) else { return }
isLoading = true
let api = DFAPI(url: url, token: serverInstance.token)
do {
let response = try await api.getFiles(page: 1, selectedServer: serverInstance, search: query)
files = response.files
switch scope {
case .files:
let response = try await api.getFiles(page: 1, selectedServer: serverInstance, search: query)
files = response.files
case .albums:
let response = try await api.getAlbums(search: query, selectedServer: serverInstance)
albums = response.albums
case .shorts:
let response = try await api.getShorts(selectedServer: serverInstance)
let q = query.lowercased()
shorts = response.shorts.filter {
$0.short.lowercased().contains(q) || $0.url.lowercased().contains(q)
}
case .streams:
let response = try await api.getStreams(selectedServer: serverInstance)
let q = query.lowercased()
streams = response.streams.filter {
$0.name.lowercased().contains(q) || $0.title.lowercased().contains(q)
}
}
} catch {
files = []
files = []; albums = []; shorts = []; streams = []
}
isLoading = false
}
Expand Down
10 changes: 8 additions & 2 deletions Django Files/Views/TabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct TabViewWindow: View {
@State private var albumsNavigationPath = NavigationPath()
@State private var showFileInfo = false
@State private var searchQuery = ""
@State private var searchScope: SearchScope = .files

@AppStorage("tabOrder") private var tabOrderString = "files,albums,shorts,streams"
@AppStorage("hiddenTabs") private var hiddenTabsString = ""
Expand Down Expand Up @@ -154,9 +155,14 @@ struct TabViewWindow: View {
if server.auth {
SwiftUI.Tab(value: Tab.search, role: .search) {
NavigationStack {
SearchView(server: $sessionManager.selectedSession, searchQuery: $searchQuery)
SearchView(server: $sessionManager.selectedSession, searchQuery: $searchQuery, scope: $searchScope)
}
.searchable(text: $searchQuery, prompt: "Search…")
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { s in
Text(s.rawValue).tag(s)
}
}
.searchable(text: $searchQuery, prompt: "Search files…")
}
}
}
Expand Down
Loading