@assistkick/create 1.7.0 → 1.8.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.
- package/dist/bin/create.js +0 -0
- package/package.json +9 -7
- package/templates/assistkick-product-system/.env.example +1 -0
- package/templates/assistkick-product-system/local.db +0 -0
- package/templates/assistkick-product-system/package.json +4 -2
- package/templates/assistkick-product-system/packages/backend/package.json +2 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
- package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
- package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
- package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
- package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
- package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
- package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
- package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
- package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
- package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
- package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
- package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
- package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
- package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
- package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
- package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
- package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
- package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
- package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
- package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
- package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
- package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
- package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +202 -171
- package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
- package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
- package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
- package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
- package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
- package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
- package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
- package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
- package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
- package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
- package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
- package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
- package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
- package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
- package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
- package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
- package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
- package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
- package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
- package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
- package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
- package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
- package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
- package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
- package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
- package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
- package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
- package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
- package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
- package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
- package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
- package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
- package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
- package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
- package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
- package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
- package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
- package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
- package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
- package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
- package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
- package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
- package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
- package/templates/assistkick-product-system/packages/video/index.ts +4 -0
- package/templates/assistkick-product-system/packages/video/package.json +28 -0
- package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
- package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
- package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
- package/templates/assistkick-product-system/packages/video/style.css +1 -0
- package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
- package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
- package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
- package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
- package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
- package/templates/skills/assistkick-developer/SKILL.md +3 -0
- package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
- package/templates/skills/product-system/graph.json +1890 -0
- package/templates/skills/product-system/kanban.json +304 -0
- package/templates/skills/product-system/nodes/comp_001.md +56 -0
- package/templates/skills/product-system/nodes/comp_002.md +57 -0
- package/templates/skills/product-system/nodes/data_001.md +51 -0
- package/templates/skills/product-system/nodes/data_002.md +40 -0
- package/templates/skills/product-system/nodes/data_004.md +38 -0
- package/templates/skills/product-system/nodes/dec_001.md +34 -0
- package/templates/skills/product-system/nodes/dec_016.md +32 -0
- package/templates/skills/product-system/nodes/feat_008.md +30 -0
- package/templates/skills/video-composition-agent/SKILL.md +232 -0
- package/templates/skills/video-script-writer/SKILL.md +136 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Film,
|
|
4
|
+
Download,
|
|
5
|
+
Trash2,
|
|
6
|
+
Play,
|
|
7
|
+
X,
|
|
8
|
+
Clock,
|
|
9
|
+
HardDrive,
|
|
10
|
+
Loader2,
|
|
11
|
+
AlertCircle,
|
|
12
|
+
CheckCircle2,
|
|
13
|
+
CircleDot,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
import { apiClient } from '../api/client';
|
|
16
|
+
import type { VideoRenderStatus } from '../api/client';
|
|
17
|
+
import { IconButton } from './ds/IconButton';
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL_MS = 3000;
|
|
20
|
+
|
|
21
|
+
interface VideoGalleryProps {
|
|
22
|
+
projectId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const formatFileSize = (bytes: number | null): string => {
|
|
26
|
+
if (bytes === null || bytes === 0) return '—';
|
|
27
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
28
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
29
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const formatDuration = (seconds: number | null): string => {
|
|
33
|
+
if (seconds === null) return '—';
|
|
34
|
+
const m = Math.floor(seconds / 60);
|
|
35
|
+
const s = Math.round(seconds % 60);
|
|
36
|
+
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const formatDate = (iso: string): string => {
|
|
40
|
+
const d = new Date(iso);
|
|
41
|
+
return d.toLocaleDateString(undefined, {
|
|
42
|
+
month: 'short',
|
|
43
|
+
day: 'numeric',
|
|
44
|
+
hour: '2-digit',
|
|
45
|
+
minute: '2-digit',
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const statusConfig: Record<VideoRenderStatus['status'], { icon: React.ReactNode; label: string; color: string }> = {
|
|
50
|
+
queued: {
|
|
51
|
+
icon: <Clock size={12} />,
|
|
52
|
+
label: 'Queued',
|
|
53
|
+
color: 'text-content-muted',
|
|
54
|
+
},
|
|
55
|
+
rendering: {
|
|
56
|
+
icon: <Loader2 size={12} className="animate-spin" />,
|
|
57
|
+
label: 'Rendering',
|
|
58
|
+
color: 'text-amber-500',
|
|
59
|
+
},
|
|
60
|
+
complete: {
|
|
61
|
+
icon: <CheckCircle2 size={12} />,
|
|
62
|
+
label: 'Complete',
|
|
63
|
+
color: 'text-emerald-500',
|
|
64
|
+
},
|
|
65
|
+
failed: {
|
|
66
|
+
icon: <AlertCircle size={12} />,
|
|
67
|
+
label: 'Failed',
|
|
68
|
+
color: 'text-error',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const VideoGallery = ({ projectId }: VideoGalleryProps) => {
|
|
73
|
+
const [renders, setRenders] = useState<VideoRenderStatus[]>([]);
|
|
74
|
+
const [loading, setLoading] = useState(true);
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const [playingId, setPlayingId] = useState<string | null>(null);
|
|
77
|
+
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
78
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
79
|
+
|
|
80
|
+
const fetchRenders = useCallback(async () => {
|
|
81
|
+
try {
|
|
82
|
+
const data = await apiClient.fetchVideoRenders(projectId);
|
|
83
|
+
setRenders(data.renders);
|
|
84
|
+
setError(null);
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
setError(err.message);
|
|
87
|
+
} finally {
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
}, [projectId]);
|
|
91
|
+
|
|
92
|
+
// Initial fetch
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
setLoading(true);
|
|
95
|
+
fetchRenders();
|
|
96
|
+
}, [fetchRenders]);
|
|
97
|
+
|
|
98
|
+
// Poll when there are in-progress renders
|
|
99
|
+
const hasActiveRenders = renders.some(r => r.status === 'queued' || r.status === 'rendering');
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (hasActiveRenders) {
|
|
103
|
+
pollRef.current = setInterval(fetchRenders, POLL_INTERVAL_MS);
|
|
104
|
+
}
|
|
105
|
+
return () => {
|
|
106
|
+
if (pollRef.current) {
|
|
107
|
+
clearInterval(pollRef.current);
|
|
108
|
+
pollRef.current = null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}, [hasActiveRenders, fetchRenders]);
|
|
112
|
+
|
|
113
|
+
const handleDelete = useCallback(async (renderId: string) => {
|
|
114
|
+
setDeletingId(renderId);
|
|
115
|
+
try {
|
|
116
|
+
await apiClient.deleteVideoRender(renderId);
|
|
117
|
+
setRenders(prev => prev.filter(r => r.id !== renderId));
|
|
118
|
+
if (playingId === renderId) setPlayingId(null);
|
|
119
|
+
} catch (err: any) {
|
|
120
|
+
setError(err.message);
|
|
121
|
+
} finally {
|
|
122
|
+
setDeletingId(null);
|
|
123
|
+
}
|
|
124
|
+
}, [playingId]);
|
|
125
|
+
|
|
126
|
+
const handleDownload = useCallback((renderId: string) => {
|
|
127
|
+
const url = apiClient.getVideoFileUrl(renderId);
|
|
128
|
+
const a = document.createElement('a');
|
|
129
|
+
a.href = url;
|
|
130
|
+
a.download = `${renderId}.mp4`;
|
|
131
|
+
document.body.appendChild(a);
|
|
132
|
+
a.click();
|
|
133
|
+
document.body.removeChild(a);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
if (loading) {
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex items-center justify-center py-8 text-content-muted">
|
|
139
|
+
<Loader2 size={20} className="animate-spin mr-2" />
|
|
140
|
+
<span className="text-sm">Loading renders...</span>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (error && renders.length === 0) {
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex flex-col items-center gap-2 py-8 text-content-muted">
|
|
148
|
+
<AlertCircle size={20} />
|
|
149
|
+
<span className="text-sm">{error}</span>
|
|
150
|
+
<button
|
|
151
|
+
onClick={fetchRenders}
|
|
152
|
+
className="mt-1 px-3 py-1.5 rounded-md border border-edge bg-surface text-xs text-content hover:bg-surface-hover transition-colors"
|
|
153
|
+
>
|
|
154
|
+
Retry
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (renders.length === 0) {
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex flex-col items-center gap-2 py-8 text-content-muted">
|
|
163
|
+
<Film size={32} strokeWidth={1} />
|
|
164
|
+
<span className="text-sm font-medium">No rendered videos yet</span>
|
|
165
|
+
<span className="text-xs text-center max-w-xs">
|
|
166
|
+
Start a render from the workflow pipeline to see your videos here.
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex flex-col gap-2">
|
|
174
|
+
{error && (
|
|
175
|
+
<div className="text-xs text-error px-2 py-1 rounded bg-error/10 border border-error/20">
|
|
176
|
+
{error}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{renders.map(render => {
|
|
181
|
+
const cfg = statusConfig[render.status];
|
|
182
|
+
const isPlaying = playingId === render.id;
|
|
183
|
+
const isDeleting = deletingId === render.id;
|
|
184
|
+
const isComplete = render.status === 'complete';
|
|
185
|
+
const isActive = render.status === 'queued' || render.status === 'rendering';
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
key={render.id}
|
|
190
|
+
className="rounded-lg border border-edge bg-surface p-3 transition-colors hover:bg-surface-alt"
|
|
191
|
+
>
|
|
192
|
+
{/* Inline video player */}
|
|
193
|
+
{isPlaying && isComplete && (
|
|
194
|
+
<div className="mb-3 rounded-md overflow-hidden bg-black relative">
|
|
195
|
+
<video
|
|
196
|
+
src={apiClient.getVideoFileUrl(render.id)}
|
|
197
|
+
controls
|
|
198
|
+
autoPlay
|
|
199
|
+
className="w-full max-h-[400px]"
|
|
200
|
+
/>
|
|
201
|
+
<button
|
|
202
|
+
onClick={() => setPlayingId(null)}
|
|
203
|
+
className="absolute top-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80 transition-colors"
|
|
204
|
+
>
|
|
205
|
+
<X size={14} />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<div className="flex items-center justify-between gap-3">
|
|
211
|
+
{/* Left side: status + info */}
|
|
212
|
+
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
213
|
+
{/* Status icon */}
|
|
214
|
+
<div className={`flex items-center gap-1.5 shrink-0 ${cfg.color}`}>
|
|
215
|
+
{cfg.icon}
|
|
216
|
+
<span className="text-[11px] font-medium">{cfg.label}</span>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Composition name */}
|
|
220
|
+
<span className="text-[12px] font-mono text-content truncate">
|
|
221
|
+
{render.compositionId}
|
|
222
|
+
</span>
|
|
223
|
+
|
|
224
|
+
{/* Progress for active renders */}
|
|
225
|
+
{isActive && (
|
|
226
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
227
|
+
<div className="w-20 h-1.5 rounded-full bg-surface-alt overflow-hidden">
|
|
228
|
+
<div
|
|
229
|
+
className="h-full rounded-full bg-amber-500 transition-all duration-300"
|
|
230
|
+
style={{ width: `${render.progress}%` }}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
<span className="text-[11px] text-content-muted">{render.progress}%</span>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* Metadata for completed renders */}
|
|
238
|
+
{isComplete && (
|
|
239
|
+
<div className="flex items-center gap-3 text-[11px] text-content-muted shrink-0">
|
|
240
|
+
{render.resolution && (
|
|
241
|
+
<span className="flex items-center gap-1">
|
|
242
|
+
<CircleDot size={10} />
|
|
243
|
+
{render.resolution}
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
{render.durationSeconds !== null && (
|
|
247
|
+
<span className="flex items-center gap-1">
|
|
248
|
+
<Clock size={10} />
|
|
249
|
+
{formatDuration(render.durationSeconds)}
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
{render.fileSize !== null && (
|
|
253
|
+
<span className="flex items-center gap-1">
|
|
254
|
+
<HardDrive size={10} />
|
|
255
|
+
{formatFileSize(render.fileSize)}
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Error message */}
|
|
262
|
+
{render.status === 'failed' && render.error && (
|
|
263
|
+
<span className="text-[11px] text-error truncate" title={render.error}>
|
|
264
|
+
{render.error}
|
|
265
|
+
</span>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Right side: date + actions */}
|
|
270
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
271
|
+
<span className="text-[11px] text-content-muted">
|
|
272
|
+
{formatDate(render.createdAt)}
|
|
273
|
+
</span>
|
|
274
|
+
|
|
275
|
+
{isComplete && (
|
|
276
|
+
<>
|
|
277
|
+
<IconButton
|
|
278
|
+
variant="ghost"
|
|
279
|
+
size="sm"
|
|
280
|
+
label={isPlaying ? 'Stop playback' : 'Play video'}
|
|
281
|
+
onClick={() => setPlayingId(isPlaying ? null : render.id)}
|
|
282
|
+
>
|
|
283
|
+
{isPlaying ? <X size={13} /> : <Play size={13} />}
|
|
284
|
+
</IconButton>
|
|
285
|
+
|
|
286
|
+
<IconButton
|
|
287
|
+
variant="ghost"
|
|
288
|
+
size="sm"
|
|
289
|
+
label="Download MP4"
|
|
290
|
+
onClick={() => handleDownload(render.id)}
|
|
291
|
+
>
|
|
292
|
+
<Download size={13} />
|
|
293
|
+
</IconButton>
|
|
294
|
+
</>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
<IconButton
|
|
298
|
+
variant="danger"
|
|
299
|
+
size="sm"
|
|
300
|
+
label="Delete render"
|
|
301
|
+
disabled={isDeleting}
|
|
302
|
+
onClick={() => handleDelete(render.id)}
|
|
303
|
+
>
|
|
304
|
+
{isDeleting ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
|
305
|
+
</IconButton>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
};
|
package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { Player } from '@remotion/player';
|
|
3
|
+
import { ChevronDown, RefreshCw, Video, AlertCircle, Film, Library } from 'lucide-react';
|
|
4
|
+
import { apiClient } from '../api/client';
|
|
5
|
+
import type { VideoCompositionMeta, VideoBundleStatus } from '../api/client';
|
|
6
|
+
import { CompositionPlaceholder } from './CompositionPlaceholder';
|
|
7
|
+
import { VideoGallery } from './VideoGallery';
|
|
8
|
+
import { findCompositionModuleKey } from '../utils/composition_matcher';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover composition modules from the video package at build time.
|
|
12
|
+
* Each matching file becomes a lazy-loaded chunk via Vite's code splitting.
|
|
13
|
+
* The Remotion Player requires the same React instance as the host page,
|
|
14
|
+
* so compositions must be loaded through the build system rather than
|
|
15
|
+
* fetched as standalone scripts from a separate bundle.
|
|
16
|
+
*
|
|
17
|
+
* When the Video Composition Agent creates a new composition and it's merged,
|
|
18
|
+
* the auto-rebuild produces both the Remotion bundle (for server-side rendering)
|
|
19
|
+
* and the frontend build (picking up new compositions via this glob).
|
|
20
|
+
*/
|
|
21
|
+
const compositionModules = import.meta.glob<{
|
|
22
|
+
component?: React.FC<Record<string, unknown>>;
|
|
23
|
+
default?: React.FC<Record<string, unknown>>;
|
|
24
|
+
}>('../../../video/compositions/*/index.tsx');
|
|
25
|
+
|
|
26
|
+
interface VideographyViewProps {
|
|
27
|
+
projectId: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const VideographyView = ({ projectId }: VideographyViewProps) => {
|
|
31
|
+
const [compositions, setCompositions] = useState<VideoCompositionMeta[]>([]);
|
|
32
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
33
|
+
const [bundleStatus, setBundleStatus] = useState<VideoBundleStatus | null>(null);
|
|
34
|
+
const [loading, setLoading] = useState(true);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
const [rebuilding, setRebuilding] = useState(false);
|
|
37
|
+
|
|
38
|
+
const fetchData = useCallback(async () => {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
setError(null);
|
|
41
|
+
try {
|
|
42
|
+
const [compositionsData, statusData] = await Promise.all([
|
|
43
|
+
apiClient.fetchVideoCompositions(),
|
|
44
|
+
apiClient.fetchVideoBundleStatus(),
|
|
45
|
+
]);
|
|
46
|
+
setCompositions(compositionsData.compositions);
|
|
47
|
+
setBundleStatus(statusData);
|
|
48
|
+
if (compositionsData.compositions.length > 0 && !selectedId) {
|
|
49
|
+
setSelectedId(compositionsData.compositions[0].id);
|
|
50
|
+
}
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
setError(err.message);
|
|
53
|
+
} finally {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, [selectedId]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
fetchData();
|
|
60
|
+
}, [projectId]);
|
|
61
|
+
|
|
62
|
+
const handleRebuild = useCallback(async () => {
|
|
63
|
+
setRebuilding(true);
|
|
64
|
+
try {
|
|
65
|
+
const status = await apiClient.rebuildVideoBundle();
|
|
66
|
+
setBundleStatus(status);
|
|
67
|
+
await fetchData();
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
setError(err.message);
|
|
70
|
+
} finally {
|
|
71
|
+
setRebuilding(false);
|
|
72
|
+
}
|
|
73
|
+
}, [fetchData]);
|
|
74
|
+
|
|
75
|
+
const selected = compositions.find(c => c.id === selectedId);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a stable lazyComponent loader for the Remotion Player.
|
|
79
|
+
* Loads the actual composition from the video package when available,
|
|
80
|
+
* falls back to CompositionPlaceholder otherwise.
|
|
81
|
+
*/
|
|
82
|
+
const compositionLoader = useMemo(() => {
|
|
83
|
+
if (!selectedId || !selected) {
|
|
84
|
+
return () => Promise.resolve({
|
|
85
|
+
default: CompositionPlaceholder as React.FC<Record<string, unknown>>,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Match composition module by directory name (equals composition ID by convention)
|
|
90
|
+
const moduleKey = findCompositionModuleKey(Object.keys(compositionModules), selectedId);
|
|
91
|
+
|
|
92
|
+
if (moduleKey) {
|
|
93
|
+
return async () => {
|
|
94
|
+
try {
|
|
95
|
+
const mod = await compositionModules[moduleKey]();
|
|
96
|
+
const comp = mod.component || mod.default;
|
|
97
|
+
if (comp) return { default: comp };
|
|
98
|
+
} catch {
|
|
99
|
+
// Composition failed to load — fall through to placeholder
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
default: CompositionPlaceholder as React.FC<Record<string, unknown>>,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Composition exists in API but not in the build — use placeholder
|
|
108
|
+
return () => Promise.resolve({
|
|
109
|
+
default: CompositionPlaceholder as React.FC<Record<string, unknown>>,
|
|
110
|
+
});
|
|
111
|
+
}, [selectedId, selected]);
|
|
112
|
+
|
|
113
|
+
if (loading) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex h-full items-center justify-center">
|
|
116
|
+
<div className="flex flex-col items-center gap-3 text-content-muted">
|
|
117
|
+
<Film size={32} strokeWidth={1.5} className="animate-pulse" />
|
|
118
|
+
<span className="text-sm font-medium">Loading compositions...</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (error) {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex h-full items-center justify-center">
|
|
127
|
+
<div className="flex flex-col items-center gap-3 text-content-muted">
|
|
128
|
+
<AlertCircle size={32} strokeWidth={1.5} />
|
|
129
|
+
<span className="text-sm font-medium">Failed to load video data</span>
|
|
130
|
+
<span className="text-xs">{error}</span>
|
|
131
|
+
<button
|
|
132
|
+
onClick={fetchData}
|
|
133
|
+
className="mt-2 px-3 py-1.5 rounded-md border border-edge bg-surface text-xs text-content hover:bg-surface-hover transition-colors"
|
|
134
|
+
>
|
|
135
|
+
Retry
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex flex-col h-full w-full overflow-y-auto p-4 gap-4">
|
|
144
|
+
{/* Header bar */}
|
|
145
|
+
<div className="flex items-center justify-between gap-4 shrink-0">
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<Video size={18} strokeWidth={1.5} className="text-content-muted" />
|
|
148
|
+
<h2 className="text-sm font-semibold text-content">Remotion Preview</h2>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="flex items-center gap-3">
|
|
152
|
+
{/* Composition selector */}
|
|
153
|
+
{compositions.length > 0 && (
|
|
154
|
+
<div className="relative">
|
|
155
|
+
<select
|
|
156
|
+
value={selectedId || ''}
|
|
157
|
+
onChange={(e) => setSelectedId(e.target.value)}
|
|
158
|
+
className="px-3 py-1.5 rounded-md border border-edge bg-surface text-[12px] text-content appearance-none pr-7 min-w-[200px]"
|
|
159
|
+
>
|
|
160
|
+
{compositions.map(c => (
|
|
161
|
+
<option key={c.id} value={c.id}>
|
|
162
|
+
{c.id} ({c.width}x{c.height} @ {c.fps}fps)
|
|
163
|
+
</option>
|
|
164
|
+
))}
|
|
165
|
+
</select>
|
|
166
|
+
<ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* Bundle status + rebuild */}
|
|
171
|
+
<div className="flex items-center gap-2">
|
|
172
|
+
{bundleStatus && (
|
|
173
|
+
<span className="text-[11px] text-content-muted">
|
|
174
|
+
{bundleStatus.ready ? 'Bundle ready' : 'No bundle'}
|
|
175
|
+
{bundleStatus.lastBuiltAt && (
|
|
176
|
+
<> · {new Date(bundleStatus.lastBuiltAt).toLocaleString()}</>
|
|
177
|
+
)}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
<button
|
|
181
|
+
onClick={handleRebuild}
|
|
182
|
+
disabled={rebuilding}
|
|
183
|
+
title="Rebuild Remotion bundle"
|
|
184
|
+
className="flex h-7 w-7 items-center justify-center rounded-md border border-edge text-content-muted hover:text-content hover:border-content/20 transition-all disabled:opacity-50"
|
|
185
|
+
>
|
|
186
|
+
<RefreshCw size={13} strokeWidth={2} className={rebuilding ? 'animate-spin' : ''} />
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Player area */}
|
|
193
|
+
<div className="flex-1 flex items-center justify-center rounded-lg border border-edge bg-black/5 dark:bg-white/5 overflow-hidden min-h-0">
|
|
194
|
+
{compositions.length === 0 ? (
|
|
195
|
+
<div className="flex flex-col items-center gap-3 text-content-muted">
|
|
196
|
+
<Video size={48} strokeWidth={1} />
|
|
197
|
+
<span className="text-sm font-medium">No compositions available</span>
|
|
198
|
+
<span className="text-xs text-center max-w-sm">
|
|
199
|
+
Compositions are created by the Video Composition Agent.
|
|
200
|
+
Run the agent workflow to generate your first composition.
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
) : selected ? (
|
|
204
|
+
<div className="w-full h-full flex items-center justify-center p-4">
|
|
205
|
+
<Player
|
|
206
|
+
lazyComponent={compositionLoader}
|
|
207
|
+
inputProps={{
|
|
208
|
+
compositionId: selected.id,
|
|
209
|
+
width: selected.width,
|
|
210
|
+
height: selected.height,
|
|
211
|
+
}}
|
|
212
|
+
durationInFrames={selected.durationInFrames}
|
|
213
|
+
compositionWidth={selected.width}
|
|
214
|
+
compositionHeight={selected.height}
|
|
215
|
+
fps={selected.fps}
|
|
216
|
+
controls
|
|
217
|
+
style={{
|
|
218
|
+
width: '100%',
|
|
219
|
+
maxHeight: '100%',
|
|
220
|
+
aspectRatio: `${selected.width} / ${selected.height}`,
|
|
221
|
+
}}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
) : null}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Composition metadata */}
|
|
228
|
+
{selected && (
|
|
229
|
+
<div className="flex items-center gap-4 text-[11px] text-content-muted shrink-0">
|
|
230
|
+
<span>ID: <span className="font-mono text-content">{selected.id}</span></span>
|
|
231
|
+
<span>Resolution: <span className="font-mono text-content">{selected.width}x{selected.height}</span></span>
|
|
232
|
+
<span>FPS: <span className="font-mono text-content">{selected.fps}</span></span>
|
|
233
|
+
<span>Duration: <span className="font-mono text-content">{selected.durationInFrames} frames ({(selected.durationInFrames / selected.fps).toFixed(1)}s)</span></span>
|
|
234
|
+
{selected.folder && <span>Folder: <span className="font-mono text-content">{selected.folder}</span></span>}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Video Gallery */}
|
|
239
|
+
{projectId && (
|
|
240
|
+
<div className="flex flex-col gap-3 shrink-0">
|
|
241
|
+
<div className="flex items-center gap-2">
|
|
242
|
+
<Library size={16} strokeWidth={1.5} className="text-content-muted" />
|
|
243
|
+
<h3 className="text-sm font-semibold text-content">Rendered Videos</h3>
|
|
244
|
+
</div>
|
|
245
|
+
<VideoGallery projectId={projectId} />
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
};
|