detached ac16f2b

refactor(webui): replace blocked page with unified error page component

commit
ac16f2b23d02413bfa63da1e1e32af74c4bdbe41
author
bnema · · 2025-12-21 13:53 UTC
parent
899908d
signature
unsigned
4 files · +196 -346
D webui/src/pages/BlockedPage.svelte +0 -334
@@ -1,334 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3-
4- let blockedUrl = $state('');
5- let blockedDomain = $state('');
6- let reason = $state('Content blocked by filter');
7- let isLoading = $state(false);
8- let error = $state('');
9-
10- onMount(() => {
11- // Parse URL parameters
12- const params = new URLSearchParams(window.location.search);
13- blockedUrl = params.get('url') || '';
14- reason = params.get('reason') || 'Content blocked by filter';
15-
16- // Extract domain from URL
17- try {
18- if (blockedUrl) {
19- const url = new URL(blockedUrl);
20- blockedDomain = url.hostname;
21- }
22- } catch {
23- blockedDomain = blockedUrl;
24- }
25- });
26-
27- function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
28- return new Promise((resolve, reject) => {
29- const requestId = `blocked_${Date.now()}_${Math.random().toString(36).slice(2)}`;
30-
31- const handler = (event: MessageEvent) => {
32- if (event.data?.requestId === requestId) {
33- window.removeEventListener('message', handler);
34- if (event.data.success) {
35- resolve(event.data.data);
36- } else {
37- reject(new Error(event.data.error || 'Unknown error'));
38- }
39- }
40- };
41-
42- window.addEventListener('message', handler);
43-
44- // Send to WebKit message handler
45- if (window.webkit?.messageHandlers?.dumber) {
46- window.webkit.messageHandlers.dumber.postMessage(
47- JSON.stringify({ type, requestId, payload })
48- );
49- } else {
50- reject(new Error('WebKit message handler not available'));
51- }
52-
53- // Timeout after 10 seconds
54- setTimeout(() => {
55- window.removeEventListener('message', handler);
56- reject(new Error('Request timeout'));
57- }, 10000);
58- });
59- }
60-
61- async function addToWhitelist() {
62- if (!blockedDomain) return;
63-
64- isLoading = true;
65- error = '';
66-
67- try {
68- await sendMessage('addToWhitelist', { domain: blockedDomain });
69- // Reload the original URL after whitelisting
70- window.location.href = blockedUrl;
71- } catch (e) {
72- error = e instanceof Error ? e.message : 'Failed to add to whitelist';
73- isLoading = false;
74- }
75- }
76-
77- async function bypassOnce() {
78- if (!blockedUrl) return;
79-
80- isLoading = true;
81- error = '';
82-
83- try {
84- await sendMessage('bypassOnce', { url: blockedUrl });
85- // Reload the original URL after bypass
86- window.location.href = blockedUrl;
87- } catch (e) {
88- error = e instanceof Error ? e.message : 'Failed to bypass';
89- isLoading = false;
90- }
91- }
92-
93- function goBack() {
94- window.history.back();
95- }
96-</script>
97-
98-<svelte:head>
99- <title>Page Blocked</title>
100- {@html `<style>
101- html, body {
102- background: var(--background, #0a0a0a);
103- margin: 0;
104- padding: 0;
105- min-height: 100vh;
106- }
107- </style>`}
108-</svelte:head>
109-
110-<div class="blocked-container">
111- <div class="blocked-card">
112- <div class="icon-container">
113- <svg class="shield-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
114- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
115- <path d="M9 12l2 2 4-4"/>
116- </svg>
117- </div>
118-
119- <h1 class="title">Page Blocked</h1>
120-
121- <p class="reason">{reason}</p>
122-
123- {#if blockedUrl}
124- <div class="url-display">
125- <span class="url-label">Blocked URL:</span>
126- <code class="url-value">{blockedUrl}</code>
127- </div>
128- {/if}
129-
130- {#if error}
131- <div class="error-message">{error}</div>
132- {/if}
133-
134- <div class="actions">
135- <button
136- class="btn btn-primary"
137- onclick={addToWhitelist}
138- disabled={isLoading || !blockedDomain}
139- >
140- {#if isLoading}
141- <span class="loading-spinner"></span>
142- {:else}
143- Add to Whitelist
144- {/if}
145- </button>
146-
147- <button
148- class="btn btn-secondary"
149- onclick={bypassOnce}
150- disabled={isLoading || !blockedUrl}
151- >
152- Go Anyway (Once)
153- </button>
154-
155- <button
156- class="btn btn-ghost"
157- onclick={goBack}
158- disabled={isLoading}
159- >
160- Go Back
161- </button>
162- </div>
163-
164- {#if blockedDomain}
165- <p class="domain-note">
166- Adding <strong>{blockedDomain}</strong> to whitelist will disable content filtering for this site.
167- </p>
168- {/if}
169- </div>
170-</div>
171-
172-<style>
173- .blocked-container {
174- min-height: 100vh;
175- display: flex;
176- align-items: center;
177- justify-content: center;
178- padding: 2rem;
179- background: var(--background, #0a0a0a);
180- color: var(--foreground, #e5e5e5);
181- font-family: 'Inter', system-ui, -apple-system, sans-serif;
182- }
183-
184- .blocked-card {
185- max-width: 480px;
186- width: 100%;
187- padding: 2.5rem 2rem;
188- background: var(--card, #141414);
189- border-width: 1px;
190- border-style: solid;
191- border-color: var(--border, #262626);
192- text-align: center;
193- }
194-
195- .icon-container {
196- margin-bottom: 1.5rem;
197- }
198-
199- .shield-icon {
200- width: 64px;
201- height: 64px;
202- color: var(--primary, #3b82f6);
203- opacity: 0.9;
204- }
205-
206- .title {
207- font-size: 1.5rem;
208- font-weight: 600;
209- margin: 0 0 0.75rem 0;
210- color: var(--foreground, #e5e5e5);
211- }
212-
213- .reason {
214- font-size: 0.9375rem;
215- color: var(--muted-foreground, #737373);
216- margin: 0 0 1.5rem 0;
217- }
218-
219- .url-display {
220- background: var(--background, #0a0a0a);
221- border-width: 1px;
222- border-style: solid;
223- border-color: var(--border, #262626);
224- padding: 0.75rem 1rem;
225- margin-bottom: 1.5rem;
226- text-align: left;
227- }
228-
229- .url-label {
230- display: block;
231- font-size: 0.75rem;
232- color: var(--muted-foreground, #737373);
233- margin-bottom: 0.25rem;
234- text-transform: uppercase;
235- letter-spacing: 0.05em;
236- }
237-
238- .url-value {
239- font-family: 'JetBrains Mono NF', monospace;
240- font-size: 0.8125rem;
241- color: var(--foreground, #e5e5e5);
242- word-break: break-all;
243- display: block;
244- }
245-
246- .error-message {
247- background: rgba(239, 68, 68, 0.1);
248- border: 1px solid rgba(239, 68, 68, 0.3);
249- color: #ef4444;
250- padding: 0.75rem 1rem;
251- margin-bottom: 1.5rem;
252- font-size: 0.875rem;
253- }
254-
255- .actions {
256- display: flex;
257- flex-direction: column;
258- gap: 0.75rem;
259- margin-bottom: 1.5rem;
260- }
261-
262- .btn {
263- padding: 0.75rem 1.5rem;
264- font-size: 0.875rem;
265- font-weight: 500;
266- border: 1px solid transparent;
267- cursor: pointer;
268- transition: all 150ms ease;
269- display: inline-flex;
270- align-items: center;
271- justify-content: center;
272- gap: 0.5rem;
273- }
274-
275- .btn:disabled {
276- opacity: 0.5;
277- cursor: not-allowed;
278- }
279-
280- .btn-primary {
281- background: var(--primary, #3b82f6);
282- color: white;
283- border-color: var(--primary, #3b82f6);
284- }
285-
286- .btn-primary:hover:not(:disabled) {
287- filter: brightness(1.1);
288- }
289-
290- .btn-secondary {
291- background: transparent;
292- color: var(--foreground, #e5e5e5);
293- border-color: var(--border, #262626);
294- }
295-
296- .btn-secondary:hover:not(:disabled) {
297- background: var(--secondary, #1a1a1a);
298- border-color: var(--muted-foreground, #737373);
299- }
300-
301- .btn-ghost {
302- background: transparent;
303- color: var(--muted-foreground, #737373);
304- border-color: transparent;
305- }
306-
307- .btn-ghost:hover:not(:disabled) {
308- color: var(--foreground, #e5e5e5);
309- }
310-
311- .loading-spinner {
312- width: 1rem;
313- height: 1rem;
314- border: 2px solid transparent;
315- border-top-color: currentColor;
316- border-radius: 50%;
317- animation: spin 0.6s linear infinite;
318- }
319-
320- @keyframes spin {
321- to { transform: rotate(360deg); }
322- }
323-
324- .domain-note {
325- font-size: 0.75rem;
326- color: var(--muted-foreground, #737373);
327- margin: 0;
328- }
329-
330- .domain-note strong {
331- color: var(--foreground, #e5e5e5);
332- font-weight: 500;
333- }
334-</style>
@@ -1,334 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3-
4- let blockedUrl = $state('');
5- let blockedDomain = $state('');
6- let reason = $state('Content blocked by filter');
7- let isLoading = $state(false);
8- let error = $state('');
9-
10- onMount(() => {
11- // Parse URL parameters
12- const params = new URLSearchParams(window.location.search);
13- blockedUrl = params.get('url') || '';
14- reason = params.get('reason') || 'Content blocked by filter';
15-
16- // Extract domain from URL
17- try {
18- if (blockedUrl) {
19- const url = new URL(blockedUrl);
20- blockedDomain = url.hostname;
21- }
22- } catch {
23- blockedDomain = blockedUrl;
24- }
25- });
26-
27- function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
28- return new Promise((resolve, reject) => {
29- const requestId = `blocked_${Date.now()}_${Math.random().toString(36).slice(2)}`;
30-
31- const handler = (event: MessageEvent) => {
32- if (event.data?.requestId === requestId) {
33- window.removeEventListener('message', handler);
34- if (event.data.success) {
35- resolve(event.data.data);
36- } else {
37- reject(new Error(event.data.error || 'Unknown error'));
38- }
39- }
40- };
41-
42- window.addEventListener('message', handler);
43-
44- // Send to WebKit message handler
45- if (window.webkit?.messageHandlers?.dumber) {
46- window.webkit.messageHandlers.dumber.postMessage(
47- JSON.stringify({ type, requestId, payload })
48- );
49- } else {
50- reject(new Error('WebKit message handler not available'));
51- }
52-
53- // Timeout after 10 seconds
54- setTimeout(() => {
55- window.removeEventListener('message', handler);
56- reject(new Error('Request timeout'));
57- }, 10000);
58- });
59- }
60-
61- async function addToWhitelist() {
62- if (!blockedDomain) return;
63-
64- isLoading = true;
65- error = '';
66-
67- try {
68- await sendMessage('addToWhitelist', { domain: blockedDomain });
69- // Reload the original URL after whitelisting
70- window.location.href = blockedUrl;
71- } catch (e) {
72- error = e instanceof Error ? e.message : 'Failed to add to whitelist';
73- isLoading = false;
74- }
75- }
76-
77- async function bypassOnce() {
78- if (!blockedUrl) return;
79-
80- isLoading = true;
81- error = '';
82-
83- try {
84- await sendMessage('bypassOnce', { url: blockedUrl });
85- // Reload the original URL after bypass
86- window.location.href = blockedUrl;
87- } catch (e) {
88- error = e instanceof Error ? e.message : 'Failed to bypass';
89- isLoading = false;
90- }
91- }
92-
93- function goBack() {
94- window.history.back();
95- }
96-</script>
97-
98-<svelte:head>
99- <title>Page Blocked</title>
100- {@html `<style>
101- html, body {
102- background: var(--background, #0a0a0a);
103- margin: 0;
104- padding: 0;
105- min-height: 100vh;
106- }
107- </style>`}
108-</svelte:head>
109-
110-<div class="blocked-container">
111- <div class="blocked-card">
112- <div class="icon-container">
113- <svg class="shield-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
114- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
115- <path d="M9 12l2 2 4-4"/>
116- </svg>
117- </div>
118-
119- <h1 class="title">Page Blocked</h1>
120-
121- <p class="reason">{reason}</p>
122-
123- {#if blockedUrl}
124- <div class="url-display">
125- <span class="url-label">Blocked URL:</span>
126- <code class="url-value">{blockedUrl}</code>
127- </div>
128- {/if}
129-
130- {#if error}
131- <div class="error-message">{error}</div>
132- {/if}
133-
134- <div class="actions">
135- <button
136- class="btn btn-primary"
137- onclick={addToWhitelist}
138- disabled={isLoading || !blockedDomain}
139- >
140- {#if isLoading}
141- <span class="loading-spinner"></span>
142- {:else}
143- Add to Whitelist
144- {/if}
145- </button>
146-
147- <button
148- class="btn btn-secondary"
149- onclick={bypassOnce}
150- disabled={isLoading || !blockedUrl}
151- >
152- Go Anyway (Once)
153- </button>
154-
155- <button
156- class="btn btn-ghost"
157- onclick={goBack}
158- disabled={isLoading}
159- >
160- Go Back
161- </button>
162- </div>
163-
164- {#if blockedDomain}
165- <p class="domain-note">
166- Adding <strong>{blockedDomain}</strong> to whitelist will disable content filtering for this site.
167- </p>
168- {/if}
169- </div>
170-</div>
171-
172-<style>
173- .blocked-container {
174- min-height: 100vh;
175- display: flex;
176- align-items: center;
177- justify-content: center;
178- padding: 2rem;
179- background: var(--background, #0a0a0a);
180- color: var(--foreground, #e5e5e5);
181- font-family: 'Inter', system-ui, -apple-system, sans-serif;
182- }
183-
184- .blocked-card {
185- max-width: 480px;
186- width: 100%;
187- padding: 2.5rem 2rem;
188- background: var(--card, #141414);
189- border-width: 1px;
190- border-style: solid;
191- border-color: var(--border, #262626);
192- text-align: center;
193- }
194-
195- .icon-container {
196- margin-bottom: 1.5rem;
197- }
198-
199- .shield-icon {
200- width: 64px;
201- height: 64px;
202- color: var(--primary, #3b82f6);
203- opacity: 0.9;
204- }
205-
206- .title {
207- font-size: 1.5rem;
208- font-weight: 600;
209- margin: 0 0 0.75rem 0;
210- color: var(--foreground, #e5e5e5);
211- }
212-
213- .reason {
214- font-size: 0.9375rem;
215- color: var(--muted-foreground, #737373);
216- margin: 0 0 1.5rem 0;
217- }
218-
219- .url-display {
220- background: var(--background, #0a0a0a);
221- border-width: 1px;
222- border-style: solid;
223- border-color: var(--border, #262626);
224- padding: 0.75rem 1rem;
225- margin-bottom: 1.5rem;
226- text-align: left;
227- }
228-
229- .url-label {
230- display: block;
231- font-size: 0.75rem;
232- color: var(--muted-foreground, #737373);
233- margin-bottom: 0.25rem;
234- text-transform: uppercase;
235- letter-spacing: 0.05em;
236- }
237-
238- .url-value {
239- font-family: 'JetBrains Mono NF', monospace;
240- font-size: 0.8125rem;
241- color: var(--foreground, #e5e5e5);
242- word-break: break-all;
243- display: block;
244- }
245-
246- .error-message {
247- background: rgba(239, 68, 68, 0.1);
248- border: 1px solid rgba(239, 68, 68, 0.3);
249- color: #ef4444;
250- padding: 0.75rem 1rem;
251- margin-bottom: 1.5rem;
252- font-size: 0.875rem;
253- }
254-
255- .actions {
256- display: flex;
257- flex-direction: column;
258- gap: 0.75rem;
259- margin-bottom: 1.5rem;
260- }
261-
262- .btn {
263- padding: 0.75rem 1.5rem;
264- font-size: 0.875rem;
265- font-weight: 500;
266- border: 1px solid transparent;
267- cursor: pointer;
268- transition: all 150ms ease;
269- display: inline-flex;
270- align-items: center;
271- justify-content: center;
272- gap: 0.5rem;
273- }
274-
275- .btn:disabled {
276- opacity: 0.5;
277- cursor: not-allowed;
278- }
279-
280- .btn-primary {
281- background: var(--primary, #3b82f6);
282- color: white;
283- border-color: var(--primary, #3b82f6);
284- }
285-
286- .btn-primary:hover:not(:disabled) {
287- filter: brightness(1.1);
288- }
289-
290- .btn-secondary {
291- background: transparent;
292- color: var(--foreground, #e5e5e5);
293- border-color: var(--border, #262626);
294- }
295-
296- .btn-secondary:hover:not(:disabled) {
297- background: var(--secondary, #1a1a1a);
298- border-color: var(--muted-foreground, #737373);
299- }
300-
301- .btn-ghost {
302- background: transparent;
303- color: var(--muted-foreground, #737373);
304- border-color: transparent;
305- }
306-
307- .btn-ghost:hover:not(:disabled) {
308- color: var(--foreground, #e5e5e5);
309- }
310-
311- .loading-spinner {
312- width: 1rem;
313- height: 1rem;
314- border: 2px solid transparent;
315- border-top-color: currentColor;
316- border-radius: 50%;
317- animation: spin 0.6s linear infinite;
318- }
319-
320- @keyframes spin {
321- to { transform: rotate(360deg); }
322- }
323-
324- .domain-note {
325- font-size: 0.75rem;
326- color: var(--muted-foreground, #737373);
327- margin: 0;
328- }
329-
330- .domain-note strong {
331- color: var(--foreground, #e5e5e5);
332- font-weight: 500;
333- }
334-</style>
A webui/src/pages/ErrorPage.svelte +185 -0
@@ -0,0 +1,185 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { Button } from '$lib/components/ui/button';
4+ import * as Card from '$lib/components/ui/card';
5+
6+ // Error configurations
7+ const errorConfigs: Record<string, { code: number; title: string; message: string; icon: string; actions: string[] }> = {
8+ blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] },
9+ '404': { code: 404, title: 'Page Not Found', message: 'The page you are looking for does not exist.', icon: 'warning', actions: ['back'] },
10+ '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] },
11+ '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] },
12+ '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] },
13+ offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] },
14+ ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] },
15+ timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] },
16+ };
17+
18+ let config = $state(errorConfigs['404']);
19+ let targetUrl = $state('');
20+ let targetDomain = $state('');
21+ let customMessage = $state('');
22+ let isLoading = $state(false);
23+ let actionError = $state('');
24+
25+ onMount(() => {
26+ const params = new URLSearchParams(window.location.search);
27+ const typeParam = params.get('type') || params.get('code') || '404';
28+
29+ config = errorConfigs[typeParam] || {
30+ code: parseInt(typeParam) || 0,
31+ title: `Error ${typeParam}`,
32+ message: 'An unexpected error occurred.',
33+ icon: 'error',
34+ actions: ['retry', 'back'],
35+ };
36+
37+ targetUrl = params.get('url') || '';
38+ customMessage = params.get('reason') || params.get('message') || '';
39+
40+ if (targetUrl) {
41+ try {
42+ targetDomain = new URL(targetUrl).hostname;
43+ } catch {
44+ targetDomain = targetUrl;
45+ }
46+ }
47+ });
48+
49+ function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
50+ return new Promise((resolve, reject) => {
51+ const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`;
52+ const handler = (event: MessageEvent) => {
53+ if (event.data?.requestId === requestId) {
54+ window.removeEventListener('message', handler);
55+ event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error'));
56+ }
57+ };
58+ window.addEventListener('message', handler);
59+ if (window.webkit?.messageHandlers?.dumber) {
60+ window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload }));
61+ } else {
62+ reject(new Error('WebKit message handler not available'));
63+ }
64+ setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000);
65+ });
66+ }
67+
68+ async function addToWhitelist() {
69+ if (!targetDomain) return;
70+ isLoading = true;
71+ actionError = '';
72+ try {
73+ await sendMessage('addToWhitelist', { domain: targetDomain });
74+ window.location.href = targetUrl;
75+ } catch (e) {
76+ actionError = e instanceof Error ? e.message : 'Failed';
77+ isLoading = false;
78+ }
79+ }
80+
81+ async function bypassOnce() {
82+ if (!targetUrl) return;
83+ isLoading = true;
84+ actionError = '';
85+ try {
86+ await sendMessage('bypassOnce', { url: targetUrl });
87+ window.location.href = targetUrl;
88+ } catch (e) {
89+ actionError = e instanceof Error ? e.message : 'Failed';
90+ isLoading = false;
91+ }
92+ }
93+
94+ function retry() {
95+ targetUrl ? window.location.href = targetUrl : window.location.reload();
96+ }
97+
98+ function goBack() {
99+ window.history.back();
100+ }
101+
102+ const icons: Record<string, string> = {
103+ shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
104+ warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
105+ error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>',
106+ offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>',
107+ lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
108+ };
109+
110+ const iconColors: Record<string, string> = {
111+ shield: 'text-primary',
112+ warning: 'text-yellow-500',
113+ error: 'text-destructive',
114+ offline: 'text-muted-foreground',
115+ lock: 'text-yellow-500',
116+ };
117+</script>
118+
119+<svelte:head>
120+ <title>{config.title}</title>
121+</svelte:head>
122+
123+<div class="min-h-screen flex items-center justify-center p-8 bg-background">
124+ <Card.Root class="w-full max-w-md">
125+ <Card.Header class="text-center">
126+ <div class="flex justify-center mb-4">
127+ <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
128+ {@html icons[config.icon] || icons.error}
129+ </svg>
130+ </div>
131+ {#if config.code > 0}
132+ <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div>
133+ {/if}
134+ <Card.Title class="text-xl">{config.title}</Card.Title>
135+ <Card.Description>{customMessage || config.message}</Card.Description>
136+ </Card.Header>
137+
138+ <Card.Content class="space-y-4">
139+ {#if targetUrl}
140+ <div class="bg-muted p-3 rounded-md">
141+ <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p>
142+ <code class="text-sm break-all">{targetUrl}</code>
143+ </div>
144+ {/if}
145+
146+ {#if actionError}
147+ <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm">
148+ {actionError}
149+ </div>
150+ {/if}
151+ </Card.Content>
152+
153+ <Card.Footer class="flex flex-col gap-3">
154+ {#if config.actions.includes('whitelist') && targetDomain}
155+ <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}>
156+ {isLoading ? 'Adding...' : 'Add to Whitelist'}
157+ </Button>
158+ {/if}
159+
160+ {#if config.actions.includes('bypass') && targetUrl}
161+ <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}>
162+ Proceed Anyway
163+ </Button>
164+ {/if}
165+
166+ {#if config.actions.includes('retry')}
167+ <Button class="w-full" onclick={retry} disabled={isLoading}>
168+ Try Again
169+ </Button>
170+ {/if}
171+
172+ {#if config.actions.includes('back')}
173+ <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}>
174+ Go Back
175+ </Button>
176+ {/if}
177+
178+ {#if config.actions.includes('whitelist') && targetDomain}
179+ <p class="text-xs text-muted-foreground text-center">
180+ Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering.
181+ </p>
182+ {/if}
183+ </Card.Footer>
184+ </Card.Root>
185+</div>
@@ -0,0 +1,185 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { Button } from '$lib/components/ui/button';
4+ import * as Card from '$lib/components/ui/card';
5+
6+ // Error configurations
7+ const errorConfigs: Record<string, { code: number; title: string; message: string; icon: string; actions: string[] }> = {
8+ blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] },
9+ '404': { code: 404, title: 'Page Not Found', message: 'The page you are looking for does not exist.', icon: 'warning', actions: ['back'] },
10+ '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] },
11+ '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] },
12+ '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] },
13+ offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] },
14+ ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] },
15+ timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] },
16+ };
17+
18+ let config = $state(errorConfigs['404']);
19+ let targetUrl = $state('');
20+ let targetDomain = $state('');
21+ let customMessage = $state('');
22+ let isLoading = $state(false);
23+ let actionError = $state('');
24+
25+ onMount(() => {
26+ const params = new URLSearchParams(window.location.search);
27+ const typeParam = params.get('type') || params.get('code') || '404';
28+
29+ config = errorConfigs[typeParam] || {
30+ code: parseInt(typeParam) || 0,
31+ title: `Error ${typeParam}`,
32+ message: 'An unexpected error occurred.',
33+ icon: 'error',
34+ actions: ['retry', 'back'],
35+ };
36+
37+ targetUrl = params.get('url') || '';
38+ customMessage = params.get('reason') || params.get('message') || '';
39+
40+ if (targetUrl) {
41+ try {
42+ targetDomain = new URL(targetUrl).hostname;
43+ } catch {
44+ targetDomain = targetUrl;
45+ }
46+ }
47+ });
48+
49+ function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
50+ return new Promise((resolve, reject) => {
51+ const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`;
52+ const handler = (event: MessageEvent) => {
53+ if (event.data?.requestId === requestId) {
54+ window.removeEventListener('message', handler);
55+ event.data.success ? resolve(event.data.data) : reject(new Error(event.data.error || 'Unknown error'));
56+ }
57+ };
58+ window.addEventListener('message', handler);
59+ if (window.webkit?.messageHandlers?.dumber) {
60+ window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload }));
61+ } else {
62+ reject(new Error('WebKit message handler not available'));
63+ }
64+ setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000);
65+ });
66+ }
67+
68+ async function addToWhitelist() {
69+ if (!targetDomain) return;
70+ isLoading = true;
71+ actionError = '';
72+ try {
73+ await sendMessage('addToWhitelist', { domain: targetDomain });
74+ window.location.href = targetUrl;
75+ } catch (e) {
76+ actionError = e instanceof Error ? e.message : 'Failed';
77+ isLoading = false;
78+ }
79+ }
80+
81+ async function bypassOnce() {
82+ if (!targetUrl) return;
83+ isLoading = true;
84+ actionError = '';
85+ try {
86+ await sendMessage('bypassOnce', { url: targetUrl });
87+ window.location.href = targetUrl;
88+ } catch (e) {
89+ actionError = e instanceof Error ? e.message : 'Failed';
90+ isLoading = false;
91+ }
92+ }
93+
94+ function retry() {
95+ targetUrl ? window.location.href = targetUrl : window.location.reload();
96+ }
97+
98+ function goBack() {
99+ window.history.back();
100+ }
101+
102+ const icons: Record<string, string> = {
103+ shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
104+ warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
105+ error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>',
106+ offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>',
107+ lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
108+ };
109+
110+ const iconColors: Record<string, string> = {
111+ shield: 'text-primary',
112+ warning: 'text-yellow-500',
113+ error: 'text-destructive',
114+ offline: 'text-muted-foreground',
115+ lock: 'text-yellow-500',
116+ };
117+</script>
118+
119+<svelte:head>
120+ <title>{config.title}</title>
121+</svelte:head>
122+
123+<div class="min-h-screen flex items-center justify-center p-8 bg-background">
124+ <Card.Root class="w-full max-w-md">
125+ <Card.Header class="text-center">
126+ <div class="flex justify-center mb-4">
127+ <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
128+ {@html icons[config.icon] || icons.error}
129+ </svg>
130+ </div>
131+ {#if config.code > 0}
132+ <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div>
133+ {/if}
134+ <Card.Title class="text-xl">{config.title}</Card.Title>
135+ <Card.Description>{customMessage || config.message}</Card.Description>
136+ </Card.Header>
137+
138+ <Card.Content class="space-y-4">
139+ {#if targetUrl}
140+ <div class="bg-muted p-3 rounded-md">
141+ <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p>
142+ <code class="text-sm break-all">{targetUrl}</code>
143+ </div>
144+ {/if}
145+
146+ {#if actionError}
147+ <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm">
148+ {actionError}
149+ </div>
150+ {/if}
151+ </Card.Content>
152+
153+ <Card.Footer class="flex flex-col gap-3">
154+ {#if config.actions.includes('whitelist') && targetDomain}
155+ <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}>
156+ {isLoading ? 'Adding...' : 'Add to Whitelist'}
157+ </Button>
158+ {/if}
159+
160+ {#if config.actions.includes('bypass') && targetUrl}
161+ <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}>
162+ Proceed Anyway
163+ </Button>
164+ {/if}
165+
166+ {#if config.actions.includes('retry')}
167+ <Button class="w-full" onclick={retry} disabled={isLoading}>
168+ Try Again
169+ </Button>
170+ {/if}
171+
172+ {#if config.actions.includes('back')}
173+ <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}>
174+ Go Back
175+ </Button>
176+ {/if}
177+
178+ {#if config.actions.includes('whitelist') && targetDomain}
179+ <p class="text-xs text-muted-foreground text-center">
180+ Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering.
181+ </p>
182+ {/if}
183+ </Card.Footer>
184+ </Card.Root>
185+</div>
D webui/src/pages/blocked.ts +0 -12
@@ -1,12 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import BlockedPage from "./BlockedPage.svelte";
4-
5-// Mount the BlockedPage component to the DOM
6-console.log("[dumber] Mounting BlockedPage component to document.body");
7-try {
8- const app = mount(BlockedPage, { target: document.body });
9- console.log("[dumber] BlockedPage component mounted successfully", app);
10-} catch (error) {
11- console.error("[dumber] Failed to mount BlockedPage component:", error);
12-}
@@ -1,12 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import BlockedPage from "./BlockedPage.svelte";
4-
5-// Mount the BlockedPage component to the DOM
6-console.log("[dumber] Mounting BlockedPage component to document.body");
7-try {
8- const app = mount(BlockedPage, { target: document.body });
9- console.log("[dumber] BlockedPage component mounted successfully", app);
10-} catch (error) {
11- console.error("[dumber] Failed to mount BlockedPage component:", error);
12-}
A webui/src/pages/error.ts +11 -0
@@ -0,0 +1,11 @@
1+import { mount } from "svelte";
2+import "../styles/app.css";
3+import ErrorPage from "./ErrorPage.svelte";
4+
5+console.log("[dumber] Mounting ErrorPage component");
6+try {
7+ const app = mount(ErrorPage, { target: document.body });
8+ console.log("[dumber] ErrorPage mounted successfully", app);
9+} catch (error) {
10+ console.error("[dumber] Failed to mount ErrorPage:", error);
11+}
@@ -0,0 +1,11 @@
1+import { mount } from "svelte";
2+import "../styles/app.css";
3+import ErrorPage from "./ErrorPage.svelte";
4+
5+console.log("[dumber] Mounting ErrorPage component");
6+try {
7+ const app = mount(ErrorPage, { target: document.body });
8+ console.log("[dumber] ErrorPage mounted successfully", app);
9+} catch (error) {
10+ console.error("[dumber] Failed to mount ErrorPage:", error);
11+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close