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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// ────────────────────────────────────────────────────────────────────────────
// ErrorBoundary Component
// ────────────────────────────────────────────────────────────────────────────
class ErrorBoundary extends React.Component {
constructor(props) {
interface ErrorBoundaryProps {
children?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
componentStack: string | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, componentStack: null };
}
static getDerivedStateFromError(error) {
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error, info) {
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', {
error,
componentStack: info?.componentStack,
});
this.setState({ error, componentStack: info?.componentStack });
this.setState({ error, componentStack: info?.componentStack ?? null });
}
handleReset = () => {
@ -102,8 +111,11 @@ class ErrorBoundary extends React.Component {
// ────────────────────────────────────────────────────────────────────────────
// withErrorBoundary HOC
// ────────────────────────────────────────────────────────────────────────────
export function withErrorBoundary(Component, displayName = Component.name || 'Component') {
function WrappedComponent(props) {
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
displayName: string = Component.displayName || Component.name || 'Component',
) {
function WrappedComponent(props: P) {
return (
<ErrorBoundary>
<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;
function renderToken(token, key) {
function renderToken(token: string, key: string): ReactNode {
if (token.startsWith('**') && token.endsWith('**')) {
return (
<strong key={key} className="font-semibold text-foreground">
@ -37,18 +37,19 @@ function renderToken(token, key) {
return token;
}
export function renderInlineMarkdown(text) {
export function renderInlineMarkdown(text: string | null | undefined): ReactNode {
if (!text) return null;
const parts = [];
const parts: ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(TOKEN_RE)) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
const mi = match.index ?? 0;
if (mi > lastIndex) {
parts.push(text.slice(lastIndex, mi));
}
parts.push(renderToken(match[0], `md-${match.index}`));
lastIndex = match.index + match[0].length;
parts.push(renderToken(match[0], `md-${mi}`));
lastIndex = mi + match[0].length;
}
if (lastIndex < text.length) {
@ -56,12 +57,12 @@ export function renderInlineMarkdown(text) {
}
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}
</Fragment>
));
}
export function MarkdownText({ text }) {
export function MarkdownText({ text }: { text?: string | null }): ReactNode {
return renderInlineMarkdown(text);
}

View File

@ -1,6 +1,7 @@
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();
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' },
};
export const StatusBadge = React.memo(function StatusBadge({ status }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
export const StatusBadge = React.memo(function StatusBadge({ status }: { status: string }) {
const meta = useMemo(() => STATUS_META[status as keyof typeof STATUS_META] || STATUS_META.upcoming, [status]);
const isUrgent = status === 'late' || status === 'missed';
return (
<span className={cn(