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:
parent
609de2a946
commit
8937dc5e25
|
|
@ -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} />
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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(
|
||||
Loading…
Reference in New Issue