feat: added error pages
c3064961
4 file(s) · +507 −13
| 1 | 1 | CLAUDE.md |
|
| 2 | + | .claude |
| 412 | 412 | "$(inherited)", |
|
| 413 | 413 | "@executable_path/Frameworks", |
|
| 414 | 414 | ); |
|
| 415 | - | MARKETING_VERSION = 0.1; |
|
| 415 | + | MARKETING_VERSION = 0.2; |
|
| 416 | 416 | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII; |
|
| 417 | 417 | PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 418 | 418 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
|
| 451 | 451 | "$(inherited)", |
|
| 452 | 452 | "@executable_path/Frameworks", |
|
| 453 | 453 | ); |
|
| 454 | - | MARKETING_VERSION = 0.1; |
|
| 454 | + | MARKETING_VERSION = 0.2; |
|
| 455 | 455 | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII; |
|
| 456 | 456 | PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 457 | 457 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
|
| 47 | 47 | // URL input focus state |
|
| 48 | 48 | @FocusState private var isURLFocused: Bool |
|
| 49 | 49 | ||
| 50 | + | // Error state |
|
| 51 | + | @State private var currentError: GeminiErrorType? |
|
| 52 | + | ||
| 50 | 53 | private let maxRedirects = 5 |
|
| 51 | 54 | ||
| 52 | 55 | var body: some View { |
|
| 53 | 56 | ScrollViewReader { proxy in |
|
| 54 | 57 | ScrollView { |
|
| 55 | - | TitanContentView(content: responseText, baseURL: urlText, onLinkTap: { url in |
|
| 56 | - | navigateTo(url) |
|
| 57 | - | }) |
|
| 58 | - | .frame(maxWidth: .infinity, alignment: .leading) |
|
| 59 | - | .padding(.horizontal, 4) |
|
| 60 | - | .id("top") |
|
| 58 | + | if let error = currentError { |
|
| 59 | + | ErrorPageView(errorType: error, onRetry: { |
|
| 60 | + | navigateTo(urlText) |
|
| 61 | + | }) |
|
| 62 | + | .id("top") |
|
| 63 | + | } else { |
|
| 64 | + | TitanContentView(content: responseText, baseURL: urlText, onLinkTap: { url in |
|
| 65 | + | navigateTo(url) |
|
| 66 | + | }) |
|
| 67 | + | .frame(maxWidth: .infinity, alignment: .leading) |
|
| 68 | + | .padding(.horizontal, 4) |
|
| 69 | + | .id("top") |
|
| 70 | + | } |
|
| 61 | 71 | } |
|
| 62 | 72 | .onChange(of: responseText) { |
|
| 63 | 73 | withAnimation { |
|
| 294 | 304 | ||
| 295 | 305 | switch response.statusCategory { |
|
| 296 | 306 | case .success: |
|
| 307 | + | currentError = nil |
|
| 297 | 308 | let mimeType = response.meta |
|
| 298 | 309 | ||
| 299 | 310 | if MediaType.isMediaContent(mimeType) { |
|
| 323 | 334 | saveCurrentTabState() |
|
| 324 | 335 | } |
|
| 325 | 336 | case .input: |
|
| 337 | + | currentError = nil |
|
| 326 | 338 | pendingInputURL = finalURL |
|
| 327 | 339 | inputPromptText = response.meta |
|
| 328 | 340 | inputIsSensitive = response.statusCode == 11 |
|
| 329 | 341 | showInputPrompt = true |
|
| 330 | 342 | case .redirect: |
|
| 331 | - | responseText = "Too many redirects" |
|
| 343 | + | currentError = .tooManyRedirects |
|
| 332 | 344 | case .temporaryFailure: |
|
| 333 | - | responseText = "Temporary failure (\(response.statusCode)): \(response.meta)" |
|
| 345 | + | currentError = .temporaryFailure(code: response.statusCode, meta: response.meta) |
|
| 334 | 346 | case .permanentFailure: |
|
| 335 | - | responseText = "Error (\(response.statusCode)): \(response.meta)" |
|
| 347 | + | currentError = .permanentFailure(code: response.statusCode, meta: response.meta) |
|
| 336 | 348 | case .clientCertificate: |
|
| 337 | - | responseText = "Client certificate required: \(response.meta)" |
|
| 349 | + | currentError = .clientCertificate(code: response.statusCode, meta: response.meta) |
|
| 338 | 350 | } |
|
| 339 | 351 | isLoading = false |
|
| 340 | 352 | } catch is CancellationError { |
|
| 343 | 355 | } catch let error as GeminiError where error == .cancelled { |
|
| 344 | 356 | // Request was cancelled, don't update UI |
|
| 345 | 357 | return |
|
| 358 | + | } catch let error as GeminiError { |
|
| 359 | + | switch error { |
|
| 360 | + | case .invalidResponse: |
|
| 361 | + | currentError = .invalidResponse |
|
| 362 | + | case .invalidURL: |
|
| 363 | + | currentError = .invalidURL |
|
| 364 | + | case .cancelled: |
|
| 365 | + | return |
|
| 366 | + | } |
|
| 367 | + | isLoading = false |
|
| 346 | 368 | } catch { |
|
| 347 | - | responseText = "Error: \(error.localizedDescription)" |
|
| 369 | + | currentError = .networkError(error.localizedDescription) |
|
| 348 | 370 | isLoading = false |
|
| 349 | 371 | } |
|
| 350 | 372 | } |
|
| 1 | + | // |
|
| 2 | + | // ErrorPageView.swift |
|
| 3 | + | // Titan |
|
| 4 | + | // |
|
| 5 | + | ||
| 6 | + | import SwiftUI |
|
| 7 | + | ||
| 8 | + | enum GeminiErrorType { |
|
| 9 | + | case temporaryFailure(code: Int, meta: String) |
|
| 10 | + | case permanentFailure(code: Int, meta: String) |
|
| 11 | + | case clientCertificate(code: Int, meta: String) |
|
| 12 | + | case tooManyRedirects |
|
| 13 | + | case networkError(String) |
|
| 14 | + | case invalidResponse |
|
| 15 | + | case invalidURL |
|
| 16 | + | ||
| 17 | + | var title: String { |
|
| 18 | + | switch self { |
|
| 19 | + | case .temporaryFailure(let code, _): |
|
| 20 | + | switch code { |
|
| 21 | + | case 40: return "Temporary Failure" |
|
| 22 | + | case 41: return "Server Unavailable" |
|
| 23 | + | case 42: return "CGI Error" |
|
| 24 | + | case 43: return "Proxy Error" |
|
| 25 | + | case 44: return "Slow Down" |
|
| 26 | + | default: return "Temporary Failure" |
|
| 27 | + | } |
|
| 28 | + | case .permanentFailure(let code, _): |
|
| 29 | + | switch code { |
|
| 30 | + | case 50: return "Permanent Failure" |
|
| 31 | + | case 51: return "Not Found" |
|
| 32 | + | case 52: return "Gone" |
|
| 33 | + | case 53: return "Proxy Request Refused" |
|
| 34 | + | case 59: return "Bad Request" |
|
| 35 | + | default: return "Error" |
|
| 36 | + | } |
|
| 37 | + | case .clientCertificate(let code, _): |
|
| 38 | + | switch code { |
|
| 39 | + | case 60: return "Certificate Required" |
|
| 40 | + | case 61: return "Certificate Not Authorized" |
|
| 41 | + | case 62: return "Certificate Not Valid" |
|
| 42 | + | default: return "Certificate Error" |
|
| 43 | + | } |
|
| 44 | + | case .tooManyRedirects: |
|
| 45 | + | return "Too Many Redirects" |
|
| 46 | + | case .networkError: |
|
| 47 | + | return "Connection Failed" |
|
| 48 | + | case .invalidResponse: |
|
| 49 | + | return "Invalid Response" |
|
| 50 | + | case .invalidURL: |
|
| 51 | + | return "Invalid URL" |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | var statusCode: String? { |
|
| 56 | + | switch self { |
|
| 57 | + | case .temporaryFailure(let code, _), |
|
| 58 | + | .permanentFailure(let code, _), |
|
| 59 | + | .clientCertificate(let code, _): |
|
| 60 | + | return "(\(code))" |
|
| 61 | + | default: |
|
| 62 | + | return nil |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | var meta: String? { |
|
| 67 | + | switch self { |
|
| 68 | + | case .temporaryFailure(_, let meta), |
|
| 69 | + | .permanentFailure(_, let meta), |
|
| 70 | + | .clientCertificate(_, let meta): |
|
| 71 | + | return meta.isEmpty ? nil : meta |
|
| 72 | + | case .networkError(let message): |
|
| 73 | + | return message |
|
| 74 | + | default: |
|
| 75 | + | return nil |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | var explanation: String { |
|
| 80 | + | switch self { |
|
| 81 | + | case .temporaryFailure(let code, _): |
|
| 82 | + | switch code { |
|
| 83 | + | case 40: |
|
| 84 | + | return "The server encountered a temporary problem and couldn't complete your request." |
|
| 85 | + | case 41: |
|
| 86 | + | return "The server is currently unavailable, possibly due to maintenance or high load." |
|
| 87 | + | case 42: |
|
| 88 | + | return "A script on the server failed to execute properly." |
|
| 89 | + | case 43: |
|
| 90 | + | return "There was an error communicating through a proxy server." |
|
| 91 | + | case 44: |
|
| 92 | + | return "You're making requests too quickly. The server needs you to slow down." |
|
| 93 | + | default: |
|
| 94 | + | return "The server encountered a temporary issue processing your request." |
|
| 95 | + | } |
|
| 96 | + | case .permanentFailure(let code, _): |
|
| 97 | + | switch code { |
|
| 98 | + | case 50: |
|
| 99 | + | return "The server cannot fulfill this request. The issue is permanent." |
|
| 100 | + | case 51: |
|
| 101 | + | return "The page you requested doesn't exist on this server." |
|
| 102 | + | case 52: |
|
| 103 | + | return "This content used to exist but has been permanently removed." |
|
| 104 | + | case 53: |
|
| 105 | + | return "This server does not accept proxy requests." |
|
| 106 | + | case 59: |
|
| 107 | + | return "The server couldn't understand your request. The URL may be malformed." |
|
| 108 | + | default: |
|
| 109 | + | return "The server cannot process this request." |
|
| 110 | + | } |
|
| 111 | + | case .clientCertificate(let code, _): |
|
| 112 | + | switch code { |
|
| 113 | + | case 60: |
|
| 114 | + | return "This page requires a client certificate for authentication." |
|
| 115 | + | case 61: |
|
| 116 | + | return "Your certificate is not authorized to access this resource." |
|
| 117 | + | case 62: |
|
| 118 | + | return "Your certificate is invalid or has expired." |
|
| 119 | + | default: |
|
| 120 | + | return "There's an issue with certificate authentication." |
|
| 121 | + | } |
|
| 122 | + | case .tooManyRedirects: |
|
| 123 | + | return "The page redirected too many times, possibly in a loop." |
|
| 124 | + | case .networkError: |
|
| 125 | + | return "Unable to establish a connection to the server." |
|
| 126 | + | case .invalidResponse: |
|
| 127 | + | return "The server sent a response that couldn't be understood." |
|
| 128 | + | case .invalidURL: |
|
| 129 | + | return "The URL you entered isn't valid for the Gemini protocol." |
|
| 130 | + | } |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | var suggestions: [String] { |
|
| 134 | + | switch self { |
|
| 135 | + | case .temporaryFailure(let code, _): |
|
| 136 | + | switch code { |
|
| 137 | + | case 41: |
|
| 138 | + | return [ |
|
| 139 | + | "Wait a few minutes and try again", |
|
| 140 | + | "Check if the site is down for others", |
|
| 141 | + | "Try a different page on the same site" |
|
| 142 | + | ] |
|
| 143 | + | case 44: |
|
| 144 | + | return [ |
|
| 145 | + | "Wait before making another request", |
|
| 146 | + | "Avoid refreshing repeatedly" |
|
| 147 | + | ] |
|
| 148 | + | default: |
|
| 149 | + | return [ |
|
| 150 | + | "Try again in a few moments", |
|
| 151 | + | "The issue may resolve itself" |
|
| 152 | + | ] |
|
| 153 | + | } |
|
| 154 | + | case .permanentFailure(let code, _): |
|
| 155 | + | switch code { |
|
| 156 | + | case 51: |
|
| 157 | + | return [ |
|
| 158 | + | "Check the URL for typos", |
|
| 159 | + | "The page may have moved", |
|
| 160 | + | "Try the site's homepage" |
|
| 161 | + | ] |
|
| 162 | + | case 52: |
|
| 163 | + | return [ |
|
| 164 | + | "This content has been removed", |
|
| 165 | + | "Try searching for similar content", |
|
| 166 | + | "Check for an archived version" |
|
| 167 | + | ] |
|
| 168 | + | case 59: |
|
| 169 | + | return [ |
|
| 170 | + | "Check the URL format", |
|
| 171 | + | "Ensure special characters are encoded", |
|
| 172 | + | "Try a simpler URL" |
|
| 173 | + | ] |
|
| 174 | + | default: |
|
| 175 | + | return [ |
|
| 176 | + | "Double-check the URL", |
|
| 177 | + | "Try a different page" |
|
| 178 | + | ] |
|
| 179 | + | } |
|
| 180 | + | case .clientCertificate(let code, _): |
|
| 181 | + | switch code { |
|
| 182 | + | case 60: |
|
| 183 | + | return [ |
|
| 184 | + | "Client certificates are not yet supported", |
|
| 185 | + | "Some Gemini sites require authentication" |
|
| 186 | + | ] |
|
| 187 | + | default: |
|
| 188 | + | return [ |
|
| 189 | + | "Certificate authentication failed", |
|
| 190 | + | "Contact the site administrator" |
|
| 191 | + | ] |
|
| 192 | + | } |
|
| 193 | + | case .tooManyRedirects: |
|
| 194 | + | return [ |
|
| 195 | + | "The site may have a configuration issue", |
|
| 196 | + | "Try accessing the page directly", |
|
| 197 | + | "Contact the site administrator" |
|
| 198 | + | ] |
|
| 199 | + | case .networkError: |
|
| 200 | + | return [ |
|
| 201 | + | "Check your internet connection", |
|
| 202 | + | "Verify the hostname is correct", |
|
| 203 | + | "The server may be offline" |
|
| 204 | + | ] |
|
| 205 | + | case .invalidResponse: |
|
| 206 | + | return [ |
|
| 207 | + | "The server may not support Gemini", |
|
| 208 | + | "Try again later", |
|
| 209 | + | "Report the issue to the site owner" |
|
| 210 | + | ] |
|
| 211 | + | case .invalidURL: |
|
| 212 | + | return [ |
|
| 213 | + | "URLs should start with gemini://", |
|
| 214 | + | "Check for typos in the address", |
|
| 215 | + | "Ensure the hostname is valid" |
|
| 216 | + | ] |
|
| 217 | + | } |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | var asciiArt: String { |
|
| 221 | + | switch self { |
|
| 222 | + | case .temporaryFailure(let code, _): |
|
| 223 | + | if code == 44 { |
|
| 224 | + | // Hourglass for "slow down" |
|
| 225 | + | return """ |
|
| 226 | + | ╭━━━━━━━╮ |
|
| 227 | + | │░░░░░░░│ |
|
| 228 | + | ╰┬─────┬╯ |
|
| 229 | + | │░░░░░│ |
|
| 230 | + | │░░░░░│ |
|
| 231 | + | ╭┴─────┴╮ |
|
| 232 | + | │ │ |
|
| 233 | + | ╰━━━━━━━╯ |
|
| 234 | + | """ |
|
| 235 | + | } |
|
| 236 | + | // Clock for temporary issues |
|
| 237 | + | return """ |
|
| 238 | + | ╭───────╮ |
|
| 239 | + | ╱ │ ╲ |
|
| 240 | + | │ │ │ |
|
| 241 | + | │ ╰── │ |
|
| 242 | + | │ │ |
|
| 243 | + | ╲ ╱ |
|
| 244 | + | ╰───────╯ |
|
| 245 | + | """ |
|
| 246 | + | case .permanentFailure(let code, _): |
|
| 247 | + | if code == 51 { |
|
| 248 | + | // Question mark for not found |
|
| 249 | + | return """ |
|
| 250 | + | ╭━━━━━╮ |
|
| 251 | + | │ ??? │ |
|
| 252 | + | ╰──┬──╯ |
|
| 253 | + | │ |
|
| 254 | + | ╭─┴─╮ |
|
| 255 | + | │ ? │ |
|
| 256 | + | ╰───╯ |
|
| 257 | + | """ |
|
| 258 | + | } |
|
| 259 | + | if code == 52 { |
|
| 260 | + | // Ghost for "gone" |
|
| 261 | + | return """ |
|
| 262 | + | ╭─────╮ |
|
| 263 | + | ╱ ◠ ◠ ╲ |
|
| 264 | + | │ ▽ │ |
|
| 265 | + | │ │ |
|
| 266 | + | ╲ ╱ ╲ ╱ ╱ |
|
| 267 | + | ╵ ╵ |
|
| 268 | + | """ |
|
| 269 | + | } |
|
| 270 | + | // X mark for errors |
|
| 271 | + | return """ |
|
| 272 | + | ╲ ╱ |
|
| 273 | + | ╲ ╱ |
|
| 274 | + | ╲ ╱ |
|
| 275 | + | ╲ ╱ |
|
| 276 | + | ╱ ╲ |
|
| 277 | + | ╱ ╲ |
|
| 278 | + | ╱ ╲ |
|
| 279 | + | ╱ ╲ |
|
| 280 | + | """ |
|
| 281 | + | case .clientCertificate: |
|
| 282 | + | // Lock icon |
|
| 283 | + | return """ |
|
| 284 | + | ╭─────╮ |
|
| 285 | + | │ │ |
|
| 286 | + | ╭─┴─────┴─╮ |
|
| 287 | + | │ ┌───┐ │ |
|
| 288 | + | │ │ ◉ │ │ |
|
| 289 | + | │ └─┬─┘ │ |
|
| 290 | + | ╰────┴────╯ |
|
| 291 | + | """ |
|
| 292 | + | case .tooManyRedirects: |
|
| 293 | + | // Circular arrows |
|
| 294 | + | return """ |
|
| 295 | + | ╭──────╮ |
|
| 296 | + | ╱ ──▶ ╲ |
|
| 297 | + | │ ▲ │ |
|
| 298 | + | │ ▼ |
|
| 299 | + | ╲ ◀── ╱ |
|
| 300 | + | ╰──────╯ |
|
| 301 | + | """ |
|
| 302 | + | case .networkError: |
|
| 303 | + | // Disconnected plug |
|
| 304 | + | return """ |
|
| 305 | + | ╭───╮ |
|
| 306 | + | │ ● │ |
|
| 307 | + | │ ● │╶╶╶╮ |
|
| 308 | + | ╰───╯ ┊ |
|
| 309 | + | ┊ |
|
| 310 | + | ╭───╮ ┊ |
|
| 311 | + | │ ○ │╶╶╶╯ |
|
| 312 | + | │ ○ │ |
|
| 313 | + | ╰───╯ |
|
| 314 | + | """ |
|
| 315 | + | case .invalidResponse: |
|
| 316 | + | // Broken document |
|
| 317 | + | return """ |
|
| 318 | + | ╭───────╮ |
|
| 319 | + | │ ≋≋≋≋≋ │ |
|
| 320 | + | │ ≋≋≋ ╱─┤ |
|
| 321 | + | ├───╱ │ |
|
| 322 | + | │ ╲───┤ |
|
| 323 | + | │ ≋≋ ╲ │ |
|
| 324 | + | ╰───────╯ |
|
| 325 | + | """ |
|
| 326 | + | case .invalidURL: |
|
| 327 | + | // Broken link |
|
| 328 | + | return """ |
|
| 329 | + | ╭───╮ |
|
| 330 | + | ╱ ╲────╮ |
|
| 331 | + | ╲ ╱ │ |
|
| 332 | + | ╰─╳─╯ │ |
|
| 333 | + | ╭─╳──╯ |
|
| 334 | + | │╱ ╲ |
|
| 335 | + | ╰────╱ |
|
| 336 | + | """ |
|
| 337 | + | } |
|
| 338 | + | } |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | struct ErrorPageView: View { |
|
| 342 | + | let errorType: GeminiErrorType |
|
| 343 | + | let onRetry: (() -> Void)? |
|
| 344 | + | ||
| 345 | + | @EnvironmentObject private var themeSettings: ThemeSettings |
|
| 346 | + | ||
| 347 | + | init(errorType: GeminiErrorType, onRetry: (() -> Void)? = nil) { |
|
| 348 | + | self.errorType = errorType |
|
| 349 | + | self.onRetry = onRetry |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | private var errorColor: Color { |
|
| 353 | + | switch errorType { |
|
| 354 | + | case .temporaryFailure: |
|
| 355 | + | return .orange |
|
| 356 | + | case .permanentFailure: |
|
| 357 | + | return .red |
|
| 358 | + | case .clientCertificate: |
|
| 359 | + | return .purple |
|
| 360 | + | case .tooManyRedirects: |
|
| 361 | + | return .yellow |
|
| 362 | + | case .networkError, .invalidResponse, .invalidURL: |
|
| 363 | + | return .gray |
|
| 364 | + | } |
|
| 365 | + | } |
|
| 366 | + | ||
| 367 | + | var body: some View { |
|
| 368 | + | VStack(spacing: 24) { |
|
| 369 | + | // ASCII Art |
|
| 370 | + | Text(errorType.asciiArt) |
|
| 371 | + | .font(.system(size: 14, design: .monospaced)) |
|
| 372 | + | .foregroundColor(errorColor) |
|
| 373 | + | .multilineTextAlignment(.center) |
|
| 374 | + | ||
| 375 | + | // Title and status code |
|
| 376 | + | VStack(spacing: 4) { |
|
| 377 | + | Text(errorType.title) |
|
| 378 | + | .font(.system(.title2, design: .rounded)) |
|
| 379 | + | .fontWeight(.bold) |
|
| 380 | + | .foregroundColor(themeSettings.textColor) |
|
| 381 | + | ||
| 382 | + | if let code = errorType.statusCode { |
|
| 383 | + | Text(code) |
|
| 384 | + | .font(.system(.subheadline, design: .monospaced)) |
|
| 385 | + | .foregroundColor(.secondary) |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | // Server message if present |
|
| 390 | + | if let meta = errorType.meta { |
|
| 391 | + | Text(meta) |
|
| 392 | + | .font(.system(.caption, design: .monospaced)) |
|
| 393 | + | .foregroundColor(.secondary) |
|
| 394 | + | .padding(.horizontal, 16) |
|
| 395 | + | .padding(.vertical, 8) |
|
| 396 | + | .background( |
|
| 397 | + | RoundedRectangle(cornerRadius: 6) |
|
| 398 | + | .fill(Color.secondary.opacity(0.1)) |
|
| 399 | + | ) |
|
| 400 | + | } |
|
| 401 | + | ||
| 402 | + | // Explanation |
|
| 403 | + | Text(errorType.explanation) |
|
| 404 | + | .font(.system(.body, design: .default)) |
|
| 405 | + | .foregroundColor(themeSettings.textColor.opacity(0.8)) |
|
| 406 | + | .multilineTextAlignment(.center) |
|
| 407 | + | .padding(.horizontal, 24) |
|
| 408 | + | ||
| 409 | + | // Suggestions |
|
| 410 | + | VStack(alignment: .leading, spacing: 8) { |
|
| 411 | + | ForEach(errorType.suggestions, id: \.self) { suggestion in |
|
| 412 | + | HStack(alignment: .top, spacing: 8) { |
|
| 413 | + | Text("•") |
|
| 414 | + | .foregroundColor(errorColor) |
|
| 415 | + | Text(suggestion) |
|
| 416 | + | .font(.system(.callout)) |
|
| 417 | + | .foregroundColor(.secondary) |
|
| 418 | + | } |
|
| 419 | + | } |
|
| 420 | + | } |
|
| 421 | + | .padding(.horizontal, 32) |
|
| 422 | + | ||
| 423 | + | // Retry button for temporary errors |
|
| 424 | + | if let onRetry = onRetry, canRetry { |
|
| 425 | + | Button(action: onRetry) { |
|
| 426 | + | HStack { |
|
| 427 | + | Image(systemName: "arrow.clockwise") |
|
| 428 | + | Text("Try Again") |
|
| 429 | + | } |
|
| 430 | + | .font(.system(.body, weight: .medium)) |
|
| 431 | + | .foregroundColor(.white) |
|
| 432 | + | .padding(.horizontal, 24) |
|
| 433 | + | .padding(.vertical, 12) |
|
| 434 | + | .background( |
|
| 435 | + | Capsule() |
|
| 436 | + | .fill(themeSettings.accentColor) |
|
| 437 | + | ) |
|
| 438 | + | } |
|
| 439 | + | .padding(.top, 8) |
|
| 440 | + | } |
|
| 441 | + | } |
|
| 442 | + | .padding(.vertical, 40) |
|
| 443 | + | .frame(maxWidth: .infinity) |
|
| 444 | + | } |
|
| 445 | + | ||
| 446 | + | private var canRetry: Bool { |
|
| 447 | + | switch errorType { |
|
| 448 | + | case .temporaryFailure, .networkError: |
|
| 449 | + | return true |
|
| 450 | + | default: |
|
| 451 | + | return false |
|
| 452 | + | } |
|
| 453 | + | } |
|
| 454 | + | } |
|
| 455 | + | ||
| 456 | + | #Preview { |
|
| 457 | + | ScrollView { |
|
| 458 | + | VStack(spacing: 40) { |
|
| 459 | + | ErrorPageView(errorType: .permanentFailure(code: 51, meta: "Resource not found")) |
|
| 460 | + | ||
| 461 | + | Divider() |
|
| 462 | + | ||
| 463 | + | ErrorPageView(errorType: .temporaryFailure(code: 44, meta: "Please wait 30 seconds"), onRetry: {}) |
|
| 464 | + | ||
| 465 | + | Divider() |
|
| 466 | + | ||
| 467 | + | ErrorPageView(errorType: .networkError("Connection refused")) |
|
| 468 | + | } |
|
| 469 | + | } |
|
| 470 | + | .environmentObject(ThemeSettings()) |
|
| 471 | + | } |