ソースを参照

feat(ui): add shared presentation components for redesign

AI-Co-Authored-By: Grok
chendeben 1 ヶ月 前
コミット
cabb55ec46

+ 28 - 0
src/components/common/CodeBlock.tsx

@@ -0,0 +1,28 @@
+import { CopyOutlined } from '@ant-design/icons';
+import { Button, Typography } from 'antd';
+
+export function CodeBlock({
+  value,
+  onCopy
+}: {
+  value: string;
+  onCopy?: (value: string) => void;
+}) {
+  return (
+    <div className="code-block">
+      <Typography.Paragraph code className="code-block__value">
+        {value}
+      </Typography.Paragraph>
+      {onCopy ? (
+        <Button
+          type="text"
+          size="small"
+          icon={<CopyOutlined />}
+          className="code-block__copy"
+          onClick={() => onCopy(value)}
+          aria-label="Copy"
+        />
+      ) : null}
+    </div>
+  );
+}

+ 20 - 0
src/components/common/EmptyState.tsx

@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react';
+import { Empty } from 'antd';
+
+export function EmptyState({
+  description,
+  action,
+  icon
+}: {
+  description: ReactNode;
+  action?: ReactNode;
+  icon?: ReactNode;
+}) {
+  return (
+    <div className="empty-state">
+      <Empty image={icon || Empty.PRESENTED_IMAGE_SIMPLE} description={description}>
+        {action}
+      </Empty>
+    </div>
+  );
+}

+ 28 - 0
src/components/common/MetricCard.tsx

@@ -0,0 +1,28 @@
+import type { ReactNode } from 'react';
+import { Card, Typography } from 'antd';
+
+export function MetricCard({
+  label,
+  value,
+  hint,
+  tone = 'default'
+}: {
+  label: ReactNode;
+  value: ReactNode;
+  hint?: ReactNode;
+  tone?: 'default' | 'warning' | 'danger';
+}) {
+  return (
+    <Card className={`metric-card metric-card--${tone}`}>
+      <Typography.Text type="secondary">{label}</Typography.Text>
+      <Typography.Title level={3} className="metric-value">
+        {value}
+      </Typography.Title>
+      {hint ? (
+        <Typography.Text type="secondary" className="metric-card__hint">
+          {hint}
+        </Typography.Text>
+      ) : null}
+    </Card>
+  );
+}

+ 28 - 0
src/components/common/PageHeader.tsx

@@ -0,0 +1,28 @@
+import type { ReactNode } from 'react';
+import { Typography } from 'antd';
+
+export function PageHeader({
+  title,
+  subtitle,
+  extra
+}: {
+  title: ReactNode;
+  subtitle?: ReactNode;
+  extra?: ReactNode;
+}) {
+  return (
+    <div className="page-header">
+      <div className="page-header__text">
+        <Typography.Title level={3} className="page-header__title">
+          {title}
+        </Typography.Title>
+        {subtitle ? (
+          <Typography.Text type="secondary" className="page-header__subtitle">
+            {subtitle}
+          </Typography.Text>
+        ) : null}
+      </div>
+      {extra ? <div className="page-header__extra">{extra}</div> : null}
+    </div>
+  );
+}

+ 22 - 0
src/components/common/SectionCard.tsx

@@ -0,0 +1,22 @@
+import type { ReactNode } from 'react';
+import { Card } from 'antd';
+
+export function SectionCard({
+  title,
+  extra,
+  children,
+  className
+}: {
+  title?: ReactNode;
+  extra?: ReactNode;
+  children: ReactNode;
+  className?: string;
+}) {
+  const classes = ['section-card', className].filter(Boolean).join(' ');
+
+  return (
+    <Card title={title} extra={extra} className={classes}>
+      {children}
+    </Card>
+  );
+}

+ 20 - 0
src/components/common/StatusPill.tsx

