@skspwork/config-doc 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +2 -2
  2. package/packages/web/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  3. package/packages/web/.next/standalone/.next/server/app/_not-found.html +1 -1
  4. package/packages/web/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  5. package/packages/web/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  6. package/packages/web/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  7. package/packages/web/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  8. package/packages/web/.next/standalone/.next/server/app/api/config/save/route.js +1 -1
  9. package/packages/web/.next/standalone/.next/server/app/api/config/save/route.js.nft.json +1 -1
  10. package/packages/web/.next/standalone/.next/server/app/api/export/route.js +3 -3
  11. package/packages/web/.next/standalone/.next/server/app/api/export/route.js.nft.json +1 -1
  12. package/packages/web/.next/standalone/.next/server/app/index.html +1 -1
  13. package/packages/web/.next/standalone/.next/server/app/index.rsc +3 -3
  14. package/packages/web/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  15. package/packages/web/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  16. package/packages/web/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  17. package/packages/web/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  18. package/packages/web/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  19. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__40e87302._.js +3 -0
  20. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__93da9fce._.js +1 -1
  21. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__c9655ac8._.js +3 -0
  22. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__e19366f6._.js +1 -1
  23. package/packages/web/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_d09de205.js +345 -27
  24. package/packages/web/.next/standalone/.next/server/chunks/ssr/app_page_tsx_55b2e5ee._.js +1 -1
  25. package/packages/web/.next/standalone/.next/server/pages/404.html +1 -1
  26. package/packages/web/.next/standalone/.next/static/chunks/02de70e4c30afe2f.js +1 -0
  27. package/packages/web/.next/standalone/.next/static/chunks/862e384b52cfebf3.css +3 -0
  28. package/packages/web/.next/standalone/app/api/config/metadata/route.ts +5 -3
  29. package/packages/web/.next/standalone/playwright-report/index.html +1 -1
  30. package/packages/web/.next/static/chunks/02de70e4c30afe2f.js +1 -0
  31. package/packages/web/.next/static/chunks/862e384b52cfebf3.css +3 -0
  32. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__1a68b1f3._.js +0 -3
  33. package/packages/web/.next/standalone/.next/server/chunks/[root-of-the-server]__2c94dfea._.js +0 -3
  34. package/packages/web/.next/standalone/.next/static/chunks/9726c2cde77e0916.js +0 -1
  35. package/packages/web/.next/standalone/.next/static/chunks/cd878566fda12635.css +0 -3
  36. package/packages/web/.next/standalone/app/api/config/load/route.ts +0 -57
  37. package/packages/web/.next/standalone/app/api/config/save/route.ts +0 -73
  38. package/packages/web/.next/standalone/app/api/export/route.ts +0 -75
  39. package/packages/web/.next/standalone/app/api/export/settings/route.ts +0 -144
  40. package/packages/web/.next/standalone/app/api/files/browse/route.ts +0 -46
  41. package/packages/web/.next/standalone/app/api/project/route.ts +0 -41
  42. package/packages/web/.next/standalone/app/globals.css +0 -26
  43. package/packages/web/.next/standalone/app/icon.svg +0 -41
  44. package/packages/web/.next/standalone/app/layout.tsx +0 -34
  45. package/packages/web/.next/standalone/app/page.tsx +0 -135
  46. package/packages/web/.next/standalone/components/ConfigFileTabs.tsx +0 -188
  47. package/packages/web/.next/standalone/components/ConfigTree.tsx +0 -176
  48. package/packages/web/.next/standalone/components/EditableList.tsx +0 -337
  49. package/packages/web/.next/standalone/components/ExportDialog.tsx +0 -234
  50. package/packages/web/.next/standalone/components/FieldsEditor.tsx +0 -92
  51. package/packages/web/.next/standalone/components/FileBrowser.tsx +0 -290
  52. package/packages/web/.next/standalone/components/Header.tsx +0 -37
  53. package/packages/web/.next/standalone/components/PropertyEditor.tsx +0 -102
  54. package/packages/web/.next/standalone/components/TagEditor.tsx +0 -86
  55. package/packages/web/.next/standalone/components/Toast.tsx +0 -91
  56. package/packages/web/.next/standalone/eslint.config.mjs +0 -18
  57. package/packages/web/.next/standalone/hooks/useConfigManager.ts +0 -653
  58. package/packages/web/.next/standalone/lib/configManagerUtils.ts +0 -84
  59. package/packages/web/.next/standalone/lib/configParser.ts +0 -155
  60. package/packages/web/.next/standalone/lib/fileSystem.ts +0 -186
  61. package/packages/web/.next/standalone/lib/getRootPath.ts +0 -45
  62. package/packages/web/.next/standalone/lib/htmlGenerator.ts +0 -865
  63. package/packages/web/.next/standalone/lib/jsonUtils.ts +0 -26
  64. package/packages/web/.next/standalone/lib/markdownGenerator.ts +0 -110
  65. package/packages/web/.next/standalone/lib/markdownTableGenerator.ts +0 -103
  66. package/packages/web/.next/standalone/lib/storage.ts +0 -104
  67. package/packages/web/.next/standalone/lib/utils.ts +0 -89
  68. package/packages/web/.next/standalone/next.config.ts +0 -10
  69. package/packages/web/.next/standalone/package-lock.json +0 -8216
  70. package/packages/web/.next/standalone/playwright.config.ts +0 -27
  71. package/packages/web/.next/standalone/postcss.config.mjs +0 -7
  72. package/packages/web/.next/standalone/test-results/.last-run.json +0 -4
  73. package/packages/web/.next/standalone/tsconfig.json +0 -34
  74. package/packages/web/.next/standalone/tsconfig.tsbuildinfo +0 -1
  75. package/packages/web/.next/standalone/types/index.ts +0 -74
  76. package/packages/web/.next/standalone/vitest.config.ts +0 -14
  77. package/packages/web/.next/static/chunks/9726c2cde77e0916.js +0 -1
  78. package/packages/web/.next/static/chunks/cd878566fda12635.css +0 -3
