Initial import
This commit is contained in:
44
src/widgets/api-sample-card/ApiSampleCardWidget.tsx
Executable file
44
src/widgets/api-sample-card/ApiSampleCardWidget.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
import { Space, Tag, Typography } from 'antd';
|
||||
import { forwardRef } from 'react';
|
||||
import { WidgetShell } from '../core';
|
||||
import type { SampleMeta, WidgetHandle } from '../core';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type ApiSampleCardWidgetProps = {
|
||||
sampleMeta: SampleMeta;
|
||||
cardWrapper?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ApiSampleCardWidget = forwardRef<WidgetHandle, ApiSampleCardWidgetProps>(
|
||||
function ApiSampleCardWidget({ sampleMeta, cardWrapper, children }, ref) {
|
||||
return (
|
||||
<WidgetShell
|
||||
ref={ref}
|
||||
id={sampleMeta.id}
|
||||
title={sampleMeta.title}
|
||||
cardWrapper={cardWrapper}
|
||||
features={[
|
||||
'api-sample',
|
||||
'component-sample',
|
||||
'feature-registry',
|
||||
'imperative-handle',
|
||||
...(sampleMeta.features ?? []),
|
||||
]}
|
||||
featureSlot={
|
||||
<Tag icon={<AppstoreOutlined />} color="geekblue">
|
||||
{sampleMeta.category}
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="widget-shell__body">
|
||||
<Paragraph className="widget-shell__description">{sampleMeta.description}</Paragraph>
|
||||
<Text code>{sampleMeta.id}</Text>
|
||||
<div>{children}</div>
|
||||
</Space>
|
||||
</WidgetShell>
|
||||
);
|
||||
},
|
||||
);
|
||||
2
src/widgets/api-sample-card/index.ts
Executable file
2
src/widgets/api-sample-card/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { ApiSampleCardWidget } from './ApiSampleCardWidget';
|
||||
export type { ApiSampleCardWidgetProps } from './ApiSampleCardWidget';
|
||||
61
src/widgets/api-sample-card/samples/Sample.tsx
Executable file
61
src/widgets/api-sample-card/samples/Sample.tsx
Executable file
@@ -0,0 +1,61 @@
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
import type { SampleMeta, SampleRenderProps, WidgetHandle } from '../../core';
|
||||
import { ApiSampleCardWidget } from '../ApiSampleCardWidget';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'api-sample-card-widget',
|
||||
componentId: 'api-sample-card-widget',
|
||||
title: 'API Sample Card Widget',
|
||||
description: 'Wrapper, title, main content, feature tag를 공통 규칙으로 가지는 위젯 샘플입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'base',
|
||||
variantLabel: 'Base',
|
||||
order: 10,
|
||||
features: ['api-sample', 'feature-registry', 'imperative-handle'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
const widgetRef = useRef<WidgetHandle>(null);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} className="widget-shell__body">
|
||||
<Paragraph>
|
||||
이 샘플은 실제 위젯 자체를 보여줍니다. 아래 버튼은{' '}
|
||||
<Text strong>`useImperativeHandle`</Text>로 노출한 메서드를 호출합니다.
|
||||
</Paragraph>
|
||||
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={() => {
|
||||
widgetRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
Widget Focus
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
widgetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}}
|
||||
>
|
||||
Scroll Into View
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ApiSampleCardWidget
|
||||
ref={widgetRef}
|
||||
sampleMeta={sampleMeta}
|
||||
cardWrapper={!disableWidgetCardWrapper}
|
||||
>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text code>GET /api/components/samples</Text>
|
||||
<Text type="secondary">
|
||||
컴포넌트 대표 샘플을 API 게시판 카드 위젯으로 재사용하는 예시입니다.
|
||||
</Text>
|
||||
</Space>
|
||||
</ApiSampleCardWidget>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
66
src/widgets/core/WidgetShell.tsx
Executable file
66
src/widgets/core/WidgetShell.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import { Card, Space, Tag, Typography } from 'antd';
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { resolveWidgetFeatures } from './registry/widget-features';
|
||||
import type { WidgetHandle, WidgetShellProps } from './types/widget';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const WidgetShell = forwardRef<WidgetHandle, WidgetShellProps>(function WidgetShell(
|
||||
{ id, title, features = [], featureSlot, cardWrapper = true, children },
|
||||
ref,
|
||||
) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedFeatures = resolveWidgetFeatures(features);
|
||||
const hasHeader = Boolean(title) || resolvedFeatures.length > 0 || Boolean(featureSlot);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => {
|
||||
wrapperRef.current?.focus();
|
||||
},
|
||||
scrollIntoView: (options) => {
|
||||
wrapperRef.current?.scrollIntoView(options);
|
||||
},
|
||||
getId: () => id,
|
||||
getFeatures: () => features,
|
||||
}),
|
||||
[features, id],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Space direction="vertical" size={16} className="widget-shell__stack">
|
||||
{hasHeader ? (
|
||||
<div className="widget-shell__header">
|
||||
<Title level={4} className="widget-shell__title">
|
||||
{title}
|
||||
</Title>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{resolvedFeatures.map((feature) => (
|
||||
<Tag key={feature.key} color="blue">
|
||||
{feature.label}
|
||||
</Tag>
|
||||
))}
|
||||
{featureSlot}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="widget-shell__content">{children}</div>
|
||||
</Space>
|
||||
);
|
||||
|
||||
if (!cardWrapper) {
|
||||
return (
|
||||
<div ref={wrapperRef} className="widget-shell widget-shell--plain" tabIndex={-1}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card ref={wrapperRef} className="widget-shell" tabIndex={-1}>
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
12
src/widgets/core/index.ts
Executable file
12
src/widgets/core/index.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
export { WidgetShell } from './WidgetShell';
|
||||
export { resolveWidgetFeatures, widgetFeatureRegistry } from './registry/widget-features';
|
||||
export type {
|
||||
SampleMeta,
|
||||
SampleModule,
|
||||
SampleRenderProps,
|
||||
WidgetFeatureDefinition,
|
||||
WidgetFeatureKey,
|
||||
WidgetHandle,
|
||||
WidgetRegistryItem,
|
||||
WidgetShellProps,
|
||||
} from './types/widget';
|
||||
33
src/widgets/core/registry/widget-features.ts
Executable file
33
src/widgets/core/registry/widget-features.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import type { WidgetFeatureDefinition, WidgetFeatureKey } from '../types/widget';
|
||||
|
||||
export const widgetFeatureRegistry: Record<WidgetFeatureKey, WidgetFeatureDefinition> = {
|
||||
'api-sample': {
|
||||
key: 'api-sample',
|
||||
label: 'API Sample',
|
||||
description: 'API 게시판 또는 문서에서 재사용할 수 있는 샘플입니다.',
|
||||
},
|
||||
docs: {
|
||||
key: 'docs',
|
||||
label: 'Docs',
|
||||
description: '컴포넌트 문서와 연결되는 예제입니다.',
|
||||
},
|
||||
'component-sample': {
|
||||
key: 'component-sample',
|
||||
label: 'Component Sample',
|
||||
description: '컴포넌트 단위 동작을 보여주는 샘플입니다.',
|
||||
},
|
||||
'imperative-handle': {
|
||||
key: 'imperative-handle',
|
||||
label: 'Imperative Handle',
|
||||
description: '위젯 제어를 위해 useImperativeHandle을 사용합니다.',
|
||||
},
|
||||
'feature-registry': {
|
||||
key: 'feature-registry',
|
||||
label: 'Feature Registry',
|
||||
description: '공통 feature registry를 통해 위젯 기능을 관리합니다.',
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveWidgetFeatures(featureKeys: WidgetFeatureKey[] = []) {
|
||||
return featureKeys.map((featureKey) => widgetFeatureRegistry[featureKey]);
|
||||
}
|
||||
58
src/widgets/core/types/widget.ts
Executable file
58
src/widgets/core/types/widget.ts
Executable file
@@ -0,0 +1,58 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
export type WidgetFeatureKey =
|
||||
| 'api-sample'
|
||||
| 'docs'
|
||||
| 'component-sample'
|
||||
| 'imperative-handle'
|
||||
| 'feature-registry';
|
||||
|
||||
export type WidgetFeatureDefinition = {
|
||||
key: WidgetFeatureKey;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type WidgetRegistryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
features: WidgetFeatureKey[];
|
||||
};
|
||||
|
||||
export type WidgetHandle = {
|
||||
focus: () => void;
|
||||
scrollIntoView: (options?: ScrollIntoViewOptions) => void;
|
||||
getId: () => string;
|
||||
getFeatures: () => WidgetFeatureKey[];
|
||||
};
|
||||
|
||||
export type WidgetShellProps = {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
features?: WidgetFeatureKey[];
|
||||
featureSlot?: ReactNode;
|
||||
cardWrapper?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type SampleRenderProps = {
|
||||
disableWidgetCardWrapper?: boolean;
|
||||
};
|
||||
|
||||
export type SampleMeta = {
|
||||
id: string;
|
||||
componentId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
kind?: 'base' | 'plugin' | 'feature';
|
||||
variantLabel?: string;
|
||||
order?: number;
|
||||
features?: WidgetFeatureKey[];
|
||||
};
|
||||
|
||||
export type SampleModule = {
|
||||
Sample: ComponentType<SampleRenderProps>;
|
||||
sampleMeta: SampleMeta;
|
||||
};
|
||||
321
src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx
Executable file
321
src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx
Executable file
@@ -0,0 +1,321 @@
|
||||
import { Tag, Typography, Flex } from 'antd';
|
||||
import { forwardRef } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { WidgetShell } from '../core';
|
||||
import type { WidgetHandle } from '../core';
|
||||
import { ProgressUI } from '../../components/dashboard/progress';
|
||||
import { MultiProgressUI } from '../../components/dashboard/multiProgress';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export type ProgressListItem = {
|
||||
label: string;
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type ProgressListGroup = {
|
||||
legend: string;
|
||||
items: ProgressListItem[];
|
||||
};
|
||||
|
||||
export type LineChartPoint = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type MetricItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SegmentedProgressItem = {
|
||||
label: string;
|
||||
percent: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type DashboardReportSection =
|
||||
| {
|
||||
kind: 'progress-list';
|
||||
legend: string;
|
||||
title: string;
|
||||
items: ProgressListItem[];
|
||||
}
|
||||
| {
|
||||
kind: 'grouped-progress-list';
|
||||
legend: string;
|
||||
title: string;
|
||||
groups: [ProgressListGroup, ProgressListGroup];
|
||||
}
|
||||
| {
|
||||
kind: 'line-chart';
|
||||
legend: string;
|
||||
title: string;
|
||||
points: LineChartPoint[];
|
||||
}
|
||||
| {
|
||||
kind: 'metrics';
|
||||
legend: string;
|
||||
title: string;
|
||||
metrics: MetricItem[];
|
||||
}
|
||||
| {
|
||||
kind: 'segmented-progress';
|
||||
legend: string;
|
||||
title: string;
|
||||
segments: SegmentedProgressItem[];
|
||||
}
|
||||
| {
|
||||
kind: 'pie-chart';
|
||||
legend: string;
|
||||
title: string;
|
||||
totalLabel?: string;
|
||||
segments: SegmentedProgressItem[];
|
||||
};
|
||||
|
||||
export type DashboardReportCardWidgetProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
moduleLabel?: string;
|
||||
sections: [DashboardReportSection, DashboardReportSection];
|
||||
sectionLayout?: 'vertical' | 'horizontal';
|
||||
cardWrapper?: boolean;
|
||||
};
|
||||
|
||||
function ProgressListSection({ section }: { section: Extract<DashboardReportSection, { kind: 'progress-list' }> }) {
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
{section.items.map((item) => (
|
||||
<ProgressUI
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
data={{
|
||||
percent: item.percent,
|
||||
color: item.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function LineChartSection({ section }: { section: Extract<DashboardReportSection, { kind: 'line-chart' }> }) {
|
||||
const chartData = section.points.map((point) => ({
|
||||
name: point.label,
|
||||
value: point.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
<div className="dashboard-report__chart-shell">
|
||||
<ResponsiveContainer width="100%" height={190}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 12, right: 12, left: -8, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(24, 34, 48, 0.08)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: 'rgba(24, 34, 48, 0.62)', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fill: 'rgba(24, 34, 48, 0.62)', fontSize: 12 }}
|
||||
width={42}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'rgba(22, 93, 255, 0.18)', strokeWidth: 1 }}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(22, 93, 255, 0.12)',
|
||||
boxShadow: '0 16px 30px rgba(23, 61, 130, 0.08)',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#165dff"
|
||||
strokeWidth={3}
|
||||
dot={{ r: 4, fill: '#165dff', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
activeDot={{ r: 6, fill: '#165dff', stroke: '#ffffff', strokeWidth: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedProgressListSection({
|
||||
section,
|
||||
}: {
|
||||
section: Extract<DashboardReportSection, { kind: 'grouped-progress-list' }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="dashboard-report__grouped-progress">
|
||||
{section.groups.map((group) => (
|
||||
<div key={group.legend} className="dashboard-report__grouped-column">
|
||||
<Tag color="blue" className="dashboard-report__grouped-badge">
|
||||
{group.legend}
|
||||
</Tag>
|
||||
<Flex vertical gap={10}>
|
||||
{group.items.map((item) => (
|
||||
<ProgressUI
|
||||
key={`${group.legend}-${item.label}`}
|
||||
label={item.label}
|
||||
data={{
|
||||
percent: item.percent,
|
||||
color: item.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsSection({ section }: { section: Extract<DashboardReportSection, { kind: 'metrics' }> }) {
|
||||
return (
|
||||
<div className="dashboard-report__metrics">
|
||||
{section.metrics.map((metric) => (
|
||||
<div key={metric.label} className="dashboard-report__metric-item">
|
||||
<Text type="secondary">{metric.label}</Text>
|
||||
<Title level={4}>{metric.value}</Title>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedProgressSection({
|
||||
section,
|
||||
}: {
|
||||
section: Extract<DashboardReportSection, { kind: 'segmented-progress' }>;
|
||||
}) {
|
||||
return (
|
||||
<MultiProgressUI label={section.title} meta={section.legend} data={section.segments} />
|
||||
);
|
||||
}
|
||||
|
||||
function PieChartSection({ section }: { section: Extract<DashboardReportSection, { kind: 'pie-chart' }> }) {
|
||||
const totalPercent = section.segments.reduce((sum, segment) => sum + segment.percent, 0);
|
||||
|
||||
return (
|
||||
<div className="dashboard-report__pie-layout">
|
||||
<div className="dashboard-report__chart-shell dashboard-report__chart-shell--pie">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Tooltip
|
||||
formatter={(value) => `${value ?? 0}%`}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(22, 93, 255, 0.12)',
|
||||
boxShadow: '0 16px 30px rgba(23, 61, 130, 0.08)',
|
||||
}}
|
||||
/>
|
||||
<Pie
|
||||
data={section.segments}
|
||||
dataKey="percent"
|
||||
nameKey="label"
|
||||
innerRadius={54}
|
||||
outerRadius={82}
|
||||
paddingAngle={2}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{section.segments.map((segment) => (
|
||||
<Cell key={segment.label} fill={segment.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
{section.segments.map((segment) => (
|
||||
<div key={segment.label} className="dashboard-report__segment-legend">
|
||||
<span
|
||||
className="dashboard-report__segment-color"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<Text>{segment.label}</Text>
|
||||
<Text className="dashboard-report__percent">{segment.percent}%</Text>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="dashboard-report__segment-legend dashboard-report__segment-legend--total">
|
||||
<span className="dashboard-report__segment-color dashboard-report__segment-color--total" />
|
||||
<Text strong>{section.totalLabel ?? '총재고'}</Text>
|
||||
<Text className="dashboard-report__percent">{totalPercent}%</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardReportSectionView({ section }: { section: DashboardReportSection }) {
|
||||
return (
|
||||
<div className="dashboard-report__section">
|
||||
<Tag color="blue" className="dashboard-report__legend">
|
||||
{section.legend}
|
||||
</Tag>
|
||||
<Title level={5}>{section.title}</Title>
|
||||
<div className="dashboard-report__section-copy">
|
||||
{section.kind === 'progress-list' ? (
|
||||
<ProgressListSection section={section} />
|
||||
) : section.kind === 'grouped-progress-list' ? (
|
||||
<GroupedProgressListSection section={section} />
|
||||
) : section.kind === 'line-chart' ? (
|
||||
<LineChartSection section={section} />
|
||||
) : section.kind === 'metrics' ? (
|
||||
<MetricsSection section={section} />
|
||||
) : section.kind === 'pie-chart' ? (
|
||||
<PieChartSection section={section} />
|
||||
) : (
|
||||
<SegmentedProgressSection section={section} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const DashboardReportCardWidget = forwardRef<WidgetHandle, DashboardReportCardWidgetProps>(
|
||||
function DashboardReportCardWidget(
|
||||
{ id, title, moduleLabel, sections, sectionLayout = 'vertical', cardWrapper },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<WidgetShell
|
||||
ref={ref}
|
||||
id={id}
|
||||
title={title}
|
||||
cardWrapper={cardWrapper}
|
||||
features={['imperative-handle', 'feature-registry']}
|
||||
featureSlot={moduleLabel ? <Tag color="geekblue">{moduleLabel}</Tag> : undefined}
|
||||
>
|
||||
<div className={`dashboard-report dashboard-report--${sectionLayout}`}>
|
||||
{sections.map((section, index) => (
|
||||
<DashboardReportSectionView key={`${section.legend}-${index}`} section={section} />
|
||||
))}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
},
|
||||
);
|
||||
9
src/widgets/dashboard-report-card/index.ts
Executable file
9
src/widgets/dashboard-report-card/index.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
export { DashboardReportCardWidget } from './DashboardReportCardWidget';
|
||||
export type {
|
||||
DashboardReportCardWidgetProps,
|
||||
DashboardReportSection,
|
||||
LineChartPoint,
|
||||
MetricItem,
|
||||
ProgressListItem,
|
||||
SegmentedProgressItem,
|
||||
} from './DashboardReportCardWidget';
|
||||
24
src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx
Executable file
24
src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { DashboardReportCardWidget } from '../DashboardReportCardWidget';
|
||||
import { tmsDeliveryFlowCardPreset } from '../../../data/dashboard-report-presets';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'tms-delivery-flow-report',
|
||||
componentId: 'dashboard-report-card',
|
||||
title: 'TMS 배송 진행 현황',
|
||||
description: '배송 진행 상태와 배송 건수 추이를 함께 보여주는 TMS 대시보드 카드입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'TMS / 진행 현황',
|
||||
order: 22,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return (
|
||||
<DashboardReportCardWidget
|
||||
{...tmsDeliveryFlowCardPreset}
|
||||
cardWrapper={!disableWidgetCardWrapper}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx
Executable file
24
src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { DashboardReportCardWidget } from '../DashboardReportCardWidget';
|
||||
import { tmsDeliveryMetricsCardPreset } from '../../../data/dashboard-report-presets';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'tms-delivery-metrics-report',
|
||||
componentId: 'dashboard-report-card',
|
||||
title: 'TMS 배송 실적 지표',
|
||||
description: '배송 실적 지표와 정시/지연 비율을 함께 보여주는 TMS 대시보드 카드입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'TMS / 배송 지표',
|
||||
order: 23,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return (
|
||||
<DashboardReportCardWidget
|
||||
{...tmsDeliveryMetricsCardPreset}
|
||||
cardWrapper={!disableWidgetCardWrapper}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx
Executable file
24
src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { DashboardReportCardWidget } from '../DashboardReportCardWidget';
|
||||
import { wmsInboundOutboundCardPreset } from '../../../data/dashboard-report-presets';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'wms-inbound-outbound-report',
|
||||
componentId: 'dashboard-report-card',
|
||||
title: 'WMS 입출고 진행 현황',
|
||||
description: '입고/출고 진행 상태를 progress list로 보여주는 WMS 대시보드 카드입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'base',
|
||||
variantLabel: 'WMS / 진행 현황',
|
||||
order: 20,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return (
|
||||
<DashboardReportCardWidget
|
||||
{...wmsInboundOutboundCardPreset}
|
||||
cardWrapper={!disableWidgetCardWrapper}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx
Executable file
24
src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { DashboardReportCardWidget } from '../DashboardReportCardWidget';
|
||||
import { wmsInventoryTrendCardPreset } from '../../../data/dashboard-report-presets';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'wms-inventory-trend-report',
|
||||
componentId: 'dashboard-report-card',
|
||||
title: 'WMS 재고 수량 추이',
|
||||
description: '재고 수량 추이와 재고 상태 비중을 함께 보여주는 WMS 대시보드 카드입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'WMS / 재고 추이',
|
||||
order: 21,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return (
|
||||
<DashboardReportCardWidget
|
||||
{...wmsInventoryTrendCardPreset}
|
||||
cardWrapper={!disableWidgetCardWrapper}
|
||||
/>
|
||||
);
|
||||
}
|
||||
200
src/widgets/gps-sample-card/GpsSampleWidget.css
Executable file
200
src/widgets/gps-sample-card/GpsSampleWidget.css
Executable file
@@ -0,0 +1,200 @@
|
||||
.gps-sample-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(260px, 0.9fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__hero-card,
|
||||
.gps-sample-widget__panel,
|
||||
.gps-sample-widget__map-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94)),
|
||||
rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.gps-sample-widget__hero-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__hero-copy.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.gps-sample-widget__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__metric {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(241, 245, 249, 0.8);
|
||||
}
|
||||
|
||||
.gps-sample-widget__metric-value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.gps-sample-widget__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__quick-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gps-sample-widget__quick-form .ant-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gps-sample-widget__panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-item {
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
box-shadow 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-item--active {
|
||||
border-color: rgba(37, 99, 235, 0.36);
|
||||
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-head,
|
||||
.gps-sample-widget__anchor-actions,
|
||||
.gps-sample-widget__config-row,
|
||||
.gps-sample-widget__event-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__anchor-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__event-item {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(241, 245, 249, 0.78);
|
||||
}
|
||||
|
||||
.gps-sample-widget__map-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.gps-sample-widget__map-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.gps-sample-widget__map-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
box-shadow: 0 0 0 10px rgba(37, 99, 235, 0.18);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.gps-sample-widget__map-radius {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(42%, 220px);
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed rgba(14, 165, 233, 0.72);
|
||||
border-radius: 999px;
|
||||
background: rgba(125, 211, 252, 0.14);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.gps-sample-widget__hero,
|
||||
.gps-sample-widget__panel-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.gps-sample-widget__metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.gps-sample-widget__quick-form,
|
||||
.gps-sample-widget__anchor-head,
|
||||
.gps-sample-widget__anchor-actions,
|
||||
.gps-sample-widget__config-row,
|
||||
.gps-sample-widget__event-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
433
src/widgets/gps-sample-card/GpsSampleWidget.tsx
Executable file
433
src/widgets/gps-sample-card/GpsSampleWidget.tsx
Executable file
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
AimOutlined,
|
||||
BellOutlined,
|
||||
EnvironmentOutlined,
|
||||
DeleteOutlined,
|
||||
RadarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Flex, Input, InputNumber, Space, Switch, Tag, Typography, message } from 'antd';
|
||||
import { forwardRef, useMemo, useState } from 'react';
|
||||
import { EmbeddedMapUI } from '../../components/embeddedMap';
|
||||
import { useGpsLayer } from '../../layer';
|
||||
import { WidgetShell } from '../core';
|
||||
import type { WidgetHandle } from '../core';
|
||||
import './GpsSampleWidget.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
export type GpsSampleWidgetProps = {
|
||||
cardWrapper?: boolean;
|
||||
};
|
||||
|
||||
function formatCoordinate(value: number | null | undefined) {
|
||||
return typeof value === 'number' ? value.toFixed(6) : '-';
|
||||
}
|
||||
|
||||
function formatDistanceMeters(value: number) {
|
||||
return `${Math.round(value).toLocaleString()}m`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number | string | null | undefined) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function toRadians(value: number) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function calculateDistanceMeters(
|
||||
latitudeA: number,
|
||||
longitudeA: number,
|
||||
latitudeB: number,
|
||||
longitudeB: number,
|
||||
) {
|
||||
const earthRadiusMeters = 6371000;
|
||||
const deltaLatitude = toRadians(latitudeB - latitudeA);
|
||||
const deltaLongitude = toRadians(longitudeB - longitudeA);
|
||||
const sinLatitude = Math.sin(deltaLatitude / 2);
|
||||
const sinLongitude = Math.sin(deltaLongitude / 2);
|
||||
const root =
|
||||
sinLatitude * sinLatitude +
|
||||
Math.cos(toRadians(latitudeA)) *
|
||||
Math.cos(toRadians(latitudeB)) *
|
||||
sinLongitude *
|
||||
sinLongitude;
|
||||
|
||||
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(root), Math.sqrt(1 - root));
|
||||
}
|
||||
|
||||
export const GpsSampleWidget = forwardRef<WidgetHandle, GpsSampleWidgetProps>(function GpsSampleWidget(
|
||||
{ cardWrapper },
|
||||
ref,
|
||||
) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [anchorName, setAnchorName] = useState('');
|
||||
const {
|
||||
enabled,
|
||||
supported,
|
||||
permissionState,
|
||||
currentPosition,
|
||||
anchors,
|
||||
config,
|
||||
selectedAnchorId,
|
||||
anchorPresence,
|
||||
events,
|
||||
errorMessage,
|
||||
enableGps,
|
||||
disableGps,
|
||||
saveCurrentAnchor,
|
||||
removeAnchor,
|
||||
selectAnchor,
|
||||
updateConfig,
|
||||
clearEvents,
|
||||
} = useGpsLayer();
|
||||
|
||||
const selectedAnchor = anchors.find((anchor) => anchor.id === selectedAnchorId) ?? null;
|
||||
const focusTarget = selectedAnchor ?? (currentPosition ? { ...currentPosition, name: '현재 위치' } : null);
|
||||
const distanceToSelectedAnchor =
|
||||
selectedAnchor && currentPosition
|
||||
? calculateDistanceMeters(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
selectedAnchor.latitude,
|
||||
selectedAnchor.longitude,
|
||||
)
|
||||
: null;
|
||||
|
||||
const locationSummary = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '위도',
|
||||
value: formatCoordinate(currentPosition?.latitude),
|
||||
},
|
||||
{
|
||||
label: '경도',
|
||||
value: formatCoordinate(currentPosition?.longitude),
|
||||
},
|
||||
{
|
||||
label: '정확도',
|
||||
value: currentPosition?.accuracy ? `±${Math.round(currentPosition.accuracy)}m` : '-',
|
||||
},
|
||||
{
|
||||
label: '마지막 갱신',
|
||||
value: formatTimestamp(currentPosition?.timestamp),
|
||||
},
|
||||
],
|
||||
[currentPosition],
|
||||
);
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
ref={ref}
|
||||
id="gps-sample-widget"
|
||||
title="GPS Sample Widget"
|
||||
cardWrapper={cardWrapper}
|
||||
features={['component-sample', 'feature-registry', 'imperative-handle', 'docs']}
|
||||
featureSlot={
|
||||
<Tag icon={<RadarChartOutlined />} color="cyan">
|
||||
Layer Context Hook
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
{contextHolder}
|
||||
<div className="gps-sample-widget">
|
||||
<div className="gps-sample-widget__hero">
|
||||
<section className="gps-sample-widget__hero-card">
|
||||
<Space align="start" size={12}>
|
||||
<Tag color={enabled ? 'blue' : 'default'} icon={<AimOutlined />}>
|
||||
GPS {enabled ? 'ON' : 'OFF'}
|
||||
</Tag>
|
||||
<Tag color={supported ? 'green' : 'red'}>
|
||||
{supported ? `권한 상태: ${permissionState}` : '브라우저 미지원'}
|
||||
</Tag>
|
||||
<Tag icon={<BellOutlined />} color="gold">
|
||||
반경 {config.radiusMeters}m
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Title level={4}>실시간 GPS 좌표와 거점 지오펜스를 한 레이어에서 관리합니다.</Title>
|
||||
<Paragraph className="gps-sample-widget__hero-copy">
|
||||
현재 좌표를 즉시 거점으로 저장하고, In/Out 푸시 알림 설정과 반경을 조절한 뒤, 선택한
|
||||
거점을 하단 지도에서 바로 확인할 수 있습니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="gps-sample-widget__metrics">
|
||||
{locationSummary.map((item) => (
|
||||
<div key={item.label} className="gps-sample-widget__metric">
|
||||
<Text type="secondary">{item.label}</Text>
|
||||
<span className="gps-sample-widget__metric-value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="gps-sample-widget__actions">
|
||||
<Space size={12}>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!supported}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
enableGps();
|
||||
return;
|
||||
}
|
||||
|
||||
disableGps();
|
||||
}}
|
||||
checkedChildren="GPS ON"
|
||||
unCheckedChildren="GPS OFF"
|
||||
/>
|
||||
<Text type="secondary">브라우저 Geolocation watchPosition 사용</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{!supported ? (
|
||||
<Alert type="error" message="현재 브라우저는 GPS 기능을 지원하지 않습니다." showIcon />
|
||||
) : null}
|
||||
{errorMessage ? <Alert type="warning" message={errorMessage} showIcon /> : null}
|
||||
</section>
|
||||
|
||||
<section className="gps-sample-widget__panel">
|
||||
<Flex vertical gap={16}>
|
||||
<div>
|
||||
<Text strong>현재 좌표를 거점으로 등록</Text>
|
||||
<Paragraph type="secondary">
|
||||
저장된 거점은 Local Storage에 유지되고, 선택 즉시 하단 지도 중심이 해당 위치로 이동합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="gps-sample-widget__quick-form">
|
||||
<Input
|
||||
placeholder="거점명 입력"
|
||||
value={anchorName}
|
||||
onChange={(event) => {
|
||||
setAnchorName(event.target.value);
|
||||
}}
|
||||
onPressEnter={() => {
|
||||
const result = saveCurrentAnchor(anchorName || `거점 ${anchors.length + 1}`);
|
||||
|
||||
if (!result.ok) {
|
||||
void messageApi.warning(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setAnchorName('');
|
||||
void messageApi.success(`${result.anchor?.name ?? '거점'} 저장 완료`);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => {
|
||||
const result = saveCurrentAnchor(anchorName || `거점 ${anchors.length + 1}`);
|
||||
|
||||
if (!result.ok) {
|
||||
void messageApi.warning(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setAnchorName('');
|
||||
void messageApi.success(`${result.anchor?.name ?? '거점'} 저장 완료`);
|
||||
}}
|
||||
>
|
||||
현재 위치 저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="gps-sample-widget__config">
|
||||
<div className="gps-sample-widget__config-row">
|
||||
<Space>
|
||||
<Switch
|
||||
checked={config.notifyOnEnter}
|
||||
onChange={(checked) => {
|
||||
updateConfig({ notifyOnEnter: checked });
|
||||
}}
|
||||
/>
|
||||
<Text>거점 In 푸시알림</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={config.notifyOnExit}
|
||||
onChange={(checked) => {
|
||||
updateConfig({ notifyOnExit: checked });
|
||||
}}
|
||||
/>
|
||||
<Text>거점 Out 푸시알림</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="gps-sample-widget__config-row">
|
||||
<Text>반경 거리 설정</Text>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={5000}
|
||||
step={10}
|
||||
addonAfter="m"
|
||||
value={config.radiusMeters}
|
||||
onChange={(value) => {
|
||||
updateConfig({ radiusMeters: Number(value ?? config.radiusMeters) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="gps-sample-widget__panel-grid">
|
||||
<section className="gps-sample-widget__panel">
|
||||
<div className="gps-sample-widget__event-head">
|
||||
<div>
|
||||
<Text strong>등록된 거점</Text>
|
||||
<Paragraph type="secondary">
|
||||
거점명을 선택하면 하단 지도에서 위치를 확인할 수 있고, 현재 좌표와의 In / Out 상태도 같이 봅니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Tag color="blue">{anchors.length}개</Tag>
|
||||
</div>
|
||||
|
||||
{anchors.length === 0 ? (
|
||||
<Empty description="저장된 거점이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="gps-sample-widget__anchor-list">
|
||||
{anchors.map((anchor) => {
|
||||
const active = anchor.id === selectedAnchorId;
|
||||
const isInside = anchorPresence[anchor.id];
|
||||
const distance =
|
||||
currentPosition &&
|
||||
calculateDistanceMeters(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
anchor.latitude,
|
||||
anchor.longitude,
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={anchor.id}
|
||||
className={[
|
||||
'gps-sample-widget__anchor-item',
|
||||
active ? 'gps-sample-widget__anchor-item--active' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="gps-sample-widget__anchor-head">
|
||||
<div className="gps-sample-widget__anchor-meta">
|
||||
<Text strong>{anchor.name}</Text>
|
||||
<Text type="secondary">
|
||||
{formatCoordinate(anchor.latitude)}, {formatCoordinate(anchor.longitude)}
|
||||
</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color={isInside ? 'green' : 'default'}>{isInside ? 'IN' : 'OUT'}</Tag>
|
||||
{distance !== null && distance !== undefined ? (
|
||||
<Tag>{formatDistanceMeters(distance)}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="gps-sample-widget__anchor-actions">
|
||||
<Button
|
||||
type={active ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
selectAnchor(anchor.id);
|
||||
}}
|
||||
>
|
||||
지도에서 보기
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
removeAnchor(anchor.id);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="gps-sample-widget__panel">
|
||||
<div className="gps-sample-widget__event-head">
|
||||
<div>
|
||||
<Text strong>최근 지오펜스 이벤트</Text>
|
||||
<Paragraph type="secondary">반경 경계 진입/이탈 이력이 최근 순서대로 쌓입니다.</Paragraph>
|
||||
</div>
|
||||
<Button onClick={clearEvents}>초기화</Button>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<Empty description="아직 감지된 이벤트가 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="gps-sample-widget__event-list">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="gps-sample-widget__event-item">
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space wrap>
|
||||
<Tag color={event.type === 'enter' ? 'green' : 'orange'}>
|
||||
{event.type === 'enter' ? 'IN' : 'OUT'}
|
||||
</Tag>
|
||||
<Text strong>{event.anchorName}</Text>
|
||||
<Text type="secondary">{formatDistanceMeters(event.distanceMeters)}</Text>
|
||||
</Space>
|
||||
<Text type="secondary">{formatTimestamp(event.createdAt)}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="gps-sample-widget__map-card">
|
||||
{focusTarget ? (
|
||||
<EmbeddedMapUI
|
||||
title={selectedAnchor ? `${selectedAnchor.name} 거점 지도` : '현재 GPS 위치'}
|
||||
description={
|
||||
selectedAnchor
|
||||
? `선택한 거점 중심으로 지도를 고정했습니다. 반경 ${config.radiusMeters}m 오버레이 정확도를 위해 지도 이동은 잠금 처리합니다.`
|
||||
: '선택한 거점이 없어서 현재 GPS 위치를 지도 중심으로 사용합니다.'
|
||||
}
|
||||
latitude={focusTarget.latitude}
|
||||
longitude={focusTarget.longitude}
|
||||
zoom={16}
|
||||
height={360}
|
||||
markerLabel={selectedAnchor ? '선택 거점' : '현재 위치'}
|
||||
radiusMeters={selectedAnchor ? config.radiusMeters : undefined}
|
||||
lockViewport={Boolean(selectedAnchor)}
|
||||
secondaryMarker={
|
||||
selectedAnchor && currentPosition
|
||||
? {
|
||||
latitude: currentPosition.latitude,
|
||||
longitude: currentPosition.longitude,
|
||||
label: '현재 위치',
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="GPS를 켜거나 거점을 선택하면 지도가 표시됩니다." />
|
||||
)}
|
||||
|
||||
{selectedAnchor && distanceToSelectedAnchor !== null ? (
|
||||
<Paragraph type="secondary" style={{ marginTop: 14, marginBottom: 0 }}>
|
||||
현재 좌표와 선택 거점의 직선거리는 {formatDistanceMeters(distanceToSelectedAnchor)} 입니다.
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
});
|
||||
1
src/widgets/gps-sample-card/index.ts
Executable file
1
src/widgets/gps-sample-card/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { GpsSampleWidget } from './GpsSampleWidget';
|
||||
18
src/widgets/gps-sample-card/samples/Sample.tsx
Executable file
18
src/widgets/gps-sample-card/samples/Sample.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { GpsSampleWidget } from '../GpsSampleWidget';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'gps-sample-widget',
|
||||
componentId: 'gps-sample-widget',
|
||||
title: 'GPS Sample Widget',
|
||||
description: 'Layer Context hooks 기반으로 GPS On/Off, 실시간 좌표, 거점 저장, 반경 설정과 지도 선택을 묶은 샘플 위젯입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'GPS / Geofence',
|
||||
order: 26,
|
||||
features: ['docs'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return <GpsSampleWidget cardWrapper={!disableWidgetCardWrapper} />;
|
||||
}
|
||||
32
src/widgets/registry.ts
Executable file
32
src/widgets/registry.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import type { WidgetRegistryItem } from './core';
|
||||
|
||||
export const registeredWidgets: WidgetRegistryItem[] = [
|
||||
{
|
||||
id: 'api-sample-card-widget',
|
||||
title: 'API Sample Card Widget',
|
||||
description:
|
||||
'컴포넌트 대표 샘플을 Wrapper, title, main content, feature tag 구조로 감싸는 위젯입니다.',
|
||||
features: ['api-sample', 'component-sample', 'feature-registry', 'imperative-handle'],
|
||||
},
|
||||
{
|
||||
id: 'dashboard-report-card-widget',
|
||||
title: 'Dashboard Report Card Widget',
|
||||
description:
|
||||
'타이틀과 2개의 콘텐츠 섹션을 가지며 진행 현황, 선형 그래프, 지표, 분할 progress를 조합하는 대시보드 카드 위젯입니다.',
|
||||
features: ['feature-registry', 'imperative-handle'],
|
||||
},
|
||||
{
|
||||
id: 'gps-sample-widget',
|
||||
title: 'GPS Sample Widget',
|
||||
description:
|
||||
'GPS Layer Context hooks를 바탕으로 실시간 좌표, 거점 저장, 지오펜스 반경 설정, 선택 거점 지도 표시를 제공하는 위젯입니다.',
|
||||
features: ['component-sample', 'docs', 'feature-registry', 'imperative-handle'],
|
||||
},
|
||||
{
|
||||
id: 'text-memo-widget',
|
||||
title: 'Text Memo Widget',
|
||||
description:
|
||||
'텍스트 메모를 작성하고 최근 저장본을 브라우저 Local Storage에 유지한 뒤 다시 불러올 수 있는 위젯입니다.',
|
||||
features: ['component-sample', 'feature-registry', 'imperative-handle'],
|
||||
},
|
||||
];
|
||||
190
src/widgets/text-memo-widget/TextMemoWidget.css
Executable file
190
src/widgets/text-memo-widget/TextMemoWidget.css
Executable file
@@ -0,0 +1,190 @@
|
||||
.text-memo-widget {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__body--list-open {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-memo-widget__toolbar .ant-btn {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-memo-widget__toolbar .ant-btn:not(:disabled):hover {
|
||||
color: #111827;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.text-memo-widget__editor {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(245, 158, 11, 0.18);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.42)),
|
||||
repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 248, 216, 0.98) 0,
|
||||
rgba(255, 248, 216, 0.98) 37px,
|
||||
rgba(236, 221, 177, 0.78) 37px,
|
||||
rgba(236, 221, 177, 0.78) 38px
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 44px rgba(15, 23, 42, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.text-memo-widget__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
padding: 14px 18px 0;
|
||||
color: rgba(100, 116, 139, 0.92);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.text-memo-widget__editor .ant-input-textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__input.ant-input,
|
||||
.text-memo-widget__editor .ant-input {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 10px 18px 20px;
|
||||
color: #3f3a2f;
|
||||
font-size: 16px;
|
||||
line-height: 38px;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.text-memo-widget__editor .ant-input::placeholder {
|
||||
color: rgba(120, 113, 91, 0.7);
|
||||
}
|
||||
|
||||
.text-memo-widget__editor .ant-input[readonly] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text-memo-widget__sheet {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-memo-widget__empty {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.text-memo-widget__list {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.text-memo-widget__list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-memo-widget__list-item:hover {
|
||||
background: rgba(241, 245, 249, 1);
|
||||
}
|
||||
|
||||
.text-memo-widget__list-item--active {
|
||||
background: rgba(254, 240, 138, 0.42);
|
||||
}
|
||||
|
||||
.text-memo-widget__list-time {
|
||||
color: rgba(100, 116, 139, 0.92);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-memo-widget__list-preview {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.text-memo-widget__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text-memo-widget__editor {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.text-memo-widget__sheet {
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
347
src/widgets/text-memo-widget/TextMemoWidget.tsx
Executable file
347
src/widgets/text-memo-widget/TextMemoWidget.tsx
Executable file
@@ -0,0 +1,347 @@
|
||||
import { CheckOutlined, DeleteOutlined, EditOutlined, LeftOutlined, PlusOutlined, RightOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Input, Modal, message } from 'antd';
|
||||
import { forwardRef, useEffect, useMemo, useState } from 'react';
|
||||
import { WidgetShell } from '../core';
|
||||
import type { WidgetHandle } from '../core';
|
||||
import './TextMemoWidget.css';
|
||||
|
||||
const STORAGE_KEY = 'ai-code-app:text-memo-widget';
|
||||
const MAX_NOTE_COUNT = 12;
|
||||
const MAX_BODY_LENGTH = 1200;
|
||||
|
||||
type MemoNote = {
|
||||
id: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function readStoredNotes() {
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((note): note is MemoNote => {
|
||||
return (
|
||||
typeof note === 'object' &&
|
||||
note !== null &&
|
||||
typeof note.id === 'string' &&
|
||||
typeof note.body === 'string' &&
|
||||
typeof note.createdAt === 'string'
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatMemoTimestamp(value: string) {
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getPreviewText(body: string) {
|
||||
const preview = body.replace(/\s+/g, ' ').trim();
|
||||
return preview || '새 메모';
|
||||
}
|
||||
|
||||
export type TextMemoWidgetProps = {
|
||||
cardWrapper?: boolean;
|
||||
};
|
||||
|
||||
export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(function TextMemoWidget(
|
||||
{ cardWrapper },
|
||||
ref,
|
||||
) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const [body, setBody] = useState('');
|
||||
const [notes, setNotes] = useState<MemoNote[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [isListOpen, setIsListOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const storedNotes = readStoredNotes();
|
||||
setNotes(storedNotes);
|
||||
|
||||
if (storedNotes[0]) {
|
||||
setSelectedId(storedNotes[0].id);
|
||||
setBody(storedNotes[0].body);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
|
||||
}, [notes]);
|
||||
|
||||
const selectedIndex = useMemo(() => {
|
||||
if (!selectedId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return notes.findIndex((note) => note.id === selectedId);
|
||||
}, [notes, selectedId]);
|
||||
|
||||
const hasDraft = body.trim().length > 0;
|
||||
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
|
||||
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
|
||||
|
||||
const saveNote = () => {
|
||||
const trimmedBody = body.trim();
|
||||
if (!trimmedBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (selectedNote) {
|
||||
const updatedNote: MemoNote = {
|
||||
...selectedNote,
|
||||
body: trimmedBody,
|
||||
createdAt: timestamp,
|
||||
};
|
||||
const nextNotes = [updatedNote, ...notes.filter((note) => note.id !== selectedNote.id)].slice(
|
||||
0,
|
||||
MAX_NOTE_COUNT,
|
||||
);
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(updatedNote.id);
|
||||
setBody(updatedNote.body);
|
||||
setIsEditing(false);
|
||||
void messageApi.success('저장됨');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNote: MemoNote = {
|
||||
id: `${Date.now()}`,
|
||||
body: trimmedBody,
|
||||
createdAt: timestamp,
|
||||
};
|
||||
const nextNotes = [nextNote, ...notes].slice(0, MAX_NOTE_COUNT);
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(nextNote.id);
|
||||
setBody(nextNote.body);
|
||||
setIsEditing(false);
|
||||
void messageApi.success('저장됨');
|
||||
};
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (isEditing || isDirty || !selectedNote) {
|
||||
saveNote();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setSelectedId(null);
|
||||
setBody('');
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedNote && !hasDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDraftOnly = !selectedNote;
|
||||
|
||||
void modalApi.confirm({
|
||||
title: isDraftOnly ? '작성 중인 메모를 삭제할까요?' : '선택한 메모를 삭제할까요?',
|
||||
content: isDraftOnly
|
||||
? '저장하지 않은 내용이 사라집니다.'
|
||||
: '삭제한 메모는 다시 복구할 수 없습니다.',
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
if (isDraftOnly) {
|
||||
setBody('');
|
||||
setIsEditing(true);
|
||||
void messageApi.success('메모를 삭제했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
|
||||
const nextSelectedNote = nextNotes[0] ?? null;
|
||||
|
||||
setNotes(nextNotes);
|
||||
setSelectedId(nextSelectedNote?.id ?? null);
|
||||
setBody(nextSelectedNote?.body ?? '');
|
||||
setIsEditing(nextSelectedNote ? false : true);
|
||||
setIsListOpen(nextNotes.length > 0 && isListOpen);
|
||||
void messageApi.success('메모를 삭제했습니다.');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (note: MemoNote) => {
|
||||
setSelectedId(note.id);
|
||||
setBody(note.body);
|
||||
setIsEditing(false);
|
||||
setIsListOpen(false);
|
||||
};
|
||||
|
||||
const moveSelection = (direction: -1 | 1) => {
|
||||
if (notes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = baseIndex + direction;
|
||||
|
||||
if (nextIndex < 0 || nextIndex >= notes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNote = notes[nextIndex];
|
||||
setSelectedId(nextNote.id);
|
||||
setBody(nextNote.body);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetShell ref={ref} id="text-memo-widget" title="" cardWrapper={cardWrapper}>
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
<div className="text-memo-widget">
|
||||
<div className="text-memo-widget__toolbar" role="toolbar" aria-label="메모 도구">
|
||||
<div className="text-memo-widget__toolbar-group">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
aria-label="새 메모"
|
||||
onClick={handleNew}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="메모 삭제"
|
||||
disabled={!selectedNote && !hasDraft}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<UnorderedListOutlined />}
|
||||
aria-label="메모 목록"
|
||||
onClick={() => {
|
||||
setIsListOpen((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-memo-widget__toolbar-group">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<LeftOutlined />}
|
||||
aria-label="이전 메모"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() => {
|
||||
moveSelection(-1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={<RightOutlined />}
|
||||
aria-label="다음 메모"
|
||||
disabled={selectedIndex < 0 || selectedIndex >= notes.length - 1}
|
||||
onClick={() => {
|
||||
moveSelection(1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
icon={isEditing || isDirty || !selectedNote ? <CheckOutlined /> : <EditOutlined />}
|
||||
aria-label={isEditing || isDirty || !selectedNote ? '저장' : '편집'}
|
||||
disabled={!hasDraft && !selectedNote}
|
||||
onClick={handlePrimaryAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`text-memo-widget__body${isListOpen ? ' text-memo-widget__body--list-open' : ''}`}>
|
||||
{isListOpen ? (
|
||||
<div className="text-memo-widget__sheet">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-memo-widget__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={false} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-memo-widget__list">
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
className={`text-memo-widget__list-item${
|
||||
note.id === selectedId ? ' text-memo-widget__list-item--active' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleSelect(note);
|
||||
}}
|
||||
>
|
||||
<span className="text-memo-widget__list-time">{formatMemoTimestamp(note.createdAt)}</span>
|
||||
<span className="text-memo-widget__list-preview">{getPreviewText(note.body)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-memo-widget__editor">
|
||||
<div className="text-memo-widget__meta">
|
||||
<span>{selectedNote ? formatMemoTimestamp(selectedNote.createdAt) : ''}</span>
|
||||
<span>{body.length}/{MAX_BODY_LENGTH}</span>
|
||||
</div>
|
||||
|
||||
<Input.TextArea
|
||||
className="text-memo-widget__input"
|
||||
value={body}
|
||||
placeholder="메모 입력"
|
||||
variant="borderless"
|
||||
maxLength={MAX_BODY_LENGTH}
|
||||
readOnly={!isEditing && !!selectedNote}
|
||||
onChange={(event) => {
|
||||
setBody(event.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!selectedNote) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
});
|
||||
2
src/widgets/text-memo-widget/index.ts
Executable file
2
src/widgets/text-memo-widget/index.ts
Executable file
@@ -0,0 +1,2 @@
|
||||
export { TextMemoWidget } from './TextMemoWidget';
|
||||
export type { TextMemoWidgetProps } from './TextMemoWidget';
|
||||
18
src/widgets/text-memo-widget/samples/Sample.tsx
Executable file
18
src/widgets/text-memo-widget/samples/Sample.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import type { SampleMeta, SampleRenderProps } from '../../core';
|
||||
import { TextMemoWidget } from '../TextMemoWidget';
|
||||
|
||||
export const sampleMeta: SampleMeta = {
|
||||
id: 'text-memo-widget',
|
||||
componentId: 'text-memo-widget',
|
||||
title: 'Text Memo Widget',
|
||||
description: '짧은 텍스트 메모를 작성하고 최근 저장본을 Local Storage에 유지하는 위젯입니다.',
|
||||
category: 'Widgets',
|
||||
kind: 'feature',
|
||||
variantLabel: 'Memo / Notes',
|
||||
order: 28,
|
||||
features: ['feature-registry', 'imperative-handle'],
|
||||
};
|
||||
|
||||
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
|
||||
return <TextMemoWidget cardWrapper={!disableWidgetCardWrapper} />;
|
||||
}
|
||||
Reference in New Issue
Block a user