refactor(ts): convert content pages to TSX (B13)

PrivacyPage (PrivacyDoc/PrivacySection + Record-typed iconByTitle), NotFoundPage
(typed GlitchDigit rAF loop, HTMLSpanElement ref), AboutPage (UpdateStatus/
AboutInfo + UpdateBadge), ReleaseNotesPage (ReleaseHistoryData, typed markdown
line parser). typecheck 0, lint 0 errors (35 warns), build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 20:32:06 -05:00
parent 5e803f4547
commit f34f03af51
4 changed files with 62 additions and 29 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowLeft, ArrowUpCircle, CheckCircle2, Info, Loader2, Sparkles, AlertCircle } from 'lucide-react'; import { ArrowLeft, ArrowUpCircle, CheckCircle2, Info, Loader2, Sparkles, AlertCircle } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
@ -9,7 +9,22 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
const REPOSITORY_URL = 'https://dream.scheller.ltd/null/BillTracker'; const REPOSITORY_URL = 'https://dream.scheller.ltd/null/BillTracker';
function UpdateBadge({ status, loading }) { interface UpdateStatus {
has_update?: boolean;
latest_release_url?: string | null;
latest_version?: string;
up_to_date?: boolean | null;
error?: string | null;
}
interface AboutInfo {
version?: string;
name?: string;
description?: string;
stack?: { backend?: string; database?: string };
}
function UpdateBadge({ status, loading }: { status: UpdateStatus | null; loading: boolean }) {
if (loading) { if (loading) {
return ( return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground/60"> <span className="inline-flex items-center gap-1 text-xs text-muted-foreground/60">
@ -55,15 +70,15 @@ function UpdateBadge({ status, loading }) {
export default function AboutPage() { export default function AboutPage() {
const { user } = useAuth(); const { user } = useAuth();
const [about, setAbout] = useState(null); const [about, setAbout] = useState<AboutInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [updateStatus, setUpdateStatus] = useState(null); const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [updateLoading, setUpdateLoading] = useState(true); const [updateLoading, setUpdateLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setAbout(await api.about()); setAbout(await api.about() as AboutInfo);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -74,7 +89,7 @@ export default function AboutPage() {
useEffect(() => { useEffect(() => {
setUpdateLoading(true); setUpdateLoading(true);
api.updateStatus() api.updateStatus()
.then(setUpdateStatus) .then(d => setUpdateStatus(d as UpdateStatus))
.catch(() => setUpdateStatus(null)) .catch(() => setUpdateStatus(null))
.finally(() => setUpdateLoading(false)); .finally(() => setUpdateLoading(false));
}, []); }, []);

View File

@ -5,8 +5,8 @@ import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
// Animated digit — cycles through random numbers before settling // Animated digit — cycles through random numbers before settling
function GlitchDigit({ value, delay = 0 }) { function GlitchDigit({ value, delay = 0 }: { value: string; delay?: number }) {
const ref = useRef(null); const ref = useRef<HTMLSpanElement>(null);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
@ -15,20 +15,20 @@ function GlitchDigit({ value, delay = 0 }) {
const frames = 14; const frames = 14;
const digits = '0123456789'; const digits = '0123456789';
let frame = 0; let frame = 0;
let start = null; let start: number | null = null;
function tick(ts) { function tick(ts: number) {
if (!start) start = ts + delay; if (!start) start = ts + delay;
const elapsed = ts - start; const elapsed = ts - start;
if (elapsed < 0) { requestAnimationFrame(tick); return; } if (elapsed < 0) { requestAnimationFrame(tick); return; }
if (frame < frames) { if (frame < frames) {
el.textContent = digits[Math.floor(Math.random() * 10)]; el!.textContent = digits[Math.floor(Math.random() * 10)] ?? '0';
frame++; frame++;
setTimeout(() => requestAnimationFrame(tick), 40 + frame * 6); setTimeout(() => requestAnimationFrame(tick), 40 + frame * 6);
} else { } else {
el.textContent = value; el!.textContent = value;
el.classList.add('settled'); el!.classList.add('settled');
} }
} }

View File

@ -1,15 +1,15 @@
import React, { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
ArrowLeft, BadgeCheck, Database, EyeOff, FileText, Fingerprint, ArrowLeft, BadgeCheck, Database, EyeOff, FileText, Fingerprint,
LockKeyhole, RadioTower, ShieldCheck, UserRoundCog, LockKeyhole, RadioTower, ShieldCheck, UserRoundCog, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const iconByTitle = { const iconByTitle: Record<string, LucideIcon> = {
Overview: ShieldCheck, Overview: ShieldCheck,
'Data Collection': Database, 'Data Collection': Database,
'Version Checking': RadioTower, 'Version Checking': RadioTower,
@ -21,15 +21,27 @@ const iconByTitle = {
Contact: Fingerprint, Contact: Fingerprint,
}; };
interface PrivacySection {
title: string;
items: string[];
}
interface PrivacyDoc {
name?: string;
summary?: string;
last_updated?: string;
sections?: PrivacySection[];
}
export default function PrivacyPage() { export default function PrivacyPage() {
const { user } = useAuth(); const { user } = useAuth();
const [privacy, setPrivacy] = useState(null); const [privacy, setPrivacy] = useState<PrivacyDoc | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setPrivacy(await api.privacy()); setPrivacy(await api.privacy() as PrivacyDoc);
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -1,26 +1,32 @@
import React, { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowLeft, RefreshCw } from 'lucide-react'; import { ArrowLeft, RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MarkdownText } from '@/components/MarkdownText'; import { MarkdownText } from '@/components/MarkdownText';
function formatDateTime(value) { interface ReleaseHistoryData {
history?: string;
version?: string;
updated_at?: string;
}
function formatDateTime(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(); return date.toLocaleString();
} }
function parseImageLine(line) { function parseImageLine(line: string): { alt: string; src: string } | null {
const match = line.match(/^!\[([^\]\n]*)\]\(([^)\s]+)\)$/); const match = line.match(/^!\[([^\]\n]*)\]\(([^)\s]+)\)$/);
if (!match) return null; if (!match) return null;
return { alt: match[1], src: match[2] }; return { alt: match[1] ?? '', src: match[2] ?? '' };
} }
function HistoryLine({ line, index }) { function HistoryLine({ line, index }: { line: string; index: number }) {
const trimmed = line.trim(); const trimmed = line.trim();
const image = parseImageLine(trimmed); const image = parseImageLine(trimmed);
@ -82,18 +88,18 @@ function HistoryLine({ line, index }) {
} }
export default function ReleaseNotesPage() { export default function ReleaseNotesPage() {
const [data, setData] = useState(null); const [data, setData] = useState<ReleaseHistoryData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
setData(await api.releaseHistory()); setData(await api.releaseHistory() as ReleaseHistoryData);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to load release notes.'); setError(errMessage(err, 'Failed to load release notes.'));
toast.error(err.message || 'Failed to load release notes.'); toast.error(errMessage(err, 'Failed to load release notes.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }