diff --git a/Django Files.xcodeproj/project.pbxproj b/Django Files.xcodeproj/project.pbxproj index 06706fe..38cc109 100644 --- a/Django Files.xcodeproj/project.pbxproj +++ b/Django Files.xcodeproj/project.pbxproj @@ -79,11 +79,15 @@ 4CA2E47D2D6D22CA006EF3F0 /* Exceptions for "Django Files" folder in "UploadAndCopy" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + API/Albums.swift, API/DFAPI.swift, API/Error.swift, + API/Files.swift, + API/Gallery.swift, API/Short.swift, API/Stats.swift, API/Upload.swift, + API/Websocket.swift, Models/DjangoFilesSession.swift, ); target = 4C82CB7E2D624E8700C0893B /* UploadAndCopy */; @@ -445,7 +449,7 @@ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This lets you save or upload photos to your Django Files"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -491,7 +495,7 @@ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This lets you save or upload photos to your Django Files"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13f3d71..1836e56 100644 --- a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "92f8ebe1937b4256bd2ad3830b28e1a8d86ccbc17fb04b921574cc4f6e156702", + "originHash" : "8a862c5942fae692f106a0e41c0fdd2a42f554e2bc2075d972093519c0e5fc16", "pins" : [ { "identity" : "fastlane", diff --git a/Django Files/API/Albums.swift b/Django Files/API/Albums.swift new file mode 100644 index 0000000..222a3ef --- /dev/null +++ b/Django Files/API/Albums.swift @@ -0,0 +1,82 @@ +// +// Albums.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import Foundation + +// Album model that matches the JSON payload +struct DFAlbum: Identifiable, Decodable, Hashable { + let id: Int + let user: Int + let name: String + let password: String + let `private`: Bool + let info: String + let view: Int + let maxv: Int + let expr: String + let date: String + let url: String + + // Format date for display + func formattedDate() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + + if let date = dateFormatter.date(from: date) { + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .medium + displayFormatter.timeStyle = .short + return displayFormatter.string(from: date) + } + + return date + } +} + +// Response structure for album API call +struct AlbumsResponse: Decodable { + let albums: [DFAlbum] + let next: Int? + let count: Int +} + +extension DFAPI { + // Fetch albums with pagination + func getAlbums(page: Int = 1) async -> AlbumsResponse? { + guard var components = URLComponents(string: "\(url)/api/albums/") else { + return nil + } + + components.queryItems = [URLQueryItem(name: "page", value: "\(page)")] + + guard let requestURL = components.url else { + return nil + } + + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + return nil + } + + let decoder = JSONDecoder() + let albumsResponse = try decoder.decode(AlbumsResponse.self, from: data) + return albumsResponse + + } catch { + print("Error fetching albums: \(error)") + return nil + } + } +} + diff --git a/Django Files/API/DFAPI.swift b/Django Files/API/DFAPI.swift index 4bad3ef..c9a6bd9 100644 --- a/Django Files/API/DFAPI.swift +++ b/Django Files/API/DFAPI.swift @@ -8,22 +8,36 @@ import Foundation import HTTPTypes import HTTPTypesFoundation +import UIKit + +// Custom imports +import SwiftUI // Needed for ToastManager + +// Add an import for the models file +// This line should be modified if the module structure is different +// Or the models should be declared here if needed struct DFAPI { private static let API_PATH = "/api/" + // Add a shared WebSocket instance + private static var sharedWebSocket: DFWebSocket? + enum DjangoFilesAPIs: String { case stats = "stats/" case upload = "upload/" case short = "shorten/" case auth_methods = "auth/methods/" case login = "auth/token/" + case files = "files/" + case shorts = "shorts/" } let url: URL let token: String var decoder: JSONDecoder + init(url: URL, token: String){ self.url = url self.token = token @@ -159,6 +173,28 @@ struct DFAPI { } } + public func getShorts(amount: Int = 50, start: Int? = nil) async -> ShortsResponse? { + var parameters: [String: String] = ["amount": "\(amount)"] + if let start = start { + parameters["start"] = "\(start)" + } + + do { + let responseBody = try await makeAPIRequest( + path: getAPIPath(.shorts), + parameters: parameters, + method: .get + ) + + let shorts = try decoder.decode([DFShort].self, from: responseBody) + return ShortsResponse(shorts: shorts) + + } catch { + print("Error fetching shorts: \(error)") + return nil + } + } + public func getAuthMethods() async -> DFAuthMethodsResponse? { do { let responseBody = try await makeAPIRequest( @@ -248,6 +284,7 @@ struct DFAPI { if let url = urlRequest.url { // Set the cookie directly in the request header urlRequest.setValue("sessionid=\(sessionKey)", forHTTPHeaderField: "Cookie") + print("Using session key cookie: \(sessionKey) on \(url)") // Also set it in the cookie storage let cookieProperties: [HTTPCookiePropertyKey: Any] = [ @@ -279,6 +316,8 @@ struct DFAPI { let session = URLSession(configuration: configuration) let (_, response) = try await session.data(for: urlRequest) + print("response: \(response)") + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { print("Request failed with status: \((response as? HTTPURLResponse)?.statusCode ?? -1)") @@ -321,9 +360,50 @@ struct DFAPI { return false } } -} + public func getFiles(page: Int = 1) async -> DFFilesResponse? { + do { + let responseBody = try await makeAPIRequest( + path: getAPIPath(.files) + "\(page)/", + parameters: [:], + method: .get + ) + + // Use the default decoder since dates are now handled as strings + let specialDecoder = JSONDecoder() + specialDecoder.keyDecodingStrategy = .convertFromSnakeCase + return try specialDecoder.decode(DFFilesResponse.self, from: responseBody) + } catch let DecodingError.keyNotFound(key, context) { + print("Missing key: \(key.stringValue) in context: \(context.debugDescription)") + } catch { + print("Request failed \(error)") + } + return nil + } + // Create and connect to a WebSocket, also setting up WebSocketToastObserver + public func connectToWebSocket() -> DFWebSocket { + let webSocket = self.createWebSocket() + + // Instead of directly accessing WebSocketToastObserver, post a notification + // that the observer will pick up + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketConnectionRequest"), + object: nil, + userInfo: ["api": self] + ) + + // Store as the shared instance + DFAPI.sharedWebSocket = webSocket + + return webSocket + } + + // Get the shared WebSocket or create a new one if none exists + public static func getSharedWebSocket() -> DFWebSocket? { + return sharedWebSocket + } +} class DjangoFilesUploadDelegate: NSObject, StreamDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate, URLSessionStreamDelegate{ enum States { diff --git a/Django Files/API/Files.swift b/Django Files/API/Files.swift new file mode 100644 index 0000000..b242369 --- /dev/null +++ b/Django Files/API/Files.swift @@ -0,0 +1,85 @@ +// +// Files.swift +// Django Files +// +// Created by Ralph Luaces on 4/23/25. +// + +import Foundation + +public struct DFFile: Codable, Hashable, Equatable { + public let id: Int + public let user: Int + public let size: Int + public let mime: String + public let name: String + public let userName: String? = "" + public let userUsername: String? = "" + public let info: String + public let expr: String + public let view: Int + public let maxv: Int + public let password: String + public let `private`: Bool + public let avatar: Bool + public let url: String + public let thumb: String + public let raw: String + public let date: String + public let albums: [Int] + + // 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 + } + + // Helper property to get a Date object when needed + public var dateObject: Date? { + let iso8601Formatter = ISO8601DateFormatter() + iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + if let date = iso8601Formatter.date(from: date) { + return date + } + + // Fall back to other formatters if needed + let backupFormatter = DateFormatter() + backupFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + backupFormatter.locale = Locale(identifier: "en_US_POSIX") + backupFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + if let date = backupFormatter.date(from: date) { + return date + } + + return nil + } + + // Format the date string for display + public func formattedDate() -> String { + guard let date = dateObject else { + return date // Return the raw string if we can't parse it + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + // Add hash implementation for Hashable conformance + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + // Add equality implementation for Equatable conformance + public static func == (lhs: DFFile, rhs: DFFile) -> Bool { + return lhs.id == rhs.id + } +} + +public struct DFFilesResponse: Codable { + public let files: [DFFile] + public let next: Int? + public let count: Int +} diff --git a/Django Files/API/Gallery.swift b/Django Files/API/Gallery.swift new file mode 100644 index 0000000..b7b4f7f --- /dev/null +++ b/Django Files/API/Gallery.swift @@ -0,0 +1,8 @@ +// +// Gallery.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import Foundation diff --git a/Django Files/API/Short.swift b/Django Files/API/Short.swift index 727cb8d..6147a26 100644 --- a/Django Files/API/Short.swift +++ b/Django Files/API/Short.swift @@ -39,3 +39,30 @@ struct DFShortResponse: Codable{ url = try container.decode(String.self, forKey: .url) } } + +struct DFShort: Identifiable, Codable, Hashable { + let id: Int + let short: String + let url: String + let max: Int + let views: Int + let user: Int + let fullUrl: String + + enum CodingKeys: String, CodingKey { + case id, short, url, max, views, user, fullUrl + } +} + +// Response structure for shorts API call +struct ShortsResponse: Codable { + let shorts: [DFShort] + + init(shorts: [DFShort]) { + self.shorts = shorts + } + + enum CodingKeys: String, CodingKey { + case shorts + } +} diff --git a/Django Files/API/Websocket.swift b/Django Files/API/Websocket.swift new file mode 100644 index 0000000..fd4e745 --- /dev/null +++ b/Django Files/API/Websocket.swift @@ -0,0 +1,317 @@ +// +// Websocket.swift +// Django Files +// +// Created by Ralph Luaces on 5/1/25. +// + +import Foundation +import Combine + +protocol DFWebSocketDelegate: AnyObject { + func webSocketDidConnect(_ webSocket: DFWebSocket) + func webSocketDidDisconnect(_ webSocket: DFWebSocket, withError error: Error?) + func webSocket(_ webSocket: DFWebSocket, didReceiveMessage data: DFWebSocketMessage) +} + +// Message types that can be received from the server +struct DFWebSocketMessage: Codable { + let event: String + let message: String? + let bsClass: String? + let delay: String? + let id: Int? + let name: String? + let user: Int? + let expr: String? + let `private`: Bool? + let password: String? + let old_name: String? + let objects: [DFWebSocketObject]? +} + +struct DFWebSocketObject: Codable { + let id: Int + let name: String + let expr: String? + let `private`: Bool? +} + +class DFWebSocket: NSObject { + private var webSocketTask: URLSessionWebSocketTask? + private var pingTimer: Timer? + private var reconnectTimer: Timer? + private var session: URLSession! + + private var isConnected = false + private var isReconnecting = false + + // URL components for the WebSocket connection + private let server: URL + private let token: String + + weak var delegate: DFWebSocketDelegate? + + init(server: URL, token: String) { + self.server = server + self.token = token + super.init() + + // Create a URLSession with the delegate set to self + let configuration = URLSessionConfiguration.default + session = URLSession(configuration: configuration, delegate: nil, delegateQueue: .main) + + // Connect to the WebSocket server + connect() + } + + deinit { + disconnect() + } + + // MARK: - Connection Management + + func connect() { + // Create the WebSocket URL + var components = URLComponents(url: server, resolvingAgainstBaseURL: true)! + + // Determine if we need wss or ws + let isSecure = components.scheme == "https" + components.scheme = isSecure ? "wss" : "ws" + + // Set the path for the WebSocket + components.path = "/ws/home/" + + guard let url = components.url else { + print("Invalid WebSocket URL") + return + } + + print("WebSocket: Connecting to \(url.absoluteString)...") + + // Create the WebSocket task + var request = URLRequest(url: url) + request.setValue(token, forHTTPHeaderField: "Authorization") + + webSocketTask = session.webSocketTask(with: request) + webSocketTask?.resume() + + // Set up message receiving + receiveMessage() + + // Setup ping timer to keep connection alive + setupPingTimer() + + // Post a notification that we're attempting connection + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "Connecting to WebSocket..."] + ) + + print("WebSocket: Connection attempt started") + } + + func disconnect() { + pingTimer?.invalidate() + pingTimer = nil + + reconnectTimer?.invalidate() + reconnectTimer = nil + + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + + isConnected = false + } + + private func reconnect() { + guard !isReconnecting else { return } + + isReconnecting = true + print("WebSocket Disconnected! Reconnecting...") + + // Clean up existing connection + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + pingTimer?.invalidate() + pingTimer = nil + + // Schedule reconnection + reconnectTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.isReconnecting = false + self.connect() + } + } + + private func setupPingTimer() { + pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.ping() + } + } + + private func ping() { + webSocketTask?.sendPing { [weak self] error in + print("websocket ping") + if let error = error { + print("WebSocket ping error: \(error)") + self?.reconnect() + } + } + } + + // MARK: - Message Handling + + private func receiveMessage() { + webSocketTask?.receive { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let message): + switch message { + case .string(let text): + self.handleMessage(text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + self.handleMessage(text) + } + @unknown default: + break + } + + // Continue listening for more messages + self.receiveMessage() + + case .failure(let error): + print("WebSocket receive error: \(error)") + self.delegate?.webSocketDidDisconnect(self, withError: error) + self.reconnect() + } + } + } + + private func handleMessage(_ messageText: String) { + print("WebSocket message received: \(messageText)") + + guard let data = messageText.data(using: .utf8) else { return } + + do { + let message = try JSONDecoder().decode(DFWebSocketMessage.self, from: data) + + // Post a notification for toast messages if the event is appropriate + if message.event == "toast" || message.event == "notification" { + let userInfo: [String: Any] = ["message": message.message ?? "New notification"] + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: userInfo + ) + } else if message.event == "file-new" { + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "New file (\(message.name ?? "Untitled.file"))"] + ) + } else if message.event == "file-delete" { + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "File (\(message.name ?? "Untitled.file")) deleted."] + ) + } else { + // For debugging - post a notification for all message types + print("WebSocket: Received message with event: \(message.event)") + let displayText = "WebSocket: \(message.event) - \(message.message ?? "No message")" + + // Post notification for all WebSocket events during debugging + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": displayText] + ) + } + + // Process the message + DispatchQueue.main.async { + self.delegate?.webSocket(self, didReceiveMessage: message) + } + } catch { + print("Failed to decode WebSocket message: \(error)") + + // Try to show the raw message as a toast for debugging + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "Raw WebSocket message: \(messageText)"] + ) + } + } + + // MARK: - Sending Messages + + func send(message: String) { + webSocketTask?.send(.string(message)) { error in + if let error = error { + print("WebSocket send error: \(error)") + } + } + } + + func send(object: T) { + do { + let data = try JSONEncoder().encode(object) + if let json = String(data: data, encoding: .utf8) { + send(message: json) + } + } catch { + print("Failed to encode WebSocket message: \(error)") + } + } +} + +// MARK: - URLSessionWebSocketDelegate + +extension DFWebSocket: URLSessionWebSocketDelegate { + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + print("WebSocket Connected.") + isConnected = true + + // Post a notification that connection was successful + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "WebSocket Connected"] + ) + + delegate?.webSocketDidConnect(self) + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + isConnected = false + let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "Unknown reason" + print("WebSocket Closed with code: \(closeCode), reason: \(reasonString)") + + // Post a notification about the disconnection + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "WebSocket Disconnected: \(closeCode)"] + ) + + // Only trigger reconnect for abnormal closures + if closeCode != .normalClosure && closeCode != .goingAway { + delegate?.webSocketDidDisconnect(self, withError: nil) + reconnect() + } + } +} + +// MARK: - Extension to DFAPI + +extension DFAPI { + func createWebSocket() -> DFWebSocket { + return DFWebSocket(server: self.url, token: self.token) + } +} + diff --git a/Django Files/Django_FilesApp.swift b/Django Files/Django_FilesApp.swift index 4ed1fe4..88769da 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -8,6 +8,32 @@ import SwiftUI import SwiftData +class SessionManager: ObservableObject { + @Published var selectedSession: DjangoFilesSession? + private let userDefaultsKey = "lastSelectedSessionURL" + + func saveSelectedSession() { + if let session = selectedSession { + UserDefaults.standard.set(session.url, forKey: userDefaultsKey) + } + } + + func loadLastSelectedSession(from sessions: [DjangoFilesSession]) { + // Return if we already have a session loaded + if selectedSession != nil { return } + + if let lastSessionURL = UserDefaults.standard.string(forKey: userDefaultsKey) { + selectedSession = sessions.first(where: { $0.url == lastSessionURL }) + } else if let defaultSession = sessions.first(where: { $0.defaultSession }) { + // Fall back to any session marked as default + selectedSession = defaultSession + } else if let firstSession = sessions.first { + // Fall back to the first available session + selectedSession = firstSession + } + } +} + @main struct Django_FilesApp: App { var sharedModelContainer: ModelContainer = { @@ -22,7 +48,17 @@ struct Django_FilesApp: App { } }() + @StateObject private var sessionManager = SessionManager() + @State private var hasExistingSessions = false + init() { + // Initialize WebSocket debugging + print("📱 App initializing - WebSocket toast system will use direct approach") + + // Initialize WebSocket toast observer - make sure this runs at startup + print("📱 Setting up WebSocketToastObserver") + let _ = WebSocketToastObserver.shared + // Handle reset arguments if CommandLine.arguments.contains("--DeleteAllData") { // Clear UserDefaults @@ -55,7 +91,19 @@ struct Django_FilesApp: App { var body: some Scene { WindowGroup { - ContentView() + Group { + if hasExistingSessions { + TabViewWindow(sessionManager: sessionManager) + } else { + SessionEditor(session: nil, onSessionCreated: { newSession in + sessionManager.selectedSession = newSession + hasExistingSessions = true + }) + .onAppear { + checkForExistingSessions() + } + } + } } .modelContainer(sharedModelContainer) #if os(macOS) @@ -64,4 +112,16 @@ struct Django_FilesApp: App { } #endif } + + private func checkForExistingSessions() { + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + do { + let sessionsCount = try context.fetchCount(descriptor) + hasExistingSessions = sessionsCount > 0 + } catch { + print("Error checking for existing sessions: \(error)") + } + } } diff --git a/Django Files/Info.plist b/Django Files/Info.plist index 2003c87..7c72a32 100644 --- a/Django Files/Info.plist +++ b/Django Files/Info.plist @@ -2,8 +2,6 @@ - ITSAppUsesNonExemptEncryption - CFBundleURLTypes diff --git a/Django Files/Models/DjangoFilesSession.swift b/Django Files/Models/DjangoFilesSession.swift index 2d79959..274b5bf 100644 --- a/Django Files/Models/DjangoFilesSession.swift +++ b/Django Files/Models/DjangoFilesSession.swift @@ -14,6 +14,8 @@ public final class DjangoFilesSession: Equatable { var defaultSession: Bool = false var token: String var auth: Bool = false + var userID: Int? + var username: String? @Transient var cookies: [HTTPCookie] = [] init() { diff --git a/Django Files/Views/AlbumList.swift b/Django Files/Views/AlbumList.swift new file mode 100644 index 0000000..f3474aa --- /dev/null +++ b/Django Files/Views/AlbumList.swift @@ -0,0 +1,229 @@ +// +// AlbumList.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import SwiftUI +import SwiftData +import Foundation + +struct AlbumListView: View { + let server: Binding + + @State private var albums: [DFAlbum] = [] + @State private var currentPage = 1 + @State private var hasNextPage = false + @State private var isLoading = true + @State private var errorMessage: String? = nil + + @State private var selectedAlbum: DFAlbum? = nil + @State private var navigationPath = NavigationPath() + + var body: some View { + ZStack { + if isLoading && albums.isEmpty { + LoadingView() + .frame(width: 100, height: 100) + } else if let error = errorMessage { + VStack { + Text("Error loading albums") + .font(.headline) + .padding(.bottom, 4) + Text(error) + .foregroundColor(.secondary) + Button("Retry") { + loadAlbums() + } + .padding(.top) + .buttonStyle(.bordered) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) + .shadow(radius: 5) + } else if albums.isEmpty { + VStack { + Image(systemName: "photo.stack.fill") + .font(.system(size: 50)) + .padding(.bottom) + Text("No albums found") + .font(.headline) + Text("Create an album to get started") + .foregroundColor(.secondary) + } + .padding() + } else { + NavigationStack(path: $navigationPath) { + List { + ForEach(albums, id: \.id) { album in + NavigationLink(value: album) { + AlbumRowView(album: album) + .contextMenu { + Button(action: { + UIPasteboard.general.string = album.url + }) { + Label("Copy Link", systemImage: "link") + } + + Button(action: { + // Toggle private action + }) { + Label(album.private ? "Make Public" : "Make Private", + systemImage: album.private ? "lock.open" : "lock") + } + } + } + .id(album.id) + + // If this is the last item and we have more pages, load more when it appears + if hasNextPage && album.id == albums.last?.id { + Color.clear + .frame(height: 20) + .onAppear { + loadNextPage() + } + } + } + + if isLoading && hasNextPage { + HStack { + ProgressView() + } + } + } + .navigationDestination(for: DFAlbum.self) { album in + Text("Album: \(album.name)") + } + .listStyle(.plain) + .refreshable { + refreshAlbums() + } + .listStyle(.plain) + .navigationTitle(server.wrappedValue != nil ? "Albums (\(URL(string: server.wrappedValue!.url)?.host ?? "unknown"))" : "Albums") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button(action: { + // Create album action + }) { + Label("Create Album", systemImage: "plus") + } + + Button(action: { + refreshAlbums() + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + .onChange(of: selectedAlbum) { oldValue, newValue in + if let album = newValue { + navigationPath.append(album) + selectedAlbum = nil // Reset after navigation + } + } + } + } + .onAppear { + loadAlbums() + } + } + + private func loadAlbums() { + isLoading = true + errorMessage = nil + currentPage = 1 + + Task { + await fetchAlbums(page: currentPage) + } + } + + private func loadNextPage() { + guard hasNextPage else { return } + guard !isLoading else { return } // Prevent multiple simultaneous loading requests + isLoading = true + + Task { + await fetchAlbums(page: currentPage + 1, append: true) + } + } + + private func refreshAlbums() { + Task { + await refreshAlbumsAsync() + } + } + + @MainActor + private func refreshAlbumsAsync() async { + isLoading = true + errorMessage = nil + currentPage = 1 + + await fetchAlbums(page: currentPage) + } + + @MainActor + private func fetchAlbums(page: Int, append: Bool = false) async { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + errorMessage = "Invalid server URL" + isLoading = false + return + } + + let api = DFAPI(url: url, token: serverInstance.token) + + if let albumsResponse = await api.getAlbums(page: page) { + if append { + albums.append(contentsOf: albumsResponse.albums) + } else { + albums = albumsResponse.albums + } + + hasNextPage = albumsResponse.next != nil + currentPage = page + isLoading = false + } else { + if !append { + albums = [] + } + errorMessage = "Failed to load albums from server" + isLoading = false + } + } +} + +struct AlbumRowView: View { + let album: DFAlbum + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.headline) + .lineLimit(1) + .foregroundColor(.blue) + HStack(spacing: 5) { + Label("\(album.view) views", systemImage: "eye") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + + if album.private { + Label("Private", systemImage: "lock") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + } + Text(album.formattedDate()) + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} diff --git a/Django Files/Views/ContentView.swift b/Django Files/Views/ContentView.swift index 14402be..65e5abf 100644 --- a/Django Files/Views/ContentView.swift +++ b/Django Files/Views/ContentView.swift @@ -8,141 +8,6 @@ import SwiftData import SwiftUI -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Environment(\.dismiss) private var dismiss - - @Query private var items: [DjangoFilesSession] - @State private var showingEditor = false - @State private var columnVisibility = NavigationSplitViewVisibility - .detailOnly - @State private var selectedServer: DjangoFilesSession? - @State private var selectedSession: DjangoFilesSession? // Track session for settings - @State private var needsRefresh = false // Added to handle refresh after adding server - @State private var itemToDelete: DjangoFilesSession? // Track item to be deleted - @State private var showingDeleteAlert = false // Track if delete alert is showing - - @State private var token: String? - - @State private var viewingSettings: Bool = false - - // stupid work around where we have to show the toolbar on ipad so splitview does not crash due to toolbar state - let toolbarVisibility: Visibility = - UIDevice.current.userInterfaceIdiom == .pad ? .visible : .hidden - - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: $selectedServer) { - ForEach(items, id: \.self) { item in - NavigationLink(value: item) { - Text(item.url) - .swipeActions { - Button(role: .destructive) { - itemToDelete = item - showingDeleteAlert = true - } label: { - Label("Delete", systemImage: "trash.fill") - } - Button { - selectedSession = item - } label: { - Label("Settings", systemImage: "gear") - } - .tint(.indigo) - } - } - } - } - .animation(.linear, value: self.items) - .toolbar { - ToolbarItem { - Button(action: { - self.showingEditor.toggle() - }) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - if let server = selectedServer { - if server.auth { - AuthViewContainer( - viewingSettings: $viewingSettings, - selectedServer: server, - columnVisibility: $columnVisibility, - showingEditor: $showingEditor, - needsRefresh: $needsRefresh - ) - .id(server.url) - .onAppear { - columnVisibility = .detailOnly - } - .toolbar(toolbarVisibility) - } else { - LoginView( - selectedServer: server, - onLoginSuccess: { - needsRefresh = true - } - ) - .id(server.url) - .onAppear { - columnVisibility = .detailOnly - } - .toolbarBackground(.hidden, for: .navigationBar) - } - } - } - .sheet(isPresented: $showingEditor) { - SessionEditor(session: nil) - .onDisappear { - if items.count > 0 { - needsRefresh = true - selectedServer = items.last - } - } - } - .sheet(item: $selectedSession) { session in - SessionSelector(session: session) - } - .onAppear { - selectedServer = - items.first(where: { $0.defaultSession }) ?? items.first - if items.count == 0 { - self.showingEditor.toggle() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .edgesIgnoringSafeArea(.all) - .alert("Delete Server", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - if let item = itemToDelete, - let index = items.firstIndex(of: item) - { - deleteItems(offsets: [index]) - if selectedServer == item { - needsRefresh = true - selectedServer = nil - } - } - } - } message: { - Text( - "Are you sure you want to delete \(URL(string: itemToDelete?.url ?? "")?.host ?? "this server")? This action cannot be undone." - ) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - public struct AuthViewContainer: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @@ -152,104 +17,81 @@ public struct AuthViewContainer: View { @State private var isAuthViewLoading: Bool = true - var viewingSettings: Binding - let selectedServer: DjangoFilesSession - var columnVisibility: Binding - var showingEditor: Binding + @State var selectedServer: DjangoFilesSession + + var needsRefresh: Binding @State private var authController: AuthController = AuthController() public var body: some View { - if viewingSettings.wrappedValue { - SessionSelector( - session: selectedServer, - viewingSelect: viewingSettings + if selectedServer.url != "" { + AuthView( + authController: authController, + httpsUrl: selectedServer.url, + doReset: authController.url?.absoluteString ?? "" + != selectedServer.url || !selectedServer.auth, + session: selectedServer ) - .onAppear { - columnVisibility.wrappedValue = .automatic + .onStartedLoading { + isAuthViewLoading = true } - } else if selectedServer.url != "" { - Color.djangoFilesBackground.ignoresSafeArea() - .overlay { - AuthView( - authController: authController, - httpsUrl: selectedServer.url, - doReset: authController.url?.absoluteString ?? "" - != selectedServer.url || !selectedServer.auth, - session: selectedServer - ) - .onStartedLoading { - isAuthViewLoading = true - } - .onCancelled { - isAuthViewLoading = false - dismiss() - } - .onAppear { - if needsRefresh.wrappedValue { - authController.reset() - needsRefresh.wrappedValue = false - } + .onCancelled { + isAuthViewLoading = false + dismiss() + } + .onAppear { + if needsRefresh.wrappedValue { + authController.reset() + needsRefresh.wrappedValue = false + } - authController.onStartedLoadingAction = { - } + authController.onStartedLoadingAction = { + } - authController.onLoadedAction = { - isAuthViewLoading = false + authController.onLoadedAction = { + isAuthViewLoading = false - } - authController.onCancelledAction = { - isAuthViewLoading = false - dismiss() - } + } + authController.onCancelledAction = { + isAuthViewLoading = false + dismiss() + } - authController.onSchemeRedirectAction = { - isAuthViewLoading = false - guard let resolve = authController.schemeURL else { - return - } - switch resolve { - case "serverlist": - if UIDevice.current.userInterfaceIdiom == .phone - { - self.presentationMode.wrappedValue.dismiss() - } - columnVisibility.wrappedValue = .all - break - case "serversettings": - viewingSettings.wrappedValue = true - break - case "logout": - selectedServer.auth = false - columnVisibility.wrappedValue = .all - modelContext.insert(selectedServer) - do { - try modelContext.save() - } catch { - print("Error saving session: \(error)") - } - self.presentationMode.wrappedValue.dismiss() - break - default: - return - } - } + authController.onSchemeRedirectAction = { + isAuthViewLoading = false + guard let resolve = authController.schemeURL else { + return } - .overlay { - if isAuthViewLoading { - LoadingView().frame(width: 100, height: 100) + switch resolve { + case "serverlist": + if UIDevice.current.userInterfaceIdiom == .phone + { + self.presentationMode.wrappedValue.dismiss() } + break + case "logout": + selectedServer.auth = false + modelContext.insert(selectedServer) + do { + try modelContext.save() + } catch { + print("Error saving session: \(error)") + } + self.presentationMode.wrappedValue.dismiss() + break + default: + return } } - .ignoresSafeArea() - .edgesIgnoringSafeArea(.all) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .overlay { + if isAuthViewLoading { + LoadingView().frame(width: 100, height: 100) + } + } } else { Text("Loading...") - .onAppear { - columnVisibility.wrappedValue = .all - } } } } @@ -288,8 +130,3 @@ struct LoadingView: View { } } } - -#Preview { - ContentView() - .modelContainer(for: DjangoFilesSession.self, inMemory: true) -} diff --git a/Django Files/Views/FileContextMenu.swift b/Django Files/Views/FileContextMenu.swift new file mode 100644 index 0000000..8d8d9b6 --- /dev/null +++ b/Django Files/Views/FileContextMenu.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct FileContextMenuButtons: View { + + var isPreviewing: Bool = false + + var onPreview: () -> Void = {} + var onCopyShareLink: () -> Void = {} + var onCopyRawLink: () -> Void = {} + var openRawBrowser: () -> Void = {} + var onTogglePrivate: () -> Void = {} + var setExpire: () -> Void = {} + var setPassword: () -> Void = {} + var addToAlbum: () -> Void = {} + var manageAlbums: () -> Void = {} + var renameFile: () -> Void = {} + var deleteFile: () -> Void = {} + + var body: some View { + Group { + if !isPreviewing { + Button { + onPreview() + } label: { + Label("Open Preview", systemImage: "arrow.up.forward.app") + } + } + + Button { + onCopyShareLink() + notifyClipboard() + } label: { + Label("Copy Share Link", systemImage: "link") + } + + Button { + onCopyRawLink() + notifyClipboard() + } label: { + Label("Copy Raw Link", systemImage: "link.circle") + } + + Button { + openRawBrowser() + } label: { + Label("Open Raw in Browser", systemImage: "globe") + } + + Divider() + Button { + onTogglePrivate() + } label: { + Label("Set Private", systemImage: "lock") + } + + Button { + setExpire() + } label: { + Label("Set Expire", systemImage: "calendar.badge.exclamationmark") + } + + Button { + setPassword() + } label: { + Label("Set Password", systemImage: "key") + } + + Button { + addToAlbum() + } label: { + Label("Add To Album", systemImage: "rectangle.stack.badge.plus") + } + + Button { + manageAlbums() + } label: { + Label("Manage Albums", systemImage: "person.2.crop.square.stack") + } + Divider() + + Button { + renameFile() + } label: { + Label("Rename File", systemImage: "character.cursor.ibeam") + } + Divider() + Button(role: .destructive) { + deleteFile() + } label: { + Label("Delete File", systemImage: "trash") + } + } + } + + func notifyClipboard() { + // Generate haptic feedback + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + ToastManager.shared.showToast(message: "Copied to clipboard") + } + } +} diff --git a/Django Files/Views/FileList.swift b/Django Files/Views/FileList.swift new file mode 100644 index 0000000..a6516d2 --- /dev/null +++ b/Django Files/Views/FileList.swift @@ -0,0 +1,282 @@ +// +// FileList.swift +// Django Files +// +// Created by Ralph Luaces on 4/19/25. +// + +import SwiftUI +import SwiftData +import Foundation + + +struct FileListView: View { + let server: Binding + + @State private var files: [DFFile] = [] + @State private var currentPage = 1 + @State private var hasNextPage = false + @State private var isLoading = true + @State private var errorMessage: String? = nil + + @State private var previewFile: Bool = true + @State private var selectedFile: DFFile? = nil + @State private var navigationPath = NavigationPath() + + var body: some View { + ZStack { + if isLoading && files.isEmpty { + LoadingView() + .frame(width: 100, height: 100) + } else if let error = errorMessage { + VStack { + Text("Error loading files") + .font(.headline) + .padding(.bottom, 4) + Text(error) + .foregroundColor(.secondary) + Button("Retry") { + loadFiles() + } + .padding(.top) + .buttonStyle(.bordered) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) + .shadow(radius: 5) + } else if files.isEmpty { + VStack { + Image(systemName: "doc.fill") + .font(.system(size: 50)) + .padding(.bottom) + Text("No files found") + .font(.headline) + Text("Upload some files to get started") + .foregroundColor(.secondary) + } + .padding() + } else { + NavigationStack(path: $navigationPath) { + List { + ForEach(files, id: \.id) { file in + NavigationLink(value: file) { + FileRowView(file: file) + .contextMenu { + FileContextMenuButtons( + isPreviewing: false, + onPreview: { + selectedFile = file + }, + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + UIPasteboard.general.string = file.raw + }, + onTogglePrivate: { + // Add this item to a list of favorites. + }, + setExpire: { + // Open Maps and center it on this item. + } + ) + } + } + .id(file.id) + + // If this is the last item and we have more pages, load more when it appears + if hasNextPage && file.id == files.last?.id { + Color.clear + .frame(height: 20) + .onAppear { + loadNextPage() + } + } + } + + if isLoading && hasNextPage { + HStack { + + ProgressView() + } + } + } + .navigationDestination(for: DFFile.self) { file in + ContentPreview(mimeType: file.mime, fileURL: URL(string: file.raw)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + FileContextMenuButtons( + isPreviewing: true, + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + UIPasteboard.general.string = file.raw + }, + onTogglePrivate: { + // Add this item to a list of favorites. + }, + setExpire: { + // Open Maps and center it on this item. + } + ) + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + + .listStyle(.plain) + .refreshable { + await refreshFiles() + } + .navigationTitle(server.wrappedValue != nil ? "Files (\(URL(string: server.wrappedValue!.url)?.host ?? "unknown"))" : "Files") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button(action: { + // Upload action + }) { + Label("Upload File", systemImage: "arrow.up.doc") + } + + Button(action: { + refreshFiles() + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + } + .onChange(of: selectedFile) { oldValue, newValue in + if let file = newValue { + navigationPath.append(file) + selectedFile = nil // Reset after navigation + } + } + } + } + .onAppear { + loadFiles() + } + } + + private func loadFiles() { + isLoading = true + errorMessage = nil + currentPage = 1 + + Task { + await fetchFiles(page: currentPage) + } + } + + private func loadNextPage() { + guard hasNextPage else { return } + guard !isLoading else { return } // Prevent multiple simultaneous loading requests + isLoading = true + + Task { + await fetchFiles(page: currentPage + 1, append: true) + } + } + + private func refreshFiles() { + Task { + await refreshFiles() + } + } + + @MainActor + private func refreshFiles() async { + isLoading = true + errorMessage = nil + currentPage = 1 + + await fetchFiles(page: currentPage) + } + + @MainActor + private func fetchFiles(page: Int, append: Bool = false) async { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + errorMessage = "Invalid server URL" + isLoading = false + return + } + + let api = DFAPI(url: url, token: serverInstance.token) + + if let filesResponse = await api.getFiles(page: page) { + if append { + files.append(contentsOf: filesResponse.files) + } else { + files = filesResponse.files + } + + hasNextPage = filesResponse.next != nil + currentPage = page + isLoading = false + } else { + if !append { + files = [] + } + errorMessage = "Failed to load files from server" + isLoading = false + } + } +} + +struct CustomLabel: LabelStyle { + var spacing: Double = 0.0 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: spacing) { + configuration.icon + configuration.title + } + } +} + +struct FileRowView: View { + let file: DFFile + private func getIcon() -> String { + switch file.mime { + case "image/jpeg": + return "photo.artframe" + default: + return "doc.fill" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .font(.headline) + .lineLimit(1) + .foregroundColor(.blue) + + HStack(spacing: 5) { + Label(file.mime, systemImage: getIcon()) + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + + Label(file.userUsername!, systemImage: "person") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + + Spacer() + + Text(file.formattedDate()) + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} diff --git a/Django Files/Views/LoginView.swift b/Django Files/Views/LoginView.swift index 9839534..ded1328 100644 --- a/Django Files/Views/LoginView.swift +++ b/Django Files/Views/LoginView.swift @@ -3,6 +3,7 @@ import WebKit struct LoginView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss @State private var selectedServer: DjangoFilesSession @State private var username: String = "" @@ -66,6 +67,9 @@ struct LoginView: View { try? modelContext.save() } onLoginSuccess() + Task { + self.dismiss() + } } else { showErrorBanner = true oauthSheetURL = nil @@ -318,11 +322,11 @@ struct OAuthURL: Identifiable { let url: String } -#Preview { - LoginView( - selectedServer: DjangoFilesSession(url: "http://localhost"), - onLoginSuccess: { - print("Login success") - } - ) -} +//#Preview { +// LoginView( +// selectedServer: DjangoFilesSession(url: "http://localhost"), +// onLoginSuccess: { +// print("Login success") +// } +// ) +//} diff --git a/Django Files/Views/Preview.swift b/Django Files/Views/Preview.swift new file mode 100644 index 0000000..9a3b996 --- /dev/null +++ b/Django Files/Views/Preview.swift @@ -0,0 +1,186 @@ +import SwiftUI +import AVKit + +struct ContentPreview: View { + let mimeType: String + let fileURL: URL + + init(mimeType: String, fileURL: URL?) { + self.mimeType = mimeType + self.fileURL = fileURL ?? URL(string: "about:blank")! + } + + @State private var content: Data? + @State private var isLoading = true + @State private var error: Error? + @State private var imageScale: CGFloat = 1.0 + @State private var lastImageScale: CGFloat = 1.0 + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if let error = error { + VStack { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + Text("Error: \(error.localizedDescription)") + .multilineTextAlignment(.center) + .padding() + } + } else { + contentView + } + } + .onAppear { + loadContent() + } + } + + // Determine the appropriate view based on MIME type + private var contentView: some View { + Group { + if mimeType.starts(with: "text/") { + textPreview + } else if mimeType.starts(with: "image/") { + imagePreview + } else if mimeType.starts(with: "video/") { + videoPreview + } else { + genericFilePreview + } + } + } + + // Text Preview + private var textPreview: some View { + ScrollView { + if let content = content, let text = String(data: content, encoding: .utf8) { + Text(text) + .padding() + } else { + Text("Unable to decode text content") + .foregroundColor(.red) + } + } + } + + // Image Preview + private var imagePreview: some View { + GeometryReader { geometry in + ScrollView([.horizontal, .vertical]) { + if let content = content, let uiImage = UIImage(data: content) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(width: geometry.size.width) + .scaleEffect(imageScale) + .frame( + minWidth: geometry.size.width * imageScale, + minHeight: geometry.size.height * imageScale + ) + .gesture( + MagnificationGesture() + .onChanged { value in + let delta = value / lastImageScale + lastImageScale = value + imageScale = min(max(imageScale * delta, 1), 5) + } + .onEnded { _ in + lastImageScale = 1.0 + } + ) + .onTapGesture(count: 2) { + withAnimation { + imageScale = imageScale > 1 ? 1 : 2 + } + } + } else { + Text("Unable to load image") + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + } + + // Video Preview + private var videoPreview: some View { + VideoPlayer(player: AVPlayer(url: fileURL)) + .aspectRatio(contentMode: .fit) + } + + // Generic File Preview + private var genericFilePreview: some View { + VStack { + Image(systemName: "doc") + .font(.system(size: 72)) + .foregroundColor(.gray) + Text(fileURL.lastPathComponent) + .font(.headline) + .padding(.top) + Text(mimeType) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + } + + // Load content from URL + private func loadContent() { + isLoading = true + + // For video, we don't need to download the content as we'll use the URL directly + if mimeType.starts(with: "video/") { + isLoading = false + return + } + + URLSession.shared.dataTask(with: fileURL) { data, response, error in + DispatchQueue.main.async { + self.isLoading = false + + if let error = error { + self.error = error + return + } + + self.content = data + } + }.resume() + } +} + +// Preview +struct ContentPreview_Previews: PreviewProvider { + static var previews: some View { + Group { + // Text Preview + ContentPreview( + mimeType: "text/plain", + fileURL: URL(string: "https://example.com/sample.txt")! + ) + .previewDisplayName("Text Preview") + + // Image Preview + ContentPreview( + mimeType: "image/jpeg", + fileURL: URL(string: "https://example.com/sample.jpg")! + ) + .previewDisplayName("Image Preview") + + // Video Preview + ContentPreview( + mimeType: "video/mp4", + fileURL: URL(string: "https://example.com/sample.mp4")! + ) + .previewDisplayName("Video Preview") + + // Generic File Preview + ContentPreview( + mimeType: "application/pdf", + fileURL: URL(string: "https://example.com/sample.pdf")! + ) + .previewDisplayName("Generic File Preview") + } + } +} diff --git a/Django Files/Views/SessionEditor.swift b/Django Files/Views/SessionEditor.swift index 75aa608..7ecf0c6 100644 --- a/Django Files/Views/SessionEditor.swift +++ b/Django Files/Views/SessionEditor.swift @@ -13,6 +13,9 @@ struct SessionEditor: View { @Environment(\.dismiss) private var dismiss @Query private var items: [DjangoFilesSession] + @State private var showLoginSheet: Bool = false + @State private var newSession: DjangoFilesSession? + let session: DjangoFilesSession? var onSessionCreated: ((DjangoFilesSession) -> Void)? @@ -22,31 +25,22 @@ struct SessionEditor: View { @State private var showDuplicateAlert = false - private func save() { + private func checkURLAuthAndSave() { if let session { session.url = url?.absoluteString ?? "" session.token = token session.auth = false } else { - // Check for duplicate URL if items.contains(where: { $0.url == url?.absoluteString }) { - // Set the alert state to true to show the alert showDuplicateAlert = true return } - - let newSession = DjangoFilesSession() - newSession.url = url?.absoluteString ?? "" - newSession.token = token - newSession.auth = false - modelContext.insert(newSession) - do { - try modelContext.save() - onSessionCreated?(newSession) - dismiss() // Dismiss the editor only after successful save - } catch { - print("Error saving session: \(error)") - } + newSession = DjangoFilesSession() + newSession!.url = url?.absoluteString ?? "" + newSession!.token = token + newSession!.auth = false + modelContext.insert(newSession!) + showLoginSheet = true } } @@ -105,7 +99,6 @@ struct SessionEditor: View { } ToolbarItem(placement: .confirmationAction) { Button(action:{ - // Remove trailing slash if present if var urlString = url?.absoluteString { if urlString.hasSuffix("/") { urlString.removeLast() @@ -114,7 +107,7 @@ struct SessionEditor: View { } if url != nil { withAnimation { - save() + checkURLAuthAndSave() } } else { badURL.toggle() @@ -149,6 +142,27 @@ struct SessionEditor: View { dismissButton: .default(Text("OK")) ) } + .sheet(isPresented: $showLoginSheet, onDismiss: { + if let newSession = newSession, newSession.auth { + do { + try modelContext.save() + onSessionCreated?(newSession) + dismiss() + } catch { + print("Error saving session: \(error)") + } + } + }) { + if let newSession = newSession { + LoginView(selectedServer: newSession, onLoginSuccess: { + newSession.auth = true + }) + } else if let session = session { + LoginView(selectedServer: session, onLoginSuccess: { + session.auth = true + }) + } + } } } } diff --git a/Django Files/Views/SessionSelector.swift b/Django Files/Views/SessionSelector.swift index 12da1b3..daccc1b 100644 --- a/Django Files/Views/SessionSelector.swift +++ b/Django Files/Views/SessionSelector.swift @@ -16,8 +16,6 @@ struct SessionSelector: View { let session: DjangoFilesSession - var viewingSelect: Binding? = nil - @State private var url = "" @State private var token = "" @State private var sessionStarted = false @@ -104,19 +102,13 @@ struct SessionSelector: View { Text("Auth Status:") } } - .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .principal) { Text("Server Options") } ToolbarItem(placement: .confirmationAction) { Button("Back") { - if viewingSelect == nil{ - dismiss() - } - else{ - viewingSelect?.wrappedValue = false - } + dismiss() } } } @@ -124,7 +116,6 @@ struct SessionSelector: View { url = session.url defaultSession = session.defaultSession token = session.token - } } } diff --git a/Django Files/Views/ShortList.swift b/Django Files/Views/ShortList.swift new file mode 100644 index 0000000..2b12e4c --- /dev/null +++ b/Django Files/Views/ShortList.swift @@ -0,0 +1,235 @@ +// +// ShortList.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import Foundation +import SwiftUI + +struct ShortListView: View { + let server: Binding + + @State private var shorts: [DFShort] = [] + @State private var isLoading = false + @State private var hasMoreResults = true + @State private var error: String? = nil + + private let shortsPerPage = 50 + + var body: some View { + NavigationView { + List { + ForEach(shorts) { short in + ShortRow(short: short) + } + + if hasMoreResults && !shorts.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .onAppear { + loadMoreShorts() + } + } + + if isLoading && shorts.isEmpty { + loadingView + } + } + .listStyle(.plain) + .refreshable{ + await refreshShorts() + } + .overlay { + if shorts.isEmpty && !isLoading { + emptyView + } + + if let error = error { + errorView(message: error) + } + } + .navigationTitle("Short URLs") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button { + // Create album action + } label: { + Label("Create Short", systemImage: "plus") + } + + Button("Refresh") { + loadInitialShorts() + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .onAppear { + if shorts.isEmpty { + loadInitialShorts() + } + } + } + } + + private var loadingView: some View { + VStack { + ProgressView() + Text("Loading shorts...") + .foregroundColor(.secondary) + .padding(.top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + VStack(spacing: 16) { + Image(systemName: "link.slash") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("No short URLs found") + .font(.headline) + + Text("Create your first short URL to get started") + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Create Short URL") { + // TODO: Implement create new short URL action + } + .buttonStyle(.borderedProminent) + .padding(.top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemBackground)) + } + + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Error") + .font(.headline) + + Text(message) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Try Again") { + loadInitialShorts() + } + .buttonStyle(.borderedProminent) + .padding(.top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemBackground)) + } + + private func loadInitialShorts() { + isLoading = true + error = nil + shorts = [] + + Task { + await fetchShorts() + await MainActor.run { + isLoading = false + } + } + } + + private func refreshShorts() async { + await MainActor.run { + isLoading = true + error = nil + shorts = [] + hasMoreResults = true + } + + await fetchShorts() + + await MainActor.run { + isLoading = false + } + } + + private func loadMoreShorts() { + guard !isLoading, hasMoreResults else { return } + + Task { + await fetchShorts() + } + } + + private func fetchShorts() async { + await MainActor.run { + isLoading = true + } + + // Create API client from session URL and token + guard let url = URL(string: server.wrappedValue!.url) else { + await MainActor.run { + error = "Invalid session URL" + isLoading = false + } + return + } + + let api = DFAPI(url: url, token: server.wrappedValue!.token) + + // Get the ID of the last short for pagination + let lastShortId = shorts.last?.id + + if let response = await api.getShorts(amount: shortsPerPage, start: lastShortId) { + await MainActor.run { + // Append new shorts to the existing list + shorts.append(contentsOf: response.shorts) + + // Check if there might be more results + hasMoreResults = response.shorts.count >= shortsPerPage + error = nil + } + } else { + await MainActor.run { + error = "Failed to load shorts. Please try again." + } + } + + await MainActor.run { + isLoading = false + } + } +} + +struct ShortRow: View { + let short: DFShort + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(short.short) + .font(.headline) + .foregroundColor(.blue) + Text("\(short.views) uses") + .font(.caption) + .foregroundColor(.secondary) + Label(String(short.user), systemImage: "person") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + } + Text(short.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } +} diff --git a/Django Files/Views/TabView.swift b/Django Files/Views/TabView.swift new file mode 100644 index 0000000..e03e0a8 --- /dev/null +++ b/Django Files/Views/TabView.swift @@ -0,0 +1,189 @@ +// +// TabView.swift +// Django Files +// +// Created by Ralph Luaces on 4/19/25. +// + +import SwiftUI +import SwiftData + +struct TabViewWindow: View { + @Environment(\.modelContext) private var modelContext + @ObservedObject var sessionManager: SessionManager + + @State private var showingServerSelector = false + @Query private var sessions: [DjangoFilesSession] + @State private var needsRefresh = false + + init(sessionManager: SessionManager) { + self.sessionManager = sessionManager + } + + var body: some View { + TabView { + Tab("Files", systemImage: "document.fill") { + FileListView(server: $sessionManager.selectedSession) + } + + Tab("Gallery", systemImage: "photo.artframe") { + // ReceivedView() + } + + Tab("Albums", systemImage: "square.stack") { + AlbumListView(server: $sessionManager.selectedSession) + } + + + Tab("Shorts", systemImage: "link") { + ShortListView(server: $sessionManager.selectedSession) + } + + Tab("Web", systemImage: "globe"){ + if let selectedSession = sessionManager.selectedSession { + AuthViewContainer( + selectedServer: selectedSession, + needsRefresh: $needsRefresh + ) + .id(selectedSession.url) + } else { + Text("Please select a server") + } + } + + Tab("Server List", systemImage: "server.rack") { + ServerSelector(selectedSession: $sessionManager.selectedSession) + } + } + .onAppear { + sessionManager.loadLastSelectedSession(from: sessions) + + // Connect to WebSocket if a session is selected + if let selectedSession = sessionManager.selectedSession { + connectToWebSocket(session: selectedSession) + } + } + .onChange(of: sessionManager.selectedSession) { oldValue, newValue in + if newValue != nil { + sessionManager.saveSelectedSession() + + // Connect to WebSocket when session changes + if let session = newValue { + connectToWebSocket(session: session) + } + } + } + .navigationTitle(Text("Servers")) + + } + + // Helper function to connect to WebSocket + private func connectToWebSocket(session: DjangoFilesSession) { + // Create the DFAPI instance + let api = DFAPI(url: URL(string: session.url)!, token: session.token) + + // Connect to WebSocket + print("TabViewWindow: Connecting to WebSocket for session \(session.url)") + _ = api.connectToWebSocket() + } +} + +struct ServerSelector: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Binding var selectedSession: DjangoFilesSession? + @State private var itemToDelete: DjangoFilesSession? // Track item to be deleted + @State private var showingDeleteAlert = false // Track if delete alert is showing + @State private var showingEditor = false + @State private var editSession: DjangoFilesSession? + @State private var authSession: DjangoFilesSession? + + @State private var showLoginSheet: Bool = false + + @Query private var items: [DjangoFilesSession] + + var body: some View { + List(selection: $selectedSession) { + ForEach(items, id: \.self) { item in + HStack { + Label("", systemImage: item.defaultSession ? "star.fill" : "") + Text(item.url) + .swipeActions { + Button(role: .destructive) { + itemToDelete = item + showingDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash.fill") + } + Button { + editSession = item + } label: { + Label("Settings", systemImage: "gear") + } + .tint(.indigo) + } + .onTapGesture { + if !item.auth { + authSession = item + } else { + selectedSession = item + } + } + } + } + } + .sheet(item: $authSession) { session in + if !session.auth { + LoginView(selectedServer: session, onLoginSuccess:{ + selectedSession = session + }) + } + } + .sheet(item: $editSession) { session in + SessionSelector(session: session) + } + .sheet(isPresented: $showingEditor) { + SessionEditor(session: nil) + } + .confirmationDialog("Delete Server", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + if let item = itemToDelete, + let index = items.firstIndex(of: item) + { + deleteItems(offsets: [index]) + if selectedSession == item { + selectedSession = nil + } + } + } + } message: { + Text( + "Are you sure you want to delete \(URL(string: itemToDelete?.url ?? "")?.host ?? "this server")? This action cannot be undone." + ) + } + .toolbar { + ToolbarItem { + Button(action: { + self.showingEditor.toggle() + }) { + Label("Add Item", systemImage: "plus") + } + } + } + .navigationTitle("Servers") + + + } + + + private func deleteItems(offsets: IndexSet) { + withAnimation { + print("Deleting items: \(offsets)") + for index in offsets { + modelContext.delete(items[index]) + } + } + } +} diff --git a/Django Files/Views/Toast.swift b/Django Files/Views/Toast.swift new file mode 100644 index 0000000..6bd2c6f --- /dev/null +++ b/Django Files/Views/Toast.swift @@ -0,0 +1,77 @@ +// +// Toast.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import SwiftUI + +class ToastManager { + static let shared = ToastManager() + + func showToast(message: String) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { return } + + // Create a label for the toast + let toastContainer = UIView() + toastContainer.backgroundColor = UIColor.darkGray.withAlphaComponent(0.9) + toastContainer.layer.cornerRadius = 16 + toastContainer.clipsToBounds = true + + let messageLabel = UILabel() + messageLabel.text = message + messageLabel.textColor = .white + messageLabel.textAlignment = .center + messageLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) + messageLabel.numberOfLines = 0 + +// // Create an image view for the clipboard icon +// let imageView = UIImageView(image: UIImage(systemName: "doc.on.clipboard")) +// imageView.tintColor = .white +// imageView.contentMode = .scaleAspectFit + + // Create a stack view to hold the image and label + let stackView = UIStackView(arrangedSubviews: [/*imageView,*/ messageLabel]) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + + // Add the stack view to the container + toastContainer.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: toastContainer.topAnchor, constant: 12), + stackView.bottomAnchor.constraint(equalTo: toastContainer.bottomAnchor, constant: -12), + stackView.leadingAnchor.constraint(equalTo: toastContainer.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(equalTo: toastContainer.trailingAnchor, constant: -16), +// imageView.widthAnchor.constraint(equalToConstant: 20), +// imageView.heightAnchor.constraint(equalToConstant: 20) + ]) + + // Add the toast container to the window + window.addSubview(toastContainer) + toastContainer.translatesAutoresizingMaskIntoConstraints = false + + // Position the toast at the center bottom of the screen + NSLayoutConstraint.activate([ + toastContainer.centerXAnchor.constraint(equalTo: window.centerXAnchor), + toastContainer.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -64), + toastContainer.widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, multiplier: 0.85) + ]) + + // Animate the toast + toastContainer.alpha = 0 + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: { + toastContainer.alpha = 1 + }) { _ in + // Dismiss the toast after a delay + UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseOut, animations: { + toastContainer.alpha = 0 + }) { _ in + toastContainer.removeFromSuperview() + } + } + } +} diff --git a/Django Files/Views/WebSocketObs.swift b/Django Files/Views/WebSocketObs.swift new file mode 100644 index 0000000..1f9e948 --- /dev/null +++ b/Django Files/Views/WebSocketObs.swift @@ -0,0 +1,97 @@ +// +// WebSocketObs.swift +// Django Files +// +// Created by Ralph Luaces on 5/1/25. +// + +import SwiftUI + +/// This class observes WebSocket notifications and displays them as toasts +class WebSocketToastObserver: DFWebSocketDelegate { + static let shared = WebSocketToastObserver() + private var webSocket: DFWebSocket? + + private init() { + print("WebSocketToastObserver initialized") + + // Register for WebSocket toast notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWebSocketToast), + name: Notification.Name("DFWebSocketToastNotification"), + object: nil + ) + + // Register for WebSocket connection requests + NotificationCenter.default.addObserver( + self, + selector: #selector(handleConnectionRequest), + name: Notification.Name("DFWebSocketConnectionRequest"), + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleWebSocketToast(notification: Notification) { + print("WebSocketToastObserver: Received notification with info: \(String(describing: notification.userInfo))") + if let message = notification.userInfo?["message"] as? String { + // Use the existing ToastManager to display the message + DispatchQueue.main.async { + print("WebSocketToastObserver: Showing toast with message: \(message)") + ToastManager.shared.showToast(message: message) + } + } + } + + @objc private func handleConnectionRequest(notification: Notification) { + print("WebSocketToastObserver: Received connection request") + if let api = notification.userInfo?["api"] as? DFAPI { + connectToWebSocket(api: api) + } + } + + // Connect directly to the WebSocket service + func connectToWebSocket(api: DFAPI) { + print("WebSocketToastObserver: Connecting to WebSocket") + webSocket = api.createWebSocket() + webSocket?.delegate = self + } + + // MARK: - DFWebSocketDelegate methods + + func webSocketDidConnect(_ webSocket: DFWebSocket) { + print("WebSocketToastObserver: WebSocket connected") + } + + func webSocketDidDisconnect(_ webSocket: DFWebSocket, withError error: Error?) { + print("WebSocketToastObserver: WebSocket disconnected with error: \(String(describing: error))") + } + + func webSocket(_ webSocket: DFWebSocket, didReceiveMessage data: DFWebSocketMessage) { + print("WebSocketToastObserver: Received message: \(data.event), message: \(String(describing: data.message))") + + // Directly handle toast messages + if data.event == "toast" || data.event == "notification" { + DispatchQueue.main.async { + ToastManager.shared.showToast(message: data.message ?? "New notification") + } + } + } +} + +// Extension to set up the observer in the app +extension UIApplication { + static func setupWebSocketToastObserver() { + print("Setting up WebSocketToastObserver") + _ = WebSocketToastObserver.shared + } + + static func connectWebSocketObserver(api: DFAPI) { + print("Connecting WebSocketToastObserver to API") + WebSocketToastObserver.shared.connectToWebSocket(api: api) + } +}