@izumisy-tailor/tailor-data-viewer 0.1.0

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 (37) hide show
  1. package/README.md +255 -0
  2. package/package.json +47 -0
  3. package/src/component/column-selector.tsx +264 -0
  4. package/src/component/data-table.tsx +428 -0
  5. package/src/component/data-view-tab-content.tsx +324 -0
  6. package/src/component/data-viewer.tsx +280 -0
  7. package/src/component/hooks/use-accessible-tables.ts +22 -0
  8. package/src/component/hooks/use-column-state.ts +281 -0
  9. package/src/component/hooks/use-relation-data.ts +387 -0
  10. package/src/component/hooks/use-table-data.ts +317 -0
  11. package/src/component/index.ts +15 -0
  12. package/src/component/pagination.tsx +56 -0
  13. package/src/component/relation-content.tsx +250 -0
  14. package/src/component/saved-view-context.tsx +145 -0
  15. package/src/component/search-filter.tsx +319 -0
  16. package/src/component/single-record-tab-content.tsx +676 -0
  17. package/src/component/table-selector.tsx +102 -0
  18. package/src/component/types.ts +20 -0
  19. package/src/component/view-save-load.tsx +112 -0
  20. package/src/generator/metadata-generator.ts +461 -0
  21. package/src/lib/utils.ts +6 -0
  22. package/src/providers/graphql-client.ts +31 -0
  23. package/src/styles/theme.css +105 -0
  24. package/src/types/table-metadata.ts +73 -0
  25. package/src/ui/alert.tsx +66 -0
  26. package/src/ui/badge.tsx +46 -0
  27. package/src/ui/button.tsx +62 -0
  28. package/src/ui/card.tsx +92 -0
  29. package/src/ui/checkbox.tsx +30 -0
  30. package/src/ui/collapsible.tsx +31 -0
  31. package/src/ui/dialog.tsx +143 -0
  32. package/src/ui/dropdown-menu.tsx +255 -0
  33. package/src/ui/input.tsx +21 -0
  34. package/src/ui/label.tsx +24 -0
  35. package/src/ui/select.tsx +188 -0
  36. package/src/ui/table.tsx +116 -0
  37. package/src/utils/query-builder.ts +190 -0
