diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb45520..f2efc29 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,6 +36,10 @@ jobs: with: ssh-private-key: ${{ secrets.SSH_KEY }} + - name: "Google Services File" + run: | + echo "${{ secrets.GOOGLE_SERVICES }}" | base64 --decode > "Django Files/GoogleService-Info.plist" + - name: "Fastlane ${{ env.command }}" run: fastlane ${{ env.command }} env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e636efb..5351d9e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,10 @@ jobs: with: ssh-private-key: ${{ secrets.SSH_KEY }} + - name: "Google Services File" + run: | + echo "${{ secrets.GOOGLE_SERVICES }}" | base64 --decode > "Django Files/GoogleService-Info.plist" + - name: "Fastlane Tests" run: fastlane tests env: diff --git a/.gitignore b/.gitignore index ce0f709..dc750ba 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ xcuserdata/ .DS_Store report.xml report.html -report.junit \ No newline at end of file +report.junit + +GoogleService-Info.plist \ No newline at end of file diff --git a/Django Files.xcodeproj/project.pbxproj b/Django Files.xcodeproj/project.pbxproj index 06706fe..b729aa1 100644 --- a/Django Files.xcodeproj/project.pbxproj +++ b/Django Files.xcodeproj/project.pbxproj @@ -13,6 +13,9 @@ 4CA2E48A2D6D49D6006EF3F0 /* HTTPTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA2E4892D6D49D6006EF3F0 /* HTTPTypes */; }; 4CA2E48C2D6D49D6006EF3F0 /* HTTPTypesFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 4CA2E48B2D6D49D6006EF3F0 /* HTTPTypesFoundation */; }; 4CE57E8B2D7C9F440073CFC1 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE57E8A2D7C9F440073CFC1 /* SnapshotHelper.swift */; }; + A21CC8852DEB967300EF776C /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = A21CC8842DEB967300EF776C /* FirebaseAnalytics */; }; + A21CC88B2DEB991100EF776C /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = A21CC88A2DEB991100EF776C /* FirebaseCrashlytics */; }; + A2DF11D52DDA13FE0096E7C4 /* HighlightSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,11 +82,14 @@ 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/Short.swift, API/Stats.swift, API/Upload.swift, + API/Websocket.swift, Models/DjangoFilesSession.swift, ); target = 4C82CB7E2D624E8700C0893B /* UploadAndCopy */; @@ -125,7 +131,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A2DF11D52DDA13FE0096E7C4 /* HighlightSwift in Frameworks */, 4C82CB512D62372200C0893B /* HTTPTypes in Frameworks */, + A21CC88B2DEB991100EF776C /* FirebaseCrashlytics in Frameworks */, + A21CC8852DEB967300EF776C /* FirebaseAnalytics in Frameworks */, 4C82CB532D62372200C0893B /* HTTPTypesFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -211,6 +220,9 @@ packageProductDependencies = ( 4C82CB502D62372200C0893B /* HTTPTypes */, 4C82CB522D62372200C0893B /* HTTPTypesFoundation */, + A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */, + A21CC8842DEB967300EF776C /* FirebaseAnalytics */, + A21CC88A2DEB991100EF776C /* FirebaseCrashlytics */, ); productName = "Django Files"; productReference = 4C5E20EC2D603C3B009EE83A /* Django Files.app */; @@ -324,6 +336,8 @@ packageReferences = ( 4C82CB4F2D62372200C0893B /* XCRemoteSwiftPackageReference "swift-http-types" */, 4CE57E892D7C9EB70073CFC1 /* XCRemoteSwiftPackageReference "fastlane" */, + A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */, + A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 4C5E20ED2D603C3B009EE83A /* Products */; @@ -445,7 +459,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 +505,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)", @@ -564,7 +578,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -621,7 +635,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -639,7 +653,7 @@ CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ZY6BPTGK47; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 0.0; PRODUCT_BUNDLE_IDENTIFIER = blastsoftstudios.djangofilesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -658,7 +672,7 @@ CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_TEAM = ZY6BPTGK47; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 0.0; PRODUCT_BUNDLE_IDENTIFIER = blastsoftstudios.djangofilesTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -848,6 +862,22 @@ minimumVersion = 2.226.0; }; }; + A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 11.13.0; + }; + }; + A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/appstefan/highlightswift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.9; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -871,6 +901,21 @@ package = 4C82CB4F2D62372200C0893B /* XCRemoteSwiftPackageReference "swift-http-types" */; productName = HTTPTypesFoundation; }; + A21CC8842DEB967300EF776C /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + A21CC88A2DEB991100EF776C /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = A21CC8832DEB950C00EF776C /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + A2DF11D42DDA13FE0096E7C4 /* HighlightSwift */ = { + isa = XCSwiftPackageProductDependency; + package = A2DF11D32DDA12CA0096E7C4 /* XCRemoteSwiftPackageReference "highlightswift" */; + productName = HighlightSwift; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 4C5E20E42D603C3B009EE83A /* Project object */; diff --git a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13f3d71..8dca451 100644 --- a/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Django Files.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "92f8ebe1937b4256bd2ad3830b28e1a8d86ccbc17fb04b921574cc4f6e156702", + "originHash" : "95078f967a74c51784635d525bd21732de1f1e8c1af188b95996cd3de41eafca", "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, { "identity" : "fastlane", "kind" : "remoteSourceControl", @@ -10,6 +28,105 @@ "version" : "2.226.0" } }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "3663b1aa6c7a1bed67ee80fd09dc6d0f9c3bb660", + "version" : "11.13.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "543071966b3fb6613a2fc5c6e7112d1e998184a7", + "version" : "11.13.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c756a29784521063b6a1202907e2cc47f41b667c", + "version" : "4.5.0" + } + }, + { + "identity" : "highlightswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/appstefan/highlightswift", + "state" : { + "revision" : "784ca3ccfc8a2cf724fbb2f06cb6ec3329424da3", + "version" : "1.1.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, { "identity" : "swift-http-types", "kind" : "remoteSourceControl", @@ -19,6 +136,15 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "102a647b573f60f73afdce5613a51d71349fe507", + "version" : "1.30.0" + } + }, { "identity" : "swiftshell", "kind" : "remoteSourceControl", diff --git a/Django Files/API/Albums.swift b/Django Files/API/Albums.swift new file mode 100644 index 0000000..9716e70 --- /dev/null +++ b/Django Files/API/Albums.swift @@ -0,0 +1,144 @@ +// +// 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 +} + +// Request structure for album creation +struct CreateAlbumRequest: Codable { + let name: String + let maxv: Int? + let expr: String? + let password: String? + let `private`: Bool? + let info: String? +} + +// Response structure for album creation +struct CreateAlbumResponse: Decodable { + let url: String + + // Extract album ID from the URL + var albumId: Int? { + guard let urlComponents = URLComponents(string: url), + let queryItems = urlComponents.queryItems, + let albumQuery = queryItems.first(where: { $0.name == "album" }), + let albumId = Int(albumQuery.value ?? "") else { + return nil + } + return albumId + } +} + +extension DFAPI { + // Fetch albums with pagination + func getAlbums(page: Int = 1, selectedServer: DjangoFilesSession? = nil) async -> AlbumsResponse? { + do { + let responseBody = try await makeAPIRequest( + path: getAPIPath(.albums) + "\(page)/", + parameters: [:], + method: .get, + expectedResponse: .ok, + selectedServer: selectedServer + ) + let decoder = JSONDecoder() + return try decoder.decode(AlbumsResponse.self, from: responseBody) + } catch { + print("Error fetching albums: \(error)") + return nil + } + } + + // Create a new album + func createAlbum(name: String, maxViews: Int? = nil, expiration: String? = nil, + password: String? = nil, isPrivate: Bool? = nil, description: String? = nil, + selectedServer: DjangoFilesSession? = nil) async -> CreateAlbumResponse? { + let request = CreateAlbumRequest( + name: name, + maxv: maxViews, + expr: expiration, + password: password, + private: isPrivate, + info: description + ) + + do { + let json = try JSONEncoder().encode(request) + let responseBody = try await makeAPIRequest( + body: json, + path: getAPIPath(.album), + parameters: [:], + method: .post, + expectedResponse: .ok, + headerFields: [.contentType: "application/json"], + selectedServer: selectedServer + ) + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(CreateAlbumResponse.self, from: responseBody) + } catch { + print("Error creating album: \(error)") + return nil + } + } + + // Delete an album by ID + func deleteAlbum(albumId: Int, selectedServer: DjangoFilesSession? = nil) async -> Bool { + do { + let path = "\(getAPIPath(.album))\(albumId)" + _ = try await makeAPIRequest( + path: path, + parameters: [:], + method: .delete, + expectedResponse: .noContent, + selectedServer: selectedServer + ) + return true + } catch { + print("Error deleting album: \(error)") + return false + } + } +} + diff --git a/Django Files/API/DFAPI.swift b/Django Files/API/DFAPI.swift index 4bad3ef..d2a5937 100644 --- a/Django Files/API/DFAPI.swift +++ b/Django Files/API/DFAPI.swift @@ -8,22 +8,43 @@ 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 + internal 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/" + case delete_file = "files/delete/" + case edit_file = "files/edit/" + case file = "file/" + case raw = "raw/" + case album = "album/" + case albums = "albums/" + case auth_application = "auth/application/" } let url: URL let token: String var decoder: JSONDecoder + init(url: URL, token: String){ self.url = url self.token = token @@ -38,21 +59,31 @@ struct DFAPI { return components.url! } - private func getAPIPath(_ api: DjangoFilesAPIs) -> String { + internal func getAPIPath(_ api: DjangoFilesAPIs) -> String { return DFAPI.API_PATH + api.rawValue } - private func handleError(_ status: HTTPResponse.Status, data: Data?){ + @MainActor + private func updateSessionAuth(_ selectedServer: DjangoFilesSession, _ isAuthenticated: Bool) { + selectedServer.auth = isAuthenticated + } + + private func handleError(_ status: HTTPResponse.Status, data: Data?, selectedServer: DjangoFilesSession? = nil) async { print("Server response status code: \(status)") - do{ + do { let e = try decoder.decode(DFErrorResponse.self, from: data!) print("\(e.error): \(e.message)") - }catch { + + // Check for 401 Unauthorized and update session auth status + if status.code == 401, let server = selectedServer { + await updateSessionAuth(server, false) + } + } catch { print("Invalid error response.") } } - private func makeAPIRequest(body: Data, path: String, parameters: [String:String], method: HTTPRequest.Method = .get, expectedResponse: HTTPResponse.Status = .ok, headerFields: [HTTPField.Name:String] = [:], taskDelegate: URLSessionTaskDelegate? = nil) async throws -> Data + internal func makeAPIRequest(body: Data, path: String, parameters: [String:String], method: HTTPRequest.Method = .get, expectedResponse: HTTPResponse.Status = .ok, headerFields: [HTTPField.Name:String] = [:], taskDelegate: URLSessionTaskDelegate? = nil, selectedServer: DjangoFilesSession? = nil) async throws -> Data { var request = HTTPRequest(method: method, url: encodeParametersIntoURL(path: path, parameters: parameters)) request.headerFields[.authorization] = token @@ -62,14 +93,17 @@ struct DFAPI { } let session = URLSession(configuration: .ephemeral, delegate: taskDelegate, delegateQueue: .main) let (responseBody, response) = try await session.upload(for: request, from: body) - guard response.status == .ok else { - handleError(response.status, data: responseBody) + + // Handle non-2xx responses + if response.status.code < 200 || response.status.code >= 300 { + await handleError(response.status, data: responseBody, selectedServer: selectedServer) throw URLError(.badServerResponse) } + return responseBody } - private func makeAPIRequest(path: String, parameters: [String:String], method: HTTPRequest.Method = .get, expectedResponse: HTTPResponse.Status = .ok, headerFields: [HTTPField.Name:String] = [:], taskDelegate: URLSessionTaskDelegate? = nil) async throws -> Data { + internal func makeAPIRequest(path: String, parameters: [String:String], method: HTTPRequest.Method = .get, expectedResponse: HTTPResponse.Status = .ok, headerFields: [HTTPField.Name:String] = [:], taskDelegate: URLSessionTaskDelegate? = nil, selectedServer: DjangoFilesSession? = nil) async throws -> Data { var request = HTTPRequest(method: method, url: encodeParametersIntoURL(path: path, parameters: parameters)) request.headerFields[.referer] = url.absoluteString request.headerFields[.authorization] = self.token @@ -79,10 +113,13 @@ struct DFAPI { let session = URLSession(configuration: .ephemeral, delegate: taskDelegate ?? nil, delegateQueue: .main) let (responseBody, response) = try await session.upload(for: request, from: Data()) - guard response.status != .created else { - handleError(response.status, data: responseBody) + + // Handle non-2xx responses + if response.status.code < 200 || response.status.code >= 300 { + await handleError(response.status, data: responseBody, selectedServer: selectedServer) throw URLError(.badServerResponse) } + return responseBody } private func makeAPIRequestStreamed(path: String, parameters: [String:String], method: HTTPRequest.Method = .get, expectedResponse: HTTPResponse.Status = .ok, headerFields: [HTTPField.Name:String] = [:], taskDelegate: URLSessionStreamDelegate) throws -> URLSessionUploadTask{ @@ -96,18 +133,8 @@ struct DFAPI { let session = URLSession(configuration: .ephemeral, delegate: taskDelegate, delegateQueue: .main) return session.uploadTask(withStreamedRequest: URLRequest(httpRequest: request)!) } - - public func getStats(amount: Int? = nil) async -> DFStatsResponse?{ - do{ - let responseBody = try await makeAPIRequest(path: getAPIPath(.stats), parameters: amount == nil ? [:] : ["amount" : amount?.description ?? ""]) - return try decoder.decode(DFStatsResponse.self, from: responseBody) - }catch { - print("Request failed \(error)") - return nil; - } - } - - public func uploadFile(url: URL, fileName: String? = nil, taskDelegate: URLSessionTaskDelegate? = nil) async -> DFUploadResponse?{ + + public func uploadFile(url: URL, fileName: String? = nil, albums: String = "", privateUpload: Bool = false, taskDelegate: URLSessionTaskDelegate? = nil, selectedServer: DjangoFilesSession? = nil) async -> DFUploadResponse? { let boundary = UUID().uuidString let filename = fileName ?? (url.absoluteString as NSString).lastPathComponent @@ -115,21 +142,36 @@ struct DFAPI { data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) data.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) - do{ + do { try data.append(Data(contentsOf: url)) - } - catch{ + } catch { print("Error reading file \(error)") return nil } data.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - do{ - let responseBody = try await makeAPIRequest(body: data, path: getAPIPath(.upload), parameters: [:], method: .post, expectedResponse: .ok, headerFields: [.contentType: "multipart/form-data; boundary=\(boundary)"], taskDelegate: taskDelegate) + do { + var headers: [HTTPField.Name: String] = [.contentType: "multipart/form-data; boundary=\(boundary)"] + if !albums.isEmpty { + headers[HTTPField.Name("Albums")!] = albums + } + if privateUpload { + headers[HTTPField.Name("Private")!] = "true" + } + let responseBody = try await makeAPIRequest( + body: data, + path: getAPIPath(.upload), + parameters: [:], + method: .post, + expectedResponse: .ok, + headerFields: headers, + taskDelegate: taskDelegate, + selectedServer: selectedServer + ) return try decoder.decode(DFUploadResponse.self, from: responseBody) - }catch { + } catch { print("Request failed \(error)") - return nil; + return nil } } @@ -146,19 +188,7 @@ struct DFAPI { return nil; } } - - public func createShort(url: URL, short: String, maxViews: Int? = nil) async -> DFShortResponse?{ - let request = DFShortRequest(url: url.absoluteString, vanity: short, maxViews: maxViews ?? 0) - do{ - let json = try JSONEncoder().encode(request) - let responseBody = try await makeAPIRequest(body: json, path: getAPIPath(.short), parameters: [:], method: .post, expectedResponse: .ok, headerFields: [:], taskDelegate: nil) - return try decoder.decode(DFShortResponse.self, from: responseBody) - }catch { - print("Request failed \(error)") - return nil; - } - } - + public func getAuthMethods() async -> DFAuthMethodsResponse? { do { let responseBody = try await makeAPIRequest( @@ -178,11 +208,37 @@ struct DFAPI { let username: String let password: String } - + struct UserToken: Codable { let token: String } + @MainActor + private func updateSessionCookies(_ selectedServer: DjangoFilesSession, _ cookies: [HTTPCookie]) { + selectedServer.cookies = cookies + } + + @MainActor + private func updateSessionToken(_ selectedServer: DjangoFilesSession, _ token: String) { + selectedServer.token = token + } + + @MainActor + private func handleAuthResponse(response: HTTPURLResponse, url: URL, selectedServer: DjangoFilesSession, token: String) { + // Extract cookies from response + if let headerFields = response.allHeaderFields as? [String: String] { + let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) + // Store cookies in the shared cookie storage + cookies.forEach { cookie in + HTTPCookieStorage.shared.setCookie(cookie) + } + selectedServer.cookies = cookies + } + + // Update the token in the server object + selectedServer.token = token + } + public func localLogin(username: String, password: String, selectedServer: DjangoFilesSession) async -> Bool { let request = DFLocalLoginRequest(username: username, password: password) do { @@ -204,24 +260,11 @@ struct DFAPI { throw URLError(.badServerResponse) } - // Extract cookies from response - if let headerFields = httpResponse.allHeaderFields as? [String: String] { - let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: urlRequest.url!) - // Store cookies in the shared cookie storage - cookies.forEach { cookie in - HTTPCookieStorage.shared.setCookie(cookie) - } - await MainActor.run { - selectedServer.cookies = cookies - } - } + let userToken = try decoder.decode(UserToken.self, from: data) - let userToken = try JSONDecoder().decode(UserToken.self, from: data) + // Use shared function to handle cookies and token + await handleAuthResponse(response: httpResponse, url: urlRequest.url!, selectedServer: selectedServer, token: userToken.token) - // Update the token in the server object - await MainActor.run { - selectedServer.token = userToken.token - } return true } catch { print("Local login request failed \(error)") @@ -261,7 +304,6 @@ struct DFAPI { if let cookie = HTTPCookie(properties: cookieProperties) { HTTPCookieStorage.shared.setCookie(cookie) - print("Set cookie: \(cookie)") } } @@ -270,12 +312,6 @@ struct DFAPI { configuration.httpCookieStorage = .shared configuration.httpCookieAcceptPolicy = .always - // Print all cookies before making the request - print("Cookies before request:") - HTTPCookieStorage.shared.cookies?.forEach { cookie in - print(" - \(cookie.name): \(cookie.value)") - } - let session = URLSession(configuration: configuration) let (_, response) = try await session.data(for: urlRequest) @@ -285,46 +321,91 @@ struct DFAPI { throw URLError(.badServerResponse) } - // Print request headers for debugging - print("Request headers:") - urlRequest.allHTTPHeaderFields?.forEach { key, value in - print(" - \(key): \(value)") - } + // Use shared function to handle cookies and token + await handleAuthResponse(response: httpResponse, url: urlRequest.url!, selectedServer: selectedServer, token: token) - // Print response headers for debugging - print("Response headers:") - (response as? HTTPURLResponse)?.allHeaderFields.forEach { key, value in - print(" - \(key): \(value)") - } + return true + } catch { + print("OAuth login request failed \(error)") + return false + } + } + + public func checkRedirect(url: String) async -> String? { + do { + guard let targetURL = URL(string: url) else { return nil } - // Extract cookies from response - if let headerFields = httpResponse.allHeaderFields as? [String: String] { - let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: urlRequest.url!) - // Store cookies in the shared cookie storage - cookies.forEach { cookie in - HTTPCookieStorage.shared.setCookie(cookie) - print("Received cookie from response: \(cookie)") - } - await MainActor.run { - selectedServer.cookies = cookies + var request = HTTPRequest(method: .get, url: targetURL) + request.headerFields[.authorization] = self.token + request.headerFields[.referer] = self.url.absoluteString + + let configuration = URLSessionConfiguration.ephemeral + let delegate = RedirectDelegate() + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + + let (_, response) = try await session.data(for: request) + + if response.status.code == 302 { + if let newURL = URL(string: response.headerFields[.location]!) { + if newURL.host() == nil { + return "\(targetURL.scheme ?? "https")://\(targetURL.host() ?? "")\(response.headerFields[.location] ?? "")" + } else { + return response.headerFields[.location] + } } } + + return nil + } catch { + print("Redirect check failed: \(error)") + return nil + } + } + + struct DFApplicationAuthRequest: Codable { + let signature: String + } + + private struct DFApplicationAuthResponse: Codable { + let token: String + } + + public func applicationAuth(signature: String, selectedServer: DjangoFilesSession? = nil) async -> String? { + let request = DFApplicationAuthRequest(signature: signature) + do { + let json = try JSONEncoder().encode(request) + + // Create URL request manually to access response headers + var urlRequest = URLRequest(url: encodeParametersIntoURL(path: getAPIPath(.auth_application), parameters: [:])) + urlRequest.httpMethod = "POST" + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.httpBody = json + + // Use default session configuration which persists cookies + let configuration = URLSessionConfiguration.default + let session = URLSession(configuration: configuration) + let (data, response) = try await session.data(for: urlRequest) - // Update the token in the server object - await MainActor.run { - selectedServer.token = token + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) } - return true + let auth_response = try decoder.decode(DFApplicationAuthResponse.self, from: data) + + // Use shared function to handle cookies and token if server is provided + if let selectedServer = selectedServer { + await handleAuthResponse(response: httpResponse, url: urlRequest.url!, selectedServer: selectedServer, token: auth_response.token) + } + + return auth_response.token } catch { - print("OAuth login request failed \(error)") - return false + print("Application auth request failed \(error)") + return nil } } } - - class DjangoFilesUploadDelegate: NSObject, StreamDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate, URLSessionStreamDelegate{ enum States { case invalid //Invalid/uninitialized state @@ -639,12 +720,25 @@ class DjangoFilesUploadDelegate: NSObject, StreamDelegate, URLSessionDelegate, U } } -struct DFAuthMethod: Codable { - let name: String - let url: String -} + struct DFAuthMethod: Codable { + let name: String + let url: String + } -struct DFAuthMethodsResponse: Codable { - let authMethods: [DFAuthMethod] - let siteName: String + struct DFAuthMethodsResponse: Codable { + let authMethods: [DFAuthMethod] + let siteName: String + } + +class RedirectDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // Don't follow the redirect by passing nil + completionHandler(nil) + } } diff --git a/Django Files/API/Files.swift b/Django Files/API/Files.swift new file mode 100644 index 0000000..09fa4a9 --- /dev/null +++ b/Django Files/API/Files.swift @@ -0,0 +1,336 @@ +// +// Files.swift +// Django Files +// +// Created by Ralph Luaces on 4/23/25. +// + +import Foundation + +public struct DFFile: Codable, Hashable, Equatable { + public var id: Int + public var user: Int + public var size: Int + public var mime: String + public var name: String + public var userName: String + public var userUsername: String + public var info: String + public var expr: String + public var view: Int + public var maxv: Int + public var password: String + public var `private`: Bool + public var avatar: Bool + public var url: String + public var thumb: String + public var raw: String + public var date: String + public var albums: [Int] + 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 + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + user = try container.decode(Int.self, forKey: .user) + size = try container.decode(Int.self, forKey: .size) + mime = try container.decode(String.self, forKey: .mime) + name = try container.decode(String.self, forKey: .name) + userName = try container.decode(String.self, forKey: .userName) + userUsername = try container.decode(String.self, forKey: .userUsername) + info = try container.decode(String.self, forKey: .info) + expr = try container.decode(String.self, forKey: .expr) + view = try container.decode(Int.self, forKey: .view) + maxv = try container.decode(Int.self, forKey: .maxv) + password = try container.decode(String.self, forKey: .password) + `private` = try container.decode(Bool.self, forKey: .private) + avatar = try container.decode(Bool.self, forKey: .avatar) + url = try container.decode(String.self, forKey: .url) + thumb = try container.decode(String.self, forKey: .thumb) + raw = try container.decode(String.self, forKey: .raw) + date = try container.decode(String.self, forKey: .date) + albums = try container.decode([Int].self, forKey: .albums) + + // Decode exif and meta as dynamic JSON objects + if let exifContainer = try? container.decode([String: AnyCodable].self, forKey: .exif) { + exif = exifContainer + } else { + exif = nil + } + + if let metaContainer = try? container.decode([String: AnyCodable].self, forKey: .meta) { + meta = metaContainer + } else { + meta = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(user, forKey: .user) + try container.encode(size, forKey: .size) + try container.encode(mime, forKey: .mime) + try container.encode(name, forKey: .name) + try container.encode(userName, forKey: .userName) + try container.encode(userUsername, forKey: .userUsername) + try container.encode(info, forKey: .info) + try container.encode(expr, forKey: .expr) + try container.encode(view, forKey: .view) + try container.encode(maxv, forKey: .maxv) + try container.encode(password, forKey: .password) + try container.encode(`private`, forKey: .private) + try container.encode(avatar, forKey: .avatar) + try container.encode(url, forKey: .url) + try container.encode(thumb, forKey: .thumb) + try container.encode(raw, forKey: .raw) + try container.encode(date, forKey: .date) + try container.encode(albums, forKey: .albums) + try container.encode(exif, forKey: .exif) + try container.encode(meta, forKey: .meta) + } + + // 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) + } + + // Format file size to human readable string + public func formatSize() -> String { + let bytes = Double(size) + let units = ["B", "KB", "MB", "GB", "TB"] + var index = 0 + var value = bytes + + while value >= 1024 && index < units.count - 1 { + value /= 1024 + index += 1 + } + + // Format with appropriate decimal places + if index == 0 { + return "\(Int(value)) \(units[index])" + } else { + return String(format: "%.1f %@", value, units[index]) + } + } + + // 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 + } +} + +// Helper type to handle dynamic JSON values +public struct AnyCodable: Codable { + private let storage: Any + + public init(_ value: Any) { + self.storage = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.storage = NSNull() + } else if let bool = try? container.decode(Bool.self) { + self.storage = bool + } else if let int = try? container.decode(Int.self) { + self.storage = int + } else if let double = try? container.decode(Double.self) { + self.storage = double + } else if let string = try? container.decode(String.self) { + self.storage = string + } else if let array = try? container.decode([AnyCodable].self) { + self.storage = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + self.storage = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch storage { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded") + throw EncodingError.invalidValue(storage, context) + } + } + + public var value: Any { + switch storage { + case let array as [AnyCodable]: + return array.map { $0.value } + case let dict as [String: AnyCodable]: + return dict.mapValues { $0.value } + default: + return storage + } + } +} + +public struct DFFilesResponse: Codable { + public let files: [DFFile] + public let next: Int? + public let count: Int +} + +extension DFAPI { + public func getFiles(page: Int = 1, album: Int? = nil, selectedServer: DjangoFilesSession? = nil) async -> DFFilesResponse? { + do { + var parameters: [String: String] = [:] + if let album = album { + parameters["album"] = String(album) + } + + let responseBody = try await makeAPIRequest( + body: Data(), + path: getAPIPath(.files) + "\(page)/", + parameters: parameters, + method: .get, + selectedServer: selectedServer + ) + 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 + } + + public func getFileDetails(fileID: Int, selectedServer: DjangoFilesSession? = nil) async -> DFFile? { + do { + let responseBody = try await makeAPIRequest( + body: Data(), + path: getAPIPath(.file) + "\(fileID)", + parameters: [:], + method: .get, + selectedServer: selectedServer + ) + let specialDecoder = JSONDecoder() + specialDecoder.keyDecodingStrategy = .convertFromSnakeCase + return try specialDecoder.decode(DFFile.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 + } + + public func deleteFiles(fileIDs: [Int], selectedServer: DjangoFilesSession? = nil) async -> Bool { + do { + let fileIDsData = try JSONSerialization.data(withJSONObject: ["ids": fileIDs]) + let _ = try await makeAPIRequest( + body: fileIDsData, + path: getAPIPath(.delete_file), + parameters: [:], + method: .delete, + selectedServer: selectedServer + ) + return true + } catch { + print("File Delete Failed \(error)") + return false + } + } + + public func editFiles(fileIDs: [Int], changes: [String: Any], selectedServer: DjangoFilesSession? = nil) async -> Bool { + do { + var requestData: [String: Any] = ["ids": fileIDs] + for (key, value) in changes { + requestData[key] = value + } + let jsonData = try JSONSerialization.data(withJSONObject: requestData) + let _ = try await makeAPIRequest( + body: jsonData, + path: getAPIPath(.edit_file), + parameters: [:], + method: .post, + selectedServer: selectedServer + ) + return true + } catch { + print("File Edit Failed \(error)") + return false + } + } + + public func renameFile(fileID: Int, name: String, selectedServer: DjangoFilesSession? = nil) async -> Bool { + do { + let jsonData = try JSONSerialization.data(withJSONObject: ["name": name]) + let _ = try await makeAPIRequest( + body: jsonData, + path: getAPIPath(.file) + "\(fileID)", + parameters: [:], + method: .post, + selectedServer: selectedServer + ) + return true + } catch { + print("File Edit Failed \(error)") + return false + } + } +} diff --git a/Django Files/API/Short.swift b/Django Files/API/Short.swift index 727cb8d..d6fa72f 100644 --- a/Django Files/API/Short.swift +++ b/Django Files/API/Short.swift @@ -39,3 +39,77 @@ 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 + } +} + +extension DFAPI { + public func createShort(url: URL, short: String, maxViews: Int? = nil, selectedServer: DjangoFilesSession? = nil) async -> DFShortResponse? { + let request = DFShortRequest(url: url.absoluteString, vanity: short, maxViews: maxViews ?? 0) + do { + let json = try JSONEncoder().encode(request) + let responseBody = try await makeAPIRequest( + body: json, + path: getAPIPath(.short), + parameters: [:], + method: .post, + expectedResponse: .ok, + headerFields: [:], + taskDelegate: nil, + selectedServer: selectedServer + ) + return try decoder.decode(DFShortResponse.self, from: responseBody) + } catch { + print("Request failed \(error)") + return nil + } + } + + public func getShorts(amount: Int = 50, start: Int? = nil, selectedServer: DjangoFilesSession? = nil) async -> ShortsResponse? { + var parameters: [String: String] = ["amount": "\(amount)"] + if let start = start { + parameters["start"] = "\(start)" + } + + do { + let responseBody = try await makeAPIRequest( + body: Data(), + path: getAPIPath(.shorts), + parameters: parameters, + method: .get, + selectedServer: selectedServer + ) + + let shorts = try decoder.decode([DFShort].self, from: responseBody) + return ShortsResponse(shorts: shorts) + + } catch { + print("Error fetching shorts: \(error)") + return nil + } + } +} diff --git a/Django Files/API/Stats.swift b/Django Files/API/Stats.swift index 7a61565..82535b3 100644 --- a/Django Files/API/Stats.swift +++ b/Django Files/API/Stats.swift @@ -128,3 +128,20 @@ struct DFStatType: Codable{ count = try container.decode(Int.self, forKey: .count) } } + +extension DFAPI { + public func getStats(amount: Int? = nil, selectedServer: DjangoFilesSession? = nil) async -> DFStatsResponse? { + do { + let responseBody = try await makeAPIRequest( + body: Data(), + path: getAPIPath(.stats), + parameters: amount == nil ? [:] : ["amount" : amount?.description ?? ""], + selectedServer: selectedServer + ) + return try decoder.decode(DFStatsResponse.self, from: responseBody) + } catch { + print("Request failed \(error)") + return nil + } + } +} diff --git a/Django Files/API/Websocket.swift b/Django Files/API/Websocket.swift new file mode 100644 index 0000000..0c40688 --- /dev/null +++ b/Django Files/API/Websocket.swift @@ -0,0 +1,358 @@ +// +// 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 + 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") uploaded."] + ) + } else if message.event == "file-delete" { + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "File \(message.name ?? "Untitled.file") deleted."] + ) + } else if message.event == "file-update" { + print(message) + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "File \(String(describing: message.id!)) renamed to \(message.name ?? "unknown.file")."] + ) + } else if message.event == "album-new" { + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "Album \(message.name ?? "Untitled.file") created."] + ) + } else if message.event == "album-delete"{ + NotificationCenter.default.post( + name: Notification.Name("DFWebSocketToastNotification"), + object: nil, + userInfo: ["message": "Album (\(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 { + // 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 + } + + 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..7cfabf2 100644 --- a/Django Files/Django_FilesApp.swift +++ b/Django Files/Django_FilesApp.swift @@ -7,9 +7,35 @@ import SwiftUI import SwiftData +import FirebaseCore +import FirebaseAnalytics +import FirebaseCrashlytics + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + // Skip Firebase initialization if disabled via launch arguments + let shouldDisableFirebase = ProcessInfo.processInfo.arguments.contains("--DisableFirebase") + if !shouldDisableFirebase { + FirebaseApp.configure() + + // Initialize Firebase Analytics based on user preference + let analyticsEnabled = UserDefaults.standard.bool(forKey: "firebaseAnalyticsEnabled") + Analytics.setAnalyticsCollectionEnabled(analyticsEnabled) + + // Initialize Crashlytics based on user preference + let crashlyticsEnabled = UserDefaults.standard.bool(forKey: "crashlyticsEnabled") + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(crashlyticsEnabled) + } + + return true + } +} + @main struct Django_FilesApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var sharedModelContainer: ModelContainer = { let schema = Schema([ DjangoFilesSession.self, @@ -22,32 +48,43 @@ struct Django_FilesApp: App { } }() + @StateObject private var sessionManager = SessionManager() + @State private var hasExistingSessions = false + @State private var isLoading = true + @State private var selectedTab: TabViewWindow.Tab = .files + @State private var showingServerConfirmation = false + @State private var pendingAuthURL: URL? = nil + @State private var pendingAuthSignature: String? = nil + init() { + // print("📱 Setting up WebSocketToastObserver") + let _ = WebSocketToastObserver.shared + // Handle reset arguments if CommandLine.arguments.contains("--DeleteAllData") { // Clear UserDefaults if let bundleID = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleID) - } - // Clear SwiftData store - do { - let context = sharedModelContainer.mainContext - // Delete all DjangoFilesSession objects - try context.delete(model: DjangoFilesSession.self) - try context.save() - } catch { - print("Error clearing SwiftData store: \(error)") - } - // Clear any files in the app's documents directory - if let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { do { - let fileURLs = try FileManager.default.contentsOfDirectory(at: documentsPath, - includingPropertiesForKeys: nil) - for fileURL in fileURLs { - try FileManager.default.removeItem(at: fileURL) - } + let context = sharedModelContainer.mainContext + // Delete all DjangoFilesSession objects + try context.delete(model: DjangoFilesSession.self) + try context.save() } catch { - print("Error clearing documents directory: \(error)") + print("Error clearing SwiftData store: \(error)") + // Clear any files in the app's documents directory + if let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + do { + let fileURLs = try FileManager.default.contentsOfDirectory(at: documentsPath, + includingPropertiesForKeys: nil) + for fileURL in fileURLs { + try FileManager.default.removeItem(at: fileURL) + } + } catch { + print("Error clearing documents directory: \(error)") + return + } + } } } } @@ -55,7 +92,46 @@ struct Django_FilesApp: App { var body: some Scene { WindowGroup { - ContentView() + Group { + if isLoading { + ProgressView() + .onAppear { + checkForExistingSessions() + } + } else if !hasExistingSessions { + SessionEditor(onBoarding: true, session: nil, onSessionCreated: { newSession in + sessionManager.selectedSession = newSession + hasExistingSessions = true + }) + } else if sessionManager.selectedSession == nil { + NavigationStack { + ServerSelector(selectedSession: $sessionManager.selectedSession) + .navigationTitle("Select Server") + } + } else { + TabViewWindow(sessionManager: sessionManager, selectedTab: $selectedTab) + } + } + .onOpenURL { url in + handleDeepLink(url) + } + .sheet(isPresented: $showingServerConfirmation) { + ServerConfirmationView( + serverURL: $pendingAuthURL, + signature: $pendingAuthSignature, + onConfirm: { setAsDefault in + Task { + await handleServerConfirmation(confirmed: true, setAsDefault: setAsDefault) + } + }, + onCancel: { + Task { + await handleServerConfirmation(confirmed: false, setAsDefault: false) + } + }, + context: sharedModelContainer.mainContext + ) + } } .modelContainer(sharedModelContainer) #if os(macOS) @@ -64,4 +140,183 @@ struct Django_FilesApp: App { } #endif } + + private func handleDeepLink(_ url: URL) { + print("Deep link received: \(url)") + guard url.scheme == "djangofiles" else { return } + + // Extract the signature from the URL parameters + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + print("Invalid deep link URL") + return + } + print("Deep link host: \(components.host ?? "unknown")") + switch components.host { + case "authorize": + deepLinkAuth(components) + case "serverlist": + selectedTab = .settings + case "filelist": + handleFileListDeepLink(components) + default: + ToastManager.shared.showToast(message: "Unsupported deep link \(url)") + print("Unsupported deep link type: \(components.host ?? "unknown")") + } + } + + private func handleFileListDeepLink(_ components: URLComponents) { + guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding, + let serverURL = URL(string: urlString) else { + print("Invalid server URL in filelist deep link") + return + } + + // Find the session with matching URL and select it + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let matchingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + await MainActor.run { + sessionManager.selectedSession = matchingSession + selectedTab = .files + } + } else { + print("No session found for URL: \(serverURL.absoluteString)") + } + } catch { + print("Error fetching sessions: \(error)") + } + } + } + + private func deepLinkAuth(_ components: URLComponents) { + guard let signature = components.queryItems?.first(where: { $0.name == "signature" })?.value?.removingPercentEncoding, + let serverURL = URL(string: components.queryItems?.first(where: { $0.name == "url" })?.value?.removingPercentEncoding ?? "") else { + print("Unable to parse auth deep link.") + return + } + + // Check if a session with this URL already exists + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + Task { + do { + let existingSessions = try context.fetch(descriptor) + if let existingSession = existingSessions.first(where: { $0.url == serverURL.absoluteString }) { + // If session exists, just select it and update UI + await MainActor.run { + sessionManager.selectedSession = existingSession + hasExistingSessions = true + ToastManager.shared.showToast(message: "Connected to existing server \(existingSession.url)") + } + return + } + + // No existing session, show confirmation dialog + await MainActor.run { + pendingAuthURL = serverURL + pendingAuthSignature = signature + showingServerConfirmation = true + } + } catch { + print("Error checking for existing sessions: \(error)") + } + } + } + + private func handleServerConfirmation(confirmed: Bool, setAsDefault: Bool) async { + guard let serverURL = pendingAuthURL, + let signature = pendingAuthSignature else { + return + } + + // If user cancelled, just clear the pending data and return + if !confirmed { + pendingAuthURL = nil + pendingAuthSignature = nil + return + } + + await MainActor.run { + // Create and authenticate the new session + let context = sharedModelContainer.mainContext + + do { + let descriptor = FetchDescriptor() + let existingSessions = try context.fetch(descriptor) + + // Create and authenticate the new session + Task { + if let newSession = await sessionManager.createAndAuthenticateSession( + url: serverURL, + signature: signature, + context: context + ) { + if setAsDefault { + // Reset all other sessions to not be default + for session in existingSessions { + session.defaultSession = false + } + newSession.defaultSession = true + } + sessionManager.selectedSession = newSession + hasExistingSessions = true + selectedTab = .files + ToastManager.shared.showToast(message: "Successfully logged into \(newSession.url)") + } + } + } catch { + ToastManager.shared.showToast(message: "Problem signing into server \(error)") + print("Error creating new session: \(error)") + } + + // Clear pending auth data + pendingAuthURL = nil + pendingAuthSignature = nil + } + } + + private func checkDefaultServer() { + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + Task { + do { + let sessions = try context.fetch(descriptor) + await MainActor.run { + if sessionManager.selectedSession == nil { + if let defaultSession = sessions.first(where: { $0.defaultSession }) { + sessionManager.selectedSession = defaultSession + selectedTab = .files + } else { + selectedTab = .settings + } + } + } + } catch { + print("Error checking for default server: \(error)") + } + } + } + + private func checkForExistingSessions() { + let context = sharedModelContainer.mainContext + let descriptor = FetchDescriptor() + + do { + let sessionsCount = try context.fetchCount(descriptor) + hasExistingSessions = sessionsCount > 0 + if hasExistingSessions { + checkDefaultServer() + } + isLoading = false // Set loading to false after check completes + } catch { + print("Error checking for existing sessions: \(error)") + isLoading = false // Ensure we exit loading state even on error + } + } } diff --git a/Django Files/Info.plist b/Django Files/Info.plist index 2003c87..5372a7c 100644 --- a/Django Files/Info.plist +++ b/Django Files/Info.plist @@ -29,5 +29,11 @@ UIImageName LaunchScreenBackgroundImage + UIRequiredDeviceCapabilities + + arm64 + microphone + still-camera + diff --git a/Django Files/Models/DjangoFilesSession.swift b/Django Files/Models/DjangoFilesSession.swift index 2d79959..702dc7a 100644 --- a/Django Files/Models/DjangoFilesSession.swift +++ b/Django Files/Models/DjangoFilesSession.swift @@ -9,11 +9,13 @@ import Foundation import SwiftData @Model -public final class DjangoFilesSession: Equatable { +public final class DjangoFilesSession: Equatable, @unchecked Sendable { var url: String 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/Util/ImageCache.swift b/Django Files/Util/ImageCache.swift new file mode 100644 index 0000000..a610534 --- /dev/null +++ b/Django Files/Util/ImageCache.swift @@ -0,0 +1,100 @@ +// +// ImageCache.swift +// Django Files +// +// Created by Ralph Luaces on 5/20/25. +// + +import SwiftUI +import Foundation + +class ImageCache { + static let shared = ImageCache() + private let cache = NSCache() + + private init() { + // Cache is unlimited + } + + func set(_ image: UIImage, for key: String) { + cache.setObject(image, forKey: key as NSString) + } + + func get(for key: String) -> UIImage? { + return cache.object(forKey: key as NSString) + } +} + +struct CachedAsyncImage: View { + let url: URL? + let scale: CGFloat + let transaction: Transaction + @ViewBuilder let content: (Image) -> Content + @ViewBuilder let placeholder: () -> Placeholder + + @State private var cachedImage: UIImage? + @State private var isLoading = false + + init( + url: URL?, + scale: CGFloat = 1.0, + transaction: Transaction = Transaction(), + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.url = url + self.scale = scale + self.transaction = transaction + self.content = content + self.placeholder = placeholder + } + + var body: some View { + Group { + if let cachedImage = cachedImage { + content(Image(uiImage: cachedImage)) + } else { + if isLoading { + placeholder() + } else { + placeholder() + .onAppear { + loadImage() + } + } + } + } + } + + private func loadImage() { + guard let url = url else { return } + + let urlString = url.absoluteString + + // Check cache first + if let cached = ImageCache.shared.get(for: urlString) { + self.cachedImage = cached + return + } + + isLoading = true + + Task { + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let uiImage = UIImage(data: data) { + await MainActor.run { + ImageCache.shared.set(uiImage, for: urlString) + self.cachedImage = uiImage + self.isLoading = false + } + } + } catch { + print("Error loading image: \(error)") + await MainActor.run { + self.isLoading = false + } + } + } + } +} diff --git a/Django Files/Util/SessionManager.swift b/Django Files/Util/SessionManager.swift new file mode 100644 index 0000000..81ebd54 --- /dev/null +++ b/Django Files/Util/SessionManager.swift @@ -0,0 +1,54 @@ +// +// SessionManager.swift +// Django Files +// +// Created by Ralph Luaces on 5/29/25. +// + +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 createAndAuthenticateSession(url: URL, signature: String, context: ModelContext) async -> DjangoFilesSession? { + let serverURL = "\(url.scheme ?? "https")://\(url.host ?? "")" + let newSession = DjangoFilesSession(url: serverURL) + + let api = DFAPI(url: URL(string: serverURL)!, token: "") + + // Get token using the signature + if let token = await api.applicationAuth(signature: signature, selectedServer: newSession) { + newSession.token = token + newSession.auth = true + + // Save the session + context.insert(newSession) + try? context.save() + + return newSession + } + + return nil + } + + func loadLastSelectedSession(from sessions: [DjangoFilesSession]) { + 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 }) { + selectedSession = defaultSession + } else if let firstSession = sessions.first { + selectedSession = firstSession + } + } + +} diff --git a/Django Files/Views/AlbumList.swift b/Django Files/Views/AlbumList.swift new file mode 100644 index 0000000..93d4734 --- /dev/null +++ b/Django Files/Views/AlbumList.swift @@ -0,0 +1,288 @@ +// +// AlbumList.swift +// Django Files +// +// Created by Ralph Luaces on 4/29/25. +// + +import SwiftUI +import SwiftData +import Foundation + +struct AlbumListView: View { + let navigationPath: Binding + 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 showDeleteConfirmation = false + @State private var albumToDelete: DFAlbum? = nil + @State private var showingAlbumCreator: Bool = false + + var body: some View { + List { + if isLoading && albums.isEmpty { + HStack { + Spacer() + LoadingView() + .frame(width: 100, height: 100) + Spacer() + } + } else if let error = errorMessage { + HStack { + Spacer() + 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) + Spacer() + } + } else if albums.isEmpty { + HStack { + Spacer() + VStack { + Spacer() + Image(systemName: "photo.stack.fill") + .font(.system(size: 50)) + .padding(.bottom) + .shadow(color: .purple, radius: 20) + Text("No albums found") + .font(.headline) + .shadow(color: .purple, radius: 20) + Text("Create an album to get started") + .foregroundColor(.secondary) + } + .padding() + Spacer() + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else { + 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(role: .destructive, action: { + albumToDelete = album + showDeleteConfirmation = true + }) { + Label("Delete Album", systemImage: "trash") + } + } + } + .id(album.id) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button() { + albumToDelete = album + showDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + + if hasNextPage && album.id == albums.last?.id { + Color.clear + .frame(height: 20) + .onAppear { + loadNextPage() + } + } + } + + if isLoading && hasNextPage { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + } + .listStyle(.plain) + .refreshable { + Task { + await refreshAlbumsAsync() + } + } + .navigationTitle(server.wrappedValue != nil ? "Albums (\(URL(string: server.wrappedValue!.url)?.host ?? "unknown"))" : "Albums") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showingAlbumCreator = true + }) { + Label("Create Album", systemImage: "plus") + } + } + } + .navigationDestination(for: DFAlbum.self) { album in + FileListView(server: server, albumID: album.id, navigationPath: navigationPath, albumName: album.name) + } + .sheet(isPresented: $showingAlbumCreator) { + if let serverInstance = server.wrappedValue { + CreateAlbumView(server: serverInstance) + .onDisappear { + showingAlbumCreator = false + } + } + } + .onChange(of: selectedAlbum) { oldValue, newValue in + if let album = newValue { + navigationPath.wrappedValue.append(album) + selectedAlbum = nil // Reset after navigation + } + } + .confirmationDialog("Are you sure?", isPresented: $showDeleteConfirmation) { + Button("Delete", role: .destructive) { + if let album = albumToDelete { + Task { + await deleteAlbum(album) + } + } + } + Button("Cancel", role: .cancel) { + // Optional: No action needed for cancel + } + } message: { + Text("Are you sure you want to delete \"\(String(describing: albumToDelete?.name ?? "Unknown Album"))\"?") + } + .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) + } + } + + @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 + } + } + + @MainActor + private func deleteAlbum(_ album: DFAlbum) async { + guard let serverInstance = server.wrappedValue else { return } + + let api = DFAPI(url: URL(string: serverInstance.url)!, token: serverInstance.token) + + if await api.deleteAlbum(albumId: album.id) { + withAnimation{ + if let index = albums.firstIndex(where: { $0.id == album.id }) { + albums.remove(at: index) + } + } + } else { + ToastManager.shared.showToast(message: "Failed to delete album") + } + + albumToDelete = nil + } +} + +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("", systemImage: "lock") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + } + if album.password != "" { + Label("", systemImage: "key") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + } + Text(album.formattedDate()) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + } + } + } +} diff --git a/Django Files/Views/AppSettings.swift b/Django Files/Views/AppSettings.swift new file mode 100644 index 0000000..8b080d9 --- /dev/null +++ b/Django Files/Views/AppSettings.swift @@ -0,0 +1,113 @@ +// +// AppSettings.swift +// Django Files +// +// Created by Ralph Luaces on 5/31/25. +// + +import SwiftUI +import FirebaseAnalytics +import FirebaseCrashlytics + +struct AppSettings: View { + @AppStorage("firebaseAnalyticsEnabled") private var firebaseAnalyticsEnabled = true + @AppStorage("crashlyticsEnabled") private var crashlyticsEnabled = true + @State private var showAnalyticsAlert = false + @State private var showCrashlyticsAlert = false + @State private var pendingAnalyticsValue = true + @State private var pendingCrashlyticsValue = true + + private var versionInfo: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" + if version == "0.0" { + return "dev (source)" + } + return "\(version) (\(build))" + } + + var body: some View { + Form { + Section(header: Text("Privacy")) { + Toggle(isOn: Binding( + get: { firebaseAnalyticsEnabled }, + set: { newValue in + if !newValue { + pendingAnalyticsValue = newValue + showAnalyticsAlert = true + } else { + firebaseAnalyticsEnabled = newValue + Analytics.setAnalyticsCollectionEnabled(newValue) + } + } + )) { + VStack(alignment: .leading) { + Text("Analytics") + Text("Help improve the app by sending anonymous usage data") + .font(.caption) + .foregroundColor(.secondary) + } + } + .alert("Disable Analytics?", isPresented: $showAnalyticsAlert) { + Button("Keep Enabled", role: .cancel) { + firebaseAnalyticsEnabled = true + } + Button("Disable", role: .destructive) { + firebaseAnalyticsEnabled = pendingAnalyticsValue + Analytics.setAnalyticsCollectionEnabled(pendingAnalyticsValue) + } + } message: { + Text("Please consider leaving analytics enabled to help improve Django Files. We do not collect ANY personal information with analytics.") + } + + Toggle(isOn: Binding( + get: { crashlyticsEnabled }, + set: { newValue in + if !newValue { + pendingCrashlyticsValue = newValue + showCrashlyticsAlert = true + } else { + crashlyticsEnabled = newValue + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(newValue) + } + } + )) { + VStack(alignment: .leading) { + Text("Crash Reporting") + Text("Send crash reports to help identify and fix issues") + .font(.caption) + .foregroundColor(.secondary) + } + } + .alert("Disable Crash Reporting?", isPresented: $showCrashlyticsAlert) { + Button("Keep Enabled", role: .cancel) { + crashlyticsEnabled = true + } + Button("Disable", role: .destructive) { + crashlyticsEnabled = pendingCrashlyticsValue + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(pendingCrashlyticsValue) + } + } message: { + Text("Please consider leaving crash analytics enabled. We collect no personal information, only information portaining to application errors.") + } + } + + Section(header: Text("About")) { + HStack { + Text("Version") + Spacer() + Text(versionInfo) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Settings") + } +} + +#Preview { + NavigationView { + AppSettings() + } +} + diff --git a/Django Files/Views/ContentView.swift b/Django Files/Views/ContentView.swift index 14402be..1123851 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,82 @@ 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 customURL: String? = nil + 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: customURL ?? 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 +131,3 @@ struct LoadingView: View { } } } - -#Preview { - ContentView() - .modelContainer(for: DjangoFilesSession.self, inMemory: true) -} diff --git a/Django Files/Views/CreateAlbum.swift b/Django Files/Views/CreateAlbum.swift new file mode 100644 index 0000000..b10ba4e --- /dev/null +++ b/Django Files/Views/CreateAlbum.swift @@ -0,0 +1,106 @@ +// +// CreateAlbum.swift +// Django Files +// +// Created by Ralph Luaces on 5/20/25. +// + +import SwiftUI + +struct CreateAlbumView: View { + let server: DjangoFilesSession + @Environment(\.dismiss) private var dismiss + + @State private var albumName = "" + @State private var isPrivate = false + @State private var password = "" + @State private var maxViews = "" + @State private var expiration = "" + @State private var description = "" + @State private var isCreating = false + @State private var errorMessage: String? = nil + + var body: some View { + NavigationView { + Form { + Section(header: Text("Album Details")) { + TextField("Album Name", text: $albumName) + Toggle("Private", isOn: $isPrivate) + } + + Section(header: Text("Security (Optional)")) { + TextField("Password", text: $password) + TextField("Max Views", text: $maxViews) + .keyboardType(.numberPad) + TextField("Expiration (e.g., 1h, 5days, 2y)", text: $expiration) + } + + Section(header: Text("Additional Info")) { + TextEditor(text: $description) + .frame(height: 100) + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + } + } + } + .navigationTitle("Create Album") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Create") { + Task { + await createAlbum() + } + } + .disabled(albumName.isEmpty || isCreating) + } + } + } + } + + private func createAlbum() async { + isCreating = true + errorMessage = nil + + guard let url = URL(string: server.url) else { + errorMessage = "Invalid server URL" + isCreating = false + return + } + + let api = DFAPI(url: url, token: server.token) + + let maxViewsInt = Int(maxViews) + + if let _ = await api.createAlbum( + name: albumName, + maxViews: maxViewsInt, + expiration: expiration.isEmpty ? nil : expiration, + password: password.isEmpty ? nil : password, + isPrivate: isPrivate, + description: description.isEmpty ? nil : description, + selectedServer: server + ) { + // Album created successfully + await MainActor.run { + isCreating = false + dismiss() + } + } else { + await MainActor.run { + errorMessage = "Failed to create album" + isCreating = false + } + } + } +} + diff --git a/Django Files/Views/CreateShort.swift b/Django Files/Views/CreateShort.swift new file mode 100644 index 0000000..1c59d7d --- /dev/null +++ b/Django Files/Views/CreateShort.swift @@ -0,0 +1,75 @@ +// +// CreateShort.swift +// Django Files +// +// Created by Ralph Luaces on 5/20/25. +// + +import SwiftUI + +struct ShortCreatorView: View { + let server: DjangoFilesSession + @Environment(\.dismiss) private var dismiss + @State private var url: String = "" + @State private var vanityPath: String = "" + @State private var isSubmitting = false + @State private var errorMessage: String? = nil + + var body: some View { + NavigationView { + Form { + Section { + TextField("URL to shorten", text: $url) + .autocapitalization(.none) + .keyboardType(.URL) + TextField("Vanity path (optional)", text: $vanityPath) + .autocapitalization(.none) + } + + if let error = errorMessage { + Section { + Text(error) + .foregroundColor(.red) + } + } + } + .navigationTitle("Create Short") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Create") { + Task { + await submitShort() + } + } + .disabled(url.isEmpty || isSubmitting) + } + } + } + } + + private func submitShort() async { + isSubmitting = true + errorMessage = nil + + guard let urlObj = URL(string: url) else { + errorMessage = "Invalid URL format" + isSubmitting = false + return + } + + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + if let _ = await api.createShort(url: urlObj, short: vanityPath) { + dismiss() + } else { + errorMessage = "Failed to create short URL" + } + + isSubmitting = false + } +} diff --git a/Django Files/Views/FileContextMenu.swift b/Django Files/Views/FileContextMenu.swift new file mode 100644 index 0000000..72a9785 --- /dev/null +++ b/Django Files/Views/FileContextMenu.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct FileContextMenuButtons: View { + + var isPreviewing: Bool = false + var isPrivate: 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: { + if isPrivate { + Label("Make Public", systemImage: "lock.open") + } else { + Label("Make Private", systemImage: "lock") + } + } + + Button { + setExpire() + } label: { + Label("Set Expire", systemImage: "calendar.badge.exclamationmark") + } + + Button { + setPassword() + } label: { + Label("Set Password", systemImage: "key") + } + +// Divider() +// Button { +// addToAlbum() +// } label: { +// Label("Add To Album", systemImage: "rectangle.stack.badge.plus") +// } +// + Divider() + + Button { + renameFile() + } label: { + Label("Rename File", systemImage: "character.cursor.ibeam") + } + Divider() + Button(role: .destructive) { + deleteFile() + } label: { + Label("Delete File", systemImage: "trash") + } + } + } +} + + +struct FileShareMenu: View { + var onCopyShareLink: () -> Void = {} + var onCopyRawLink: () -> Void = {} + var openRawBrowser: () -> Void = {} + + var body: some View { + Group { + Button { + onCopyShareLink() + notifyClipboard() + } label: { + Label("Copy Share Link", systemImage: "link") + } + Button { + onCopyRawLink() + notifyClipboard() + } label: { + Label("Copy Raw Link", systemImage: "link.circle") + } + } + } +} + +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..1c0637a --- /dev/null +++ b/Django Files/Views/FileList.swift @@ -0,0 +1,832 @@ +// +// FileList.swift +// Django Files +// +// Created by Ralph Luaces on 4/19/25. +// + +import SwiftUI +import SwiftData +import Foundation + +protocol FileListDelegate: AnyObject { + @MainActor + func deleteFiles(fileIDs: [Int], onSuccess: (() -> Void)?) async -> Bool + @MainActor + func renameFile(fileID: Int, newName: String, onSuccess: (() -> Void)?) async -> Bool + @MainActor + func setFilePassword(fileID: Int, password: String, onSuccess: (() -> Void)?) async -> Bool + @MainActor + func setFilePrivate(fileID: Int, isPrivate: Bool, onSuccess: (() -> Void)?) async -> Bool + @MainActor + func setFileExpiration(fileID: Int, expr: String, onSuccess: (() -> Void)?) async -> Bool +} + +@MainActor +class FileListManager: ObservableObject, FileListDelegate { + @Published var files: [DFFile] = [] + var server: Binding + + init(server: Binding) { + self.server = server + } + + func deleteFiles(fileIDs: [Int], onSuccess: (() -> Void)?) async -> Bool { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return false + } + + let api = DFAPI(url: url, token: serverInstance.token) + let status = await api.deleteFiles(fileIDs: fileIDs, selectedServer: serverInstance) + if status { + withAnimation { + files.removeAll { file in + fileIDs.contains(file.id) + } + onSuccess?() + } + } + return status + } + + func renameFile(fileID: Int, newName: String, onSuccess: (() -> Void)?) async -> Bool { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return false + } + + let api = DFAPI(url: url, token: serverInstance.token) + let status = await api.renameFile(fileID: fileID, name: newName, selectedServer: serverInstance) + if status { + withAnimation { + if let index = files.firstIndex(where: { $0.id == fileID }) { + var updatedFiles = files + + // Update the name + updatedFiles[index].name = newName + + // Update URLs that contain the filename + let file = updatedFiles[index] + + // Update raw URL + if let oldRawURL = URL(string: file.raw) { + let newRawURL = oldRawURL.deletingLastPathComponent().appendingPathComponent(newName) + updatedFiles[index].raw = newRawURL.absoluteString + } + + // Update thumb URL + if let oldThumbURL = URL(string: file.thumb) { + let newThumbURL = oldThumbURL.deletingLastPathComponent().appendingPathComponent(newName) + updatedFiles[index].thumb = newThumbURL.absoluteString + } + + // Update main URL + if let oldURL = URL(string: file.url) { + let newURL = oldURL.deletingLastPathComponent().appendingPathComponent(newName) + updatedFiles[index].url = newURL.absoluteString + } + + // Reassign the entire array to trigger a view update + files = updatedFiles + } + onSuccess?() + } + } + return status + } + + func setFilePassword(fileID: Int, password: String, onSuccess: (() -> Void)?) async -> Bool { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return false + } + + let api = DFAPI(url: url, token: serverInstance.token) + let status = await api.editFiles(fileIDs: [fileID], changes: ["password": password], selectedServer: serverInstance) + if status { + withAnimation { + if let index = files.firstIndex(where: { $0.id == fileID }) { + var updatedFiles = files + updatedFiles[index].password = password + files = updatedFiles + } + onSuccess?() + } + } + return status + } + + func setFilePrivate(fileID: Int, isPrivate: Bool, onSuccess: (() -> Void)?) async -> Bool { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return false + } + + let api = DFAPI(url: url, token: serverInstance.token) + let status = await api.editFiles(fileIDs: [fileID], changes: ["private": isPrivate], selectedServer: serverInstance) + if status { + withAnimation { + if let index = files.firstIndex(where: { $0.id == fileID }) { + var updatedFiles = files + updatedFiles[index].private = isPrivate + files = updatedFiles + } + onSuccess?() + } + } + return status + } + + func setFileExpiration(fileID: Int, expr: String, onSuccess: (() -> Void)?) async -> Bool { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return false + } + + let api = DFAPI(url: url, token: serverInstance.token) + let status = await api.editFiles(fileIDs: [fileID], changes: ["expr": expr], selectedServer: serverInstance) + if status { + withAnimation { + if let index = files.firstIndex(where: { $0.id == fileID }) { + var updatedFiles = files + updatedFiles[index].expr = expr + files = updatedFiles + } + onSuccess?() + } + } + return status + } +} + +struct CustomLabel: LabelStyle { + var spacing: Double = 0.0 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: spacing) { + configuration.icon + configuration.title + } + } +} + +struct FileRowView: View { + @Binding var file: DFFile + var isPrivate: Bool { file.private } + var hasPassword: Bool { file.password != "" } + var hasExpiration: Bool { file.expr != "" } + let serverURL: URL + + private func getIcon() -> String { + if file.mime.hasPrefix("image/") { + return "photo.artframe" + } else if file.mime.hasPrefix("video/") { + return "video.fill" + } else { + return "doc.fill" + } + } + + private var thumbnailURL: URL { + var components = URLComponents(url: serverURL.appendingPathComponent("/raw/\(file.name)"), resolvingAgainstBaseURL: true) + components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] + return components?.url ?? serverURL + } + + var body: some View { + HStack(alignment: .center) { + VStack(spacing: 0) { + if file.mime.hasPrefix("image/") { + CachedAsyncImage(url: thumbnailURL) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + ProgressView() + } + .frame(width: 64, height: 64) + .clipped() + .cornerRadius(8) + } else { + Image(systemName: getIcon()) + .font(.system(size: 50)) + .frame(width: 64, height: 64) + .foregroundColor(Color.primary) + .clipped() + } + } + .listRowSeparator(.visible) + + + VStack(alignment: .leading, spacing: 5) { + + HStack(spacing: 5) { + Text(file.name) + .font(.headline) + .lineLimit(1) + .foregroundColor(.blue) + } + + + HStack(spacing: 6) { + Text(file.mime) + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + .lineLimit(1) + + Label("", systemImage: "lock") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + .opacity(isPrivate ? 1 : 0) + + Label("", systemImage: "key") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + .opacity(hasPassword ? 1 : 0) + + Label("", systemImage: "calendar.badge.exclamationmark") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + .opacity(hasExpiration ? 1 : 0) + } + + HStack(spacing: 5) { + + + Label(file.userUsername, systemImage: "person") + .font(.caption) + .labelStyle(CustomLabel(spacing: 3)) + .lineLimit(1) + + + Text(file.formattedDate()) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(1) + } + + } + } + } +} + + +struct FileListView: View { + let server: Binding + let albumID: Int? + let navigationPath: Binding + let albumName: String? + + @Environment(\.dismiss) private var dismiss + @StateObject private var fileListManager: FileListManager + + @State private var currentPage = 1 + @State private var hasNextPage: Bool = false + @State private var isLoading: Bool = true + @State private var errorMessage: String? = nil + @State private var showingUploadSheet: Bool = false + @State private var showingShortCreator: Bool = false + @State private var showingAlbumCreator: Bool = false + @State private var showingPreview: Bool = false + @State private var selectedFile: DFFile? = nil + + @State private var showingDeleteConfirmation = false + @State private var fileIDsToDelete: [Int] = [] + @State private var fileNameToDelete: String = "" + + @State private var showingExpirationDialog = false + @State private var expirationText = "" + @State private var fileToExpire: DFFile? = nil + + @State private var showingPasswordDialog = false + @State private var passwordText = "" + @State private var fileToPassword: DFFile? = nil + + @State private var showingRenameDialog = false + @State private var fileNameText = "" + @State private var fileToRename: DFFile? = nil + + @State private var redirectURLs: [String: String] = [:] + + @State private var showFileInfo: Bool = false + + init(server: Binding, albumID: Int?, navigationPath: Binding, albumName: String?) { + self.server = server + self.albumID = albumID + self.navigationPath = navigationPath + self.albumName = albumName + _fileListManager = StateObject(wrappedValue: FileListManager(server: server)) + } + + private var files: [DFFile] { + get { fileListManager.files } + nonmutating set { fileListManager.files = newValue } + } + + private func getTitle(server: Binding, albumName: String?) -> String { + if server.wrappedValue != nil && albumName == nil { + return "Files (\(String(describing: URL(string: server.wrappedValue?.url ?? "host")!.host ?? "unknown")))" + } else if server.wrappedValue != nil && albumName != nil { + return "\(String(describing: albumName!)) (\(String(describing: URL(string: server.wrappedValue?.url ?? "host")!.host ?? "unknown")))" + } else { + return "Files" + } + } + + private func thumbnailURL(file: DFFile) -> URL { + var components = URLComponents(url: URL(string: server.wrappedValue!.url)!.appendingPathComponent("/raw/\(file.name)"), resolvingAgainstBaseURL: true) + components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] + return components?.url ?? URL(string: server.wrappedValue!.url)! + } + + var body: some View { + List { + if files.count == 0 && !isLoading { + HStack { + Spacer() + VStack { + Spacer() + Image(systemName: "document.on.document.fill") + .font(.system(size: 50)) + .padding(.bottom) + .shadow(color: .purple, radius: 15) + Text("No files found") + .font(.headline) + .shadow(color: .purple, radius: 20) + Text("Upload a file to get started") + .foregroundColor(.secondary) + } + .padding() + Spacer() + } + .listRowSeparator(.hidden) + } + + ForEach(files.indices, id: \.self) { index in + Button { + selectedFile = files[index] + showingPreview = true + } label: { + if files[index].mime.starts(with: "image/") { + FileRowView( + file: $fileListManager.files[index], + serverURL: URL(string: server.wrappedValue!.url)! + ) + .contextMenu { + fileContextMenu(for: files[index], isPreviewing: false, isPrivate: files[index].private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) + } preview: { + CachedAsyncImage(url: thumbnailURL(file: files[index])) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + ProgressView() + } + .frame(width: 512, height: 512) + .cornerRadius(8) + } + } else { + FileRowView( + file: $fileListManager.files[index], + serverURL: URL(string: server.wrappedValue!.url)! + ) + .contextMenu { + fileContextMenu(for: files[index], isPreviewing: false, isPrivate: files[index].private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) + } + } + } + .id(files[index].id) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button() { + fileIDsToDelete = [files[index].id] + fileNameToDelete = files[index].name + showingDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + + if hasNextPage && files.suffix(5).contains(where: { $0.id == files[index].id }) { + Color.clear + .frame(height: 20) + .onAppear { + loadNextPage() + } + } + } + + if isLoading && hasNextPage { + HStack { + Spacer() + LoadingView() + .frame(width: 100, height: 100) + Spacer() + } + } + } + .fullScreenCover(isPresented: $showingPreview) { + if let index = files.firstIndex(where: { $0.id == selectedFile?.id }) { + FilePreviewView( + file: $fileListManager.files[index], + server: server, + showingPreview: $showingPreview, + showFileInfo: $showFileInfo, + fileListDelegate: fileListManager + ) + } + } + + .listStyle(.plain) + .refreshable { + Task { + await refreshFiles() + } + } + .navigationTitle(getTitle(server: server, albumName: albumName)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Menu { + Button(action: { + showingUploadSheet = true + }) { + Label("Upload File", systemImage: "arrow.up.doc") + } + Button(action: { + Task { + await uploadClipboard() + } + }) { + Label("Upload Clipboard", systemImage: "clipboard") + } + Button(action: { + showingShortCreator = true + }) { + Label("Create Short", systemImage: "link.badge.plus") + } + Button(action: { + showingAlbumCreator = true + }) { + Label("Create Album", systemImage: "photo.badge.plus") + } + } label: { + Image(systemName: "plus") + } + .shadow(color: .purple, radius: files.isEmpty ? 3 : 0) + } + + } + .sheet(isPresented: $showingUploadSheet, + onDismiss: { Task { await refreshFiles()} } + ) { + if let serverInstance = server.wrappedValue { + FileUploadView(server: serverInstance) + } + } + .sheet(isPresented: $showingShortCreator) { + if let serverInstance = server.wrappedValue { + ShortCreatorView(server: serverInstance) + } + } + .sheet(isPresented: $showingAlbumCreator) { + if let serverInstance = server.wrappedValue { + CreateAlbumView(server: serverInstance) + } + } + .confirmationDialog("Are you sure?", isPresented: $showingDeleteConfirmation) { + Button("Delete", role: .destructive) { + Task { + await deleteFiles(fileIDs: fileIDsToDelete) + } + } + Button("Cancel", role: .cancel) { + // Optional: No action needed for cancel + } + } message: { + Text("Are you sure you want to delete \"\(fileNameToDelete)\"?") + } + .alert("Set File Expiration", isPresented: $showingExpirationDialog) { + TextField("Enter expiration", text: $expirationText) + Button("Cancel", role: .cancel) { + fileToExpire = nil + } + Button("Set") { + if let file = fileToExpire { + let expirationValue = expirationText + Task { + await setFileExpiration(file: file, expr: expirationValue) + await MainActor.run { + expirationText = "" + fileToExpire = nil + } + } + } + } + } message: { + Text("Enter time until file expiration. Examples: 1h, 5days, 2y") + } + .alert("Set File Password", isPresented: $showingPasswordDialog) { + TextField("Enter password", text: $passwordText) + Button("Cancel", role: .cancel) { + fileToPassword = nil + } + Button("Set") { + if let file = fileToPassword { + let passwordValue = passwordText + Task { + await setFilePassword(file: file, password: passwordValue) + await MainActor.run { + passwordText = "" + fileToPassword = nil + } + } + } + } + } message: { + Text("Enter a password for the file.") + } + .alert("Rename File", isPresented: $showingRenameDialog) { + TextField("New File Name", text: $fileNameText) + Button("Cancel", role: .cancel) { + fileToRename = nil + } + Button("Set") { + if let file = fileToRename { + let fileNameValue = fileNameText + Task { + await renameFile(file: file, name: fileNameValue) + await MainActor.run { + fileNameText = "" + fileToRename = nil + } + } + } + } + } message: { + Text("Enter a new name for this file.") + } + .onAppear { + loadFiles() + } + } + + @MainActor + private func uploadClipboard() async { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + + let api = DFAPI(url: url, token: serverInstance.token) + let pasteboard = UIPasteboard.general + + // Handle text content + if let text = pasteboard.string { + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent("ios-clip.txt") + do { + try text.write(to: tempURL, atomically: true, encoding: .utf8) + let delegate = UploadProgressDelegate { _ in } + _ = await api.uploadFile(url: tempURL, taskDelegate: delegate) + try? FileManager.default.removeItem(at: tempURL) + await refreshFiles() + } catch { + print("Error uploading clipboard text: \(error)") + } + return + } + + // Handle image content + if let image = pasteboard.image { + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent("image.jpg") + if let imageData = image.jpegData(compressionQuality: 0.8) { + do { + try imageData.write(to: tempURL) + let delegate = UploadProgressDelegate { _ in } + _ = await api.uploadFile(url: tempURL, taskDelegate: delegate) + try? FileManager.default.removeItem(at: tempURL) + await refreshFiles() + } catch { + print("Error uploading clipboard image: \(error)") + } + } + return + } + + // Handle video content + if let videoData = pasteboard.data(forPasteboardType: "public.mpeg-4"), + let tempURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("video.mp4") { + do { + try videoData.write(to: tempURL) + let delegate = UploadProgressDelegate { _ in } + _ = await api.uploadFile(url: tempURL, taskDelegate: delegate) + try? FileManager.default.removeItem(at: tempURL) + await refreshFiles() + } catch { + print("Error uploading clipboard video: \(error)") + } + return + } + } + + private func fileContextMenu(for file: DFFile, isPreviewing: Bool, isPrivate: Bool, expirationText: Binding, passwordText: Binding, fileNameText: Binding) -> FileContextMenuButtons { + var isPrivate: Bool = isPrivate + return FileContextMenuButtons( + isPreviewing: isPreviewing, + isPrivate: isPrivate, + onPreview: { + selectedFile = file + showingPreview = true + }, + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only open the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw] { + await MainActor.run { + UIPasteboard.general.string = redirectURL + } + } else { + await MainActor.run { + UIPasteboard.general.string = file.raw + } + } + } + } else if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + UIPasteboard.general.string = finalURL.absoluteString + } else { + UIPasteboard.general.string = file.raw + } + }, + openRawBrowser: { + if let url = URL(string: file.raw), UIApplication.shared.canOpenURL(url) { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only open the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + await MainActor.run { + UIApplication.shared.open(finalURL) + } + } else { + await MainActor.run { + UIApplication.shared.open(url) + } + } + } + } else if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + UIApplication.shared.open(finalURL) + } else { + UIApplication.shared.open(url) + } + } + }, + onTogglePrivate: { + Task { + isPrivate = !isPrivate + await toggleFilePrivacy(file: file) + } + }, + setExpire: { + fileToExpire = file + expirationText.wrappedValue = fileToExpire?.expr ?? "" + showingExpirationDialog = true + }, + setPassword: { + fileToPassword = file + passwordText.wrappedValue = fileToPassword?.password ?? "" + showingPasswordDialog = true + }, + renameFile: { + fileToRename = file + fileNameText.wrappedValue = fileToRename?.name ?? "" + showingRenameDialog = true + }, + deleteFile: { + fileIDsToDelete = [file.id] + fileNameToDelete = file.name + showingDeleteConfirmation = true + } + ) + } + + private func loadFiles() { + if (files.count > 0) { return } + 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) + } + } + + @MainActor + private func refreshFiles() async { + isLoading = true + errorMessage = nil + currentPage = 1 + files = [] + 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, album: albumID, selectedServer: serverInstance) { + 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 + } + } + + @MainActor + private func deleteFiles(fileIDs: [Int], onSuccess: (() -> Void)? = nil) async -> Bool { + return await fileListManager.deleteFiles(fileIDs: fileIDs, onSuccess: onSuccess) + } + + @MainActor + private func loadRedirectURL(for file: DFFile) async { + guard redirectURLs[file.raw] == nil, + let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + + let api = DFAPI(url: url, token: serverInstance.token) + + if let redirectURL = await api.checkRedirect(url: file.raw) { + redirectURLs[file.raw] = redirectURL + } else { + // If redirect fails, use the original URL + redirectURLs[file.raw] = file.raw + } + } + + private func fileShareMenu(for file: DFFile) -> FileShareMenu { + FileShareMenu( + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + UIPasteboard.general.string = file.raw + } + ) + } + + @MainActor + private func toggleFilePrivacy(file: DFFile) async { + let _ = await fileListManager.setFilePrivate(fileID: file.id, isPrivate: !file.private, onSuccess: nil) + } + + @MainActor + private func setFileExpiration(file: DFFile, expr: String) async { + let _ = await fileListManager.setFileExpiration(fileID: file.id, expr: expr, onSuccess: nil) + } + + @MainActor + private func setFilePassword(file: DFFile, password: String) async { + let _ = await fileListManager.setFilePassword(fileID: file.id, password: password, onSuccess: nil) + } + + @MainActor + private func renameFile(file: DFFile, name: String) async { + let _ = await fileListManager.renameFile(fileID: file.id, newName: name, onSuccess: nil) + } + +} diff --git a/Django Files/Views/FileUploadView.swift b/Django Files/Views/FileUploadView.swift new file mode 100644 index 0000000..ec52e60 --- /dev/null +++ b/Django Files/Views/FileUploadView.swift @@ -0,0 +1,376 @@ +// +// FileUploadView.swift +// Django Files +// +// Created by Ralph Luaces on 5/18/25. +// + +import SwiftUI +import PhotosUI +import UniformTypeIdentifiers +import AVFoundation + +struct FileUploadView: View { + let server: DjangoFilesSession + @Environment(\.dismiss) private var dismiss + @State private var selectedItems: [PhotosPickerItem] = [] + @State private var selectedFiles: [URL] = [] + @State private var isUploading: Bool = false + @State private var uploadProgress: Double = 0.0 + @State private var showingFilePicker: Bool = false + @State private var showingCamera: Bool = false + @State private var capturedImage: UIImage? + + @State private var uploadPrivate: Bool = false + + // Album selection states + @State private var albums: [DFAlbum] = [] + @State private var searchText: String = "" + @State private var selectedAlbum: DFAlbum? + @State private var isLoadingAlbums: Bool = false + @FocusState private var isSearchFocused: Bool + + // Audio recording states + @State private var audioRecorder: AVAudioRecorder? + @State private var isRecording: Bool = false + @State private var recordingURL: URL? + + var body: some View { + NavigationView { + VStack(spacing: 20) { + Toggle("Make Private", isOn: $uploadPrivate) + + // Album Selection + VStack(alignment: .leading) { + TextField("Search Albums", text: $searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isSearchFocused) + .onChange(of: searchText) { _, _ in + if searchText.isEmpty { + selectedAlbum = nil + } + } + + if !searchText.isEmpty && (isSearchFocused || selectedAlbum == nil) { + ScrollView { + LazyVStack(alignment: .leading) { + ForEach(albums.filter { album in + album.name.localizedCaseInsensitiveContains(searchText) + }) { album in + Button(action: { + selectedAlbum = album + searchText = album.name + isSearchFocused = false + }) { + Text(album.name) + .foregroundColor(.primary) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + Divider() + } + } + } + .frame(maxHeight: 200) + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(radius: 2) + } + + if let album = selectedAlbum { + HStack { + Text("Selected Album: \(album.name)") + .foregroundColor(.secondary) + Spacer() + Button(action: { + selectedAlbum = nil + searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + } + } + .padding(.vertical, 4) + } + } + + // Audio Recording Button + Button(action: { + if isRecording { + stopRecording() + } else { + startRecording() + } + }) { + Label(isRecording ? "Stop Recording" : "Record Audio", systemImage: isRecording ? "stop.circle.fill" : "mic.circle.fill") + .frame(maxWidth: .infinity) + .padding() + .background(isRecording ? Color.red : Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + + // Camera Button + Button(action: { + showingCamera = true + }) { + Label("Take Photo", systemImage: "camera") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + + // Photo Picker + PhotosPicker( + selection: $selectedItems, + matching: .images, + photoLibrary: .shared()) { + Label("Select Photos", systemImage: "photo.stack") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + + // Document Picker Button + Button(action: { + showingFilePicker = true + }) { + Label("Select Files", systemImage: "doc") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + + if isUploading { + ProgressView(value: uploadProgress) { + Text("Uploading...") + } + } + } + .padding() + .navigationTitle("Upload Files") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + } + .sheet(isPresented: $showingCamera) { + ImagePicker(image: $capturedImage) + .ignoresSafeArea() + } + .onChange(of: capturedImage) { _, newImage in + if let image = newImage { + Task { + await uploadCapturedImage(image) + } + } + } + .fileImporter( + isPresented: $showingFilePicker, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + selectedFiles = urls + Task { + await uploadFiles(urls) + } + case .failure(let error): + print("Error selecting files: \(error.localizedDescription)") + } + } + .onChange(of: selectedItems) { _, newValue in + Task { + await uploadPhotos(newValue) + } + } + .task { + await loadAlbums() + } + } + } + + private func uploadCapturedImage(_ image: UIImage) async { + isUploading = true + uploadProgress = 0.0 + + if let imageData = image.jpegData(compressionQuality: 0.8), + let tempURL = saveTemporaryFile(data: imageData, filename: "ios_photo.jpg") { + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + let delegate = UploadProgressDelegate { progress in + uploadProgress = progress + } + + if let albumId = selectedAlbum?.id { + _ = await api.uploadFile(url: tempURL, albums: String(albumId), privateUpload: uploadPrivate, taskDelegate: delegate) + } else { + _ = await api.uploadFile(url: tempURL, privateUpload: uploadPrivate, taskDelegate: delegate) + } + try? FileManager.default.removeItem(at: tempURL) + } + + isUploading = false + capturedImage = nil + dismiss() + } + + private func uploadPhotos(_ items: [PhotosPickerItem]) async { + isUploading = true + uploadProgress = 0.0 + + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + let totalItems = Double(items.count) + + for (index, item) in items.enumerated() { + if let data = try? await item.loadTransferable(type: Data.self), + let tempURL = saveTemporaryFile(data: data, filename: "photo_\(index).jpg") { + let delegate = UploadProgressDelegate { progress in + uploadProgress = (Double(index) + progress) / totalItems + } + + if let albumId = selectedAlbum?.id { + _ = await api.uploadFile(url: tempURL, albums: String(albumId), privateUpload: uploadPrivate, taskDelegate: delegate) + } else { + _ = await api.uploadFile(url: tempURL, privateUpload: uploadPrivate, taskDelegate: delegate) + } + try? FileManager.default.removeItem(at: tempURL) + } + } + + isUploading = false + dismiss() + } + + private func uploadFiles(_ urls: [URL]) async { + isUploading = true + uploadProgress = 0.0 + + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + let totalFiles = Double(urls.count) + + for (index, url) in urls.enumerated() { + let delegate = UploadProgressDelegate { progress in + uploadProgress = (Double(index) + progress) / totalFiles + } + + if let albumId = selectedAlbum?.id { + _ = await api.uploadFile(url: url, albums: String(albumId), privateUpload: uploadPrivate, taskDelegate: delegate) + } else { + _ = await api.uploadFile(url: url, privateUpload: uploadPrivate, taskDelegate: delegate) + } + } + + isUploading = false + dismiss() + } + + private func saveTemporaryFile(data: Data, filename: String) -> URL? { + let tempDir = FileManager.default.temporaryDirectory + let tempURL = tempDir.appendingPathComponent(filename) + do { + try data.write(to: tempURL) + return tempURL + } catch { + print("Error saving temporary file: \(error.localizedDescription)") + return nil + } + } + + private func startRecording() { + let audioSession = AVAudioSession.sharedInstance() + + do { + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) + + let documentsPath = FileManager.default.temporaryDirectory + let audioFilename = documentsPath.appendingPathComponent("recording.m4a") + recordingURL = audioFilename + + let settings = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 2, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings) + audioRecorder?.record() + isRecording = true + } catch { + print("Recording failed: \(error)") + } + } + + private func stopRecording() { + audioRecorder?.stop() + isRecording = false + + if let url = recordingURL { + Task { + await uploadAudioRecording(url) + } + } + } + + private func uploadAudioRecording(_ url: URL) async { + isUploading = true + uploadProgress = 0.0 + + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + let delegate = UploadProgressDelegate { progress in + uploadProgress = progress + } + + if let albumId = selectedAlbum?.id { + _ = await api.uploadFile(url: url, albums: String(albumId), privateUpload: uploadPrivate, taskDelegate: delegate) + } else { + _ = await api.uploadFile(url: url, privateUpload: uploadPrivate, taskDelegate: delegate) + } + try? FileManager.default.removeItem(at: url) + + isUploading = false + recordingURL = nil + dismiss() + } + + private func loadAlbums() async { + isLoadingAlbums = true + let api = DFAPI(url: URL(string: server.url)!, token: server.token) + if let response = await api.getAlbums() { + albums = response.albums + } + isLoadingAlbums = false + } +} + +class UploadProgressDelegate: NSObject, URLSessionTaskDelegate { + var onProgress: (Double) -> Void + + init(onProgress: @escaping (Double) -> Void) { + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend) + DispatchQueue.main.async { + self.onProgress(progress) + } + } +} diff --git a/Django Files/Views/ImagePicker.swift b/Django Files/Views/ImagePicker.swift new file mode 100644 index 0000000..2eb987e --- /dev/null +++ b/Django Files/Views/ImagePicker.swift @@ -0,0 +1,39 @@ +import SwiftUI +import UIKit + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} \ No newline at end of file diff --git a/Django Files/Views/LoginView.swift b/Django Files/Views/LoginView.swift index 9839534..b44fa94 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 = "" @@ -39,14 +40,12 @@ struct LoginView: View { print("Fetching auth methods \(selectedServer.url)") isLoading = true if let response = await dfapi.getAuthMethods() { - print("methods fetched") authMethods = response.authMethods siteName = response.siteName } else { error = "Failed to fetch authentication methods, is this a Django Files server?" } - print("done") isLoading = false } @@ -66,6 +65,9 @@ struct LoginView: View { try? modelContext.save() } onLoginSuccess() + Task { + self.dismiss() + } } else { showErrorBanner = true oauthSheetURL = nil @@ -90,7 +92,6 @@ struct LoginView: View { } private func handleOAuthLogin(url: String) { - print("handleOAuthLogin received URL string: '\(url)'") if URL(string: url) != nil { print("Valid OAuth URL, showing web view") oauthSheetURL = OAuthURL(url: url) @@ -282,6 +283,9 @@ struct LoginView: View { ) if status { selectedServer.auth = true + Task { + self.dismiss() + } onLoginSuccess() } } else { @@ -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/OAuthWebView.swift b/Django Files/Views/OAuthWebView.swift index 85376d3..f8a4f2f 100644 --- a/Django Files/Views/OAuthWebView.swift +++ b/Django Files/Views/OAuthWebView.swift @@ -13,14 +13,12 @@ struct OAuthWebView: View { } private func startAuthentication() { - print("Starting authentication...") guard let authURL = URL(string: url) else { print("Failed to create URL from string: '\(url)'") onComplete(nil, nil, nil) dismiss() return } - print("Auth URL created: \(authURL)") // Create the auth session let session = ASWebAuthenticationSession( @@ -28,7 +26,7 @@ struct OAuthWebView: View { callbackURLScheme: "djangofiles", completionHandler: { callbackURL, error in if let error = error { - print("Authentication failed: \(error.localizedDescription)") + print("Authentication failed: \(error)") onComplete(nil, nil, nil) dismiss() return @@ -44,18 +42,16 @@ struct OAuthWebView: View { dismiss() return } - print(oauth_error) onComplete(token, sessionKey, oauth_error) dismiss() } ) - // Present the authentication session session.presentationContextProvider = WindowProvider.shared session.prefersEphemeralWebBrowserSession = false let started = session.start() - print("Session started: \(started)") // Debug print + print("Session started: \(started)") if !started { print("Failed to start authentication session") diff --git a/Django Files/Views/Preview.swift b/Django Files/Views/Preview.swift new file mode 100644 index 0000000..95019a1 --- /dev/null +++ b/Django Files/Views/Preview.swift @@ -0,0 +1,1275 @@ +import SwiftUI +import AVKit +import HighlightSwift +import UIKit +import PDFKit + +struct ContentPreview: View { + let mimeType: String + let fileURL: URL + @Binding var file: DFFile + var showFileInfo: Binding + + @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 + @State private var isPreviewing: Bool = false + @State private var fileDetails: DFFile? + + 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() + loadFileDetails() + isPreviewing = true + } + .onDisappear { + isPreviewing = false + } + } + + // 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: "application/") { + if mimeType == "application/pdf" { + pdfPreview + } else if mimeType.contains("json") { + textPreview + } else { + genericFilePreview + } + } else if mimeType.starts(with: "image/") { + imagePreview + } else if mimeType.starts(with: "video/") { + videoPreview + } else if mimeType.starts(with: "audio/") { + audioPreview + } else { + genericFilePreview + } + } + .sheet(isPresented: showFileInfo, onDismiss: { showFileInfo.wrappedValue = false }) { + if let details = fileDetails { + PreviewFileInfo(file: details) + .presentationBackground(.ultraThinMaterial) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } else { + PreviewFileInfo(file: file) + .presentationBackground(.ultraThinMaterial) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + } + } + + // Text Preview + private var textPreview: some View { + ScrollView { + ZStack { + if let content = content, let text = String(data: content, encoding: .utf8) { + CodeText(text) + .highlightLanguage(determineLanguage(from: mimeType, fileName: fileURL.lastPathComponent)) + .padding() + } else { + Text("Unable to decode text content") + .foregroundColor(.red) + } + } + .padding(.top, 40) + } + } + + // Helper function to determine the highlight language based on file type + private func determineLanguage(from mimeType: String, fileName: String) -> HighlightLanguage { + let fileExtension = (fileName as NSString).pathExtension.lowercased() + + switch fileExtension { + case "swift": + return .swift + case "py", "python": + return .python + case "js", "javascript": + return .javaScript + case "java": + return .java + case "cpp", "c", "h", "hpp": + return .cPlusPlus + case "html": + return .html + case "css": + return .css + case "json": + return .json + case "md", "markdown": + return .markdown + case "sh", "bash": + return .bash + case "rb", "ruby": + return .ruby + case "go": + return .go + case "rs": + return .rust + case "php": + return .php + case "sql": + return .sql + case "ts", "typescript": + return .typeScript + case "yaml", "yml": + return .yaml + default: + // For plain text or unknown types + if mimeType == "text/plain" { + return .plaintext + } + // Try to determine from mime type if extension didn't match + let mimePrimeType = mimeType.split(separator: "/").first?.lowercased() ?? "" + let mimeSubtype = mimeType.split(separator: "/").last?.lowercased() ?? "" + + switch mimePrimeType { + case "application": + switch mimeSubtype { + case "json", "x-ndjson": + return .json + default: + return .plaintext + } + case "text": + switch mimeSubtype { + case "javascript": + return .javaScript + case "python": + return .python + case "java": + return .java + case "html": + return .html + case "css": + return .css + case "json", "x-ndjson": + return .json + case "markdown": + return .markdown + case "xml": + return .html + default: + return .plaintext + } + default: + return .plaintext + } + + } + } + + // Image Preview + private var imagePreview: some View { + GeometryReader { geometry in + if let content = content, let uiImage = UIImage(data: content) { + ImageScrollView(image: uiImage) + .frame(width: geometry.size.width, height: geometry.size.height) + } else { + Text("Unable to load image") + } + } + .ignoresSafeArea() + } + + // Video Preview + private var videoPreview: some View { + VideoPlayer(player: AVPlayer(url: fileURL)) + .aspectRatio(contentMode: .fit) + } + + // Audio Preview + private var audioPreview: some View { + AudioPlayerView(url: fileURL) + .padding() + } + + // PDF Preview + private var pdfPreview: some View { + PDFView(url: fileURL) + .padding(.top, 45) + .background(.black) + + } + + // 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, audio, and PDF, we don't need to download the content as we'll use the URL directly + if mimeType.starts(with: "video/") || mimeType.starts(with: "audio/") || mimeType == "application/pdf" { + 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() + } + + private func loadFileDetails() { + guard let serverURL = URL(string: file.url)?.host else { return } + + // Construct the base URL from the file's URL + let baseURL = URL(string: "https://\(serverURL)")! + + // Create DFAPI instance + let api = DFAPI(url: baseURL, token: "") // Token will be handled by cookies + + Task { + if let details = await api.getFileDetails(fileID: file.id) { + await MainActor.run { + self.fileDetails = details + } + } + } + } +} + +struct PreviewFileInfo: View { + let file: DFFile + + // Helper function to format EXIF date string + private func formatExifDate(_ dateString: String) -> String { + let exifFormatter = DateFormatter() + exifFormatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + + guard let date = exifFormatter.date(from: dateString) else { + return dateString + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + // Helper function to convert decimal to fraction string + private func formatExposureTime(_ exposure: String) -> String { + if let value = Double(exposure) { + if value >= 1 { + return "\(Int(value))" + } else { + let denominator = Int(round(1.0 / value)) + return "1/\(denominator)" + } + } + return exposure + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("\(file.name)") + .font(.title) + HStack { + HStack { + Image(systemName: "document") + .frame(width: 20, height: 20) + .foregroundColor(.teal) + Text("\(file.mime)") + .foregroundColor(.teal) + } + + if file.password != "" { + Image(systemName: "key") + .frame(width: 20, height: 20) + } + if file.private { + Image(systemName: "lock") + .frame(width: 20, height: 20) + } + if file.expr != "" { + Image(systemName: "calendar.badge.exclamationmark") + .frame(width: 20, height: 20) + } + if file.maxv != 0 { + HStack { + Image(systemName: "eye.circle") + .frame(width: 20, height: 20) + Text("Max Views: \(String(file.maxv))") + } + } + if let width = file.meta?["PILImageWidth"]?.value as? Int, + let height = file.meta?["PILImageHeight"]?.value as? Int { + Spacer() + Text("\(width)×\(height)") + .foregroundColor(.secondary) + } + } + HStack { + HStack { + Image(systemName: "person") + .frame(width: 20, height: 20) + Text("\(file.userUsername)") + } + Spacer() + HStack { + Image(systemName: "eye") + .frame(width: 20, height: 20) + Text("\(file.view)") + } + Spacer() + HStack { + Image(systemName: "internaldrive") + .frame(width: 20, height: 20) + Text(file.formatSize()) + } + } + + HStack { + Image(systemName: "calendar") + .frame(width: 15, height: 15) + Text("\(file.formattedDate())") + } + + // Photo Information Section + if let dateTime = file.exif?["DateTimeOriginal"]?.value as? String { + HStack { + Image(systemName: "camera") + .frame(width: 15, height: 15) + .font(.caption) + Text("Captured: \(formatExifDate(dateTime))") + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let gpsArea = file.meta?["GPSArea"]?.value as? String { + HStack { + Image(systemName: "location") + .frame(width: 15, height: 15) + Text(gpsArea) + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let elevation = file.exif?["GPSInfo"]?.value as? [String: Any], + let altitude = elevation["6"] as? Double { + HStack{ + Image(systemName: "mountain.2.circle") + .frame(width: 15, height: 15) + Text(String(format: "Elevation: %.1f m", altitude)) + .font(.caption) + } + .foregroundColor(.secondary) + } + + // Camera Information Section + Group { + if let model = file.exif?["Model"]?.value as? String { + let make = file.exif?["Make"]?.value as? String ?? "" + let cameraName = make.isEmpty || model.contains(make) ? model : "\(make) \(model)" + HStack { + Image(systemName: "camera.aperture") + .frame(width: 15, height: 15) + Text("Camera: \(cameraName)") + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let lens = file.exif?["LensModel"]?.value as? String { + HStack { + Image(systemName: "camera.aperture") + .frame(width: 15, height: 15) + Text("Lens: \(lens)") + .font(.caption) + } + .foregroundColor(.secondary) + } + + + if let focalLength = file.exif?["FocalLength"]?.value as? Double { + HStack { + Image(systemName: "camera.aperture") + .frame(width: 15, height: 15) + Text(String(format: "Focal Length: %.0fmm", focalLength)) + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let fNumber = file.exif?["FNumber"]?.value as? Double { + HStack { + Image(systemName: "camera.aperture") + .frame(width: 15, height: 15) + Text(String(format: "Aperture: 𝑓%.1f", fNumber)) + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let iso = file.exif?["ISOSpeedRatings"]?.value as? Int { + HStack { + Image(systemName: "camera.aperture") + .frame(width: 20, height: 20) + Text("ISO: \(iso)") + .font(.caption) + } + .foregroundColor(.secondary) + + } + + if let exposureTime = file.exif?["ExposureTime"]?.value as? String { + HStack { + Image(systemName: "camera.aperture") + .frame(width: 15, height: 15) + Text("Exposure: \(formatExposureTime(exposureTime))s") + .font(.caption) + } + .foregroundColor(.secondary) + } + + if let software = file.exif?["Software"]?.value as? String { + HStack { + Image(systemName: "app") + .frame(width: 15, height: 15) + Text("Software: \(software)") + .font(.caption) + } + .foregroundColor(.secondary) + } + } + + if !file.info.isEmpty { + Text(file.info) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(40) + } +} + +struct ImageScrollView: UIViewRepresentable { + let image: UIImage + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = CustomScrollView() + scrollView.delegate = context.coordinator + + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleAspectFit + imageView.frame = CGRect(origin: .zero, size: image.size) + scrollView.addSubview(imageView) + + context.coordinator.imageView = imageView + context.coordinator.scrollView = scrollView + + let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTapGesture) + + // Calculate initial zoom scale + let widthScale = UIScreen.main.bounds.width / image.size.width + let heightScale = UIScreen.main.bounds.height / image.size.height + let minScale = min(widthScale, heightScale) + + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = 5.0 + + // Set content size to image size + scrollView.contentSize = image.size + + // Set initial zoom scale + scrollView.zoomScale = minScale + + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.imageView?.image = image + context.coordinator.updateZoomScaleForSize(scrollView.bounds.size) + } + + class Coordinator: NSObject, UIScrollViewDelegate { + let parent: ImageScrollView + weak var imageView: UIImageView? + weak var scrollView: UIScrollView? + + init(_ parent: ImageScrollView) { + self.parent = parent + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } + + func updateZoomScaleForSize(_ size: CGSize) { + guard let imageView = imageView, + let image = imageView.image, + let scrollView = scrollView, + size.width > 0, + size.height > 0, + image.size.width > 0, + image.size.height > 0 else { return } + + let widthScale = size.width / image.size.width + let heightScale = size.height / image.size.height + let minScale = min(widthScale, heightScale) + + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = max(minScale * 5, 5.0) + } + + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView = gesture.view as? UIScrollView else { return } + + if scrollView.zoomScale > scrollView.minimumZoomScale { + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true) + } else { + let point = gesture.location(in: imageView) + let size = scrollView.bounds.size + let w = size.width / (scrollView.maximumZoomScale / 5) + let h = size.height / (scrollView.maximumZoomScale / 5) + let x = point.x - (w / 2.0) + let y = point.y - (h / 2.0) + let rect = CGRect(x: x, y: y, width: w, height: h) + scrollView.zoom(to: rect, animated: true) + } + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + guard let imageView = imageView else { return } + + let boundsSize = scrollView.bounds.size + var frameToCenter = imageView.frame + + if frameToCenter.size.width < boundsSize.width { + frameToCenter.origin.x = (boundsSize.width - frameToCenter.size.width) / 2 + } else { + frameToCenter.origin.x = 0 + } + + if frameToCenter.size.height < boundsSize.height { + frameToCenter.origin.y = (boundsSize.height - frameToCenter.size.height) / 2 + } else { + frameToCenter.origin.y = 0 + } + + imageView.frame = frameToCenter + } + } +} + +class CustomScrollView: UIScrollView { + override func layoutSubviews() { + super.layoutSubviews() + + // Center the image after layout + if let imageView = subviews.first as? UIImageView { + var frameToCenter = imageView.frame + + if frameToCenter.size.width < bounds.size.width { + frameToCenter.origin.x = (bounds.size.width - frameToCenter.size.width) / 2 + } else { + frameToCenter.origin.x = 0 + } + + if frameToCenter.size.height < bounds.size.height { + frameToCenter.origin.y = (bounds.size.height - frameToCenter.size.height) / 2 + } else { + frameToCenter.origin.y = 0 + } + + imageView.frame = frameToCenter + } + } +} + +// Custom Audio Player View +struct AudioPlayerView: View { + let url: URL + @StateObject private var playerViewModel = AudioPlayerViewModel() + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "waveform") + .font(.system(size: 50)) + .foregroundColor(.gray) + .padding(.bottom) + + HStack { + Text(playerViewModel.currentTimeString) + .font(.caption) + .monospacedDigit() + + Slider(value: $playerViewModel.progress, in: 0...1) { editing in + if !editing { + playerViewModel.seek(to: playerViewModel.progress) + } + } + + Text(playerViewModel.durationString) + .font(.caption) + .monospacedDigit() + } + .padding(.horizontal) + + // Playback Controls + HStack(spacing: 30) { + Button(action: { playerViewModel.skipBackward() }) { + Image(systemName: "gobackward.15") + .font(.title2) + } + + Button(action: { playerViewModel.togglePlayback() }) { + Image(systemName: playerViewModel.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 44)) + } + + Button(action: { playerViewModel.skipForward() }) { + Image(systemName: "goforward.15") + .font(.title2) + } + } + } + .onAppear { + playerViewModel.setupPlayer(with: url) + } + .onDisappear { + playerViewModel.cleanup() + } + } +} + +// Audio Player View Model +class AudioPlayerViewModel: ObservableObject { + private var player: AVPlayer? + private var timeObserver: Any? + private var playerItemObserver: NSKeyValueObservation? + + @Published var isPlaying = false + @Published var progress: Double = 0 + @Published var currentTimeString = "00:00" + @Published var durationString = "00:00" + + private func configureAudioSession() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default) + try audioSession.overrideOutputAudioPort(.speaker) + try audioSession.setActive(true) + } catch { + print("Failed to configure audio session: \(error)") + } + } + + func setupPlayer(with url: URL) { + configureAudioSession() + player = AVPlayer(url: url) + + // Add periodic time observer + timeObserver = player?.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { [weak self] time in + guard let self = self, + let duration = self.player?.currentItem?.duration.seconds, + !duration.isNaN else { return } + + let currentTime = time.seconds + self.progress = currentTime / duration + self.currentTimeString = self.formatTime(currentTime) + self.durationString = self.formatTime(duration) + } + + // Observe player item status for completion + NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, + object: player?.currentItem, + queue: .main) { [weak self] _ in + self?.handlePlaybackCompletion() + } + + // Update duration when item is ready + Task { + if let duration = try? await player?.currentItem?.asset.load(.duration) as? CMTime, + !duration.seconds.isNaN { + await MainActor.run { + self.durationString = self.formatTime(duration.seconds) + } + } + } + } + + private func handlePlaybackCompletion() { + isPlaying = false + // Reset to beginning + seek(to: 0) + } + + func togglePlayback() { + if isPlaying { + player?.pause() + isPlaying = false + } else { + // If we're at the end, seek to beginning before playing + if let currentTime = player?.currentTime().seconds, + let duration = player?.currentItem?.duration.seconds, + currentTime >= duration { + seek(to: 0) + } + player?.play() + isPlaying = true + } + } + + func seek(to progress: Double) { + guard let duration = player?.currentItem?.duration else { return } + let time = CMTime(seconds: progress * duration.seconds, preferredTimescale: 600) + player?.seek(to: time) + } + + func skipForward() { + guard let currentTime = player?.currentTime().seconds else { return } + seek(to: (currentTime + 15) / (player?.currentItem?.duration.seconds ?? currentTime + 15)) + } + + func skipBackward() { + guard let currentTime = player?.currentTime().seconds else { return } + seek(to: (currentTime - 15) / (player?.currentItem?.duration.seconds ?? currentTime)) + } + + func cleanup() { + if let timeObserver = timeObserver { + player?.removeTimeObserver(timeObserver) + } + NotificationCenter.default.removeObserver(self) + player?.pause() + player = nil + } + + private func formatTime(_ timeInSeconds: Double) -> String { + let minutes = Int(timeInSeconds / 60) + let seconds = Int(timeInSeconds.truncatingRemainder(dividingBy: 60)) + return String(format: "%02d:%02d", minutes, seconds) + } +} + +struct FilePreviewView: View { + @Binding var file: DFFile + let server: Binding + @Binding var showingPreview: Bool + @Binding var showFileInfo: Bool + let fileListDelegate: FileListDelegate? + + @State private var redirectURLs: [String: String] = [:] + + @State private var showingDeleteConfirmation = false + @State private var fileIDsToDelete: [Int] = [] + @State private var fileNameToDelete: String = "" + + @State private var showingExpirationDialog = false + @State private var expirationText = "" + @State private var fileToExpire: DFFile? = nil + + @State private var showingPasswordDialog = false + @State private var passwordText = "" + @State private var fileToPassword: DFFile? = nil + + @State private var showingRenameDialog = false + @State private var fileNameText = "" + @State private var fileToRename: DFFile? = nil + + @State private var showingShareSheet = false + + var body: some View { + ZStack { + if redirectURLs[file.raw] == nil { + ProgressView() + .onAppear { + Task { + await loadRedirectURL(for: file) + } + } + } else { + ContentPreview(mimeType: file.mime, fileURL: URL(string: redirectURLs[file.raw]!)!, file: $file, showFileInfo: $showFileInfo) + .onDisappear { + showingPreview = false + } + .gesture( + DragGesture().onEnded { value in + if value.location.y - value.startLocation.y > 150 { + showingPreview = false + } + } + ) + .alert("Set File Expiration", isPresented: $showingExpirationDialog) { + TextField("Enter expiration", text: $expirationText) + Button("Cancel", role: .cancel) { + fileToExpire = nil + } + Button("Set") { + if let file = fileToExpire { + let expirationValue = expirationText + Task { + await setFileExpr(file: file, expr: expirationValue) + await MainActor.run { + expirationText = "" + fileToExpire = nil + } + } + } + } + } message: { + Text("Enter time until file expiration. Examples: 1h, 5days, 2y") + } + .alert("Set File Password", isPresented: $showingPasswordDialog) { + TextField("Enter password", text: $passwordText) + Button("Cancel", role: .cancel) { + fileToPassword = nil + } + Button("Set") { + if let file = fileToPassword { + let passwordValue = passwordText + Task { + await setFilePassword(file: file, password: passwordValue) + await MainActor.run { + passwordText = "" + fileToPassword = nil + } + } + } + } + } message: { + Text("Enter a password for the file.") + } + .alert("Rename File", isPresented: $showingRenameDialog) { + TextField("New File Name", text: $fileNameText) + Button("Cancel", role: .cancel) { + fileToRename = nil + } + Button("Set") { + if let file = fileToRename { + let fileNameValue = fileNameText + Task { + await renameFile(file: file, name: fileNameValue) + await MainActor.run { + fileNameText = "" + fileToRename = nil + } + } + } + } + } message: { + Text("Enter a new name for this file.") + } + .confirmationDialog("Are you sure?", isPresented: $showingDeleteConfirmation) { + Button("Delete", role: .destructive) { + Task { + if await deleteFiles(fileIDs: fileIDsToDelete) { + showingPreview = false + } + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Are you sure you want to delete \"\(fileNameToDelete)\"?") + } + + ZStack(alignment: .top) { + VStack { + HStack{ + Button(action: { + showingPreview = false + }) { + Image(systemName: "xmark") + .font(.system(size: 17)) + .foregroundColor(.blue) + .padding() + } + .background(.ultraThinMaterial) + .frame(width: 32, height: 32) + .cornerRadius(16) + .padding(.leading, 15) + Spacer() + Text(file.name) + .padding(5) + .font(.headline) + .lineLimit(1) + .foregroundColor(file.mime.starts(with: "text") ? .primary : .white) + .shadow(color: .black, radius: file.mime.starts(with: "text") ? 0 : 3) + Spacer() + Menu { + fileContextMenu(for: file, isPreviewing: true, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) + .padding() + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 20)) + .padding() + } + .menuStyle(.button) + .background(.ultraThinMaterial) + .frame(width: 32, height: 32) + .cornerRadius(16) + .padding(.trailing, 10) + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background { + if file.mime.starts(with: "text") { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + } + } + Spacer() + HStack { + Spacer() + Button(action: { + showFileInfo = true + }) { + Image(systemName: "info.circle") + .font(.system(size: 20)) + .padding(8) + } + .buttonStyle(.borderless) + + Menu { + fileShareMenu(for: file) + } label: { + Image(systemName: "link.icloud") + .font(.system(size: 20)) + .padding(8) + } + .menuStyle(.button) + + Button(action: { + showingShareSheet = true + }) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 20)) + .offset(y: -2) + .padding(8) + } + .buttonStyle(.borderless) + .padding(.leading, 1) + .sheet(isPresented: $showingShareSheet) { + if let url = URL(string: file.url) { + ShareSheet(url: url) + .presentationDetents([.medium]) + } + } + Spacer() + } + .background(.ultraThinMaterial) + .frame(width: 155, height: 44) + .cornerRadius(20) + } + } + } + } + } + + @MainActor + private func loadRedirectURL(for file: DFFile) async { + guard redirectURLs[file.raw] == nil, + let serverURL = URL(string: file.url)?.host else { + return + } + + let baseURL = URL(string: "https://\(serverURL)")! + let api = DFAPI(url: baseURL, token: "") // Token will be handled by cookies + + if let redirectURL = await api.checkRedirect(url: file.raw) { + redirectURLs[file.raw] = redirectURL + } else { + // If redirect fails, use the original URL + redirectURLs[file.raw] = file.raw + } + } + + @MainActor + private func toggleFilePrivacy(file: DFFile) async { + if let delegate = fileListDelegate { + let _ = await delegate.setFilePrivate(fileID: file.id, isPrivate: !file.private, onSuccess: nil) + } else { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + let api = DFAPI(url: url, token: serverInstance.token) + let _ = await api.editFiles(fileIDs: [file.id], changes: ["private": !file.private], selectedServer: serverInstance) + } + } + + @MainActor + private func setFileExpr(file: DFFile, expr: String?) async { + if let delegate = fileListDelegate { + let _ = await delegate.setFileExpiration(fileID: file.id, expr: expr ?? "", onSuccess: nil) + } else { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + let api = DFAPI(url: url, token: serverInstance.token) + let _ = await api.editFiles(fileIDs: [file.id], changes: ["expr": expr ?? ""], selectedServer: serverInstance) + } + } + + @MainActor + private func setFilePassword(file: DFFile, password: String?) async { + if let delegate = fileListDelegate { + let _ = await delegate.setFilePassword(fileID: file.id, password: password ?? "", onSuccess: nil) + } else { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + let api = DFAPI(url: url, token: serverInstance.token) + let _ = await api.editFiles(fileIDs: [file.id], changes: ["password": password ?? ""], selectedServer: serverInstance) + } + } + + + @MainActor + private func renameFile(file: DFFile, name: String) async { + if let delegate = fileListDelegate { + let _ = await delegate.renameFile(fileID: file.id, newName: name, onSuccess: nil) + } else { + guard let serverInstance = server.wrappedValue, + let url = URL(string: serverInstance.url) else { + return + } + let api = DFAPI(url: url, token: serverInstance.token) + let _ = await api.renameFile(fileID: file.id, name: name, selectedServer: serverInstance) + } + } + + @MainActor + private func deleteFiles(fileIDs: [Int]) async -> Bool { + if let delegate = fileListDelegate { + return await delegate.deleteFiles(fileIDs: fileIDs) { + // No additional success callback needed as the delegate handles list updates + } + } else { + return false + } + } + + private func fileContextMenu(for file: DFFile, isPreviewing: Bool, isPrivate: Bool, expirationText: Binding, passwordText: Binding, fileNameText: Binding) -> FileContextMenuButtons { + FileContextMenuButtons( + isPreviewing: isPreviewing, + isPrivate: isPrivate, + onPreview: { + // No-op since we're already previewing + }, + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only copy the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw] { + await MainActor.run { + UIPasteboard.general.string = redirectURL + } + } else { + await MainActor.run { + UIPasteboard.general.string = file.raw + } + } + } + } else if let redirectURL = redirectURLs[file.raw] { + UIPasteboard.general.string = redirectURL + } else { + UIPasteboard.general.string = file.raw + } + }, + openRawBrowser: { + if let url = URL(string: file.raw), UIApplication.shared.canOpenURL(url) { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only open the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + await MainActor.run { + UIApplication.shared.open(finalURL) + } + } else { + await MainActor.run { + UIApplication.shared.open(url) + } + } + } + } else if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + UIApplication.shared.open(finalURL) + } else { + UIApplication.shared.open(url) + } + } + }, + onTogglePrivate: { + Task { + await toggleFilePrivacy(file: file) + } + }, + setExpire: { + fileToExpire = file + expirationText.wrappedValue = fileToExpire?.expr ?? "" + showingExpirationDialog = true + }, + setPassword: { + fileToPassword = file + passwordText.wrappedValue = fileToPassword?.password ?? "" + showingPasswordDialog = true + }, + renameFile: { + fileToRename = file + fileNameText.wrappedValue = fileToRename?.name ?? "" + showingRenameDialog = true + }, + deleteFile: { + fileIDsToDelete = [file.id] + fileNameToDelete = file.name + showingDeleteConfirmation = true + } + ) + } + + private func fileShareMenu(for file: DFFile) -> FileShareMenu { + FileShareMenu( + onCopyShareLink: { + UIPasteboard.general.string = file.url + }, + onCopyRawLink: { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only copy the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw] { + await MainActor.run { + UIPasteboard.general.string = redirectURL + } + } else { + await MainActor.run { + UIPasteboard.general.string = file.raw + } + } + } + } else if let redirectURL = redirectURLs[file.raw] { + UIPasteboard.general.string = redirectURL + } else { + UIPasteboard.general.string = file.raw + } + }, + openRawBrowser: { + if let url = URL(string: file.raw), UIApplication.shared.canOpenURL(url) { + if redirectURLs[file.raw] == nil { + Task { + await loadRedirectURL(for: file) + // Only open the URL after we've loaded the redirect + if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + await MainActor.run { + UIApplication.shared.open(finalURL) + } + } else { + await MainActor.run { + UIApplication.shared.open(url) + } + } + } + } else if let redirectURL = redirectURLs[file.raw], let finalURL = URL(string: redirectURL) { + UIApplication.shared.open(finalURL) + } else { + UIApplication.shared.open(url) + } + } + } + ) + } +} + +// PDF View SwiftUI Wrapper +struct PDFView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> PDFKit.PDFView { + let pdfView = PDFKit.PDFView() + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + return pdfView + } + + func updateUIView(_ pdfView: PDFKit.PDFView, context: Context) { + if let document = PDFDocument(url: url) { + pdfView.document = document + } + } +} + +struct ShareSheet: View { + let url: URL + @Environment(\.dismiss) private var dismiss + + var body: some View { + ActivityViewController(activityItems: [url]) + } +} + +struct ActivityViewController: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + + diff --git a/Django Files/Views/ServerConfirmationView.swift b/Django Files/Views/ServerConfirmationView.swift new file mode 100644 index 0000000..aebe935 --- /dev/null +++ b/Django Files/Views/ServerConfirmationView.swift @@ -0,0 +1,133 @@ +import SwiftUI +import SwiftData + +struct ServerConfirmationView: View { + @Binding var serverURL: URL? + @Binding var signature: String? + let onConfirm: (Bool) -> Void + let onCancel: () -> Void + let context: ModelContext + + @Environment(\.dismiss) private var dismiss + @State private var siteName: String = "..." + @State private var isLoading = true + @State private var error: String? = nil + @State private var setAsDefault = false + @Query private var existingSessions: [DjangoFilesSession] + + var body: some View { + NavigationView { + Form { + if isLoading { + Section { + HStack { + Spacer() + ProgressView("Loading server info...") + Spacer() + } + } + } else if let error = error { + Section { + Text(error) + .foregroundColor(.red) + } + } else { + Text(siteName) + .font(.headline) + Label(serverURL?.absoluteString ?? "", systemImage: "server.rack") + Text("Please confirm that you wish to sign into \(serverURL?.absoluteString ?? "unknown") instance of Django Files.") + if existingSessions.count > 0 { + Section { + Toggle("Set as default server", isOn: $setAsDefault) + } + } + } + } + .navigationTitle("Sign into \(siteName)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + onCancel() + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Sign In") { + onConfirm(setAsDefault) + dismiss() + } + .disabled(isLoading || error != nil) + } + } + .onAppear { + Task { + await loadServerInfo() + } + } + } + } + + private func loadServerInfo() async { + guard let url = serverURL else { + await MainActor.run { + error = "QR Code or Link has invalid or unreachable server." + isLoading = false + } + return + } + + let api = DFAPI(url: url, token: "") + if let authMethods = await api.getAuthMethods() { + await MainActor.run { + siteName = authMethods.siteName + isLoading = false + } + } else { + await MainActor.run { + error = "Could not connect to server" + isLoading = false + } + } + } +} + +#Preview("Loading State") { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: DjangoFilesSession.self, configurations: config) + + return ServerConfirmationView( + serverURL: .constant(URL(string: "http://localhost")!), + signature: .constant("test"), + onConfirm: { _ in }, + onCancel: {}, + context: container.mainContext + ) +} + +#Preview("Error State") { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: DjangoFilesSession.self, configurations: config) + + return ServerConfirmationView( + serverURL: .constant(nil), + signature: .constant("test"), + onConfirm: { _ in }, + onCancel: {}, + context: container.mainContext + ) +} + +#Preview("Success State") { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: DjangoFilesSession.self, configurations: config) + + return ServerConfirmationView( + serverURL: .constant(URL(string: "http://localhost")!), + signature: .constant("test"), + onConfirm: { _ in }, + onCancel: {}, + context: container.mainContext + ) +} + diff --git a/Django Files/Views/SessionEditor.swift b/Django Files/Views/SessionEditor.swift index 75aa608..ad72fc2 100644 --- a/Django Files/Views/SessionEditor.swift +++ b/Django Files/Views/SessionEditor.swift @@ -13,54 +13,87 @@ struct SessionEditor: View { @Environment(\.dismiss) private var dismiss @Query private var items: [DjangoFilesSession] + @State private var showLoginSheet: Bool = false + @State private var tempSession: DjangoFilesSession? + + let onBoarding: Bool let session: DjangoFilesSession? var onSessionCreated: ((DjangoFilesSession) -> Void)? - + private var editorTitle: String { session == nil ? "Add Server" : "Edit Server" } @State private var showDuplicateAlert = false - private func save() { - 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)") - } - } - } - @State private var url: URL? = nil @State private var token: String = "" - @State private var badURL = false - @State private var insecureURL = false + @State private var badURL: Bool = false + @State private var insecureURL: Bool = false + @State private var isCheckingServer: Bool = false + @State private var serverError: String? = nil @FocusState private var isURLFieldFocused: Bool + private func checkURLAuthAndSave() { + Task { + isCheckingServer = true + serverError = nil + + // Create a temporary DFAPI instance to check auth methods + let api = DFAPI(url: url!, token: "") + if let _ = await api.getAuthMethods() { + isCheckingServer = false + + // Server is valid, proceed with login + if let session { + // For editing, update the URL and clear auth + session.url = url?.absoluteString ?? "" + session.token = token + session.auth = false + showLoginSheet = true + } else { + if items.contains(where: { $0.url == url?.absoluteString }) { + showDuplicateAlert = true + return + } + // Create temporary session but don't save it yet + tempSession = DjangoFilesSession() + tempSession!.url = url?.absoluteString ?? "" + tempSession!.token = token + tempSession!.auth = false + showLoginSheet = true + } + } else { + isCheckingServer = false + serverError = "Could not connect to server or server is not a Django Files instance" + } + } + } var body: some View { NavigationStack { Form { + if onBoarding { + HStack { + Spacer() + Label("", systemImage: "hand.wave.fill") + .font(.system(size: 50)) + .padding(.bottom) + .shadow(color: .purple, radius: 20) + .listRowSeparator(.hidden) + Spacer() + } + Text("Welcome to Django Files!") + .font(.system(size: 25)) + .padding(.bottom) + .shadow(color: .purple, radius: 20) + .listRowSeparator(.hidden) + Text("Thanks for using our iOS app! If you don’t have a server set up yet, check out our GitHub README to get started.") + .listRowSeparator(.hidden) + Text("https://github.com/django-files/django-files") + .listRowSeparator(.hidden) + } Section(header: Text("Server URL")) { TextField("", text: Binding( get: { @@ -74,6 +107,7 @@ struct SessionEditor: View { if temp?.scheme != nil && temp?.scheme != ""{ url = temp insecureURL = (url?.scheme?.lowercased()) == ("http") + serverError = nil } } ), prompt: Text(verbatim: "https://df.example.com")) @@ -92,11 +126,23 @@ struct SessionEditor: View { let warningMessage = "⚠️ HTTPS strongly recommend." TextField("", text: Binding( get: { warningMessage }, - set: { _ in } // Prevents user from modifying the text + set: { _ in } )) - .disabled(true) // Prevents user input + .disabled(true) .foregroundColor(.red) } + if let error = serverError { + Text("❌ " + error) + .disabled(true) + .foregroundColor(.red) + } + if isCheckingServer { + HStack { + ProgressView() + .progressViewStyle(.circular) + Text("Checking server...") + } + } } .padding(.top, -40) .toolbar { @@ -105,7 +151,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 +159,7 @@ struct SessionEditor: View { } if url != nil { withAnimation { - save() + checkURLAuthAndSave() } } else { badURL.toggle() @@ -124,6 +169,7 @@ struct SessionEditor: View { Text("Save") } .accessibilityIdentifier("serverSubmitButton") + .disabled(isCheckingServer) .alert(isPresented: $badURL){ Alert(title: Text("Invalid URL"), message: Text("Invalid URL format or scheme (http or https).\nExample: https://df.myserver.com")) } @@ -138,7 +184,6 @@ struct SessionEditor: View { } .onAppear { if let session { - // Edit the incoming animal. url = URL(string: session.url) } } @@ -149,6 +194,30 @@ struct SessionEditor: View { dismissButton: .default(Text("OK")) ) } + .sheet(isPresented: $showLoginSheet, onDismiss: { + if let session = session { + if session.auth { + try? modelContext.save() + dismiss() + } + } else if let tempSession = tempSession, tempSession.auth { + modelContext.insert(tempSession) + try? modelContext.save() + onSessionCreated?(tempSession) + dismiss() + } + }) { + if let session = session { + LoginView(selectedServer: session, onLoginSuccess: { + session.auth = true + }) + } else if let tempSession = tempSession { + LoginView(selectedServer: tempSession, onLoginSuccess: { + tempSession.auth = true + }) + } + } } + .scrollDisabled(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/SettingsView.swift b/Django Files/Views/SettingsView.swift new file mode 100644 index 0000000..752b020 --- /dev/null +++ b/Django Files/Views/SettingsView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var sessionManager: SessionManager + @Binding var showLoginSheet: Bool + @State private var needsRefresh = true + + var body: some View { + NavigationStack { + List { + if let server = sessionManager.selectedSession, !server.auth { + Text("Please sign into the selected server from the server list to use the application.") + } + + Section { + NavigationLink { + ServerSelector(selectedSession: $sessionManager.selectedSession) + .navigationTitle("Servers") + } label: { + Label("Server List", systemImage: "server.rack") + } + } header: { + Text("Select Active Server") + } + + if let server = sessionManager.selectedSession, server.auth { + Section { + NavigationLink { + AuthViewContainer( + selectedServer: server, + customURL: server.url + "/settings/user/", + needsRefresh: $needsRefresh + ) + } label: { + Label("User Settings", systemImage: "person") + } + + NavigationLink { + AuthViewContainer( + selectedServer: server, + customURL: server.url + "/settings/site/", + needsRefresh: $needsRefresh + ) + } label: { + Label("Server Settings", systemImage: "person.2.badge.gearshape") + } + } header: { + Text("Selected Server Settings") + } + } + + Section { + NavigationLink { + AppSettings() + } label: { + Label("App Settings", systemImage: "gear") + } + } header: { + Text("Application") + } + } + .navigationTitle("Settings") + .sheet(isPresented: $showLoginSheet) { + if let session = sessionManager.selectedSession { + LoginView(selectedServer: session, onLoginSuccess: { + showLoginSheet = false + }) + } + } + } + } +} + +#Preview { + SettingsView( + sessionManager: SessionManager(), + showLoginSheet: .constant(false) + ) +} diff --git a/Django Files/Views/ShortList.swift b/Django Files/Views/ShortList.swift new file mode 100644 index 0000000..7d0a5a5 --- /dev/null +++ b/Django Files/Views/ShortList.swift @@ -0,0 +1,222 @@ +// +// 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 + + @State private var showingShortCreator: Bool = false + + private let shortsPerPage = 50 + + var body: some View { + ZStack{ + if server.wrappedValue != nil { + NavigationStack { + List { + if shorts.isEmpty && !isLoading { + HStack { + Spacer() + VStack { + Spacer() + Image(systemName: "personalhotspot.slash") + .font(.system(size: 50)) + .padding(.bottom) + .shadow(color: .purple, radius: 20) + Text("No shorts found") + .font(.headline) + .shadow(color: .purple, radius: 20) + Text("Create a short URL to get started.") + .foregroundColor(.secondary) + } + .padding() + Spacer() + } + .listRowSeparator(.hidden) + } + ForEach(shorts) { short in + ShortRow(short: short) + .onTapGesture { + UIPasteboard.general.string = "\(server.wrappedValue?.url ?? "")/s/\(short.short)" + ToastManager.shared.showToast(message: "Short URL copied to clipboard") + } + } + if isLoading { + HStack { + Spacer() + LoadingView() + .frame(width: 100, height: 100) + Spacer() + } + } + } + .listStyle(.plain) + .refreshable{ + await refreshShorts() + } + .overlay { + if let error = error { + errorView(message: error) + } + } + .navigationTitle(server.wrappedValue != nil ? "Short URLS (\(URL(string: server.wrappedValue!.url)?.host ?? "unknown"))" : "Albums") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showingShortCreator = true + } label: { + Label("Create Short", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingShortCreator) { + if let serverInstance = server.wrappedValue { + ShortCreatorView(server: serverInstance) + .onDisappear { + showingShortCreator = false + } + } + } + + } + } else { + Label("No server selected.", systemImage: "exclamationmark.triangle") + } + } + .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 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() { + if shorts.count == 0 && !isLoading { + error = nil + shorts = [] + + Task { + await fetchShorts() + } + } + } + + private func refreshShorts() async { + await MainActor.run { + error = nil + shorts = [] + } + Task { + await fetchShorts() + } + } + + private func loadMoreShorts() { + guard !isLoading, hasMoreResults else { return } + + Task { + await fetchShorts() + } + } + + private func fetchShorts() async { + await MainActor.run { + isLoading = true + } + 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) + let lastShortId = shorts.last?.id + + if let response = await api.getShorts(amount: shortsPerPage, start: lastShortId, selectedServer: server.wrappedValue) { + await MainActor.run { + shorts.append(contentsOf: response.shorts) + 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..e1b3f72 --- /dev/null +++ b/Django Files/Views/TabView.swift @@ -0,0 +1,219 @@ +// +// 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 + @Binding var selectedTab: Tab + + @State private var showingServerSelector = false + @Query private var sessions: [DjangoFilesSession] + @State private var needsRefresh = false + @State private var serverChangeRefreshTrigger = UUID() + @State private var serverNeedsAuth: DjangoFilesSession? + + @State private var showLoginSheet = false + @State private var filesNavigationPath = NavigationPath() + @State private var albumsNavigationPath = NavigationPath() + + init(sessionManager: SessionManager, selectedTab: Binding) { + self.sessionManager = sessionManager + _selectedTab = selectedTab + } + + enum Tab { + case files, albums, shorts, settings, mobileWeb + } + + var body: some View { + Group { + if let server = sessionManager.selectedSession { + TabView(selection: $selectedTab) { + if server.auth { + NavigationStack(path: $filesNavigationPath) { + FileListView(server: .constant(server), albumID: nil, navigationPath: $filesNavigationPath, albumName: nil) + .id(serverChangeRefreshTrigger) + } + .tabItem { + Label("Files", systemImage: "document.fill") + } + .tag(Tab.files) + + NavigationStack(path: $albumsNavigationPath) { + AlbumListView(navigationPath: $albumsNavigationPath, server: $sessionManager.selectedSession) + .id(serverChangeRefreshTrigger) + } + .tabItem { + Label("Albums", systemImage: "square.stack") + } + .tag(Tab.albums) + + ShortListView(server: $sessionManager.selectedSession) + .id(serverChangeRefreshTrigger) + .tabItem { + Label("Shorts", systemImage: "link") + } + .tag(Tab.shorts) + } + + SettingsView(sessionManager: sessionManager, showLoginSheet: $showLoginSheet) + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(Tab.settings) + } + .onChange(of: sessionManager.selectedSession) { oldValue, newValue in + if let session = newValue { + sessionManager.saveSelectedSession() + connectToWebSocket(session: session) + serverChangeRefreshTrigger = UUID() + if !session.auth { + selectedTab = .settings + showLoginSheet = true + } + } + } + .onChange(of: sessionManager.selectedSession?.auth) { oldValue, newValue in + if let isAuth = newValue, !isAuth { + selectedTab = .settings + showLoginSheet = true + } + } + } else { + SettingsView(sessionManager: sessionManager, showLoginSheet: $showLoginSheet) + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(Tab.settings) + } + } + .onAppear { + sessionManager.loadLastSelectedSession(from: sessions) + + // Connect to WebSocket if a session is selected + if let selectedSession = sessionManager.selectedSession { + connectToWebSocket(session: selectedSession) + } + } + } + + // 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? + @State private var showingDeleteAlert = false + @State private var showAddServerSheet = false + @State private var editSession: DjangoFilesSession? + @State private var authSession: DjangoFilesSession? + + @State private var navigationPath = NavigationPath() + + @Query private var items: [DjangoFilesSession] + + var body: some View { + NavigationStack(path: $navigationPath) { + List(selection: $selectedSession) { + ForEach(items, id: \.self) { item in + HStack(spacing: 0) { + Label("", systemImage: item.defaultSession ? "star.fill" : "") + Label("", systemImage: item.auth ? "person.fill" : "person") + Text(item.url) + .swipeActions { + Button { + itemToDelete = item + showingDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash.fill") + } + .tint(.red) + 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: $showAddServerSheet) { + SessionEditor(onBoarding: false, 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.showAddServerSheet.toggle() + }) { + Label("Add Item", systemImage: "plus") + } + } + } + .navigationTitle("Server List") + } + + + } + + + private func deleteItems(offsets: IndexSet) { + withAnimation { + 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) + } +} diff --git a/Django FilesUITests/Django_FilesUITests.swift b/Django FilesUITests/Django_FilesUITests.swift index b7595ae..dc7c93c 100644 --- a/Django FilesUITests/Django_FilesUITests.swift +++ b/Django FilesUITests/Django_FilesUITests.swift @@ -15,7 +15,7 @@ final class Django_FilesUITests: XCTestCase { // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + // In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { @@ -26,7 +26,7 @@ final class Django_FilesUITests: XCTestCase { func testNewServer() throws { // UI tests must launch the application that they test. let app = XCUIApplication() - app.launchArguments = ["--DeleteAllData"] + app.launchArguments = ["--DeleteAllData", "--DisableFirebase"] app.launchEnvironment = ["RESET_STATE": "YES"] app.launch() let textField = app.textFields["urlTextField"] @@ -38,13 +38,18 @@ final class Django_FilesUITests: XCTestCase { // Use XCTAssert and related functions to verify your tests produce the correct results. } - @MainActor - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } +// @MainActor +// func testLaunchPerformance() throws { +// #if targetEnvironment(simulator) +// // Skip performance testing in simulator as it's not reliable +// return +// #else +// if #available(iOS 18.0, *) { +// // This measures how long it takes to launch your application. +// measure(metrics: [XCTApplicationLaunchMetric()]) { +// XCUIApplication().launch() +// } +// } +// #endif +// } } diff --git a/Django FilesUITests/Django_FilesUITestsLaunchTests.swift b/Django FilesUITests/Django_FilesUITestsLaunchTests.swift index 877ae54..9a616a4 100644 --- a/Django FilesUITests/Django_FilesUITestsLaunchTests.swift +++ b/Django FilesUITests/Django_FilesUITestsLaunchTests.swift @@ -20,6 +20,7 @@ final class Django_FilesUITestsLaunchTests: XCTestCase { @MainActor func testLaunch() throws { let app = XCUIApplication() + app.launchArguments = ["--DisableFirebase"] app.launch() // Insert steps here to perform after app launch but before taking a screenshot, diff --git a/README.md b/README.md index a247f20..bddd40c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,17 @@ Additionally, the URL is copied to the clipboard and the preview is show in the ### Planned +- Animated GIF/webm support (0.2.1) +- New user setup/registration in app (0.2.1) +- Scan Login QR code direct from app support (0.2.1) +- Select an album from activity/share sheet (0.2.1) +- User invitation management and sign up (0.2.2) +- Use app for preview links/non authenticated use of app for public objects (0.2.2) +- Better album management (0.2.2) +- iOS Widget (0.2.3) +- Server Statistics (0.2.3) +- Gallery (0.2.3) + Don't see your feature here? [Request a Feature](https://github.com/django-files/ios-client/discussions/categories/feature-requests). ### Known Issues diff --git a/UploadAndCopy/Base.lproj/MainInterface.storyboard b/UploadAndCopy/Base.lproj/MainInterface.storyboard index 8c54b42..5a06707 100644 --- a/UploadAndCopy/Base.lproj/MainInterface.storyboard +++ b/UploadAndCopy/Base.lproj/MainInterface.storyboard @@ -4,6 +4,7 @@ + @@ -32,12 +33,22 @@ + + + + + + Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. + + + + @@ -104,6 +115,11 @@ + + + + + @@ -122,6 +138,7 @@ + @@ -131,9 +148,15 @@ + + + + + + diff --git a/UploadAndCopy/ShareViewController.swift b/UploadAndCopy/ShareViewController.swift index 9ad4f0a..6ce84a7 100644 --- a/UploadAndCopy/ShareViewController.swift +++ b/UploadAndCopy/ShareViewController.swift @@ -10,7 +10,7 @@ import Social import SwiftData import CoreHaptics -class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTaskDelegate { +class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTaskDelegate, UITextViewDelegate { var sharedModelContainer: ModelContainer = { let schema = Schema([ DjangoFilesSession.self, @@ -31,6 +31,7 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask @IBOutlet weak var availableServers: UIButton! @IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var shortTextLabel: UILabel! + @IBOutlet weak var textView: UITextView! @IBOutlet weak var shortText: UITextField! @IBOutlet weak var shareLabel: UILabel! @@ -45,6 +46,7 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask self.activityIndicator.hidesWhenStopped = true shortText.delegate = self + textView.delegate = self NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) @@ -57,6 +59,7 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask getAvailableServers() self.progressBar.isHidden = true + self.textView.isHidden = true var loaded: Bool = false for extensionItem in extensionItems { for ele in extensionItem.attachments! { @@ -116,6 +119,9 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask self.shortText.placeholder = self.randomString(length: 5) self.shareURL = item as? URL self.shareLabel.text = "Shorten Link" + self.textView.isHidden = false + self.textView.isEditable = false + self.textView.text = self.shareURL!.absoluteString if self.shareURL!.absoluteString.hasPrefix("http://") || self.shareURL!.absoluteString.hasPrefix("https://"){ self.doShorten = true } @@ -126,6 +132,36 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask loaded = true break } + else if itemProvider.hasItemConformingToTypeIdentifier("public.text") || itemProvider.hasItemConformingToTypeIdentifier("public.plain-text") { + itemProvider.loadItem(forTypeIdentifier: itemProvider.registeredTypeIdentifiers[0], options: nil, completionHandler: { (item, error) in + DispatchQueue.main.async { + self.shortText.isHidden = true + self.shortTextLabel.isHidden = true + self.textView.isHidden = false + + if let text = item as? String { + // Show the text preview + self.textView.text = text + + // Create a temporary file to store the text + let tempDirectoryURL = NSURL.fileURL(withPath: NSTemporaryDirectory(), isDirectory: true) + let targetURL = tempDirectoryURL.appendingPathComponent("\(UUID.init().uuidString).txt") + do { + try text.write(to: targetURL, atomically: true, encoding: .utf8) + self.isTempFile = true + self.shareURL = targetURL + self.shareLabel.text = "Upload Text" + } catch { + self.showMessageAndDismiss(message: "Could not share text.") + } + } + self.activityIndicator.stopAnimating() + self.shareButton.isEnabled = true + } + }) + loaded = true + break + } else { itemProvider.loadItem(forTypeIdentifier: "public.data", options: nil, completionHandler: { (item, error) in DispatchQueue.main.async { @@ -219,6 +255,16 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask self.progressBar.isHidden = false self.progressBar.progress = 0 + // If we're sharing text and it's been edited, update the file content + if let text = textView.text, !textView.isHidden { + do { + try text.write(to: shareURL!, atomically: true, encoding: .utf8) + } catch { + self.showMessageAndDismiss(message: "Could not update text content.") + return + } + } + let api = DFAPI(url: URL(string: session.url)!, token: session.token) Task{ let task = await api.uploadFileStreamed(url: shareURL!, taskDelegate: self) @@ -245,7 +291,7 @@ class ShareViewController: UIViewController, UITextFieldDelegate, URLSessionTask let shortLink: String = (self.shortText.text == nil || self.shortText.text == "") ? randomString(length: 5) : self.shortText.text! let api = DFAPI(url: URL(string: session.url)!, token: session.token) Task{ - let response = await api.createShort(url: shareURL!, short: shortLink) + let response = await api.createShort(url: shareURL!, short: shortLink, selectedServer: session) self.activityIndicator.stopAnimating() if response == nil{