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 { 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 = () => {
|
||||||
|
|
@ -102,8 +111,11 @@ 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} />
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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(
|
||||||
Loading…
Reference in New Issue