@@ -0,0 +1,676 @@
1
+ import { useState, useCallback, useMemo, useEffect } from "react";
2
+ import { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { Button } from "../ui/button";
5
+ import { Badge } from "../ui/badge";
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from "../ui/table";
14
+ import type {
15
+ TableMetadata,
16
+ TableMetadataMap,
17
+ FieldMetadata,
18
+ } from "../types/table-metadata";
19
+ import { createGraphQLClient, executeQuery } from "../providers/graphql-client";
20
+ import { formatFieldValue } from "../utils/query-builder";
21
+ import { ColumnSelector } from "./column-selector";
22
+ import { useColumnState } from "./hooks/use-column-state";
23
+
24
+ interface SingleRecordTabContentProps {
25
+ /** Target table metadata */
26
+ tableMetadata: TableMetadata;
27
+ /** All table metadata for relation lookup */
28
+ tableMetadataMap: TableMetadataMap;
29
+ /** App URI for GraphQL requests */
30
+ appUri: string;
31
+ /** Record ID to fetch */
32
+ recordId: string;
33
+ /** Callback to open a relation as a new sheet (table view) */
34
+ onOpenAsSheet?: (
35
+ targetTableName: string,
36
+ filterField: string,
37
+ filterValue: string,
38
+ ) => void;
39
+ /** Callback to open a single record as a new sheet */
40
+ onOpenSingleRecordAsSheet?: (
41
+ targetTableName: string,
42
+ recordId: string,
43
+ ) => void;
44
+ }
45
+
46
+ interface RelationData {
47
+ data: Record<string, unknown>[] | null;
48
+ loading: boolean;
49
+ error: Error | null;
50
+ hasNextPage: boolean;
51
+ endCursor: string | null;
52
+ }
53
+
54
+ const PAGE_SIZE = 10;
55
+
56
+ /**
57
+ * Convert camelCase to PascalCase
58
+ */
59
+ function toPascalCase(str: string): string {
60
+ return str.charAt(0).toUpperCase() + str.slice(1);
61
+ }
62
+
63
+ /**
64
+ * Get display fields for a table (excluding nested types)
65
+ */
66
+ function getDisplayFields(table: TableMetadata): FieldMetadata[] {
67
+ return table.fields.filter(
68
+ (field) => field.type !== "nested" && field.type !== "array",
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Get fields for relation table display
74
+ */
75
+ function getRelationDisplayFields(table: TableMetadata): FieldMetadata[] {
76
+ return table.fields
77
+ .filter(
78
+ (field) =>
79
+ field.type !== "nested" &&
80
+ field.type !== "array" &&
81
+ field.name !== "id" &&
82
+ !field.name.endsWith("Id") &&
83
+ !["createdAt", "updatedAt"].includes(field.name),
84
+ )
85
+ .slice(0, 6);
86
+ }
87
+
88
+ /**
89
+ * Content for a single record view tab
90
+ * Shows record details and all its relations with separators
91
+ */
92
+ export function SingleRecordTabContent({
93
+ tableMetadata,
94
+ tableMetadataMap,
95
+ appUri,
96
+ recordId,
97
+ onOpenAsSheet,
98
+ onOpenSingleRecordAsSheet,
99
+ }: SingleRecordTabContentProps) {
100
+ // Record data state
101
+ const [record, setRecord] = useState<Record<string, unknown> | null>(null);
102
+ const [loading, setLoading] = useState(true);
103
+ const [error, setError] = useState<Error | null>(null);
104
+
105
+ // Relation data states (keyed by relation field name)
106
+ const [relationDataMap, setRelationDataMap] = useState<
107
+ Record<string, RelationData>
108
+ >({});
109
+
110
+ // Column state for field selection
111
+ const allDisplayFields = useMemo(
112
+ () => getDisplayFields(tableMetadata),
113
+ [tableMetadata],
114
+ );
115
+ const columnState = useColumnState(allDisplayFields, tableMetadata.relations);
116
+
117
+ const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
118
+
119
+ // Fetch the main record
120
+ const fetchRecord = useCallback(async () => {
121
+ setLoading(true);
122
+ setError(null);
123
+
124
+ try {
125
+ const fields = getDisplayFields(tableMetadata)
126
+ .map((f) => f.name)
127
+ .join("\n ");
128
+
129
+ // Also include FK fields for manyToOne relations
130
+ const fkFields = (tableMetadata.relations ?? [])
131
+ .filter((r) => r.relationType === "manyToOne")
132
+ .map((r) => r.foreignKeyField)
133
+ .filter((fk) => !tableMetadata.fields.some((f) => f.name === fk));
134
+
135
+ const allFields =
136
+ fkFields.length > 0
137
+ ? `${fields}\n ${fkFields.join("\n ")}`
138
+ : fields;
139
+
140
+ const query = `
141
+ query ${toPascalCase(tableMetadata.name)}Single($id: ID!) {
142
+ ${tableMetadata.name}(id: $id) {
143
+ ${allFields}
144
+ }
145
+ }
146
+ `.trim();
147
+
148
+ const result = await executeQuery<
149
+ Record<string, Record<string, unknown> | null>
150
+ >(client, query, { id: recordId });
151
+
152
+ setRecord(result[tableMetadata.name]);
153
+ } catch (err) {
154
+ setError(
155
+ err instanceof Error ? err : new Error("Failed to fetch record"),
156
+ );
157
+ } finally {
158
+ setLoading(false);
159
+ }
160
+ }, [client, tableMetadata, recordId]);
161
+
162
+ // Fetch relation data (oneToMany)
163
+ const fetchRelationData = useCallback(
164
+ async (relationFieldName: string, append = false) => {
165
+ const relation = tableMetadata.relations?.find(
166
+ (r) => r.fieldName === relationFieldName,
167
+ );
168
+ if (!relation) return;
169
+
170
+ const targetTable = tableMetadataMap[relation.targetTable];
171
+ if (!targetTable) return;
172
+
173
+ const existingData = relationDataMap[relationFieldName];
174
+ if (append && !existingData?.hasNextPage) return;
175
+
176
+ setRelationDataMap((prev) => ({
177
+ ...prev,
178
+ [relationFieldName]: {
179
+ ...prev[relationFieldName],
180
+ data: append ? (prev[relationFieldName]?.data ?? null) : null,
181
+ loading: true,
182
+ error: null,
183
+ hasNextPage: prev[relationFieldName]?.hasNextPage ?? false,
184
+ endCursor: prev[relationFieldName]?.endCursor ?? null,
185
+ },
186
+ }));
187
+
188
+ try {
189
+ const fields = getRelationDisplayFields(targetTable)
190
+ .map((f) => f.name)
191
+ .join("\n ");
192
+ const afterCursor = append ? existingData?.endCursor : undefined;
193
+ const afterArg = afterCursor ? `, after: "${afterCursor}"` : "";
194
+
195
+ const query = `
196
+ query ${toPascalCase(relation.targetTable)}List($parentId: ID!) {
197
+ ${targetTable.pluralForm}(query: { ${relation.foreignKeyField}: { eq: $parentId } }, first: ${PAGE_SIZE}${afterArg}) {
198
+ edges {
199
+ node {
200
+ id
201
+ ${fields}
202
+ }
203
+ }
204
+ pageInfo {
205
+ hasNextPage
206
+ endCursor
207
+ }
208
+ }
209
+ }
210
+ `.trim();
211
+
212
+ const result = await executeQuery<
213
+ Record<
214
+ string,
215
+ {
216
+ edges: { node: Record<string, unknown> }[];
217
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
218
+ }
219
+ >
220
+ >(client, query, { parentId: recordId });
221
+
222
+ const responseData = result[targetTable.pluralForm];
223
+ if (responseData) {
224
+ const newData = responseData.edges.map((edge) => edge.node);
225
+ const existingRecords =
226
+ append && Array.isArray(existingData?.data)
227
+ ? existingData.data
228
+ : [];
229
+
230
+ setRelationDataMap((prev) => ({
231
+ ...prev,
232
+ [relationFieldName]: {
233
+ data: [...existingRecords, ...newData],
234
+ loading: false,
235
+ error: null,
236
+ hasNextPage: responseData.pageInfo.hasNextPage,
237
+ endCursor: responseData.pageInfo.endCursor,
238
+ },
239
+ }));
240
+ }
241
+ } catch (err) {
242
+ setRelationDataMap((prev) => ({
243
+ ...prev,
244
+ [relationFieldName]: {
245
+ ...prev[relationFieldName],
246
+ data: prev[relationFieldName]?.data ?? null,
247
+ loading: false,
248
+ error:
249
+ err instanceof Error
250
+ ? err
251
+ : new Error("Failed to fetch relation"),
252
+ hasNextPage: false,
253
+ endCursor: null,
254
+ },
255
+ }));
256
+ }
257
+ },
258
+ [client, tableMetadata, tableMetadataMap, recordId, relationDataMap],
259
+ );
260
+
261
+ // Fetch manyToOne relation data
262
+ const fetchManyToOneData = useCallback(
263
+ async (relationFieldName: string, relatedId: string) => {
264
+ const relation = tableMetadata.relations?.find(
265
+ (r) => r.fieldName === relationFieldName,
266
+ );
267
+ if (!relation) return;
268
+
269
+ const targetTable = tableMetadataMap[relation.targetTable];
270
+ if (!targetTable) return;
271
+
272
+ setRelationDataMap((prev) => ({
273
+ ...prev,
274
+ [relationFieldName]: {
275
+ data: null,
276
+ loading: true,
277
+ error: null,
278
+ hasNextPage: false,
279
+ endCursor: null,
280
+ },
281
+ }));
282
+
283
+ try {
284
+ const fields = getDisplayFields(targetTable)
285
+ .map((f) => f.name)
286
+ .join("\n ");
287
+
288
+ const query = `
289
+ query ${toPascalCase(relation.targetTable)}Single($id: ID!) {
290
+ ${relation.targetTable}(id: $id) {
291
+ ${fields}
292
+ }
293
+ }
294
+ `.trim();
295
+
296
+ const result = await executeQuery<
297
+ Record<string, Record<string, unknown> | null>
298
+ >(client, query, { id: relatedId });
299
+
300
+ const data = result[relation.targetTable];
301
+ setRelationDataMap((prev) => ({
302
+ ...prev,
303
+ [relationFieldName]: {
304
+ data: data ? [data] : null,
305
+ loading: false,
306
+ error: null,
307
+ hasNextPage: false,
308
+ endCursor: null,
309
+ },
310
+ }));
311
+ } catch (err) {
312
+ setRelationDataMap((prev) => ({
313
+ ...prev,
314
+ [relationFieldName]: {
315
+ data: null,
316
+ loading: false,
317
+ error:
318
+ err instanceof Error
319
+ ? err
320
+ : new Error("Failed to fetch relation"),
321
+ hasNextPage: false,
322
+ endCursor: null,
323
+ },
324
+ }));
325
+ }
326
+ },
327
+ [client, tableMetadata, tableMetadataMap],
328
+ );
329
+
330
+ // Initial fetch
331
+ useEffect(() => {
332
+ fetchRecord();
333
+ }, [fetchRecord]);
334
+
335
+ // Fetch relations after record is loaded
336
+ useEffect(() => {
337
+ if (!record || !tableMetadata.relations) return;
338
+
339
+ for (const relation of tableMetadata.relations) {
340
+ if (relation.relationType === "oneToMany") {
341
+ fetchRelationData(relation.fieldName);
342
+ } else if (relation.relationType === "manyToOne") {
343
+ const relatedId = record[relation.foreignKeyField];
344
+ if (typeof relatedId === "string" && relatedId) {
345
+ fetchManyToOneData(relation.fieldName, relatedId);
346
+ }
347
+ }
348
+ }
349
+ // eslint-disable-next-line react-hooks/exhaustive-deps
350
+ }, [record, tableMetadata.relations]);
351
+
352
+ const handleRefresh = () => {
353
+ fetchRecord();
354
+ setRelationDataMap({});
355
+ };
356
+
357
+ if (loading && !record) {
358
+ return (
359
+ <div className="flex items-center justify-center py-12">
360
+ <Loader2 className="text-muted-foreground size-6 animate-spin" />
361
+ <span className="text-muted-foreground ml-2">読み込み中...</span>
362
+ </div>
363
+ );
364
+ }
365
+
366
+ if (error) {
367
+ return (
368
+ <Alert variant="destructive">
369
+ <AlertDescription>
370
+ データの取得に失敗しました: {error.message}
371
+ </AlertDescription>
372
+ </Alert>
373
+ );
374
+ }
375
+
376
+ if (!record) {
377
+ return (
378
+ <div className="text-muted-foreground py-8 text-center">
379
+ レコードが見つかりません
380
+ </div>
381
+ );
382
+ }
383
+
384
+ const displayFields = allDisplayFields.filter((f) =>
385
+ columnState.selectedFields.includes(f.name),
386
+ );
387
+ const relations = (tableMetadata.relations ?? []).filter((r) =>
388
+ columnState.selectedRelations.includes(r.fieldName),
389
+ );
390
+
391
+ return (
392
+ <div className="space-y-6">
393
+ {/* Header */}
394
+ <div className="space-y-2">
395
+ <div className="flex items-baseline gap-2">
396
+ <span className="font-medium">{tableMetadata.name}</span>
397
+ {tableMetadata.description && (
398
+ <span className="text-muted-foreground text-sm">
399
+ {tableMetadata.description}
400
+ </span>
401
+ )}
402
+ </div>
403
+ <div className="flex items-center gap-4">
404
+ <Badge variant="outline" className="text-xs">
405
+ ID: {recordId.substring(0, 8)}...
406
+ </Badge>
407
+
408
+ <ColumnSelector
409
+ fields={allDisplayFields}
410
+ selectedFields={columnState.selectedFields}
411
+ onToggle={columnState.toggleField}
412
+ onSelectAll={columnState.selectAll}
413
+ onDeselectAll={columnState.deselectAll}
414
+ relations={tableMetadata.relations}
415
+ selectedRelations={columnState.selectedRelations}
416
+ onToggleRelation={columnState.toggleRelation}
417
+ />
418
+
419
+ <div className="flex-1" />
420
+ <Button
421
+ variant="outline"
422
+ size="sm"
423
+ onClick={handleRefresh}
424
+ disabled={loading}
425
+ >
426
+ <RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
427
+ 更新
428
+ </Button>
429
+ </div>
430
+ </div>
431
+
432
+ {/* Main Record Data */}
433
+ <div className="rounded-lg border p-4">
434
+ <div className="text-muted-foreground mb-3 text-sm font-medium">
435
+ レコード詳細
436
+ </div>
437
+ <div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">
438
+ {displayFields.map((field) => (
439
+ <div key={field.name} className="flex flex-col">
440
+ <span className="text-muted-foreground text-xs font-medium">
441
+ {field.name}
442
+ </span>
443
+ <span className="text-sm">
444
+ {formatFieldValue(record[field.name], field)}
445
+ </span>
446
+ </div>
447
+ ))}
448
+ </div>
449
+ </div>
450
+
451
+ {/* Relations with Separators */}
452
+ {relations.length > 0 && (
453
+ <div className="space-y-6">
454
+ {relations.map((relation) => {
455
+ const targetTable = tableMetadataMap[relation.targetTable];
456
+ const relationData = relationDataMap[relation.fieldName];
457
+ const displayFieldsForRelation = targetTable
458
+ ? relation.relationType === "manyToOne"
459
+ ? getDisplayFields(targetTable)
460
+ : getRelationDisplayFields(targetTable)
461
+ : [];
462
+
463
+ return (
464
+ <div key={relation.fieldName}>
465
+ {/* Separator */}
466
+ <div className="relative my-4">
467
+ <div className="absolute inset-0 flex items-center">
468
+ <span className="w-full border-t" />
469
+ </div>
470
+ <div className="relative flex justify-center text-xs uppercase">
471
+ <span className="bg-background text-muted-foreground px-2">
472
+ {relation.fieldName} (
473
+ {relation.relationType === "manyToOne" ? "1" : "N"})
474
+ </span>
475
+ </div>
476
+ </div>
477
+
478
+ {/* Relation Content */}
479
+ <div className="rounded-lg border p-4">
480
+ <div className="mb-3 flex items-center gap-2">
481
+ <Badge variant="outline" className="text-xs">
482
+ {relation.targetTable}
483
+ </Badge>
484
+ <span className="text-muted-foreground text-xs">
485
+ {relation.relationType === "manyToOne"
486
+ ? "manyToOne"
487
+ : `oneToMany (${relationData?.data?.length ?? 0}件${relationData?.hasNextPage ? "+" : ""})`}
488
+ </span>
489
+ <div className="flex-1" />
490
+ {relation.relationType === "oneToMany" && onOpenAsSheet && (
491
+ <Button
492
+ variant="outline"
493
+ size="sm"
494
+ onClick={() =>
495
+ onOpenAsSheet(
496
+ relation.targetTable,
497
+ relation.foreignKeyField,
498
+ recordId,
499
+ )
500
+ }
501
+ className="h-6 text-xs"
502
+ >
503
+ <ExternalLink className="mr-1 size-3" />
504
+ シートで開く
505
+ </Button>
506
+ )}
507
+ </div>
508
+
509
+ {/* Loading state */}
510
+ {relationData?.loading && !relationData.data && (
511
+ <div className="flex items-center justify-center py-4">
512
+ <Loader2 className="text-muted-foreground size-5 animate-spin" />
513
+ <span className="text-muted-foreground ml-2 text-sm">
514
+ 読み込み中...
515
+ </span>
516
+ </div>
517
+ )}
518
+
519
+ {/* Error state */}
520
+ {relationData?.error && (
521
+ <div className="text-destructive py-4 text-center text-sm">
522
+ エラー: {relationData.error.message}
523
+ </div>
524
+ )}
525
+
526
+ {/* No data */}
527
+ {!relationData?.loading &&
528
+ !relationData?.error &&
529
+ (!relationData?.data || relationData.data.length === 0) && (
530
+ <div className="text-muted-foreground py-4 text-center text-sm">
531
+ 関連データがありません
532
+ </div>
533
+ )}
534
+
535
+ {/* Data display */}
536
+ {relationData?.data && relationData.data.length > 0 && (
537
+ <>
538
+ {relation.relationType === "manyToOne" ? (
539
+ // Single record grid display for manyToOne
540
+ <div className="space-y-3">
541
+ <div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">
542
+ {displayFieldsForRelation.map((field) => {
543
+ const records = relationData.data!;
544
+ const firstRecord = records[0];
545
+ return (
546
+ <div key={field.name} className="flex flex-col">
547
+ <span className="text-muted-foreground text-xs font-medium">
548
+ {field.name}
549
+ </span>
550
+ <span className="text-sm">
551
+ {formatFieldValue(
552
+ firstRecord?.[field.name],
553
+ field,
554
+ )}
555
+ </span>
556
+ </div>
557
+ );
558
+ })}
559
+ </div>
560
+ {/* Open as sheet button for manyToOne */}
561
+ {onOpenSingleRecordAsSheet && (
562
+ <div className="flex justify-end pt-2">
563
+ <Button
564
+ variant="outline"
565
+ size="sm"
566
+ onClick={() => {
567
+ const records = relationData.data!;
568
+ const relatedRecord = records[0];
569
+ const relatedId = relatedRecord?.id;
570
+ if (typeof relatedId === "string") {
571
+ onOpenSingleRecordAsSheet(
572
+ relation.targetTable,
573
+ relatedId,
574
+ );
575
+ }
576
+ }}
577
+ className="h-6 text-xs"
578
+ >
579
+ <ExternalLink className="mr-1 size-3" />
580
+ シートで開く
581
+ </Button>
582
+ </div>
583
+ )}
584
+ </div>
585
+ ) : (
586
+ // Table display for oneToMany
587
+ <>
588
+ <div className="overflow-hidden rounded-md border">
589
+ <Table>
590
+ <TableHeader>
591
+ <TableRow className="bg-muted/50">
592
+ {displayFieldsForRelation.map((field) => (
593
+ <TableHead
594
+ key={field.name}
595
+ className="text-xs"
596
+ >
597
+ {field.name}
598
+ </TableHead>
599
+ ))}
600
+ {onOpenSingleRecordAsSheet && (
601
+ <TableHead className="w-24 text-xs" />
602
+ )}
603
+ </TableRow>
604
+ </TableHeader>
605
+ <TableBody>
606
+ {relationData.data.map((row, index) => (
607
+ <TableRow key={(row.id as string) ?? index}>
608
+ {displayFieldsForRelation.map((field) => (
609
+ <TableCell
610
+ key={field.name}
611
+ className="text-xs"
612
+ >
613
+ {formatFieldValue(
614
+ row[field.name],
615
+ field,
616
+ )}
617
+ </TableCell>
618
+ ))}
619
+ {onOpenSingleRecordAsSheet && (
620
+ <TableCell className="text-xs">
621
+ <Button
622
+ variant="ghost"
623
+ size="sm"
624
+ onClick={() => {
625
+ const rowId = row.id;
626
+ if (typeof rowId === "string") {
627
+ onOpenSingleRecordAsSheet(
628
+ relation.targetTable,
629
+ rowId,
630
+ );
631
+ }
632
+ }}
633
+ className="h-6 text-xs"
634
+ >
635
+ <ExternalLink className="size-3" />
636
+ </Button>
637
+ </TableCell>
638
+ )}
639
+ </TableRow>
640
+ ))}
641
+ </TableBody>
642
+ </Table>
643
+ </div>
644
+ {relationData.hasNextPage && (
645
+ <div className="mt-2 flex justify-center">
646
+ <Button
647
+ variant="ghost"
648
+ size="sm"
649
+ onClick={() =>
650
+ fetchRelationData(relation.fieldName, true)
651
+ }
652
+ disabled={relationData.loading}
653
+ className="text-xs"
654
+ >
655
+ {relationData.loading ? (
656
+ <Loader2 className="mr-1 size-3 animate-spin" />
657
+ ) : (
658
+ <ChevronDown className="mr-1 size-3" />
659
+ )}
660
+ もっと見る
661
+ </Button>
662
+ </div>
663
+ )}
664
+ </>
665
+ )}
666
+ </>
667
+ )}
668
+ </div>
669
+ </div>
670
+ );
671
+ })}
672
+ </div>
673
+ )}
674
+ </div>
675
+ );
676
+ }