import { AlertTriangle, BookOpen, Bug, Calendar, ChevronDown, ChevronUp, Coffee, ExternalLink, Heart, History, Lightbulb, Loader2, Tag, } from 'lucide-react'; import { useEffect, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react'; import apiClient from '../../api/client'; import { getLocaleForLanguage, useTranslation } from '../../i18n'; import { fs } from '../shared/DialogShell'; import { SETTINGS_BUTTON, SettingsCard, StatusPill } from '../Settings/settingsKit'; const REPO = 'liketrek/TREK'; const PER_PAGE = 10; interface GithubRelease { id: number; prerelease: boolean; tag_name: string; name: string | null; body: string | null; published_at: string | null; created_at: string; author: { login: string } | null; [key: string]: unknown; } /** The Discord mark, sized and coloured like the lucide glyphs beside it. */ function DiscordIcon({ size = 18 }: { size?: number }) { return ( ); } 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 ( {link.title} {link.hint} ); } export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { const { t, language } = useTranslation(); const [releases, setReleases] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>({}); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const fetchReleases = async (pageNum = 1, append = false) => { try { const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } }); const data = Array.isArray(res.data) ? res.data : []; setReleases((prev) => (append ? [...prev, ...data] : data)); setHasMore(data.length === PER_PAGE); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Unknown error'); } }; useEffect(() => { setLoading(true); void fetchReleases(1).finally(() => setLoading(false)); }, []); const handleLoadMore = async () => { const next = page + 1; setLoadingMore(true); await fetchReleases(next, true); setPage(next); setLoadingMore(false); }; const toggleExpand = (id: number) => { setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); }; const formatDate = (dateStr: string) => { const d = new Date(dateStr); return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' }); }; // Simple markdown-to-html for release notes (handles headers, bold, lists, links) const renderBody = (body: string) => { if (!body) return null; const lines = body.split('\n'); const elements: ReactNode[] = []; let listItems: string[] = []; const flushList = () => { if (listItems.length > 0) { elements.push(
    {listItems.map((item, i) => (
  • ))}
); listItems = []; } }; const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const inlineFormat = (text: string) => { return escapeHtml(text) .replace(/\*\*(.+?)\*\*/g, '$1') .replace( /`(.+?)`/g, '$1' ) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'; return `${label}`; }); }; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { flushList(); continue; } if (trimmed.startsWith('### ')) { flushList(); elements.push(

{trimmed.slice(4)}

); } else if (trimmed.startsWith('## ')) { flushList(); elements.push(

{trimmed.slice(3)}

); } else if (/^[-*] /.test(trimmed)) { listItems.push(trimmed.slice(2)); } else { flushList(); elements.push(

); } } flushList(); return elements; }; 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 = (

); } else if (error) { releasesCard = ; } else { const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease); releasesCard = ( GitHub } > {/* Timeline */}
{/* Timeline line, from the first dot's middle down to the last one's */} {shown.length > 1 && {/* Load more */} {hasMore && (
)} ); } return (
{/* Support links: one card, the six places as tiles inside it */}
{supportLinks.map((link) => )}
{/* Loading / Error / Releases */} {releasesCard}
); }