Files
TREK/client/src/components/Admin/GitHubPanel.tsx
T
Maurice abbd2a94ce fix(admin): a failed "Load more" in the desktop release history keeps the list and can be retried
On the desktop admin GitHub panel, when "Load more" failed (for example
GitHub rate limiting), the whole release history was replaced by the
error card for the rest of the session: no list, no Load more button,
no way to retry. Had the user found a way to load again, the next load
would also have skipped the page that failed.

Cause: in its one page per load mode useGithubReleases advanced the
page counter whether the request worked or not and never cleared the
error, and GitHubPanel showed the error card whenever an error was set.
The phone (fillPages mode) already held the page, cleared the error on
the next good load and drew the error inline under its timeline.

Fix: the one page mode now moves the page on only after a good load and
clears the error then, like fillPages. GitHubPanel shows the error card
only when nothing has loaded yet; after that it keeps the timeline,
shows the error as a line above Load more and leaves the button there.
The page walk difference behind fillPages stays as it was.

Red tests:
- FE-COMP-GHREL-HOOK-012 (new, useGithubReleases.test.ts) failed with
  "AssertionError: expected [ 1, 2, 3 ] to deeply equal [ 1, 2, 2 ]".
- FE-ADMIN-GH-017 (new, GitHubPanel.test.tsx) failed with "Unable to
  find an element with the text: /v1\.\d\.0/" after the failed load.

