mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(admin): share the GitHub release history between desktop and phone
useGithubReleases holds the paged fetch, the prerelease filter, the expanded rows and the date format, and parseReleaseNotes splits a release body into the blocks both panels draw. The phone keeps its page walk past hidden prereleases through the fillPages option, its repository link and its own inline link scan; the desktop keeps one page per load.
This commit is contained in:
@@ -13,26 +13,14 @@ import {
|
||||
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 { 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';
|
||||
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 }) {
|
||||
@@ -79,73 +67,15 @@ function SupportCard({ link }: { link: SupportLink }) {
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
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' });
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
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 lines = body.split('\n');
|
||||
const elements: ReactNode[] = [];
|
||||
let listItems: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-1.5 space-y-1 ps-0">
|
||||
{listItems.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>
|
||||
);
|
||||
listItems = [];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const inlineFormat = (text: string) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
@@ -159,43 +89,42 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
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 (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="mb-1 mt-3 font-geist font-bold uppercase tracking-[.08em] text-content-faint first:mt-0" style={fs(10)}>
|
||||
{trimmed.slice(4)}
|
||||
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>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 font-semibold text-content first:mt-0" style={fs(13, 'body')}>
|
||||
{trimmed.slice(3)}
|
||||
}
|
||||
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>
|
||||
);
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
} else {
|
||||
flushList();
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 leading-relaxed text-content-secondary"
|
||||
style={fs(12.5, 'body')}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
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[] = [
|
||||
@@ -257,7 +186,6 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
} else if (error) {
|
||||
releasesCard = <SettingsCard icon={AlertTriangle} tone="danger" title={t('admin.github.error')} hint={error} />;
|
||||
} else {
|
||||
const shown = isPrerelease ? releases : releases.filter((r) => !r.prerelease);
|
||||
releasesCard = (
|
||||
<SettingsCard
|
||||
icon={History}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// FE-COMP-GHREL-HOOK-001 to -010: the release history behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import apiClient from '../../api/client';
|
||||
import { type GithubRelease, useGithubReleases } from './useGithubReleases';
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => ({ language: 'de' }),
|
||||
getLocaleForLanguage: (language: string) => (language === 'de' ? 'de-DE' : 'en-US'),
|
||||
}));
|
||||
|
||||
function release(id: number, prerelease = false): GithubRelease {
|
||||
return {
|
||||
id,
|
||||
prerelease,
|
||||
tag_name: `v${id}`,
|
||||
name: null,
|
||||
body: null,
|
||||
published_at: null,
|
||||
created_at: '2026-01-05T10:00:00Z',
|
||||
author: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A full page of ten, numbered from `from`. */
|
||||
function fullPage(from: number, prerelease = false) {
|
||||
return Array.from({ length: 10 }, (_, i) => release(from + i, prerelease));
|
||||
}
|
||||
|
||||
/** Answers each page number from `pages`; a page holding an Error rejects with it. */
|
||||
function serve(pages: Record<number, GithubRelease[] | Error>) {
|
||||
return vi
|
||||
.spyOn(apiClient, 'get')
|
||||
.mockImplementation(async (_url: string, config?: { params?: { page?: number } }) => {
|
||||
const answer = pages[config?.params?.page ?? 1] ?? [];
|
||||
if (answer instanceof Error) throw answer;
|
||||
return { data: answer };
|
||||
});
|
||||
}
|
||||
|
||||
function requestedPages(spy: ReturnType<typeof serve>) {
|
||||
return spy.mock.calls.map((call) => (call[1] as { params: { page: number } }).params.page);
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useGithubReleases, one page per load', () => {
|
||||
it('FE-COMP-GHREL-HOOK-001: loads the first page and hides prereleases from a stable install', async () => {
|
||||
const spy = serve({ 1: [release(1, true), release(2)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(spy).toHaveBeenCalledWith('/admin/github-releases', { params: { per_page: 10, page: 1 } });
|
||||
expect(result.current.releases.map((r) => r.id)).toEqual([1, 2]);
|
||||
expect(result.current.shownReleases.map((r) => r.id)).toEqual([2]);
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-002: a prerelease install shows every release', async () => {
|
||||
serve({ 1: [release(1, true), release(2)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: true }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.shownReleases.map((r) => r.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-003: load more appends the next page and keeps going while pages are full', async () => {
|
||||
const spy = serve({ 1: fullPage(1), 2: [release(11)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
await waitFor(() => expect(result.current.hasMore).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.handleLoadMore();
|
||||
});
|
||||
expect(requestedPages(spy)).toEqual([1, 2]);
|
||||
expect(result.current.releases).toHaveLength(11);
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
expect(result.current.loadingMore).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-004: a failed load more sets the error and still moves the page on', async () => {
|
||||
const spy = serve({ 1: fullPage(1), 2: new Error('rate limited'), 3: [release(21)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.handleLoadMore();
|
||||
});
|
||||
expect(result.current.error).toBe('rate limited');
|
||||
await act(async () => {
|
||||
await result.current.handleLoadMore();
|
||||
});
|
||||
expect(requestedPages(spy)).toEqual([1, 2, 3]);
|
||||
expect(result.current.error).toBe('rate limited');
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-005: a first page of only prereleases is all a stable install gets', async () => {
|
||||
const spy = serve({ 1: fullPage(1, true), 2: [release(11)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(requestedPages(spy)).toEqual([1]);
|
||||
expect(result.current.shownReleases).toEqual([]);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGithubReleases, filling pages', () => {
|
||||
it('FE-COMP-GHREL-HOOK-006: walks past pages of hidden prereleases until one release shows', async () => {
|
||||
const spy = serve({
|
||||
1: fullPage(1, true),
|
||||
2: fullPage(11, true),
|
||||
3: [release(21), ...fullPage(22, true).slice(1)],
|
||||
});
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(requestedPages(spy)).toEqual([1, 2, 3]);
|
||||
expect(result.current.releases).toHaveLength(30);
|
||||
expect(result.current.shownReleases.map((r) => r.id)).toEqual([21]);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-007: stops after five pages in one load', async () => {
|
||||
const pages: Record<number, GithubRelease[]> = {};
|
||||
for (let p = 1; p <= 6; p++) pages[p] = fullPage(p * 10, true);
|
||||
const spy = serve(pages);
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(requestedPages(spy)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(result.current.releases).toHaveLength(50);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-008: a failed load more keeps the list, holds the page, and the retry clears the error', async () => {
|
||||
let failPage2 = true;
|
||||
const spy = vi
|
||||
.spyOn(apiClient, 'get')
|
||||
.mockImplementation(async (_url: string, config?: { params?: { page?: number } }) => {
|
||||
const page = config?.params?.page;
|
||||
if (page === 1) return { data: fullPage(1) };
|
||||
if (failPage2) throw new Error('offline');
|
||||
return { data: [release(11)] };
|
||||
});
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false, fillPages: true }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.handleLoadMore();
|
||||
});
|
||||
expect(result.current.error).toBe('offline');
|
||||
expect(result.current.releases).toHaveLength(10);
|
||||
failPage2 = false;
|
||||
await act(async () => {
|
||||
await result.current.handleLoadMore();
|
||||
});
|
||||
expect(requestedPages(spy)).toEqual([1, 2, 2]);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.releases).toHaveLength(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGithubReleases, display helpers', () => {
|
||||
it('FE-COMP-GHREL-HOOK-009: toggleExpand flips one release at a time', async () => {
|
||||
serve({ 1: [release(1)] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.toggleExpand(1));
|
||||
expect(result.current.expanded).toEqual({ 1: true });
|
||||
act(() => result.current.toggleExpand(1));
|
||||
expect(result.current.expanded).toEqual({ 1: false });
|
||||
});
|
||||
|
||||
it('FE-COMP-GHREL-HOOK-010: formatDate writes the date in the locale of the UI language', async () => {
|
||||
serve({ 1: [] });
|
||||
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.formatDate('2026-01-05T10:00:00Z')).toBe(
|
||||
new Date('2026-01-05T10:00:00Z').toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
|
||||
const PER_PAGE = 10;
|
||||
const MAX_PAGES_PER_LOAD = 5;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
interface UseGithubReleasesOptions {
|
||||
/** Whether this install runs a prerelease: prereleases only show to those. */
|
||||
isPrerelease: boolean;
|
||||
/**
|
||||
* Keep pulling pages until at least one release survives the prerelease filter, up to
|
||||
* MAX_PAGES_PER_LOAD per load. A failed page then keeps what is already shown and
|
||||
* leaves the page counter where it was, and the next good load clears the error.
|
||||
* Without it each load reads exactly one page and the page counter moves on regardless.
|
||||
*/
|
||||
fillPages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The GitHub release history behind both admin shells (the desktop panel and the phone
|
||||
* panel render their own timeline over it): the paged fetch from the server's proxy,
|
||||
* which releases show, which ones are expanded, and their dates in the user's locale.
|
||||
*/
|
||||
export function useGithubReleases({ isPrerelease, fillPages = false }: UseGithubReleasesOptions) {
|
||||
const { language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const isShown = (release: GithubRelease) => isPrerelease || !release.prerelease;
|
||||
|
||||
const fetchPage = async (pageNum: number) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
|
||||
return Array.isArray(res.data) ? (res.data as GithubRelease[]) : [];
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
const data = await fetchPage(pageNum);
|
||||
if (!data) return;
|
||||
setReleases((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(data.length === PER_PAGE);
|
||||
};
|
||||
|
||||
// Keep pulling pages until at least one release survives the prerelease filter,
|
||||
// otherwise a page of nothing but prereleases leaves an empty timeline behind a
|
||||
// "Load more" button. MAX_PAGES_PER_LOAD bounds the walk.
|
||||
const loadFrom = async (startPage: number, append: boolean) => {
|
||||
const collected: GithubRelease[] = [];
|
||||
let pageNum = startPage;
|
||||
let more = true;
|
||||
|
||||
for (let i = 0; i < MAX_PAGES_PER_LOAD; i++) {
|
||||
const data = await fetchPage(pageNum);
|
||||
if (!data) return;
|
||||
collected.push(...data);
|
||||
more = data.length === PER_PAGE;
|
||||
if (!more || collected.some(isShown)) break;
|
||||
pageNum += 1;
|
||||
}
|
||||
|
||||
setReleases((prev) => (append ? [...prev, ...collected] : collected));
|
||||
setHasMore(more);
|
||||
setPage(pageNum);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void (fillPages ? loadFrom(1, false) : fetchReleases(1)).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (fillPages) {
|
||||
setLoadingMore(true);
|
||||
await loadFrom(page + 1, true);
|
||||
setLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
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' });
|
||||
};
|
||||
|
||||
return {
|
||||
releases,
|
||||
shownReleases: releases.filter(isShown),
|
||||
loading,
|
||||
error,
|
||||
expanded,
|
||||
toggleExpand,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
handleLoadMore,
|
||||
formatDate,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,11 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../../i18n'
|
||||
import apiClient from '../../../api/client'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useGithubReleases } from '../../../components/Admin/useGithubReleases'
|
||||
import { escapeReleaseHtml as escapeHtml, parseReleaseNotes } from '../../../utils/releaseNotes'
|
||||
import { MAdminButton, MAdminCard } from './MAdminUi'
|
||||
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
const MAX_PAGES_PER_LOAD = 5
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Support / community link cards (design: brand-tinted icon tile + title/sub).
|
||||
// Brand hex stays as content identity; every surface/border uses --m-* tokens.
|
||||
@@ -31,94 +18,15 @@ interface LinkCard {
|
||||
}
|
||||
|
||||
export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const isShown = (release: GithubRelease) => isPrerelease || !release.prerelease
|
||||
|
||||
const fetchPage = async (pageNum: number) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
return Array.isArray(res.data) ? res.data as GithubRelease[] : []
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Keep pulling pages until at least one release survives the prerelease filter,
|
||||
// otherwise a page of nothing but prereleases leaves an empty timeline behind a
|
||||
// "Load more" button. MAX_PAGES_PER_LOAD bounds the walk.
|
||||
const loadFrom = async (startPage: number, append: boolean) => {
|
||||
const collected: GithubRelease[] = []
|
||||
let pageNum = startPage
|
||||
let more = true
|
||||
|
||||
for (let i = 0; i < MAX_PAGES_PER_LOAD; i++) {
|
||||
const data = await fetchPage(pageNum)
|
||||
if (!data) return
|
||||
collected.push(...data)
|
||||
more = data.length === PER_PAGE
|
||||
if (!more || collected.some(isShown)) break
|
||||
pageNum += 1
|
||||
}
|
||||
|
||||
setReleases(prev => append ? [...prev, ...collected] : collected)
|
||||
setHasMore(more)
|
||||
setPage(pageNum)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
void loadFrom(1, false).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true)
|
||||
await loadFrom(page + 1, true)
|
||||
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' })
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
releases, shownReleases, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate,
|
||||
} = useGithubReleases({ isPrerelease, fillPages: true })
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body: string | null) => {
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements: ReactNode[] = []
|
||||
let listItems: string[] = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-m-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--m-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
// `[label](url)` → anchor, exactly as /\[([^\]]+)\]\(([^)]+)\)/g did. Written as a
|
||||
// scan because that pattern backtracks from both ends: `[^\]]` matches `[` and
|
||||
// `[^)]` matches `(`, so a run of either made the engine retry the whole match from
|
||||
@@ -162,37 +70,39 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
)
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-m-ink">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1 text-m-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
return parseReleaseNotes(body).map((block, idx) => {
|
||||
if (block.kind === 'ul') {
|
||||
return (
|
||||
<ul key={`ul-${idx}`} className="space-y-1 my-2">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-m-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--m-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList()
|
||||
return elements
|
||||
if (block.kind === 'h4') {
|
||||
return (
|
||||
<h4 key={idx} className="text-xs font-semibold mt-3 mb-1 text-m-ink">
|
||||
{block.text}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
if (block.kind === 'h3') {
|
||||
return (
|
||||
<h3 key={idx} className="text-sm font-semibold mt-3 mb-1 text-m-ink">
|
||||
{block.text}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p key={idx} className="text-xs my-1 text-m-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(block.text) }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const discordIcon = (
|
||||
@@ -208,8 +118,6 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea
|
||||
{ href: 'https://github.com/mauriceboe/TREK/wiki', color: '#6366f1', icon: <BookOpen size={18} className="text-[#6366f1]" />, title: 'Wiki', sub: t('settings.about.wikiHint') },
|
||||
]
|
||||
|
||||
const shownReleases = releases.filter(isShown)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support / community cards */}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// FE-UTIL-RELNOTES-001 to -005: the release body parser both admin shells render from.
|
||||
import { escapeReleaseHtml, parseReleaseNotes } from './releaseNotes';
|
||||
|
||||
describe('parseReleaseNotes', () => {
|
||||
it('FE-UTIL-RELNOTES-001: reads headings, paragraphs and list items', () => {
|
||||
expect(parseReleaseNotes('## Release\n### Fixed\nSome text\n- one\n* two')).toEqual([
|
||||
{ kind: 'h3', text: 'Release' },
|
||||
{ kind: 'h4', text: 'Fixed' },
|
||||
{ kind: 'p', text: 'Some text' },
|
||||
{ kind: 'ul', items: ['one', 'two'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-002: a blank line or any other block ends a list', () => {
|
||||
expect(parseReleaseNotes('- a\n\n- b\nplain\n- c\n### H\n- d')).toEqual([
|
||||
{ kind: 'ul', items: ['a'] },
|
||||
{ kind: 'ul', items: ['b'] },
|
||||
{ kind: 'p', text: 'plain' },
|
||||
{ kind: 'ul', items: ['c'] },
|
||||
{ kind: 'h4', text: 'H' },
|
||||
{ kind: 'ul', items: ['d'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-003: lines are trimmed and markers need their space', () => {
|
||||
expect(parseReleaseNotes(' ## Spaced \n-no space\n#### deep\n\n\n')).toEqual([
|
||||
{ kind: 'h3', text: 'Spaced' },
|
||||
{ kind: 'p', text: '-no space' },
|
||||
{ kind: 'p', text: '#### deep' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELNOTES-004: an empty body has no blocks', () => {
|
||||
expect(parseReleaseNotes('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeReleaseHtml', () => {
|
||||
it('FE-UTIL-RELNOTES-005: escapes ampersands, angle brackets and double quotes only', () => {
|
||||
expect(escapeReleaseHtml(`<a href="x">Tom & Jerry's</a>`)).toBe(
|
||||
"<a href="x">Tom & Jerry's</a>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/** One block of a release body: a heading, a paragraph, or a run of list items. */
|
||||
export type ReleaseNoteBlock = { kind: 'h3' | 'h4' | 'p'; text: string } | { kind: 'ul'; items: string[] };
|
||||
|
||||
/** Escapes the characters that would let release text break out of the HTML it is placed in. */
|
||||
export function escapeReleaseHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a GitHub release body into the blocks both admin shells render: `### ` and `## `
|
||||
* headings, `- ` or `* ` list items gathered into one list until anything else breaks the
|
||||
* run, and every other non-empty line as a paragraph. Blank lines only end a list. The
|
||||
* text stays raw; each shell formats it inline itself.
|
||||
*/
|
||||
export function parseReleaseNotes(body: string): ReleaseNoteBlock[] {
|
||||
const blocks: ReleaseNoteBlock[] = [];
|
||||
let listItems: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
blocks.push({ kind: 'ul', items: listItems });
|
||||
listItems = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of body.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h4', text: trimmed.slice(4) });
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
blocks.push({ kind: 'h3', text: trimmed.slice(3) });
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
} else {
|
||||
flushList();
|
||||
blocks.push({ kind: 'p', text: trimmed });
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return blocks;
|
||||
}
|
||||
Reference in New Issue
Block a user