Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View 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>
);
},
);

View File

@@ -0,0 +1,2 @@
export { ApiSampleCardWidget } from './ApiSampleCardWidget';
export type { ApiSampleCardWidgetProps } from './ApiSampleCardWidget';

View 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>
);
}

View 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
View 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';

View 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]);
}

View 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;
};

View 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>
);
},
);

View File

@@ -0,0 +1,9 @@
export { DashboardReportCardWidget } from './DashboardReportCardWidget';
export type {
DashboardReportCardWidgetProps,
DashboardReportSection,
LineChartPoint,
MetricItem,
ProgressListItem,
SegmentedProgressItem,
} from './DashboardReportCardWidget';

View 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}
/>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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;
}
}

View 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>
);
});

View File

@@ -0,0 +1 @@
export { GpsSampleWidget } from './GpsSampleWidget';

View 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
View 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'],
},
];

View 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;
}
}

View 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>
);
});

View File

@@ -0,0 +1,2 @@
export { TextMemoWidget } from './TextMemoWidget';
export type { TextMemoWidgetProps } from './TextMemoWidget';

View 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} />;
}