diff --git a/src/options.html b/src/options.html index 9db3f24..50990c4 100644 --- a/src/options.html +++ b/src/options.html @@ -935,6 +935,31 @@ font-size: 14px; } + .gn-conn-error { + padding: 48px 22px; + text-align: center; + color: var(--text-muted); + } + .gn-conn-error-icon { + font-size: 32px; + opacity: 0.4; + margin-bottom: 8px; + } + .gn-conn-error-title { + font-size: 15px; + font-weight: 600; + color: var(--text-secondary); + word-break: break-word; + } + .gn-conn-error-hint { + font-size: 13px; + margin-top: 6px; + line-height: 1.4; + } + .gn-conn-error .gn-conn-retry { + margin-top: 18px; + } + /* ── View Toggle ── */ .view-toggle { display: flex; diff --git a/src/options.ts b/src/options.ts index 4b1fcef..59773fd 100644 --- a/src/options.ts +++ b/src/options.ts @@ -21,6 +21,7 @@ import { DraftDesired, PlanItem, } from './utils/timelogDrafts'; +import { isConnectionError, renderConnectionError } from './utils/connectionError'; interface WeeklyTimelog { issueIid: number; @@ -2215,7 +2216,15 @@ async function loadMonth() { $('weekLabelTotal').textContent = formatDuration(monthTotal); renderCurrentView(); } catch (err: any) { - content.innerHTML = `
${escapeHtml(err.message)}
`; + if (isConnectionError(err)) { + renderConnectionError(content, { + url: gitlabUrl, + variant: 'options', + onRetry: loadMonth, + }); + } else { + content.innerHTML = `
${escapeHtml(err.message)}
`; + } } } @@ -2690,7 +2699,15 @@ async function loadWeek() { $('weekLabelTotal').textContent = formatDuration(weekTotal); renderCurrentView(); } catch (err: any) { - content.innerHTML = `
${escapeHtml(err.message)}
`; + if (isConnectionError(err)) { + renderConnectionError(content, { + url: gitlabUrl, + variant: 'options', + onRetry: loadWeek, + }); + } else { + content.innerHTML = `
${escapeHtml(err.message)}
`; + } } } diff --git a/src/popup.html b/src/popup.html index 3e4abe5..711acd5 100644 --- a/src/popup.html +++ b/src/popup.html @@ -606,6 +606,31 @@ font-weight: 500; } + .gn-conn-error { + padding: 32px 18px; + text-align: center; + color: var(--text-muted); + } + .gn-conn-error-icon { + font-size: 26px; + margin-bottom: 8px; + opacity: 0.45; + } + .gn-conn-error-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + word-break: break-word; + } + .gn-conn-error-hint { + font-size: 12px; + margin-top: 4px; + line-height: 1.4; + } + .gn-conn-error .gn-conn-retry { + margin-top: 14px; + } + .today-loading { padding: 36px 18px; text-align: center; diff --git a/src/popup.ts b/src/popup.ts index d0a2aa7..623663a 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -1,5 +1,6 @@ import { loadThemeMode, ThemeMode } from './utils/themeManager'; import { ESTIMATE_PRESETS, SPENT_PRESETS } from './utils/constants'; +import { isConnectionError, renderConnectionError } from './utils/connectionError'; export {}; @@ -507,7 +508,15 @@ async function loadToday(dateStr?: string) { const entries = await fetchDayTimelogs(dateStr); renderTodayList(entries); } catch (err: any) { - list.innerHTML = `
${escapeHtml(err.message)}
`; + if (isConnectionError(err)) { + renderConnectionError(list, { + url: tabInfo?.gitlabUrl ?? null, + variant: 'popup', + onRetry: () => loadToday(dateStr), + }); + } else { + list.innerHTML = `
${escapeHtml(err.message)}
`; + } $('todayTotal').textContent = '--'; } finally { refreshBtn.disabled = false; diff --git a/src/utils/connectionError.ts b/src/utils/connectionError.ts new file mode 100644 index 0000000..0f4cdee --- /dev/null +++ b/src/utils/connectionError.ts @@ -0,0 +1,61 @@ +/** + * When the GitLab instance is unreachable (VPN down, host offline, DNS failure) + * `fetch()` rejects with a TypeError whose message is the cryptic "Failed to + * fetch" rather than a normal HTTP error. These helpers detect that case and + * render a human-readable message plus a Retry button, instead of dumping the + * raw browser error onto the user. + */ + +/** True when `err` looks like a network/connection failure (not an HTTP error). */ +export function isConnectionError(err: unknown): boolean { + if (err instanceof TypeError) return true; + const msg = err instanceof Error ? err.message : String(err); + return /failed to fetch|networkerror|network error|load failed|err_/i.test(msg); +} + +/** Best-effort host label ("gitlab.example.com") for display. */ +function hostLabel(url: string | null | undefined): string { + if (!url) return 'your GitLab instance'; + try { + return new URL(url).host; + } catch { + return url; + } +} + +function escapeHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +export interface ConnectionErrorOptions { + /** GitLab base URL — used to show which host could not be reached. */ + url: string | null | undefined; + /** Called when the user clicks Retry. */ + onRetry: () => void; + /** Picks the button class so it matches the host page's styling. */ + variant: 'popup' | 'options'; +} + +/** + * Replaces `container`'s contents with a connection-error panel and wires up + * its Retry button to `onRetry`. + */ +export function renderConnectionError( + container: HTMLElement, + opts: ConnectionErrorOptions +): void { + const host = hostLabel(opts.url); + const btnClass = + opts.variant === 'options' ? 'btn-primary' : 'onboarding-btn-primary'; + container.innerHTML = ` +
+
🔌
+
Could not connect to ${escapeHtml(host)}
+
Make sure it's reachable — check that your VPN is connected.
+ +
`; + const btn = container.querySelector('.gn-conn-retry'); + btn?.addEventListener('click', () => opts.onRetry()); +}