@@ -0,0 +1,20 @@
+import type { ReactNode } from 'react';
+
+export type StatusTone = 'success' | 'warning' | 'error' | 'info' | 'neutral';
+
+export function StatusPill({
+  tone = 'neutral',
+  icon,
+  children
+}: {
+  tone?: StatusTone;
+  icon?: ReactNode;
+  children: ReactNode;
+}) {
+  return (
+    <span className={`status-pill status-pill--${tone}`}>
+      {icon}
+      <span>{children}</span>
+    </span>
+  );
+}

+ 17 - 2
src/components/common/StatusTag.tsx

@@ -1,7 +1,8 @@
-import { Badge, Tag } from 'antd';
+import { Badge } from 'antd';
 
 import { getRecordStatusMeta } from '../../frontend/domain-model.js';
 import { useI18n } from '../../frontend/i18n/react';
+import { StatusPill, type StatusTone } from './StatusPill';
 
 interface StatusTagProps {
   status?: string;
@@ -10,6 +11,20 @@ interface StatusTagProps {
   mode?: 'tag' | 'badge';
 }
 
+function colorToTone(color: string): StatusTone {
+  switch (color) {
+    case 'success':
+      return 'success';
+    case 'warning':
+    case 'processing':
+      return 'warning';
+    case 'error':
+      return 'error';
+    default:
+      return 'neutral';
+  }
+}
+
 export function StatusTag({ status, record, label, mode = 'tag' }: StatusTagProps) {
   const { t } = useI18n();
   const meta = getRecordStatusMeta(record || { status });
@@ -19,5 +34,5 @@ export function StatusTag({ status, record, label, mode = 'tag' }: StatusTagProp
     return <Badge status={meta.color === 'default' ? 'default' : meta.color} text={text} />;
   }
 
-  return <Tag color={meta.color}>{text}</Tag>;
+  return <StatusPill tone={colorToTone(meta.color)}>{text}</StatusPill>;
 }

+ 116 - 0
src/frontend/styles.css

@@ -570,6 +570,122 @@ body {
   text-transform: uppercase;
 }
 
+/* Shared presentation components */
+
+.page-header {
+  align-items: flex-start;
+  display: flex;
+  gap: 16px;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+
+.page-header__text {
+  display: grid;
+  gap: 4px;
+  min-width: 0;
+}
+
+.page-header__title {
+  margin: 0 !important;
+}
+
+.page-header__subtitle {
+  display: block;
+}
+
+.page-header__extra {
+  align-items: center;
+  display: flex;
+  flex-shrink: 0;
+  gap: 8px;
+}
+
+.metric-card--warning .metric-value {
+  color: var(--mh-warning);
+}
+
+.metric-card--danger .metric-value {
+  color: var(--mh-danger);
+}
+
+.metric-card__hint {
+  display: block;
+  margin-top: 4px;
+}
+
+.section-card {
+  border-radius: var(--mh-radius-card);
+}
+
+.status-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  border-radius: 999px;
+  padding: 3px 10px;
+  font-size: 12px;
+  font-weight: 600;
+  border: 1px solid transparent;
+}
+
+.status-pill--success {
+  background: #ecfdf5;
+  color: #15803d;
+  border-color: #bbf7d0;
+}
+
+.status-pill--warning {
+  background: #fffbeb;
+  color: #b45309;
+  border-color: #fde68a;
+}
+
+.status-pill--error {
+  background: #fef2f2;
+  color: #b91c1c;
+  border-color: #fecaca;
+}
+
+.status-pill--info {
+  background: var(--mh-primary-soft);
+  color: #4338ca;
+  border-color: #c7d2fe;
+}
+
+.status-pill--neutral {
+  background: #f8fafc;
+  color: #475569;
+  border-color: var(--mh-border);
+}
+
+.empty-state {
+  padding: 24px 12px;
+}
+
+.code-block {
+  align-items: flex-start;
+  background: #f8fafc;
+  border: 1px solid var(--mh-border);
+  border-radius: 10px;
+  display: flex;
+  gap: 8px;
+  padding: 10px 12px;
+}
+
+.code-block__value {
+  flex: 1;
+  margin: 0 !important;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.code-block__copy {
+  flex-shrink: 0;
+}
+
 @media (max-width: 900px) {
   .admin-header,
   .page-toolbar,