Changed assertions in src/components/Admin/useGithubReleases.test.ts:
- FE-COMP-GHREL-HOOK-004 (renamed from "sets the error and still moves
  the page on" to "sets the error and holds the page"): requested pages
  were [1, 2, 3], now [1, 2, 2].
2026-10-09 19:00:46 +02:00

318 lines
13 KiB
TypeScript

import {
AlertTriangle,
BookOpen,
Bug,
Calendar,
ChevronDown,
ChevronUp,
Coffee,
ExternalLink,
Heart,
History,
Lightbulb,
Loader2,
Tag,
} from 'lucide-react';
import { type ComponentType, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from '../../i18n';
import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../utils/releaseNotes';
import { fs } from '../shared/DialogShell';
import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit';
import { useGithubReleases } from './useGithubReleases';
const REPO = 'liketrek/TREK';
/** The Discord mark, sized and coloured like the lucide glyphs beside it. */
function DiscordIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
interface SupportLink {
href: string;
/** The brand's own colour: the icon and the hover edge take it, nothing else. */
brand: string;
icon: ComponentType<{ size?: number }>;
title: ReactNode;
hint: ReactNode;
}
/** One link to the project's other places: a card like the bookings', its brand only on the icon. */
function SupportCard({ link }: { link: SupportLink }) {
const Icon = link.icon;
return (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="group flex min-w-0 items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-3 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-[color:var(--brand)] hover:shadow-sm"
style={{ '--brand': link.brand } as CSSProperties}
>
<span
className="grid h-10 w-10 flex-none place-items-center rounded-[12px] shadow-sm"
style={{ background: 'color-mix(in srgb, var(--brand) 13%, var(--bg-card))', color: 'var(--brand)' }}
>
<Icon size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13.5, 'body')}>{link.title}</span>
<span className="block truncate text-content-faint" style={fs(11.5)}>{link.hint}</span>
</span>
<ExternalLink size={14} className="flex-none text-content-faint transition-colors group-hover:text-content-muted" />
</a>
);
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t } = useTranslation();
const {
releases, shownReleases: shown, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate,
} = useGithubReleases({ isPrerelease });
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
const renderBody = (body: string) => {
if (!body) return null;
const inlineFormat = (text: string) => {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(
/`(.+?)`/g,
'<code style="font-size:0.9em;padding:1px 5px;border-radius:6px;background:var(--bg-tertiary)">$1</code>'
)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--text-primary);text-decoration:underline;text-underline-offset:2px">${label}</a>`;
});
};
return parseReleaseNotes(body).map((block, idx) => {
if (block.kind === 'ul') {
return (
<ul key={`ul-${idx}`} className="my-1.5 space-y-1 ps-0">
{block.items.map((item, i) => (
<li key={i} className="flex gap-2 leading-relaxed text-content-secondary" style={fs(12.5, 'body')}>
<span className="mt-[0.6em] h-1 w-1 flex-shrink-0 rounded-full bg-content-faint" />
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
</li>
))}
</ul>
);
}
if (block.kind === 'h4') {
return (
<h4 key={idx} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
{block.text}
</h4>
);
}
if (block.kind === 'h3') {
return (
<h3 key={idx} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
{block.text}
</h3>
);
}
return (
<p
key={idx}
className="my-1 leading-relaxed text-content-secondary"
style={fs(12.5, 'body')}
dangerouslySetInnerHTML={{ __html: inlineFormat(block.text) }}
/>
);
});
};
const supportLinks: SupportLink[] = [
{
href: 'https://ko-fi.com/mauriceboe',
brand: '#ff5e5b', // theme-lint-disable: Ko-fi brand colour
icon: Coffee,
title: 'Ko-fi',
hint: t('admin.github.support'),
},
{
href: 'https://buymeacoffee.com/mauriceboe',
brand: '#e0b400', // theme-lint-disable: Buy Me a Coffee brand yellow, deepened so it reads on white
icon: Heart,
title: 'Buy Me a Coffee',
hint: t('admin.github.support'),
},
{
href: 'https://discord.gg/NhZBDSd4qW',
brand: '#5865F2', // theme-lint-disable: Discord brand colour
icon: DiscordIcon,
title: 'Discord',
hint: 'Join the community',
},
{
href: 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml',
brand: 'var(--danger)',
icon: Bug,
title: t('settings.about.reportBug'),
hint: t('settings.about.reportBugHint'),
},
{
href: 'https://github.com/liketrek/TREK/discussions/new?category=feature-requests',
brand: 'var(--warning)',
icon: Lightbulb,
title: t('settings.about.featureRequest'),
hint: t('settings.about.featureRequestHint'),
},
{
href: 'https://github.com/liketrek/TREK/wiki',
brand: 'var(--text-secondary)',
icon: BookOpen,
title: 'Wiki',
hint: t('settings.about.wikiHint'),
},
];
const subtitle = t('admin.github.subtitle').replace('{repo}', REPO);
let releasesCard: ReactNode;
if (loading) {
releasesCard = (
<SettingsCard icon={History} title={t('admin.github.title')} hint={subtitle}>
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-content-faint" />
</div>
</SettingsCard>
);
} else if (error && releases.length === 0) {
releasesCard = <SettingsCard icon={AlertTriangle} tone="danger" title={t('admin.github.error')} hint={error} />;
} else {
releasesCard = (
<SettingsCard
icon={History}
title={t('admin.github.title')}
hint={subtitle}
action={
<a
href={`https://github.com/${REPO}/releases`}
target="_blank"
rel="noopener noreferrer"
className={`${SETTINGS_BUTTON} no-underline`}
style={fs(12.5, 'body')}
>
<ExternalLink size={13} strokeWidth={2.2} />
GitHub
</a>
}
>
{/* Timeline */}
<div className="relative">
{/* Timeline line, from the first dot's middle down to the last one's */}
{shown.length > 1 && <div className="absolute bottom-6 start-[15px] top-4 w-px bg-edge" aria-hidden="true" />}
<div className="flex flex-col gap-5">
{shown.map((release, idx) => {
const isLatest = idx === 0;
const isExpanded = expanded[release.id];
return (
<div key={release.id} className="relative flex gap-3.5">
{/* Timeline dot */}
<span
className={`relative grid h-8 w-8 flex-none place-items-center rounded-[10px] ${
isLatest ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-faint shadow-sm ring-1 ring-edge-faint'
}`}
>
<Tag size={13} strokeWidth={2.2} />
</span>
{/* Release content */}
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-geist font-bold tabular-nums text-content" style={fs(14, 'body')}>{release.tag_name}</span>
{isLatest && <StatusPill tone="success">{t('admin.github.latest')}</StatusPill>}
{release.prerelease && <StatusPill tone="warning">{t('admin.github.prerelease')}</StatusPill>}
</div>
{release.name && release.name !== release.tag_name && (
<p className="m-0 mt-0.5 truncate font-medium text-content-secondary" style={fs(12.5, 'body')}>{release.name}</p>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-content-faint" style={fs(11.5)}>
<span className="inline-flex items-center gap-1 font-geist tabular-nums text-content-faint">
<Calendar size={11} />
{formatDate(release.published_at || release.created_at)}
</span>
{release.author && (
<span className="text-content-faint">
{t('admin.github.by')} {release.author.login}
</span>
)}
</div>
{/* Expandable body */}
{release.body && (
<div className="mt-2">
<button type="button"
onClick={() => toggleExpand(release.id)}
aria-expanded={!!isExpanded}
className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-1 font-semibold text-content-muted transition-colors hover:text-content"
style={fs(11.5, 'body')}
>
{isExpanded ? <ChevronUp size={12} strokeWidth={2.4} /> : <ChevronDown size={12} strokeWidth={2.4} />}
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
</button>
{isExpanded && (
<div className="mt-2.5 min-w-0 overflow-hidden break-words rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
{renderBody(release.body)}
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* A failed "Load more" keeps the timeline and stays retryable */}
{error && (
<p className="m-0 text-center text-content-faint" style={fs(11.5)}>
{t('admin.github.error')}: {error}
</p>
)}
{/* Load more */}
{hasMore && (
<div className="flex justify-center">
<button type="button"
onClick={handleLoadMore}
disabled={loadingMore}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{loadingMore ? <Loader2 size={13} className="animate-spin" /> : <ChevronDown size={13} strokeWidth={2.2} />}
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
</button>
</div>
)}
</SettingsCard>
);
}
return (
<div className="flex flex-col">
{/* Support links: one card, the six places as tiles inside it */}
<SettingsCard icon={Heart} title={t('settings.about')}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{supportLinks.map((link) => <SupportCard key={link.href} link={link} />)}
</div>
</SettingsCard>
{/* Loading / Error / Releases */}
{releasesCard}
</div>
);
}