@@ -1,188 +0,0 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { FolderOpenIcon, XIcon } from 'lucide-react';
5
- import { ConfigDocs } from '@/types';
6
-
7
- export interface LoadedConfig {
8
- filePath: string;
9
- configData: unknown;
10
- docs: ConfigDocs;
11
- }
12
-
13
- interface ConfigFileTabsProps {
14
- loadedConfigs: LoadedConfig[];
15
- activeConfigIndex: number;
16
- onTabClick: (index: number) => void;
17
- onRemoveConfig: (index: number) => void;
18
- onAddFileClick: () => void;
19
- onReorder: (newConfigs: LoadedConfig[], newActiveIndex: number) => void;
20
- }
21
-
22
- // 同名ファイルを区別するための表示名を生成
23
- function getDisplayName(filePath: string, allFilePaths: string[]): string {
24
- const fileName = filePath.split(/[/\\]/).pop() || filePath;
25
-
26
- // 同じファイル名を持つファイルパスを検索
27
- const sameNamePaths = allFilePaths.filter(p => {
28
- const name = p.split(/[/\\]/).pop();
29
- return name === fileName;
30
- });
31
-
32
- // 同名のファイルが1つしかない場合はファイル名のみ
33
- if (sameNamePaths.length === 1) {
34
- return fileName;
35
- }
36
-
37
- // 同名ファイルが複数ある場合、親フォルダを追加
38
- const pathParts = filePath.split(/[/\\]/);
39
-
40
- // 必要な親フォルダのレベル数を決定
41
- for (let depth = 1; depth < pathParts.length; depth++) {
42
- const displayWithParents = pathParts.slice(-depth - 1).join('/');
43
-
44
- // この表示名が他の同名ファイルと区別できるかチェック
45
- const conflicts = sameNamePaths.filter(p => {
46
- const otherParts = p.split(/[/\\]/);
47
- const otherDisplay = otherParts.slice(-depth - 1).join('/');
48
- return displayWithParents === otherDisplay && p !== filePath;
49
- });
50
-
51
- if (conflicts.length === 0) {
52
- return displayWithParents;
53
- }
54
- }
55
-
56
- // 全パスが必要な場合
57
- return filePath;
58
- }
59
-
60
- export function ConfigFileTabs({
61
- loadedConfigs,
62
- activeConfigIndex,
63
- onTabClick,
64
- onRemoveConfig,
65
- onAddFileClick,
66
- onReorder
67
- }: ConfigFileTabsProps) {
68
- // ドラッグ&ドロップ用の状態
69
- const [dragIndex, setDragIndex] = useState<number | null>(null);
70
- const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
71
-
72
- const handleDragStart = (index: number) => {
73
- setDragIndex(index);
74
- };
75
-
76
- const handleDragOver = (e: React.DragEvent, index: number) => {
77
- e.preventDefault();
78
- if (dragIndex !== null && dragIndex !== index) {
79
- setDragOverIndex(index);
80
- }
81
- };
82
-
83
- const handleDragLeave = () => {
84
- setDragOverIndex(null);
85
- };
86
-
87
- const handleDrop = (index: number) => {
88
- if (dragIndex === null || dragIndex === index) {
89
- setDragIndex(null);
90
- setDragOverIndex(null);
91
- return;
92
- }
93
-
94
- // 配列の並び替え
95
- const newConfigs = [...loadedConfigs];
96
- const [draggedItem] = newConfigs.splice(dragIndex, 1);
97
- newConfigs.splice(index, 0, draggedItem);
98
-
99
- // アクティブなインデックスを調整
100
- let newActiveIndex = activeConfigIndex;
101
- if (activeConfigIndex === dragIndex) {
102
- newActiveIndex = index;
103
- } else if (dragIndex < activeConfigIndex && index >= activeConfigIndex) {
104
- newActiveIndex = activeConfigIndex - 1;
105
- } else if (dragIndex > activeConfigIndex && index <= activeConfigIndex) {
106
- newActiveIndex = activeConfigIndex + 1;
107
- }
108
-
109
- onReorder(newConfigs, newActiveIndex);
110
-
111
- setDragIndex(null);
112
- setDragOverIndex(null);
113
- };
114
-
115
- const handleDragEnd = () => {
116
- setDragIndex(null);
117
- setDragOverIndex(null);
118
- };
119
-
120
- const allFilePaths = loadedConfigs.map(c => c.filePath);
121
-
122
- return (
123
- <div className="bg-white/90 backdrop-blur-sm rounded-2xl shadow-xl border border-gray-100 p-6 mb-8 hover:shadow-2xl transition-shadow duration-300">
124
- <div className="flex items-center justify-between mb-4">
125
- <div className="flex items-center gap-2">
126
- <div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
127
- <FolderOpenIcon className="w-5 h-5 text-white" />
128
- </div>
129
- <h2 className="text-xl font-bold text-gray-800">設定ファイル</h2>
130
- </div>
131
- <button
132
- onClick={onAddFileClick}
133
- className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 shadow-md hover:shadow-lg transition-all duration-200 transform"
134
- >
135
- <FolderOpenIcon className="w-5 h-5" />
136
- <span className="font-medium">ファイルを追加</span>
137
- </button>
138
- </div>
139
-
140
- {loadedConfigs.length > 0 ? (
141
- <div className="flex flex-wrap gap-3">
142
- {loadedConfigs.map((config, index) => (
143
- <div
144
- key={config.filePath}
145
- draggable
146
- onDragStart={() => handleDragStart(index)}
147
- onDragOver={(e) => handleDragOver(e, index)}
148
- onDragLeave={handleDragLeave}
149
- onDrop={() => handleDrop(index)}
150
- onDragEnd={handleDragEnd}
151
- onClick={() => onTabClick(index)}
152
- className={`group flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 cursor-grab transition-all duration-200 ${
153
- activeConfigIndex === index
154
- ? 'bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-400 shadow-md'
155
- : 'bg-white border-gray-200 hover:border-blue-300 hover:shadow-md'
156
- } ${
157
- dragIndex === index ? 'opacity-50' : ''
158
- } ${
159
- dragOverIndex === index ? 'border-blue-500 border-dashed' : ''
160
- }`}
161
- >
162
- <span className={`text-sm font-medium ${
163
- activeConfigIndex === index ? 'text-blue-700' : 'text-gray-700'
164
- }`}>
165
- {getDisplayName(config.filePath, allFilePaths)}
166
- </span>
167
- <button
168
- onClick={(e) => {
169
- e.stopPropagation();
170
- onRemoveConfig(index);
171
- }}
172
- className="text-gray-400 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100"
173
- >
174
- <XIcon className="w-4 h-4" />
175
- </button>
176
- </div>
177
- ))}
178
- </div>
179
- ) : (
180
- <div className="text-center py-12 bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl border-2 border-dashed border-gray-300">
181
- <FolderOpenIcon className="w-16 h-16 text-gray-400 mx-auto mb-3" />
182
- <p className="text-sm text-gray-600 font-medium">設定ファイルを選択してください</p>
183
- <p className="text-xs text-gray-500 mt-1">「ファイルを追加」ボタンから開始</p>
184
- </div>
185
- )}
186
- </div>
187
- );
188
- }
@@ -1,176 +0,0 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { ConfigTreeNode, ConfigDocs } from '@/types';
5
- import { ChevronRightIcon, ChevronDownIcon, FileTextIcon } from 'lucide-react';
6
- import { ConfigParser } from '@/lib/configParser';
7
-
8
- interface ConfigTreeProps {
9
- config: any;
10
- docs: ConfigDocs;
11
- onSelectProperty: (path: string) => void;
12
- onEditProperty: (path: string) => void;
13
- selectedPath?: string;
14
- }
15
-
16
- export function ConfigTree({
17
- config,
18
- docs,
19
- onSelectProperty,
20
- onEditProperty,
21
- selectedPath
22
- }: ConfigTreeProps) {
23
- const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
24
-
25
- const toggleNode = (path: string) => {
26
- const newExpanded = new Set(expandedNodes);
27
- if (newExpanded.has(path)) {
28
- newExpanded.delete(path);
29
- } else {
30
- newExpanded.add(path);
31
- }
32
- setExpandedNodes(newExpanded);
33
- };
34
-
35
- // すべてのノードパスを収集
36
- const getAllNodePaths = (nodes: ConfigTreeNode[]): string[] => {
37
- const paths: string[] = [];
38
- const collectPaths = (node: ConfigTreeNode) => {
39
- if (node.children && node.children.length > 0) {
40
- paths.push(node.fullPath);
41
- node.children.forEach(collectPaths);
42
- }
43
- };
44
- nodes.forEach(collectPaths);
45
- return paths;
46
- };
47
-
48
- // すべて展開
49
- const expandAll = () => {
50
- const tree = ConfigParser.buildTree(config);
51
- const allPaths = getAllNodePaths(tree);
52
- setExpandedNodes(new Set(allPaths));
53
- };
54
-
55
- // すべて閉じる
56
- const collapseAll = () => {
57
- setExpandedNodes(new Set());
58
- };
59
-
60
- // ドキュメントが有効な内容を持っているかチェック
61
- const hasValidDocumentation = (path: string): boolean => {
62
- const doc = docs.properties[path];
63
- if (!doc) return false;
64
-
65
- // タグがあるか
66
- if (doc.tags && doc.tags.length > 0) return true;
67
-
68
- // フィールドに値があるか
69
- if (doc.fields) {
70
- const hasFieldValue = Object.values(doc.fields).some(
71
- value => value && value.trim() !== ''
72
- );
73
- if (hasFieldValue) return true;
74
- }
75
-
76
- return false;
77
- };
78
-
79
- const renderNode = (node: ConfigTreeNode, depth: number = 0) => {
80
- const hasDoc = hasValidDocumentation(node.fullPath);
81
- const isExpanded = expandedNodes.has(node.fullPath);
82
- const isSelected = selectedPath === node.fullPath;
83
-
84
- return (
85
- <div key={node.fullPath}>
86
- <div
87
- style={{ paddingLeft: `${depth * 20}px` }}
88
- className={`group flex items-center gap-2 py-2 px-3 cursor-pointer rounded-lg transition-all duration-150 ${
89
- isSelected
90
- ? 'bg-gradient-to-r from-blue-100 to-indigo-100 border-l-4 border-blue-500 shadow-sm'
91
- : 'hover:bg-gradient-to-r hover:from-gray-50 hover:to-gray-100'
92
- }`}
93
- >
94
- {node.children && node.children.length > 0 ? (
95
- <button
96
- onClick={() => toggleNode(node.fullPath)}
97
- className="p-1 hover:bg-blue-100 rounded-md transition-colors"
98
- >
99
- {isExpanded ? (
100
- <ChevronDownIcon className="w-4 h-4 text-blue-600" />
101
- ) : (
102
- <ChevronRightIcon className="w-4 h-4 text-gray-600" />
103
- )}
104
- </button>
105
- ) : (
106
- <div className="w-6" />
107
- )}
108
-
109
- <div
110
- onClick={() => onSelectProperty(node.fullPath)}
111
- className="flex-1 flex items-center gap-2"
112
- >
113
- <span className={`${node.children ? 'font-bold text-gray-800' : 'font-medium text-gray-700'}`}>
114
- {node.key}
115
- </span>
116
- {!node.children && (
117
- <span className="text-sm text-gray-500 truncate max-w-[200px]">
118
- : {JSON.stringify(node.value)}
119
- </span>
120
- )}
121
- {hasDoc && (
122
- <div className="flex items-center gap-1 px-2 py-0.5 bg-green-100 border border-green-300 rounded-full">
123
- <FileTextIcon className="w-3 h-3 text-green-600" />
124
- <span className="text-xs text-green-700 font-medium">Doc</span>
125
- </div>
126
- )}
127
- </div>
128
-
129
- {!node.children && (
130
- <button
131
- onClick={(e) => {
132
- e.stopPropagation();
133
- onEditProperty(node.fullPath);
134
- }}
135
- className="p-1 hover:bg-gray-200 rounded"
136
- title="編集"
137
- />
138
- )}
139
- </div>
140
-
141
- {node.children && isExpanded && (
142
- <div>
143
- {node.children.map((child) => renderNode(child, depth + 1))}
144
- </div>
145
- )}
146
- </div>
147
- );
148
- };
149
-
150
- const tree = ConfigParser.buildTree(config);
151
-
152
- return (
153
- <div className="h-full flex flex-col border-2 border-gray-100 rounded-xl bg-white shadow-sm">
154
- {/* ツールバー */}
155
- <div className="flex items-center gap-2 p-3 border-b-2 border-gray-100 bg-gradient-to-r from-gray-50 to-gray-100">
156
- <button
157
- onClick={expandAll}
158
- className="px-4 py-1.5 text-sm font-medium bg-white border-2 border-blue-200 text-blue-600 rounded-lg hover:bg-blue-50 hover:border-blue-300 transition-all duration-200 shadow-sm hover:shadow-md"
159
- >
160
- すべて展開
161
- </button>
162
- <button
163
- onClick={collapseAll}
164
- className="px-4 py-1.5 text-sm font-medium bg-white border-2 border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
165
- >
166
- すべて閉じる
167
- </button>
168
- </div>
169
-
170
- {/* ツリー表示 */}
171
- <div className="flex-1 overflow-y-auto p-3">
172
- {tree.map((node) => renderNode(node))}
173
- </div>
174
- </div>
175
- );
176
- }
@@ -1,337 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useMemo } from 'react';
4
- import { PencilIcon, PlusIcon, XIcon, SaveIcon, GripVerticalIcon } from 'lucide-react';
5
-
6
- export interface EditingItem {
7
- originalName: string;
8
- newName: string;
9
- isNew: boolean;
10
- }
11
-
12
- interface EditableListWrapperProps {
13
- /** セクションのラベル */
14
- label: string;
15
- /** 現在のアイテム一覧 */
16
- items: string[];
17
- /** アイテム一覧が変更されたときのコールバック */
18
- onItemsChange: (items: string[]) => void;
19
- /** 編集ボタンのtitle属性 */
20
- editButtonTitle: string;
21
- /** 編集モードの説明テキスト */
22
- editModeDescription: string;
23
- /** 入力欄のプレースホルダー */
24
- inputPlaceholder: string;
25
- /** 新規追加入力欄のプレースホルダー */
26
- newItemPlaceholder: string;
27
- /** 削除ボタンのtitle属性 */
28
- deleteButtonTitle: string;
29
- /** 追加ボタンのtitle属性 */
30
- addButtonTitle: string;
31
- /** 重複エラーメッセージ */
32
- duplicateErrorMessage: string;
33
- /** 名前変更時に呼ばれるコールバック(旧名 -> 新名のマップ) */
34
- onRename?: (renamedMap: Record<string, string>) => void;
35
- /** 通常モードの表示内容 */
36
- children: React.ReactNode;
37
- }
38
-
39
- /**
40
- * 編集可能なリストを管理する共通コンポーネント
41
- * タグやフィールドの編集UIで使用
42
- */
43
- export function EditableListWrapper({
44
- label,
45
- items,
46
- onItemsChange,
47
- editButtonTitle,
48
- editModeDescription,
49
- inputPlaceholder,
50
- newItemPlaceholder,
51
- deleteButtonTitle,
52
- addButtonTitle,
53
- duplicateErrorMessage,
54
- onRename,
55
- children
56
- }: EditableListWrapperProps) {
57
- const [isEditMode, setIsEditMode] = useState(false);
58
- const [editingItems, setEditingItems] = useState<EditingItem[]>([]);
59
- const [newItemName, setNewItemName] = useState('');
60
-
61
- // ドラッグ&ドロップ用の状態
62
- const [dragIndex, setDragIndex] = useState<number | null>(null);
63
- const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
64
-
65
- // ドラッグ中のプレビュー用に並び替えた配列を計算
66
- const displayItems = useMemo(() => {
67
- if (dragIndex === null || dragOverIndex === null || dragIndex === dragOverIndex) {
68
- return editingItems.map((item, index) => ({ item, originalIndex: index }));
69
- }
70
-
71
- // ドラッグ中は見た目上の並び替えを表示
72
- const result = editingItems.map((item, index) => ({ item, originalIndex: index }));
73
- const [draggedItem] = result.splice(dragIndex, 1);
74
- result.splice(dragOverIndex, 0, draggedItem);
75
- return result;
76
- }, [editingItems, dragIndex, dragOverIndex]);
77
-
78
- // 編集モードに入る
79
- const enterEditMode = () => {
80
- const itemList = items.map(name => ({
81
- originalName: name,
82
- newName: name,
83
- isNew: false
84
- }));
85
- setEditingItems(itemList);
86
- setIsEditMode(true);
87
- setNewItemName('');
88
- };
89
-
90
- // 編集モードをキャンセル
91
- const cancelEditMode = () => {
92
- setIsEditMode(false);
93
- setEditingItems([]);
94
- setNewItemName('');
95
- setDragIndex(null);
96
- setDragOverIndex(null);
97
- };
98
-
99
- // アイテムを追加(編集モード内)
100
- const handleAddItem = () => {
101
- const trimmedName = newItemName.trim();
102
- if (trimmedName && !editingItems.some(item => item.newName === trimmedName)) {
103
- setEditingItems([...editingItems, {
104
- originalName: '',
105
- newName: trimmedName,
106
- isNew: true
107
- }]);
108
- setNewItemName('');
109
- }
110
- };
111
-
112
- // アイテムを削除(編集モード内)
113
- const handleRemoveItem = (originalIndex: number) => {
114
- setEditingItems(editingItems.filter((_, i) => i !== originalIndex));
115
- };
116
-
117
- // アイテム名を変更(編集モード内)
118
- const handleNameChange = (originalIndex: number, newName: string) => {
119
- setEditingItems(editingItems.map((item, i) =>
120
- i === originalIndex ? { ...item, newName } : item
121
- ));
122
- };
123
-
124
- // ドラッグ開始
125
- const handleDragStart = (originalIndex: number) => {
126
- setDragIndex(originalIndex);
127
- };
128
-
129
- // ドラッグオーバー(表示上のインデックスで処理)
130
- const handleDragOver = (e: React.DragEvent, displayIndex: number) => {
131
- e.preventDefault();
132
- if (dragIndex === null) return;
133
-
134
- // 表示上のインデックスをそのまま使用
135
- if (dragOverIndex !== displayIndex) {
136
- setDragOverIndex(displayIndex);
137
- }
138
- };
139
-
140
- // ドロップ時にデータを確定
141
- const handleDrop = () => {
142
- if (dragIndex === null || dragOverIndex === null || dragIndex === dragOverIndex) {
143
- setDragIndex(null);
144
- setDragOverIndex(null);
145
- return;
146
- }
147
-
148
- // 配列の並び替えを確定
149
- const newItems = [...editingItems];
150
- const [draggedItem] = newItems.splice(dragIndex, 1);
151
- newItems.splice(dragOverIndex, 0, draggedItem);
152
- setEditingItems(newItems);
153
-
154
- setDragIndex(null);
155
- setDragOverIndex(null);
156
- };
157
-
158
- // ドラッグ終了(キャンセル時など)
159
- const handleDragEnd = () => {
160
- setDragIndex(null);
161
- setDragOverIndex(null);
162
- };
163
-
164
- // 保存処理
165
- const handleSave = () => {
166
- const newItems: string[] = [];
167
- const renamedMap: Record<string, string> = {};
168
-
169
- for (const item of editingItems) {
170
- const trimmedName = item.newName.trim();
171
- if (!trimmedName) continue;
172
-
173
- newItems.push(trimmedName);
174
-
175
- // 名前変更の追跡
176
- if (!item.isNew && item.originalName !== trimmedName) {
177
- renamedMap[item.originalName] = trimmedName;
178
- }
179
- }
180
-
181
- // 入力中のアイテムがあれば追加
182
- const pendingName = newItemName.trim();
183
- if (pendingName && !newItems.includes(pendingName)) {
184
- newItems.push(pendingName);
185
- }
186
-
187
- // 名前変更コールバック
188
- if (onRename && Object.keys(renamedMap).length > 0) {
189
- onRename(renamedMap);
190
- }
191
-
192
- onItemsChange(newItems);
193
- setIsEditMode(false);
194
- setEditingItems([]);
195
- setNewItemName('');
196
- };
197
-
198
- const handleKeyDown = (e: React.KeyboardEvent) => {
199
- if (e.key === 'Enter') {
200
- e.preventDefault();
201
- handleAddItem();
202
- }
203
- };
204
-
205
- // バリデーション: 重複チェック
206
- const hasDuplicateNames = () => {
207
- const names = editingItems.map(item => item.newName.trim()).filter(Boolean);
208
- return new Set(names).size !== names.length;
209
- };
210
-
211
- // バリデーション: 空の名前チェック
212
- const hasEmptyNames = () => {
213
- return editingItems.some(item => !item.newName.trim());
214
- };
215
-
216
- const canSave = !hasDuplicateNames() && !hasEmptyNames();
217
-
218
- return (
219
- <div>
220
- <div className="flex items-center justify-between mb-2">
221
- <label className="block text-sm font-semibold text-gray-700">
222
- {label}
223
- </label>
224
- {!isEditMode && (
225
- <button
226
- onClick={enterEditMode}
227
- className="text-gray-500 hover:text-blue-600 transition-colors p-1"
228
- title={editButtonTitle}
229
- >
230
- <PencilIcon className="w-4 h-4" />
231
- </button>
232
- )}
233
- </div>
234
-
235
- {isEditMode ? (
236
- <div className="space-y-3 p-4 bg-blue-50 rounded-lg border-2 border-blue-200">
237
- <div className="text-xs font-medium text-blue-700 mb-2">
238
- {editModeDescription}(ドラッグで並び替え可能)
239
- </div>
240
-
241
- {/* 既存アイテムの編集 */}
242
- <div className="space-y-2">
243
- {displayItems.map(({ item, originalIndex }, displayIndex) => {
244
- const isDragging = dragIndex === originalIndex;
245
-
246
- return (
247
- <div
248
- key={originalIndex}
249
- draggable
250
- onDragStart={() => handleDragStart(originalIndex)}
251
- onDragOver={(e) => handleDragOver(e, displayIndex)}
252
- onDrop={handleDrop}
253
- onDragEnd={handleDragEnd}
254
- className={`flex items-center gap-2 transition-all ${
255
- isDragging ? 'opacity-50' : ''
256
- }`}
257
- >
258
- <div
259
- className="cursor-grab text-gray-400 hover:text-gray-600 p-1"
260
- title="ドラッグして並び替え"
261
- >
262
- <GripVerticalIcon className="w-4 h-4" />
263
- </div>
264
- <input
265
- type="text"
266
- value={item.newName}
267
- onChange={(e) => handleNameChange(originalIndex, e.target.value)}
268
- placeholder={inputPlaceholder}
269
- className={`flex-1 px-3 py-2 border-2 rounded-lg text-sm focus:ring-2 transition-all duration-200 ${
270
- !item.newName.trim()
271
- ? 'border-red-300 focus:border-red-400 focus:ring-red-200'
272
- : 'border-gray-200 focus:border-blue-400 focus:ring-blue-200'
273
- }`}
274
- />
275
- <button
276
- onClick={() => handleRemoveItem(originalIndex)}
277
- className="text-gray-400 hover:text-red-600 transition-colors p-2"
278
- title={deleteButtonTitle}
279
- >
280
- <XIcon className="w-4 h-4" />
281
- </button>
282
- </div>
283
- );
284
- })}
285
- </div>
286
-
287
- {/* 新規アイテム追加 */}
288
- <div className="flex items-center gap-2 pt-2 border-t border-blue-200">
289
- <input
290
- type="text"
291
- value={newItemName}
292
- onChange={(e) => setNewItemName(e.target.value)}
293
- onKeyDown={handleKeyDown}
294
- placeholder={newItemPlaceholder}
295
- className="flex-1 px-3 py-2 border-2 border-gray-200 rounded-lg text-sm focus:border-blue-400 focus:ring-2 focus:ring-blue-200 transition-all duration-200"
296
- />
297
- <button
298
- onClick={handleAddItem}
299
- disabled={!newItemName.trim() || editingItems.some(item => item.newName.trim() === newItemName.trim())}
300
- className="px-3 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
301
- title={addButtonTitle}
302
- >
303
- <PlusIcon className="w-4 h-4" />
304
- </button>
305
- </div>
306
-
307
- {/* エラーメッセージ */}
308
- {hasDuplicateNames() && (
309
- <div className="text-xs text-red-600 mt-2">
310
- {duplicateErrorMessage}
311
- </div>
312
- )}
313
-
314
- {/* 保存・キャンセルボタン */}
315
- <div className="flex items-center gap-2 pt-3 border-t border-blue-200">
316
- <button
317
- onClick={handleSave}
318
- disabled={!canSave}
319
- className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed text-sm font-medium"
320
- >
321
- <SaveIcon className="w-4 h-4" />
322
- 保存
323
- </button>
324
- <button
325
- onClick={cancelEditMode}
326
- className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
327
- >
328
- キャンセル
329
- </button>
330
- </div>
331
- </div>
332
- ) : (
333
- children
334
- )}
335
- </div>
336
- );
337
- }