diff --git a/CLAUDE.md b/CLAUDE.md index 8fdcb40c..f0f3021c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,8 @@ npm run dev:remote The web editor is built with React and WordPress packages: - **Entry Points**: - - `src/index.jsx` - Local editor entry - - `src/remote.jsx` - Remote editor entry (for plugin support) + - `src/index.js` - Bundled editor entry + - `src/remote.js` - Remote editor entry (supports plugins) - **Core Components**: - `src/components/editor/` - Main editor component with host bridge integration - `src/components/visual-editor/` - Visual editing interface diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 63651174..6df69395 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ GutenbergKit Demo Bundled editor - Offline editor with bundled assets + Local editor without plugin support Add remote editor diff --git a/docs/architecture.md b/docs/architecture.md index 33b2abc1..66234332 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,8 +19,8 @@ GutenbergKit/ │ │ └── text-editor/ # HTML text editing interface │ ├── utils/ # Utility functions │ │ └── bridge.js # Native-to-web communication -│ ├── index.jsx # Local editor entry point -│ └── remote.jsx # Remote editor entry point +│ ├── index.js # Bundled editor entry point +│ └── remote.js # Remote editor entry point ├── ios/ # iOS Swift package │ └── Sources/ │ └── GutenbergKit/ @@ -90,28 +90,28 @@ The `make build` command builds both the local and remote editors by default. To Additionally, a `make dev-server-remote` command is available for serving the latest remote editor changes through a development server. To load the development server in the Demo app, add an environment variable named `GUTENBERG_EDITOR_REMOTE_URL` with the URL of the development server plus `/remote.html`—i.e., `http://:5173/remote.html`. > [!TIP] -> The remote editor redirects to the local editor when loading fails. If you need to debug the failure, disable redirects via the `?dev_mode` query parameter.. +> The remote editor redirects to the bundled editor when loading fails. If you need to debug the failure, disable redirects via the `?dev_mode` query parameter.. -### Local Editor (`index.html`) +### Bundled Editor (`index.html`) -The local editor bundles all WordPress packages and runs entirely within the WebView. This variant: +The bundled editor relies upon local `@wordpress` packages. This variant: - Provides offline capability - Has faster initial load times - Limited to core blocks only - No plugin support -**Entry point:** `src/index.jsx` +**Entry point:** `src/index.js` ### Remote Editor (`remote.html`) -The remote editor loads WordPress packages and plugins from a remote server. This variant: +The remote editor loads `@wordpress` packages and plugins from a remote server. This variant: - Supports custom blocks and plugins - Requires network connectivity - Used in production environments with custom implementations -**Entry point:** `src/remote.jsx` +**Entry point:** `src/remote.js` ## Testing diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 18189233..9f837d44 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 0C4F59A22BEFF4980028BD96 /* AddSiteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4F59A32BEFF4980028BD96 /* AddSiteView.swift */; }; + 0C4F59A42BEFF4980028BD96 /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4F59A52BEFF4980028BD96 /* AuthenticationManager.swift */; }; + 0C4F59A62BEFF4980028BD96 /* ConfigurationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4F59A72BEFF4980028BD96 /* ConfigurationItem.swift */; }; + 0C4F59A82BEFF4980028BD96 /* ConfigurationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4F59A92BEFF4980028BD96 /* ConfigurationStorage.swift */; }; 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CE8E7842C339B0600B9DC67 /* Assets.xcassets */; }; 0CE8E78C2C339B0600B9DC67 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE8E7852C339B0600B9DC67 /* ContentView.swift */; }; 0CE8E78D2C339B0600B9DC67 /* EditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE8E7862C339B0600B9DC67 /* EditorView.swift */; }; @@ -17,6 +21,10 @@ /* Begin PBXFileReference section */ 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gutenberg.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C4F59A32BEFF4980028BD96 /* AddSiteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSiteView.swift; sourceTree = ""; }; + 0C4F59A52BEFF4980028BD96 /* AuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationManager.swift; sourceTree = ""; }; + 0C4F59A72BEFF4980028BD96 /* ConfigurationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationItem.swift; sourceTree = ""; }; + 0C4F59A92BEFF4980028BD96 /* ConfigurationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStorage.swift; sourceTree = ""; }; 0CE8E7842C339B0600B9DC67 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0CE8E7852C339B0600B9DC67 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 0CE8E7862C339B0600B9DC67 /* EditorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorView.swift; sourceTree = ""; }; @@ -68,6 +76,10 @@ 0CE8E7882C339B0600B9DC67 /* Sources */ = { isa = PBXGroup; children = ( + 0C4F59A32BEFF4980028BD96 /* AddSiteView.swift */, + 0C4F59A52BEFF4980028BD96 /* AuthenticationManager.swift */, + 0C4F59A72BEFF4980028BD96 /* ConfigurationItem.swift */, + 0C4F59A92BEFF4980028BD96 /* ConfigurationStorage.swift */, 0CE8E7852C339B0600B9DC67 /* ContentView.swift */, 0CE8E7862C339B0600B9DC67 /* EditorView.swift */, 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */, @@ -108,6 +120,7 @@ name = Gutenberg; packageProductDependencies = ( 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */, + 0C4F59A12BEFF4980028BD96 /* WordPressAPI */, ); productName = Gutenberg; productReference = 0C4F598B2BEFF4970028BD96 /* Gutenberg.app */; @@ -137,6 +150,9 @@ Base, ); mainGroup = 0C4F59822BEFF4970028BD96; + packageReferences = ( + 0C4F59A02BEFF4980028BD96 /* XCRemoteSwiftPackageReference "wordpress-rs" */, + ); productRefGroup = 0C4F598C2BEFF4970028BD96 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -163,6 +179,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C4F59A22BEFF4980028BD96 /* AddSiteView.swift in Sources */, + 0C4F59A42BEFF4980028BD96 /* AuthenticationManager.swift in Sources */, + 0C4F59A62BEFF4980028BD96 /* ConfigurationItem.swift in Sources */, + 0C4F59A82BEFF4980028BD96 /* ConfigurationStorage.swift in Sources */, 0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */, 0CE8E78C2C339B0600B9DC67 /* ContentView.swift in Sources */, 0CE8E78D2C339B0600B9DC67 /* EditorView.swift in Sources */, @@ -304,6 +324,8 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PZYM8XX95Q; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = "$(INFOPLIST_KEY_CFBundleURLTypes)"; + "INFOPLIST_KEY_CFBundleURLTypes[0]" = "{ CFBundleURLSchemes = (gutenbergkit); }"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -336,6 +358,8 @@ DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleURLTypes = "$(INFOPLIST_KEY_CFBundleURLTypes)"; + "INFOPLIST_KEY_CFBundleURLTypes[0]" = "{ CFBundleURLSchemes = (gutenbergkit); }"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -379,7 +403,23 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 0C4F59A02BEFF4980028BD96 /* XCRemoteSwiftPackageReference "wordpress-rs" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Automattic/wordpress-rs"; + requirement = { + kind = revision; + revision = "alpha-20250926"; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + 0C4F59A12BEFF4980028BD96 /* WordPressAPI */ = { + isa = XCSwiftPackageProductDependency; + package = 0C4F59A02BEFF4980028BD96 /* XCRemoteSwiftPackageReference "wordpress-rs" */; + productName = WordPressAPI; + }; 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */ = { isa = XCSwiftPackageProductDependency; productName = GutenbergKit; diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a469bac..724d44ff 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", "version" : "2.8.8" } + }, + { + "identity" : "wordpress-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Automattic/wordpress-rs", + "state" : { + "branch" : "alpha-20250926", + "revision" : "13c6207d6beeeb66c21cd7c627e13817ca5fdcae" + } } ], "version" : 2 diff --git a/ios/Demo-iOS/Sources/AddSiteView.swift b/ios/Demo-iOS/Sources/AddSiteView.swift new file mode 100644 index 00000000..f501db98 --- /dev/null +++ b/ios/Demo-iOS/Sources/AddSiteView.swift @@ -0,0 +1,85 @@ +import SwiftUI +import AuthenticationServices + +/// View for adding a new remote editor site +struct AddSiteView: View { + @Binding var siteUrl: String + @ObservedObject var authenticationManager: AuthenticationManager + let onAdd: (RemoteEditorConfiguration) -> Void + let onCancel: () -> Void + + @State private var presentationContextProvider = WebAuthPresentationContextProvider() + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Site URL", text: $siteUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .onSubmit { + startAuthentication() + } + } header: { + Text("WordPress Site") + } footer: { + Text("Enter the URL of your WordPress site (e.g., https://example.com)") + } + + if let errorMessage = authenticationManager.errorMessage { + Section { + Text(errorMessage) + .foregroundColor(.red) + } + } + } + .navigationTitle("Add Remote Editor") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onCancel() + } + .disabled(authenticationManager.isAuthenticating) + } + + ToolbarItem(placement: .confirmationAction) { + if authenticationManager.isAuthenticating { + ProgressView() + } else { + Button("Add") { + startAuthentication() + } + .disabled(siteUrl.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + } + + private func startAuthentication() { + let trimmedUrl = siteUrl.trimmingCharacters(in: .whitespaces) + guard !trimmedUrl.isEmpty else { return } + + authenticationManager.startAuthentication( + siteUrl: trimmedUrl, + presentationContext: presentationContextProvider + ) { configuration in + onAdd(configuration) + } + } +} + +/// Provides the presentation context for web authentication +class WebAuthPresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // Return the first window that can be used as a presentation anchor + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + return window + } + // Fallback to a new window (shouldn't normally happen) + return ASPresentationAnchor() + } +} diff --git a/ios/Demo-iOS/Sources/AuthenticationManager.swift b/ios/Demo-iOS/Sources/AuthenticationManager.swift new file mode 100644 index 00000000..8bab0052 --- /dev/null +++ b/ios/Demo-iOS/Sources/AuthenticationManager.swift @@ -0,0 +1,132 @@ +import Foundation +import AuthenticationServices +import WordPressAPI + +/// Manages WordPress authentication flow +@MainActor +class AuthenticationManager: NSObject, ObservableObject { + @Published var isAuthenticating = false + @Published var errorMessage: String? + + private var currentApiRootUrl: String? + private var authSession: ASWebAuthenticationSession? + private var currentClient: WordPressLoginClient? + private var onAuthenticationComplete: ((RemoteEditorConfiguration) -> Void)? + + private static let appName = "GutenbergKit iOS Demo App" + private static let callbackURLScheme = "gutenbergkit" + + /// Start the authentication flow for a WordPress site + func startAuthentication( + siteUrl: String, + presentationContext: ASWebAuthenticationPresentationContextProviding, + onComplete: @escaping (RemoteEditorConfiguration) -> Void + ) { + isAuthenticating = true + errorMessage = nil + onAuthenticationComplete = onComplete + + Task { + do { + let client = WordPressLoginClient(urlSession: URLSession(configuration: .ephemeral)) + let details = try await client.details(ofSite: siteUrl) + + let apiRootUrl = details.apiRootUrl.url() + let appId = try! WpUuid.parse(input: "00000000-0000-4000-9000-000000000000") + let authUrl = details.loginURL(for: .init( + id: appId, + name: Self.appName, + callbackUrl: "\(Self.callbackURLScheme)://authorized" + )) + + currentApiRootUrl = apiRootUrl + currentClient = client + + launchAuthenticationFlow( + authenticationUrl: authUrl, + presentationContext: presentationContext + ) + } catch { + isAuthenticating = false + errorMessage = "Authentication error: \(error.localizedDescription)" + } + } + } + + /// Launch the web authentication session + private func launchAuthenticationFlow( + authenticationUrl: URL, + presentationContext: ASWebAuthenticationPresentationContextProviding + ) { + let session = ASWebAuthenticationSession( + url: authenticationUrl, + callbackURLScheme: Self.callbackURLScheme + ) { [weak self] callbackURL, error in + guard let self = self else { return } + + Task { @MainActor in + if let error = error { + if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { + // User cancelled - just reset state + self.isAuthenticating = false + } else { + self.isAuthenticating = false + self.errorMessage = "Authentication failed: \(error.localizedDescription)" + } + return + } + + if let callbackURL = callbackURL { + self.processAuthenticationResult(callbackURL: callbackURL) + } + } + } + + session.presentationContextProvider = presentationContext + session.prefersEphemeralWebBrowserSession = false + + authSession = session + session.start() + } + + /// Process the authentication callback URL + private func processAuthenticationResult(callbackURL: URL) { + guard let client = currentClient, + let apiRootUrl = currentApiRootUrl else { + isAuthenticating = false + errorMessage = "Missing authentication parameters" + return + } + + do { + let credentials = try client.credentials(from: callbackURL) + + // Create Basic Auth header + let authString = "\(credentials.userLogin):\(credentials.password)" + guard let authData = authString.data(using: .utf8) else { + isAuthenticating = false + errorMessage = "Failed to encode credentials" + return + } + let authHeader = "Basic \(authData.base64EncodedString())" + + // Extract site name from URL + let siteName = URL(string: credentials.siteUrl)?.host ?? credentials.siteUrl + + let configuration = RemoteEditorConfiguration( + name: siteName, + siteUrl: credentials.siteUrl, + siteApiRoot: apiRootUrl, + authHeader: authHeader + ) + + isAuthenticating = false + currentApiRootUrl = nil + currentClient = nil + onAuthenticationComplete?(configuration) + } catch { + isAuthenticating = false + errorMessage = "Failed to parse credentials: \(error.localizedDescription)" + } + } +} diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift new file mode 100644 index 00000000..d8939778 --- /dev/null +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Represents a configuration item for the editor +enum ConfigurationItem: Codable, Identifiable { + case bundledEditor + case remoteEditor(RemoteEditorConfiguration) + + var id: String { + switch self { + case .bundledEditor: + return "bundled" + case .remoteEditor(let config): + return config.id + } + } + + var displayName: String { + switch self { + case .bundledEditor: + return "Bundled Editor" + case .remoteEditor(let config): + return config.name + } + } +} + +/// Configuration for a remote editor +struct RemoteEditorConfiguration: Codable, Identifiable { + let id: String + let name: String + let siteUrl: String + let siteApiRoot: String + let authHeader: String + + init(name: String, siteUrl: String, siteApiRoot: String, authHeader: String) { + self.id = UUID().uuidString + self.name = name + self.siteUrl = siteUrl + self.siteApiRoot = siteApiRoot + self.authHeader = authHeader + } +} diff --git a/ios/Demo-iOS/Sources/ConfigurationStorage.swift b/ios/Demo-iOS/Sources/ConfigurationStorage.swift new file mode 100644 index 00000000..dcc62f71 --- /dev/null +++ b/ios/Demo-iOS/Sources/ConfigurationStorage.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Manages persistence of remote editor configurations +class ConfigurationStorage { + private let userDefaults: UserDefaults + private let configurationsKey = "saved_configurations" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + /// Load saved configurations from storage + func loadConfigurations() -> [ConfigurationItem] { + guard let data = userDefaults.data(forKey: configurationsKey) else { + return [] + } + + do { + let remoteConfigs = try JSONDecoder().decode([RemoteEditorConfiguration].self, from: data) + return remoteConfigs.map { .remoteEditor($0) } + } catch { + NSLog("Failed to decode configurations: \(error)") + return [] + } + } + + /// Save configurations to storage + func saveConfigurations(_ configurations: [ConfigurationItem]) { + let remoteConfigs = configurations.compactMap { item -> RemoteEditorConfiguration? in + if case .remoteEditor(let config) = item { + return config + } + return nil + } + + do { + let data = try JSONEncoder().encode(remoteConfigs) + userDefaults.set(data, forKey: configurationsKey) + } catch { + NSLog("Failed to encode configurations: \(error)") + } + } +} diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index 6a47966d..70488d6d 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -1,13 +1,15 @@ import SwiftUI import GutenbergKit +import AuthenticationServices struct ContentView: View { - private let remoteEditors: [RemoteEditorRow] = [ - .init(id: "template", configuration: .template) - ] - - @State private var isDefaultEditorShown = false - @State private var selectedRemoteEditor: RemoteEditorRow? + @State private var selectedConfiguration: ConfigurationItem? + @State private var configurations: [ConfigurationItem] = [.bundledEditor] + @State private var showAddDialog = false + @State private var siteUrlInput = "" + @State private var authenticationManager = AuthenticationManager() + @State private var configurationStorage = ConfigurationStorage() + @State private var configurationToDelete: ConfigurationItem? @AppStorage("isNativeInserterEnabled") private var isNativeInserterEnabled = false @@ -15,65 +17,108 @@ struct ContentView: View { List { Section { Button("Bundled Editor") { - isDefaultEditorShown = true + selectedConfiguration = .bundledEditor + } + } header: { + if ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"] != nil || + ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_REMOTE_URL"] != nil + { + Text("Note: The editor is backed by the dev server created by `make dev-server` and `make dev-server-remote`.") + .textCase(nil) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Note: The editor is backed by the compiled web app created by `make build`.") + .textCase(nil) + .font(.footnote) + .foregroundStyle(.secondary) } + } footer: { + Text("Local editor without plugin support") } Section { - ForEach(remoteEditors) { editor in - Button(editor.title) { - selectedRemoteEditor = editor + ForEach(configurations.filter { + if case .remoteEditor = $0 { return true } + return false + }) { config in + Button(config.displayName) { + selectedConfiguration = config + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + configurationToDelete = config + } label: { + Label("Delete", systemImage: "trash") + } } } - if remoteEditors.isEmpty { - Text("Add `EditorConfiguration` instances to the `remoteEditorConfigurations` array to launch remote editors here.") + Button("Add New Remote Editor") { + showAddDialog = true } } header: { Text("Remote Editors") } footer: { - if ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_REMOTE_URL"] != nil { - Text("Note: The editor is backed by the dev server created by `make dev-server-remote`.") - } else { - Text("Note: The editor is backed by the compiled web app created by `make build`.") - } + Text("Site-specific editor with plugins") } Section("Configuration") { Toggle("Native Inserter", isOn: $isNativeInserterEnabled) } } - .fullScreenCover(isPresented: $isDefaultEditorShown) { - NavigationView { - EditorView(configuration: preconfigure(.default)) - } - } - .fullScreenCover(item: $selectedRemoteEditor) { editor in + .fullScreenCover(item: $selectedConfiguration) { config in NavigationView { - EditorView(configuration: preconfigure(editor.configuration)) + EditorView(configuration: preconfigure(createEditorConfiguration(for: config))) } } + .navigationTitle("GutenbergKit") .toolbar { ToolbarItem(placement: .primaryAction) { Button { - Task { - NSLog("Start to fetch assets") - for editor in remoteEditors { - let library = EditorAssetsLibrary(configuration: editor.configuration) - do { - try await library.fetchAssets() - } catch { - NSLog("Failed to fetch assets for \(editor.configuration.siteURL): \(error)") - } - } - NSLog("Done fetching assets") - } + showAddDialog = true } label: { - Image(systemName: "arrow.clockwise") + Image(systemName: "plus") } - } } + .sheet(isPresented: $showAddDialog) { + AddSiteView( + siteUrl: $siteUrlInput, + authenticationManager: authenticationManager, + onAdd: { config in + configurations.append(.remoteEditor(config)) + configurationStorage.saveConfigurations(configurations) + showAddDialog = false + siteUrlInput = "" + }, + onCancel: { + showAddDialog = false + siteUrlInput = "" + } + ) + } + .onAppear { + loadConfigurations() + } + .alert( + "Delete Remote Editor?", + isPresented: Binding( + get: { configurationToDelete != nil }, + set: { if !$0 { configurationToDelete = nil } } + ), + presenting: configurationToDelete + ) { config in + Button("Delete", role: .destructive) { + deleteConfiguration(config) + configurationToDelete = nil + } + Button("Cancel", role: .cancel) { + configurationToDelete = nil + } + } message: { config in + Text("Are you sure you want to delete \"\(config.displayName)\"?") + } } private func preconfigure(_ configuration: EditorConfiguration) -> EditorConfiguration { @@ -82,37 +127,43 @@ struct ContentView: View { .setNativeInserterEnabled(isNativeInserterEnabled) .build() } -} -private struct RemoteEditorRow: Identifiable { - let id: String - let configuration: EditorConfiguration + private func loadConfigurations() { + let saved = configurationStorage.loadConfigurations() + configurations = [.bundledEditor] + saved + } - var title: String { - URL(string: configuration.siteURL)?.host ?? configuration.siteURL + private func deleteConfiguration(_ config: ConfigurationItem) { + configurations.removeAll { $0.id == config.id } + configurationStorage.saveConfigurations(configurations) } -} -private extension EditorConfiguration { + private func createEditorConfiguration(for item: ConfigurationItem) -> EditorConfiguration { + switch item { + case .bundledEditor: + return createBundledConfiguration() + case .remoteEditor(let config): + return createRemoteConfiguration(config) + } + } - static var template: Self { - // Steps: - // 1. Update the siteURL and authHeader values below - // 2. Install the Jetpack plugin to the site - let siteUrl: String = "https://modify-me.com" - let authHeader: String = "Insert the Authorization header value here" - let siteApiRoot: String = "\(siteUrl)/wp-json/" + private func createBundledConfiguration() -> EditorConfiguration { + EditorConfigurationBuilder() + .setShouldUsePlugins(false) + .setSiteUrl("") + .setSiteApiRoot("") + .setAuthHeader("") + .build() + } - let configuration = EditorConfigurationBuilder() - .setSiteUrl(siteUrl) - .setAuthHeader(authHeader) - .setSiteApiRoot(siteApiRoot) - .setEditorAssetsEndpoint(URL(string: siteApiRoot)!.appendingPathComponent("wpcom/v2/editor-assets")) + private func createRemoteConfiguration(_ config: RemoteEditorConfiguration) -> EditorConfiguration { + EditorConfigurationBuilder() .setShouldUsePlugins(true) - - return configuration.build() + .setSiteUrl(config.siteUrl) + .setSiteApiRoot(config.siteApiRoot) + .setAuthHeader(config.authHeader) + .build() } - } #Preview {