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:
parent
5e803f4547
commit
f34f03af51
|
|
@ -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));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue