refactor(ts): convert app-wide common components to TSX (B8)

PageLoader, PageTransition, StatusBadge (top-level), MarkdownText (typed
renderInlineMarkdown, isValidElement for keys), and ErrorBoundary (typed class
component: ErrorBoundaryProps/State, getDerivedStateFromError, ErrorInfo;
generic withErrorBoundary<P> HOC). typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:52:47 -05:00
parent 609de2a946
commit 8937dc5e25
5 changed files with 44 additions and 30 deletions

View File

@ -1,27 +1,36 @@
import React from 'react'; import React, { type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react'; import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// ErrorBoundary Component // ErrorBoundary Component
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component { interface ErrorBoundaryProps {
constructor(props) { children?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
componentStack: string | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props); super(props);
this.state = { hasError: false, error: null, componentStack: null }; this.state = { hasError: false, error: null, componentStack: null };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error }; return { hasError: true, error };
} }
componentDidCatch(error, info) { componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', { console.error('ErrorBoundary caught an error:', {
error, error,
componentStack: info?.componentStack, componentStack: info?.componentStack,
}); });
this.setState({ error, componentStack: info?.componentStack }); this.setState({ error, componentStack: info?.componentStack ?? null });
} }
handleReset = () => { handleReset = () => {
@ -100,10 +109,13 @@ class ErrorBoundary extends React.Component {
} }
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
//withErrorBoundary HOC // withErrorBoundary HOC
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
export function withErrorBoundary(Component, displayName = Component.name || 'Component') { export function withErrorBoundary<P extends object>(
function WrappedComponent(props) { Component: React.ComponentType<P>,
displayName: string = Component.displayName || Component.name || 'Component',
) {
function WrappedComponent(props: P) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Component {...props} /> <Component {...props} />

View File

@ -1,8 +1,8 @@
import { Fragment } from 'react'; import { Fragment, isValidElement, type ReactNode } from 'react';
const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g; const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g;
function renderToken(token, key) { function renderToken(token: string, key: string): ReactNode {
if (token.startsWith('**') && token.endsWith('**')) { if (token.startsWith('**') && token.endsWith('**')) {
return ( return (
<strong key={key} className="font-semibold text-foreground"> <strong key={key} className="font-semibold text-foreground">
@ -37,18 +37,19 @@ function renderToken(token, key) {
return token; return token;
} }
export function renderInlineMarkdown(text) { export function renderInlineMarkdown(text: string | null | undefined): ReactNode {
if (!text) return null; if (!text) return null;
const parts = []; const parts: ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
for (const match of text.matchAll(TOKEN_RE)) { for (const match of text.matchAll(TOKEN_RE)) {
if (match.index > lastIndex) { const mi = match.index ?? 0;
parts.push(text.slice(lastIndex, match.index)); if (mi > lastIndex) {
parts.push(text.slice(lastIndex, mi));
} }
parts.push(renderToken(match[0], `md-${match.index}`)); parts.push(renderToken(match[0], `md-${mi}`));
lastIndex = match.index + match[0].length; lastIndex = mi + match[0].length;
} }
if (lastIndex < text.length) { if (lastIndex < text.length) {
@ -56,12 +57,12 @@ export function renderInlineMarkdown(text) {
} }
return parts.map((part, index) => ( return parts.map((part, index) => (
<Fragment key={typeof part === 'string' ? `text-${index}` : part.key || index}> <Fragment key={isValidElement(part) ? (part.key ?? index) : `text-${index}`}>
{part} {part}
</Fragment> </Fragment>
)); ));
} }
export function MarkdownText({ text }) { export function MarkdownText({ text }: { text?: string | null }): ReactNode {
return renderInlineMarkdown(text); return renderInlineMarkdown(text);
} }

View File

@ -1,6 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import type { ReactNode } from 'react';
export default function PageTransition({ children, routeKey }) { export default function PageTransition({ children, routeKey }: { children: ReactNode; routeKey?: string }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
if (reduceMotion) return children; if (reduceMotion) return children;

View File

@ -12,8 +12,8 @@ const STATUS_META = {
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
}; };
export const StatusBadge = React.memo(function StatusBadge({ status }) { export const StatusBadge = React.memo(function StatusBadge({ status }: { status: string }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); const meta = useMemo(() => STATUS_META[status as keyof typeof STATUS_META] || STATUS_META.upcoming, [status]);
const isUrgent = status === 'late' || status === 'missed'; const isUrgent = status === 'late' || status === 'missed';
return ( return (
<span className={cn( <span className={cn(