Compare commits
6 Commits
9bce966404
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4873020bfa | |||
| c847e321a7 | |||
| 7c0bdf02a2 | |||
| b98339d299 | |||
| 99cfef1004 | |||
| 107ccd151e |
Generated
+65
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/three": "^0.181.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"three": "^0.181.2",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -295,6 +297,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -1424,6 +1432,12 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1542,6 +1556,27 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.181.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
|
||||
"integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -1555,6 +1590,12 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
|
||||
@@ -1581,6 +1622,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.71",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
@@ -1968,6 +2015,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2738,6 +2791,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -3755,6 +3814,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.181.2",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
|
||||
"integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/three": "^0.181.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"three": "^0.181.2",
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
+1105
-82
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset>
|
||||
<contributor>
|
||||
<author>Cole</author>
|
||||
<authoring_tool>OpenCOLLADA for 3ds Max; Version: 1.3.1; Revision: 847M; Platform: x64; Configuration: Release_Max2012_static</authoring_tool>
|
||||
<source_data>file:///C:/Users/Cole/Desktop/Houses.max</source_data>
|
||||
</contributor>
|
||||
<created>2012-12-21T07:14:54</created>
|
||||
<modified>2012-12-21T07:14:54</modified>
|
||||
<unit name="inch" meter="0.0254"/>
|
||||
<up_axis>Z_UP</up_axis>
|
||||
</asset>
|
||||
<library_effects>
|
||||
<effect id="_1_-_Default">
|
||||
<profile_COMMON>
|
||||
<technique sid="common">
|
||||
<blinn>
|
||||
<emission>
|
||||
<color>0 0 0 1</color>
|
||||
</emission>
|
||||
<ambient>
|
||||
<color>1 1 1 1</color>
|
||||
</ambient>
|
||||
<diffuse>
|
||||
<color>1 1 1 1</color>
|
||||
</diffuse>
|
||||
<specular>
|
||||
<color>0.9 0.9 0.9 1</color>
|
||||
</specular>
|
||||
<shininess>
|
||||
<float>9.999999</float>
|
||||
</shininess>
|
||||
<reflective>
|
||||
<color>0 0 0 1</color>
|
||||
</reflective>
|
||||
<transparent opaque="A_ONE">
|
||||
<color>1 1 1 1</color>
|
||||
</transparent>
|
||||
<transparency>
|
||||
<float>1</float>
|
||||
</transparency>
|
||||
</blinn>
|
||||
</technique>
|
||||
</profile_COMMON>
|
||||
<extra>
|
||||
<technique profile="OpenCOLLADA3dsMax">
|
||||
<extended_shader>
|
||||
<apply_reflection_dimming>0</apply_reflection_dimming>
|
||||
<dim_level>0</dim_level>
|
||||
<falloff_type>0</falloff_type>
|
||||
<index_of_refraction>1.5</index_of_refraction>
|
||||
<opacity_type>0</opacity_type>
|
||||
<reflection_level>3</reflection_level>
|
||||
<wire_size>1</wire_size>
|
||||
<wire_units>0</wire_units>
|
||||
</extended_shader>
|
||||
<shader>
|
||||
<ambient_diffuse_lock>1</ambient_diffuse_lock>
|
||||
<ambient_diffuse_texture_lock>1</ambient_diffuse_texture_lock>
|
||||
<diffuse_specular_lock>0</diffuse_specular_lock>
|
||||
<soften>0.1</soften>
|
||||
<use_self_illum_color>0</use_self_illum_color>
|
||||
</shader>
|
||||
</technique>
|
||||
</extra>
|
||||
</effect>
|
||||
</library_effects>
|
||||
<library_materials>
|
||||
<material id="_1_-_Default-material" name="_1_-_Default">
|
||||
<instance_effect url="#_1_-_Default"/>
|
||||
</material>
|
||||
</library_materials>
|
||||
<library_geometries>
|
||||
<geometry id="geom-Cylinder001" name="Cylinder001">
|
||||
<mesh>
|
||||
<source id="geom-Cylinder001-positions">
|
||||
<float_array id="geom-Cylinder001-positions-array" count="279">63.77863 0.3839193 -94.24056 55.23391 31.88931 -94.24056 31.88931 55.23391 -94.24056 1.33153e-6 63.77862 -94.24056 -31.88931 55.23391 -94.24056 -55.2339 31.88931 -94.24056 -63.77862 0.3839193 -94.24056 55.23391 31.88931 101.6574 31.88931 55.23391 101.6574 -2.78785e-6 63.77862 101.6574 -31.88931 55.23391 101.6574 -55.23391 31.88931 101.6574 -63.77862 0.3839242 101.6574 63.77862 0.3839242 101.6574 63.77863 -135.6524 -94.24055 63.77862 -135.6524 101.6575 -63.77863 -135.6524 101.6575 -63.77862 -135.6524 -94.24055 69.09421 0.3839242 101.6574 60.08233 34.68855 101.6574 34.68855 60.08234 101.6574 -3.13332e-6 69.3771 101.6574 -34.68855 60.08233 101.6574 -60.08233 34.68855 101.6574 -69.18634 0.3839242 101.6574 -69.18634 -141.0601 101.6575 69.18633 -141.0602 101.6575 60.08233 34.68855 108.9806 34.68855 60.08234 108.9806 -3.13258e-6 69.3771 108.9806 -34.68855 60.08233 108.9806 -60.08233 34.68855 108.9806 -69.18634 0.3839244 108.9806 -69.18634 -141.0601 108.9806 69.18633 -141.0602 108.9806 69.09421 0.3839244 108.9806 -23.34707 -979.6222 17.01899 23.34707 -979.6222 17.01899 -23.34707 -979.6222 -9.602058 23.34707 -979.6222 -9.602058 -23.34707 -121.6467 17.01897 23.34707 -121.6467 17.01897 -23.34707 -121.6467 -9.602079 23.34707 -121.6467 -9.602079 -65.29713 -90.30914 34.35137 -65.29715 -90.30914 60.09743 -65.29715 -39.25652 63.45726 -73.57328 -90.30915 34.35137 -73.57329 -84.41591 63.45725 -73.57329 -39.25653 39.67269 -73.5733 -39.25652 63.45725 -73.57329 -48.59029 34.35137 -65.29715 -93.29141 67.81843 -65.29715 -39.25652 67.81852 -65.29713 -99.18414 34.35137 -65.29715 -84.41591 63.45726 -73.57329 -90.30914 60.09743 -65.29715 -99.1846 64.45859 -5.55696 73.37229 122.3759 5.556962 70.81001 120.9151 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 85.82349 122.3759 5.556962 85.82349 122.3759 -5.55696 70.81001 120.9151 5.556962 73.37229 122.3759 -5.556959 66.63747 122.3759 5.556964 69.19975 120.9151 -5.55696 69.19975 109.6903 5.556963 69.19975 109.6903 -5.556959 54.18628 122.3759 5.556964 54.18628 122.3759 -5.556959 69.19975 120.9151 5.556964 66.63747 122.3759 -66.63045 -79.03947 42.09508 -66.63046 -79.03947 57.59226 -66.63046 162.3586 42.09508 -66.63046 99.09541 42.09508 -66.63046 162.3586 55.67602 -66.63046 148.0616 12.03616 -66.63045 102.9279 10.30568 -5.556961 70.81001 100.9286 5.556961 70.81001 100.9286 -5.556961 58.29042 100.9286 5.556961 58.29042 100.9286 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 46.97038 109.6903 5.556962 46.97038 109.6903 -66.63046 101.0116 40.17884 -66.63046 160.4424 57.59225 -66.63046 145.3223 10.30569 -66.63045 101.0116 12.22192</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#geom-Cylinder001-positions-array" count="93" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<source id="geom-Cylinder001-normals">
|
||||
<float_array id="geom-Cylinder001-normals-array" count="783">0.9912451 0.132035 2.4983e-8 0.8652635 0.5013175 1.68492e-8 0.8652634 0.5013174 1.38252e-8 0.9912451 0.132035 1.60082e-8 0.5000001 0.8660254 9.92266e-9 0.5 0.8660254 9.73644e-9 -5.64708e-8 1 -9.79561e-10 -5.89261e-8 1 -1.8623e-10 -0.5 0.8660254 -1.14197e-8 -0.5 0.8660254 -1.36251e-8 -0.8652635 0.5013175 -1.98732e-8 -0.8652635 0.5013175 -2.00329e-8 -0.9912451 0.132035 -2.25966e-8 -0.9912451 0.132035 -2.56756e-8 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 1 -1.72061e-8 2.69974e-8 1 2.18421e-15 3.89457e-8 0 5.39393e-8 1 0 5.39393e-8 1 0 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 -1 1.08357e-8 -2.69974e-8 -1 2.80417e-8 -3.89457e-8 0 -5.60835e-8 -1 0 -5.60835e-8 -1 0 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 0.99172 0.1284185 -3.18533e-9 0.8672485 0.4978755 0 0.8672485 0.4978756 -4.85969e-10 0.99172 0.1284184 -3.65799e-9 0.5000001 0.8660254 -1.74247e-12 0.5 0.8660254 0 -1.08031e-7 1 -1.74247e-12 -1.37494e-7 1 1.74249e-12 -0.5 0.8660254 0 -0.5000001 0.8660254 1.74248e-12 -0.8666227 0.498964 -4.20111e-10 -0.8666227 0.498964 0 -0.9916003 0.1293404 -3.1582e-9 -0.9916003 0.1293404 -2.7491e-9 -1 0 0 -1 0 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 0.9999998 6.51317e-4 -6.11024e-13 0.9999998 6.51317e-4 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 1 0 0 1 0 0 1 0 0 1 0 0 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -1.30567e-7 -2.28558e-7 -1 1.68944e-7 -2.96333e-7 -1 -5.90983e-8 -2.5016e-7 -1 -1.06023e-7 -2.34713e-7 -1 1.82876e-7 -3.20771e-7 -1 -7.93843e-8 -2.36668e-7 7.39325e-7 -1 2.37658e-7 0 -1 0 1.82529e-7 -1 5.86744e-8 9.21853e-7 -1 2.96333e-7 -4.60927e-7 -6.88853e-14 1 -4.60927e-7 0 1 -4.60927e-7 -8.98548e-15 1 -4.60927e-7 -7.78708e-14 1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 0 -0.4952789 0.868734 0 -0.4952789 0.8687341 0 -0.4952789 0.868734 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 -1 -2.99963e-8 -3.58181e-7 -1 -1.02221e-7 -4.58289e-8 -1 -3.99212e-8 -1.73411e-7 -1 -3.23093e-8 -4.85334e-7 -1 -3.18579e-8 -4.92309e-7 -1 -9.30545e-8 -1.27403e-7 -1 -5.76775e-8 -2.63987e-7 -1 -4.88709e-8 -1.95574e-7 -1 -3.36656e-8 -3.5904e-7 -1 -1.73765e-7 1.23235e-7 -1 -1.79962e-7 2.84877e-7</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#geom-Cylinder001-normals-array" count="261" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<source id="geom-Cylinder001-map1">
|
||||
<float_array id="geom-Cylinder001-map1-array" count="450">0.1889011 0.2758261 0 0.2256371 0.2758261 0 0.2256371 0.6486552 0 0.188901 0.6486552 0 0.2623734 0.2758261 0 0.2623734 0.6486552 0 0.2991094 0.2758261 0 0.2991095 0.6486552 0 0.3358451 0.275826 0 0.3358452 0.6486552 0 0.3725807 0.275826 0 0.3725807 0.6486552 0 0.9257745 0.70301 0 0.8734617 0.7549164 0 0.802001 0.7739153 0 0.7305403 0.7549164 0 0.6782273 0.7030097 0 0.6594725 0.6335595 0 0.4093161 0.2758261 0 0.4093161 0.6486552 0 0.03795872 0.6486552 0 0.03795872 0.2758261 0 0.5602581 0.2758261 0 0.5602581 0.6486552 0 0.9443399 0.6335346 0 0.061661 0.8492339 0 0.09246806 0.8492339 0 0.09246806 0.8621775 0 0.061661 0.8621775 0 0.1245703 0.8492339 0 0.4158128 0.7749463 0 0.4288802 0.7265559 0 0.4645816 0.6911321 0 0.5133504 0.6781658 0 0.5621194 0.6911321 0 0.5978206 0.7265559 0 0.6108883 0.7749462 0 0.1245703 0.8621775 0 0.1566722 0.8492339 0 0.1566722 0.8621775 0 0.1887741 0.8492339 0 0.1887741 0.8621775 0 0.2208759 0.8492339 0 0.2208759 0.8621775 0 0.2523189 0.8492339 0 0.2523189 0.8621775 0 0.3790478 0.8492339 0 0.3790478 0.8621775 0 0.2097653 0.9498755 0 0.3783371 0.826761 0 0.6594725 0.3437682 0 0.06166129 0.8267609 0 0.9445293 0.3437679 0 0.3783363 0.702438 0 0.6108882 0.9807906 0 0.0836773 0.9498755 0 0.0836773 0.9369324 0 0.2097653 0.9369324 0 0.061661 0.7024379 0 0.4158128 0.9807907 0 0.637657 0.6559129 0 0.6597634 0.7377743 0 0.6472197 0.7449604 0 0.6234784 0.6557912 0 0.7201594 0.7977016 0 0.7129173 0.8101482 0 0.802662 0.8196367 0 0.802662 0.8340079 0 0.8851648 0.7977016 0 0.8924065 0.8101479 0 0.2192692 0.9691411 0 0.0836773 0.9691411 0 0.9455608 0.7377743 0 0.9581041 0.7449604 0 0.9676672 0.6559129 0 0.9816576 0.6577407 0 0.9676669 0.3076856 0 0.9816576 0.2938034 0 0.637657 0.3076856 0 0.0836773 0.9573665 0 0.2192692 0.9573665 0 0.6236663 0.2938034 0 0.637657 0.6559129 0 0.973251 0.1941192 0 0.945253 0.1941192 0 0.945253 0.1408443 0 0.973251 0.1408443 0 0.04288544 0.1408443 0 0.04288544 0.194119 0 0.945253 0.03266054 0 0.945253 0.08657608 0 0.04288544 0.08657608 0 0.04288544 0.03266054 0 0.945253 0.2533531 0 0.04288544 0.253353 0 0.8747699 0.9740806 0 0.8747699 0.9228441 0 0.886499 0.9161604 0 0.9763682 0.91616 0 0.8582996 0.9740796 0 0.8582998 0.9228438 0 0.878347 0.9018502 0 0.6516011 0.9556062 0 0.7323457 0.9556062 0 0.748215 0.9695692 0 0.8864987 0.8996912 0 0.9763682 0.8996903 0 0.9763678 0.9634923 0 0.6516011 0.9695697 0 0.7428828 0.9448491 0 0.7428828 0.8624189 0 0.7587513 0.8624189 0 0.9577932 0.9740818 0 0.7587519 0.9588121 0 0.6609319 0.8997087 0 0.6875241 0.8997087 0 0.6875241 0.8657955 0 0.6875241 0.8926538 0 0.6875241 0.9294978 0 0.6609319 0.9294978 0 0.6609319 0.8926538 0 0.701803 0.8955842 0 0.7283945 0.9294978 0 0.701803 0.9294978 0 0.701803 0.90264 0 0.701803 0.8657949 0 0.7283945 0.8657949 0 0.7283945 0.90264 0 0.7283945 0.8955842 0 0.304611 0.9144163 0 0.2467109 0.9144163 0 0.304611 0.8910367 0 0.0836773 0.9144163 0 0.0836773 0.8877379 0 0.291526 0.9661621 0 0.2502185 0.9691411 0 0.6609319 0.8657955 0 0.8666169 0.9085327 0 0.9434043 0.8599022 0 0.9434043 0.8377851 0 0.9683194 0.8377851 0 0.9683194 0.8599022 0 0.9259688 0.8377851 0 0.9259688 0.8599022 0 0.878527 0.8599022 0 0.878527 0.8377851 0 0.2484647 0.9177151 0 0.3028572 0.8877379 0 0.289019 0.9691411 0 0.2484647 0.9658423 0</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#geom-Cylinder001-map1-array" count="150" stride="3">
|
||||
<param name="S" type="float"/>
|
||||
<param name="T" type="float"/>
|
||||
<param name="P" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<vertices id="geom-Cylinder001-vertices">
|
||||
<input semantic="POSITION" source="#geom-Cylinder001-positions"/>
|
||||
</vertices>
|
||||
<triangles material="_1_-_Default_1" count="121">
|
||||
<input semantic="VERTEX" source="#geom-Cylinder001-vertices" offset="0"/>
|
||||
<input semantic="NORMAL" source="#geom-Cylinder001-normals" offset="1"/>
|
||||
<input semantic="TEXCOORD" source="#geom-Cylinder001-map1" offset="2" set="0"/>
|
||||
<p>0 0 0 1 1 1 7 2 2 7 2 2 13 3 3 0 0 0 1 1 1 2 4 4 8 5 5 8 5 5 7 2 2 1 1 1 2 4 4 3 6 6 9 7 7 9 7 7 8 5 5 2 4 4 3 6 6 4 8 8 10 9 9 10 9 9 9 7 7 3 6 6 4 8 8 5 10 10 11 11 11 11 11 11 10 9 9 4 8 8 5 10 10 6 12 18 12 13 19 12 13 19 11 11 11 5 10 10 27 14 12 28 15 13 29 16 14 29 17 14 30 18 15 31 19 16 27 20 12 29 21 14 31 22 16 27 23 12 31 24 16 32 25 17 27 26 12 32 27 17 35 28 24 5 29 31 4 30 32 3 31 33 3 32 33 2 33 34 1 34 35 3 35 33 1 36 35 0 37 36 5 38 31 3 39 33 0 40 36 6 41 30 5 42 31 0 43 36 0 0 0 13 3 3 15 44 20 15 44 20 14 45 21 0 0 0 35 46 24 32 47 17 33 48 50 33 49 50 34 50 52 35 51 24 12 13 19 6 12 18 17 52 22 17 52 22 16 53 23 12 13 19 6 54 30 0 55 36 14 56 54 14 57 54 17 58 59 6 59 30 14 60 49 15 61 51 17 62 53 15 63 51 16 64 58 17 65 53 13 66 60 7 67 61 19 68 62 19 69 62 18 70 63 13 71 60 7 72 61 8 73 64 20 74 65 20 75 65 19 76 62 7 77 61 8 78 64 9 79 66 21 80 67 21 81 67 20 82 65 8 83 64 9 84 66 10 85 68 22 86 69 22 87 69 21 88 67 9 89 66 10 90 68 11 91 72 23 92 73 23 93 73 22 94 69 10 95 68 11 96 72 12 97 74 24 98 75 24 99 75 23 100 73 11 101 72 12 102 74 16 103 76 25 104 77 25 105 77 24 106 75 12 107 74 16 108 76 15 109 78 26 110 81 26 111 81 25 112 77 16 113 76 15 114 78 13 115 82 18 116 63 18 117 63 26 118 81 15 119 78 18 120 25 19 121 26 27 122 27 27 122 27 35 123 28 18 120 25 19 121 26 20 124 29 28 125 37 28 125 37 27 122 27 19 121 26 20 124 29 21 126 38 29 127 39 29 127 39 28 125 37 20 124 29 21 126 38 22 128 40 30 129 41 30 129 41 29 127 39 21 126 38 22 128 40 23 130 42 31 131 43 31 131 43 30 129 41 22 128 40 23 130 42 24 132 44 32 133 45 32 133 45 31 131 43 23 130 42 24 132 44 25 134 46 33 135 47 33 135 47 32 133 45 24 132 44 25 136 70 26 137 71 34 138 79 34 139 79 33 140 80 25 141 70 26 142 48 18 120 55 35 123 56 35 123 56 34 143 57 26 142 48 36 144 83 38 145 84 39 146 85 39 146 85 37 147 86 36 144 83 36 148 89 37 149 90 41 150 91 41 150 91 40 151 92 36 148 89 37 152 90 39 153 85 43 154 87 43 154 87 41 155 91 37 152 90 39 156 85 38 157 84 42 158 88 42 158 88 43 159 87 39 156 85 38 160 84 36 161 93 40 162 94 40 162 94 42 163 88 38 160 84 47 164 95 56 165 96 48 166 97 47 164 95 48 166 97 50 167 98 50 167 98 49 168 107 51 169 112 47 164 95 50 167 98 51 169 112 44 170 99 45 171 100 56 172 96 56 172 96 47 173 95 44 170 99 55 174 105 46 175 106 50 176 98 50 176 98 48 177 97 55 174 105 46 178 102 55 179 103 52 180 104 52 181 104 53 182 108 46 183 102 45 184 109 44 185 110 54 186 111 54 187 111 57 188 113 45 189 109 45 190 137 55 191 101 48 192 97 48 193 97 56 194 96 45 195 137 55 196 103 45 197 109 57 198 113 57 199 113 52 200 104 55 201 103 64 202 117 59 203 120 61 204 136 61 204 136 60 205 116 64 202 117 65 206 114 58 207 115 62 208 118 62 209 118 63 210 119 65 211 114 64 212 117 58 213 115 65 214 114 65 215 114 59 216 120 64 217 117 72 218 127 68 219 122 69 220 123 69 220 123 67 221 124 72 218 127 73 222 121 71 223 125 70 224 126 70 225 126 66 226 128 73 227 121 72 228 127 67 229 124 73 230 121 73 231 121 66 232 128 72 233 127 85 234 143 86 235 142 82 236 139 82 236 139 81 237 138 85 234 143 81 238 138 82 239 139 84 240 140 84 241 140 83 242 141 81 243 138 86 244 142 85 245 143 87 246 144 87 247 144 88 248 145 86 249 142 76 250 129 80 251 135 92 252 149 74 253 132 75 254 133 90 255 147 90 255 147 77 256 130 74 253 132 89 257 146 77 256 130 90 255 147 90 255 147 78 258 131 89 257 146 89 257 146 78 258 131 76 250 129 76 250 129 92 252 149 89 257 146 79 259 134 91 260 148 80 251 135 80 251 135 76 250 129 79 259 134</p>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</geometry>
|
||||
</library_geometries>
|
||||
<library_lights>
|
||||
<light id="EnvironmentAmbientLight" name="EnvironmentAmbientLight">
|
||||
<technique_common>
|
||||
<ambient>
|
||||
<color>0 0 0</color>
|
||||
</ambient>
|
||||
</technique_common>
|
||||
</light>
|
||||
</library_lights>
|
||||
<library_visual_scenes>
|
||||
<visual_scene id="MaxScene">
|
||||
<node name="EnvironmentAmbientLight">
|
||||
<instance_light url="#EnvironmentAmbientLight"/>
|
||||
</node>
|
||||
<node id="node-Cylinder001" name="Cylinder001">
|
||||
<rotate>5.33851e-8 0.7071068 0.7071068 -180</rotate>
|
||||
<scale>0.0367934 0.0367934 0.06453677</scale>
|
||||
<node>
|
||||
<matrix>1 0 0 0 0 1 0 979.6222 0 0 1 -3.708471 0 0 0 1</matrix>
|
||||
<instance_geometry url="#geom-Cylinder001">
|
||||
<bind_material>
|
||||
<technique_common>
|
||||
<instance_material symbol="_1_-_Default_1" target="#_1_-_Default-material"/>
|
||||
</technique_common>
|
||||
</bind_material>
|
||||
</instance_geometry>
|
||||
</node>
|
||||
</node>
|
||||
</visual_scene>
|
||||
</library_visual_scenes>
|
||||
<scene>
|
||||
<instance_visual_scene url="#MaxScene"/>
|
||||
</scene>
|
||||
</COLLADA>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ExpandableMarkdownMessage, QaInputBox } from '@lms-renderer/components/ChatWidgets'
|
||||
import type { QaChatMessage, QaChatThread } from '@lms-renderer/features/workspace/useQaChat'
|
||||
|
||||
@@ -73,6 +73,24 @@ export default function AiGuideView({
|
||||
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
|
||||
const questionRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||
const conversationScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const latestMessageIdRef = useRef<string | null>(null)
|
||||
const followConversationRef = useRef(true)
|
||||
const latestMessage = messages.at(-1)
|
||||
|
||||
useEffect(() => {
|
||||
const latestMessageId = latestMessage?.id ?? null
|
||||
const hasNewMessage = latestMessageId !== latestMessageIdRef.current
|
||||
latestMessageIdRef.current = latestMessageId
|
||||
if (hasNewMessage) followConversationRef.current = true
|
||||
if (!hasNewMessage && !followConversationRef.current) return
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const target = conversationScrollRef.current
|
||||
if (!target) return
|
||||
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [busy, latestMessage?.content, latestMessage?.id, messages.length, typingMessageId])
|
||||
|
||||
const scrollToQuestion = (messageId: string): void => {
|
||||
setSelectedQuestionId(messageId)
|
||||
@@ -86,15 +104,18 @@ export default function AiGuideView({
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const target = conversationScrollRef.current
|
||||
if (target) target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
|
||||
if (target) {
|
||||
followConversationRef.current = true
|
||||
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
|
||||
}
|
||||
window.setTimeout(() => setSwitchingConversation(false), 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#202020] text-white">
|
||||
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#252525] px-3">
|
||||
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
|
||||
<header className="flex h-10 shrink-0 items-center justify-between border-b border-violet-200/15 bg-[#2a1d3f] px-3">
|
||||
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,11 +129,11 @@ export default function AiGuideView({
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#202228]">
|
||||
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
|
||||
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<>
|
||||
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2">
|
||||
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span>
|
||||
<div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2">
|
||||
<span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span>
|
||||
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
|
||||
</div>
|
||||
@@ -123,8 +144,8 @@ export default function AiGuideView({
|
||||
onClick={onClear}
|
||||
className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<Plus size={15} className="shrink-0 text-[#82abff]" />
|
||||
<span className="min-w-0 flex-1">새 대화</span>
|
||||
<Plus size={15} className="shrink-0 text-emerald-300" />
|
||||
<span className="min-w-0 flex-1">새 채팅</span>
|
||||
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
|
||||
</button>
|
||||
|
||||
@@ -139,7 +160,7 @@ export default function AiGuideView({
|
||||
onClick={() => onQuickSubmit(prompt)}
|
||||
className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<Icon size={15} className="shrink-0 text-[#82abff]" />
|
||||
<Icon size={15} className="shrink-0 text-violet-300" />
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
|
||||
</button>
|
||||
@@ -149,42 +170,33 @@ export default function AiGuideView({
|
||||
|
||||
<div className="mt-6 border-t border-white/[0.08] pt-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[10px] font-bold tracking-[0.08em] text-white/38">최근 대화</span>
|
||||
<span className="text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1">
|
||||
{activeConversation ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectConversation(null)}
|
||||
className={['min-w-0 rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}
|
||||
className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}
|
||||
>
|
||||
<span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 대화'}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-white/32">현재 대화</span>
|
||||
</button>
|
||||
) : viewingHistory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectConversation(null)}
|
||||
className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-semibold text-white/48 transition hover:bg-white/[0.06] hover:text-white/75"
|
||||
>
|
||||
<MessageSquareText size={13} className="shrink-0" />
|
||||
<span className="truncate">새 대화로 돌아가기</span>
|
||||
<span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span>
|
||||
</button>
|
||||
) : null}
|
||||
{history.map((thread) => (
|
||||
<div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}>
|
||||
<div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectConversation(thread.id)}
|
||||
className="min-w-0 flex-1 px-2 py-2 text-left"
|
||||
className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"
|
||||
>
|
||||
<span className="block truncate text-[11px] font-semibold">{thread.title || '이전 대화'}</span>
|
||||
<span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-white/32">{formatConversationDate(thread.lastMessageAt)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="대화 삭제"
|
||||
aria-label="대화 삭제"
|
||||
title="채팅 삭제"
|
||||
aria-label="채팅 삭제"
|
||||
onClick={() => setDeleteConfirmThread(thread)}
|
||||
className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"
|
||||
>
|
||||
@@ -200,10 +212,13 @@ export default function AiGuideView({
|
||||
|
||||
</aside>
|
||||
|
||||
<main className="relative flex min-w-0 flex-1 flex-col bg-[#181a20]">
|
||||
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7">
|
||||
<main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
|
||||
<div ref={conversationScrollRef} onScroll={(event) => {
|
||||
const target = event.currentTarget
|
||||
followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
|
||||
}} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
|
||||
{loading ? (
|
||||
<div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 대화를 불러오는 중</span></div>
|
||||
<div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div>
|
||||
) : disabled ? (
|
||||
<div className="grid h-full min-h-[300px] place-items-center text-center">
|
||||
<div>
|
||||
@@ -216,7 +231,7 @@ export default function AiGuideView({
|
||||
<div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
|
||||
{messages.length === 0 && (
|
||||
<div className="py-9 text-center">
|
||||
<span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span>
|
||||
<span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span>
|
||||
<h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2>
|
||||
<p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p>
|
||||
</div>
|
||||
@@ -228,15 +243,15 @@ export default function AiGuideView({
|
||||
className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}
|
||||
>
|
||||
<div className={message.role === 'user'
|
||||
? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]'
|
||||
: 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3'}
|
||||
? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]'
|
||||
: 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}
|
||||
>
|
||||
{message.role === 'user' ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<>
|
||||
{(message.isTyping || message.id === typingMessageId) && (
|
||||
<span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>
|
||||
<span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>
|
||||
)}
|
||||
<ExpandableMarkdownMessage
|
||||
content={message.content || '응답을 작성하고 있습니다.'}
|
||||
@@ -251,7 +266,7 @@ export default function AiGuideView({
|
||||
))}
|
||||
{busy && (
|
||||
<article className="w-full">
|
||||
<div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]">
|
||||
<div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200">
|
||||
<LoaderCircle size={15} className="animate-spin" /> 생각 중
|
||||
</div>
|
||||
</article>
|
||||
@@ -264,15 +279,15 @@ export default function AiGuideView({
|
||||
<div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div>
|
||||
</div>
|
||||
{switchingConversation && (
|
||||
<div className="absolute inset-0 z-20 grid place-items-center bg-[#181a20]/78 backdrop-blur-[2px]">
|
||||
<span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252525] px-4 py-3 text-[12px] font-semibold text-white/76 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 대화 불러오는 중</span>
|
||||
<div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]">
|
||||
<span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#202228]">
|
||||
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]">
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3">
|
||||
<span className="text-[11px] font-bold tracking-[0.08em] text-white/44">질문 목록</span>
|
||||
<span className="text-[11px] font-bold text-white/44">질문 목록</span>
|
||||
</div>
|
||||
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{questionMessages.length ? (
|
||||
@@ -282,7 +297,7 @@ export default function AiGuideView({
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => scrollToQuestion(message.id)}
|
||||
className={['w-full min-w-0 overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}
|
||||
className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}
|
||||
>
|
||||
<span className="flex w-full min-w-0 items-start gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span>
|
||||
@@ -299,15 +314,15 @@ export default function AiGuideView({
|
||||
</div>
|
||||
{deleteConfirmThread && (
|
||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
|
||||
<section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#252525] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]">
|
||||
<section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]">
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
|
||||
<Trash2 size={15} className="text-[#e87979]" />
|
||||
<strong className="text-[13px] font-bold">대화 삭제</strong>
|
||||
<strong className="text-[13px] font-bold">채팅 삭제</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-[13px] font-semibold text-white">이 대화 내역을 삭제할까요?</p>
|
||||
<p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 대화'}</p>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 대화와 답변은 복구할 수 없습니다.</p>
|
||||
<p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p>
|
||||
<p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+413
-52
@@ -2,12 +2,12 @@ import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
||||
import { driver } from 'driver.js'
|
||||
import { CarFront, Laptop, Monitor } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||
import { RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import { ControlExtensionConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
|
||||
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
|
||||
@@ -16,7 +16,7 @@ import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen'
|
||||
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
||||
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
||||
import MaterialsModal from './features/curriculum/MaterialsModal'
|
||||
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
||||
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
||||
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
||||
import { getContextHelpSteps } from './features/help/contextHelp'
|
||||
import AiGuideView from '../features/AiGuideView'
|
||||
@@ -26,7 +26,7 @@ import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
|
||||
import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel'
|
||||
import RecentSessionModal from './features/platforms/RecentSessionModal'
|
||||
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
|
||||
import { connectPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
|
||||
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
|
||||
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
|
||||
import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types'
|
||||
import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
|
||||
@@ -41,6 +41,8 @@ import type { WorkspaceGuideResourceMode } from './features/workspace/WorkspaceG
|
||||
import WorkspaceLogView from './features/workspace/WorkspaceLogView'
|
||||
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
|
||||
import ModuleListView from './features/workspace/ModuleListView'
|
||||
import WorldEditorModal from './features/workspace/WorldEditorModal'
|
||||
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
|
||||
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
|
||||
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
|
||||
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
|
||||
@@ -61,10 +63,11 @@ import { useWorkspaceTreeState } from './features/workspace/useWorkspaceTreeStat
|
||||
import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisualization'
|
||||
import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters'
|
||||
import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget'
|
||||
import { resolveRuntimeModules } from './features/workspace/runtimeModuleSet'
|
||||
import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings'
|
||||
import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
|
||||
import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
|
||||
import type { ModuleRow } from './features/workspace/types'
|
||||
import type { ModuleRow, RuntimeImageArtifact } from './features/workspace/types'
|
||||
import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters'
|
||||
import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles'
|
||||
import type { EditorFilePath } from './features/workspace/workspaceFiles'
|
||||
@@ -81,6 +84,35 @@ type IdeActivity = WorkspaceActivity
|
||||
|
||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
|
||||
|
||||
function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean {
|
||||
const target = profile.config?.target
|
||||
return target === 'remote-jetson' || target === 'platform'
|
||||
}
|
||||
|
||||
function resolveModuleRuntimeImages(
|
||||
profiles: EnvironmentProfile[],
|
||||
remoteWorkspace: boolean,
|
||||
activeRuntimeImages: RuntimeImageArtifact[],
|
||||
): RuntimeImageArtifact[] {
|
||||
if (!remoteWorkspace) return activeRuntimeImages
|
||||
|
||||
const images = profiles
|
||||
.filter(isRemoteEnvironmentProfile)
|
||||
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
|
||||
.filter((image): image is RuntimeImageArtifact => (
|
||||
typeof image === 'object'
|
||||
&& image !== null
|
||||
&& typeof (image as RuntimeImageArtifact).image === 'string'
|
||||
&& Boolean((image as RuntimeImageArtifact).image?.trim())
|
||||
// ROS bridges are started by the visualization flow, not module execution.
|
||||
&& !/ros-bridge/i.test((image as RuntimeImageArtifact).image ?? '')
|
||||
))
|
||||
|
||||
return images.filter((image, index) => (
|
||||
images.findIndex((candidate) => candidate.image?.trim() === image.image?.trim()) === index
|
||||
))
|
||||
}
|
||||
|
||||
function makeStudentSlug(email?: string | null): string {
|
||||
return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase()
|
||||
}
|
||||
@@ -343,7 +375,7 @@ const configureMonacoTheme: BeforeMount = (monaco) => {
|
||||
})
|
||||
}
|
||||
|
||||
const imagePreviewSources: Partial<Record<string, string>> = {
|
||||
const initialImagePreviewSources: Partial<Record<string, string>> = {
|
||||
'src/renderer/public/assets/icon.png': '/assets/icon.png',
|
||||
'src/renderer/public/assets/splash.png': '/assets/splash.png',
|
||||
'src/renderer/public/assets/images/cc_bg.png': '/assets/images/cc_bg.png',
|
||||
@@ -361,9 +393,11 @@ function LocalPcWorkspaceMockup({
|
||||
onClose,
|
||||
onDisconnectSession,
|
||||
onReleaseControl,
|
||||
onRequestControlExtension,
|
||||
onTitlebarNotice,
|
||||
onRegisterStopRunningModules,
|
||||
canReleaseControl,
|
||||
canExtendControl,
|
||||
controlEndsAt,
|
||||
theme,
|
||||
title,
|
||||
@@ -379,17 +413,21 @@ function LocalPcWorkspaceMockup({
|
||||
weekNumber,
|
||||
courseKey,
|
||||
curriculumWeek,
|
||||
publicGuideCourse,
|
||||
activeLearningStep,
|
||||
allowFutureLearningSteps,
|
||||
teacherQaDisabled,
|
||||
onLearningStepSelect,
|
||||
onSubmissionRecorded
|
||||
}: {
|
||||
onClose: () => void
|
||||
onDisconnectSession: () => void
|
||||
onReleaseControl: () => void
|
||||
onRequestControlExtension: () => void
|
||||
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
|
||||
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
|
||||
canReleaseControl: boolean
|
||||
canExtendControl: boolean
|
||||
controlEndsAt?: string | null
|
||||
theme: 'dark' | 'light'
|
||||
title: string
|
||||
@@ -405,8 +443,10 @@ function LocalPcWorkspaceMockup({
|
||||
weekNumber: number
|
||||
courseKey: string
|
||||
curriculumWeek: CurriculumWeek
|
||||
publicGuideCourse: boolean
|
||||
activeLearningStep: string | null
|
||||
allowFutureLearningSteps: boolean
|
||||
teacherQaDisabled?: boolean
|
||||
onLearningStepSelect?: (step: string) => void
|
||||
onSubmissionRecorded: () => void
|
||||
}): React.JSX.Element {
|
||||
@@ -453,6 +493,8 @@ function LocalPcWorkspaceMockup({
|
||||
const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false)
|
||||
const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState<Record<string, ModuleWorkspaceSnapshot>>({})
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [worldMapSelectorOpen, setWorldMapSelectorOpen] = useState(false)
|
||||
const [worldEditorOpen, setWorldEditorOpen] = useState(false)
|
||||
const [previewLarge, setPreviewLarge] = useState(false)
|
||||
const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 })
|
||||
const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 })
|
||||
@@ -494,6 +536,7 @@ function LocalPcWorkspaceMockup({
|
||||
resetTabDrag
|
||||
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
|
||||
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
|
||||
const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
|
||||
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
|
||||
const [gitFileContextMenu, setGitFileContextMenu] = useState<{ moduleName: string; folderName?: string | null; filePath: string; status: GitChangedFile['status']; x: number; y: number } | null>(null)
|
||||
const {
|
||||
@@ -634,6 +677,27 @@ function LocalPcWorkspaceMockup({
|
||||
const settingsActive = activeIdeActivity === 'settings'
|
||||
const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search'
|
||||
const remoteWorkspace = platformKey !== 'futuredrive-local-pc'
|
||||
const worldMapStorageKey = `atnmy:lms:world-map:${courseKey}:${weekNumber}:${platformKey}`
|
||||
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => (
|
||||
window.localStorage.getItem(worldMapStorageKey) ?? 'nav2-turtlebot3-world'
|
||||
))
|
||||
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
|
||||
const availableWorldMaps = useMemo(() => {
|
||||
const configuredMaps = (curriculumWeek.maps ?? [])
|
||||
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
||||
if (!configuredMaps.length) return NAV2_MAP_OPTIONS
|
||||
const localCustomMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
|
||||
return localCustomMap && !configuredMaps.some((map) => map.key === localCustomMap.key)
|
||||
? [...configuredMaps, localCustomMap]
|
||||
: configuredMaps
|
||||
}, [curriculumWeek.maps])
|
||||
const selectedWorldMap = availableWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? availableWorldMaps[0] ?? NAV2_MAP_OPTIONS[0]
|
||||
useEffect(() => {
|
||||
const storedMapKey = window.localStorage.getItem(worldMapStorageKey)
|
||||
setSelectedWorldMapKey(availableWorldMaps.some((map) => map.key === storedMapKey)
|
||||
? storedMapKey!
|
||||
: availableWorldMaps[0]?.key ?? 'nav2-turtlebot3-world')
|
||||
}, [availableWorldMaps, worldMapStorageKey])
|
||||
const visualizationProfiles = [
|
||||
...(platformEnvironmentProfiles?.filter((profile) => profile.week === weekNumber) ?? []),
|
||||
...(curriculumWeek.environmentProfiles ?? [])
|
||||
@@ -641,10 +705,10 @@ function LocalPcWorkspaceMockup({
|
||||
items.findIndex((item) => item.profileType.toLowerCase() === profile.profileType.toLowerCase()) === index
|
||||
))
|
||||
const configuredWorkspaceRoot = remoteWorkspace
|
||||
? defaultStudioSourceSettings.workspaceRoot
|
||||
? platformSourceSettingsDefaults.workspaceRoot
|
||||
: sourceSettings.workspaceRoot
|
||||
const configuredDraftWorkspaceRoot = remoteWorkspace
|
||||
? defaultStudioSourceSettings.workspaceRoot
|
||||
? platformSourceSettingsDefaults.workspaceRoot
|
||||
: sourceSettingsDraft.workspaceRoot
|
||||
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
|
||||
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
|
||||
@@ -655,7 +719,6 @@ function LocalPcWorkspaceMockup({
|
||||
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
|
||||
const studentSlug = makeStudentSlug(studentEmail)
|
||||
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey)
|
||||
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
|
||||
const {
|
||||
visualizationStacks,
|
||||
visualizationStack,
|
||||
@@ -663,6 +726,8 @@ function LocalPcWorkspaceMockup({
|
||||
visualizationRunning,
|
||||
visualizationBusy,
|
||||
visualizationViewerUrl,
|
||||
nav2VisualizationMode,
|
||||
setNav2VisualizationMode,
|
||||
activeEnvironmentProfile,
|
||||
activeRuntimeImages,
|
||||
refreshVisualizationStatus,
|
||||
@@ -672,8 +737,21 @@ function LocalPcWorkspaceMockup({
|
||||
profiles: visualizationProfiles,
|
||||
clonePathPreview,
|
||||
rosDomainId: sourceSettings.rosDomainId,
|
||||
remoteWorkspace,
|
||||
mapKey: selectedWorldMap.key,
|
||||
worldLayoutVersion,
|
||||
onTitlebarNotice
|
||||
})
|
||||
const runtimeModules = useMemo(() => resolveRuntimeModules(
|
||||
curriculumWeek.modules,
|
||||
remoteWorkspace
|
||||
? visualizationProfiles.filter(isRemoteEnvironmentProfile)
|
||||
: activeEnvironmentProfile,
|
||||
remoteWorkspace ? 'platform' : 'local'
|
||||
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
|
||||
const moduleRuntimeImages = useMemo(() => (
|
||||
resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages)
|
||||
), [activeRuntimeImages, remoteWorkspace, visualizationProfiles])
|
||||
const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser'])
|
||||
const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => {
|
||||
const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url'])
|
||||
@@ -688,7 +766,7 @@ function LocalPcWorkspaceMockup({
|
||||
host,
|
||||
user: sshUser ?? undefined,
|
||||
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
|
||||
workspaceRoot: remoteRuntimeRoot
|
||||
workspaceRoot: clonePathPreview
|
||||
}
|
||||
}
|
||||
const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => {
|
||||
@@ -709,15 +787,18 @@ function LocalPcWorkspaceMockup({
|
||||
const storedParameters = readStoredModuleParameters()
|
||||
return modules.map((module) => {
|
||||
const override = storedParameters[getModuleParameterStorageKey(module)]
|
||||
if (!override) return module
|
||||
const defaultParameters = runtimeModules.find((item) => item.folderName === module.folderName)?.defaultParameters
|
||||
?.filter((parameter): parameter is string => typeof parameter === 'string') ?? []
|
||||
return {
|
||||
...module,
|
||||
parameters: serializeModuleParameters(override).split(/\s+/).filter(Boolean)
|
||||
parameters: override
|
||||
? serializeModuleParameters(override).split(/\s+/).filter(Boolean)
|
||||
: defaultParameters.length ? defaultParameters : module.parameters
|
||||
}
|
||||
})
|
||||
}, [courseKey, weekNumber])
|
||||
const curriculumModuleKey = (curriculumWeek.modules ?? [])
|
||||
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}`)
|
||||
}, [courseKey, runtimeModules, weekNumber])
|
||||
const curriculumModuleKey = runtimeModules
|
||||
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
|
||||
.join('|')
|
||||
const {
|
||||
availableModules,
|
||||
@@ -727,7 +808,7 @@ function LocalPcWorkspaceMockup({
|
||||
loadAvailableModules
|
||||
} = useWorkspaceModuleList({
|
||||
clonePathPreview,
|
||||
curriculumModules: curriculumWeek.modules,
|
||||
curriculumModules: runtimeModules,
|
||||
curriculumModuleKey,
|
||||
remoteWorkspace,
|
||||
remoteWorkspaceTargetKey,
|
||||
@@ -807,6 +888,8 @@ function LocalPcWorkspaceMockup({
|
||||
} = useWorkspaceFileActions({
|
||||
selectedModule,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
@@ -971,7 +1054,7 @@ function LocalPcWorkspaceMockup({
|
||||
let statusMap = gitModuleStatuses
|
||||
try {
|
||||
const entries = await Promise.all(installedGitModules.map(async (module) => {
|
||||
const status = await window.localWorkspace.git.status(module.name, clonePathPreview, module.folderName, null)
|
||||
const status = await readGitStatus(module, null)
|
||||
return [module.name, status] as const
|
||||
}))
|
||||
statusMap = Object.fromEntries(entries)
|
||||
@@ -997,14 +1080,24 @@ function LocalPcWorkspaceMockup({
|
||||
try {
|
||||
let submittedCommitCount = 0
|
||||
for (const module of changedModules) {
|
||||
const result = await window.localWorkspace.git.submit(
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.git.submit(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId,
|
||||
)
|
||||
: await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId
|
||||
submissionId,
|
||||
)
|
||||
if (!result.ok) throw new Error(`[${module.name}] ${result.message}`)
|
||||
if (result.commit) submittedCommitCount += 1
|
||||
@@ -1159,6 +1252,8 @@ function LocalPcWorkspaceMockup({
|
||||
moduleName: selectedModule?.name,
|
||||
moduleFolderName: selectedModule?.folderName,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
dark,
|
||||
terminalActive,
|
||||
resetKey: `${remoteWorkspace}:${remoteWorkspaceTargetKey}`,
|
||||
@@ -1293,6 +1388,24 @@ function LocalPcWorkspaceMockup({
|
||||
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext'
|
||||
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
|
||||
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
|
||||
useEffect(() => {
|
||||
const file = currentActiveEditorFile
|
||||
if (!file || !activeFileIsImage || file.startsWith('examples/example-code/') || !selectedModule || imagePreviewSources[file]) return
|
||||
|
||||
let disposed = false
|
||||
const loadImagePreview = async (): Promise<void> => {
|
||||
try {
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.image(getRemoteWorkspaceTarget(), selectedModule.name, file, selectedModule.folderName)
|
||||
: await window.localWorkspace.moduleFiles.image(selectedModule.name, file, clonePathPreview, selectedModule.folderName)
|
||||
if (!disposed) setImagePreviewSources((sources) => ({ ...sources, [file]: result.dataUrl }))
|
||||
} catch (error) {
|
||||
console.warn('[module-files] image preview failed', error)
|
||||
}
|
||||
}
|
||||
void loadImagePreview()
|
||||
return () => { disposed = true }
|
||||
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
|
||||
const openEditorFile = (file: EditorFilePath): void => {
|
||||
if (file.startsWith('examples/example-code/')) {
|
||||
setSelectedExampleTreePath(file)
|
||||
@@ -1378,9 +1491,14 @@ function LocalPcWorkspaceMockup({
|
||||
setInstallingModuleName('__source_settings__')
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
const result = await window.localWorkspace.modules.stopMany(
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.stopMany(
|
||||
getRemoteWorkspaceTarget(),
|
||||
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
clonePathPreview
|
||||
)
|
||||
: await window.localWorkspace.modules.stopMany(
|
||||
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
clonePathPreview,
|
||||
)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
} catch (error) {
|
||||
@@ -1437,7 +1555,17 @@ function LocalPcWorkspaceMockup({
|
||||
if (!dirtyFiles.length) return
|
||||
|
||||
await Promise.all(dirtyFiles.map(async (file) => {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.write(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
file,
|
||||
editorFiles[file] ?? '',
|
||||
module.folderName,
|
||||
)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.write(module.name, file, editorFiles[file] ?? '', clonePathPreview, module.folderName)
|
||||
}
|
||||
setSavedEditorFiles((files) => ({ ...files, [file]: editorFiles[file] ?? '' }))
|
||||
}))
|
||||
setDirtyEditorFiles((dirtyFilesState) => {
|
||||
@@ -1448,13 +1576,13 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
const { prepareRemoteRuntimeModule } = useRemoteWorkspaceRuntime({
|
||||
availableModules,
|
||||
curriculumModules: curriculumWeek.modules,
|
||||
curriculumModules: runtimeModules,
|
||||
courseKey,
|
||||
weekNumber,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
remoteWorkspaceTargetKey,
|
||||
activeRuntimeImages,
|
||||
activeRuntimeImages: moduleRuntimeImages,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
saveDirtyFilesForModule,
|
||||
@@ -1481,16 +1609,29 @@ function LocalPcWorkspaceMockup({
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
sourceSettings,
|
||||
activeRuntimeImages,
|
||||
activeRuntimeImages: moduleRuntimeImages,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
prepareRemoteRuntimeModule,
|
||||
onTitlebarNotice
|
||||
})
|
||||
const deleteModule = async (module: ModuleRow, options: { deleteRepository?: boolean } = {}): Promise<void> => {
|
||||
if (module.state !== 'stopped') {
|
||||
onTitlebarNotice(`[${module.name}] 정지된 모듈만 삭제할 수 있습니다.`, 'warning')
|
||||
return
|
||||
}
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.delete(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
`modules/${module.name}`,
|
||||
module.folderName,
|
||||
)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
|
||||
}
|
||||
if (options.deleteRepository) {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, {
|
||||
method: 'DELETE',
|
||||
@@ -2094,6 +2235,12 @@ function LocalPcWorkspaceMockup({
|
||||
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
|
||||
const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null
|
||||
const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60
|
||||
const applyWorldMapSelection = (map: WorkspaceMapOption): void => {
|
||||
window.localStorage.setItem(worldMapStorageKey, map.key)
|
||||
setSelectedWorldMapKey(map.key)
|
||||
setWorldMapSelectorOpen(false)
|
||||
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
|
||||
@@ -2111,6 +2258,7 @@ function LocalPcWorkspaceMockup({
|
||||
previewOpen={previewOpen}
|
||||
showControlCountdown={showControlCountdown}
|
||||
controlCountdownDanger={Boolean(controlCountdownDanger)}
|
||||
canExtendControl={canExtendControl}
|
||||
controlEndsAt={controlEndsAt ?? null}
|
||||
submitVisible={curriculumWeek.state !== 'done'}
|
||||
submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)}
|
||||
@@ -2129,6 +2277,7 @@ function LocalPcWorkspaceMockup({
|
||||
onTogglePreview={() => setPreviewOpen((value) => !value)}
|
||||
onSubmit={() => setSubmitConfirmOpen(true)}
|
||||
onOpenPowerActions={() => setPowerActionModalOpen(true)}
|
||||
onRequestControlExtension={onRequestControlExtension}
|
||||
onClose={requestWorkspaceClose}
|
||||
/>
|
||||
|
||||
@@ -2146,16 +2295,44 @@ function LocalPcWorkspaceMockup({
|
||||
running={visualizationRunning}
|
||||
busy={visualizationBusy}
|
||||
viewerUrl={visualizationViewerUrl}
|
||||
nav2Mode={nav2VisualizationMode}
|
||||
onDragStart={startPreviewDrag}
|
||||
onToggleLarge={() => setPreviewLarge((value) => !value)}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
onStackChange={setVisualizationStack}
|
||||
onStart={() => void startVisualizationBridge()}
|
||||
onStartNav2={(mode) => {
|
||||
setNav2VisualizationMode(mode)
|
||||
void startVisualizationBridge()
|
||||
}}
|
||||
onStop={() => void stopVisualizationBridge()}
|
||||
onResizeStart={startPreviewResize}
|
||||
/>
|
||||
)}
|
||||
|
||||
{worldMapSelectorOpen && (
|
||||
<WorldMapSelectorModal
|
||||
platformKey={activeEnvironmentProfile?.profileType ?? 'NAV2'}
|
||||
weekNumber={weekNumber}
|
||||
maps={availableWorldMaps}
|
||||
selectedMapKey={selectedWorldMap.key}
|
||||
onClose={() => setWorldMapSelectorOpen(false)}
|
||||
onCreate={() => {
|
||||
setWorldMapSelectorOpen(false)
|
||||
setWorldEditorOpen(true)
|
||||
}}
|
||||
onSelect={applyWorldMapSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{worldEditorOpen && (
|
||||
<WorldEditorModal
|
||||
workspaceRoot={clonePathPreview}
|
||||
onSaved={() => setWorldLayoutVersion((version) => version + 1)}
|
||||
onClose={() => setWorldEditorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{treeDialog && (
|
||||
<TreeTargetDialog
|
||||
dark={dark}
|
||||
@@ -2616,6 +2793,7 @@ function LocalPcWorkspaceMockup({
|
||||
controllingModuleNames={controllingModuleNames}
|
||||
listError={moduleListError}
|
||||
installError={moduleInstallError}
|
||||
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
||||
onAddModuleTab={addModuleTabOnly}
|
||||
onOpenSource={openModuleSource}
|
||||
onInstallModule={(module) => void installModule(module)}
|
||||
@@ -2628,6 +2806,13 @@ function LocalPcWorkspaceMockup({
|
||||
onInstallAll={() => void installAllModules()}
|
||||
onStartAll={() => void startAllModules()}
|
||||
onStopAll={() => void stopAllModules()}
|
||||
onOpenWorldEditor={() => {
|
||||
if (remoteWorkspace) {
|
||||
onTitlebarNotice('맵 설정은 현재 로컬 플랫폼에서만 지원합니다.', 'warning')
|
||||
return
|
||||
}
|
||||
setWorldMapSelectorOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2655,6 +2840,7 @@ function LocalPcWorkspaceMockup({
|
||||
iconButtonClass={iconButtonClass}
|
||||
accessToken={accessToken}
|
||||
qaMode={qaMode}
|
||||
teacherQaDisabled={teacherQaDisabled}
|
||||
teacherChatFlash={teacherChatFlash}
|
||||
qaMessagesLoading={qaMessagesLoading}
|
||||
visibleAiMessages={visibleAiMessages}
|
||||
@@ -2671,7 +2857,7 @@ function LocalPcWorkspaceMockup({
|
||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||
}}
|
||||
onStop={stopAiResponse}
|
||||
onSubmit={(content) => void sendQaMessage(content)}
|
||||
onSubmit={(content) => void sendQaMessage(content, { contextWeek: selectedWeekState?.week })}
|
||||
onClearConversation={() => {
|
||||
void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
|
||||
}}
|
||||
@@ -2708,6 +2894,7 @@ export default function App(): React.JSX.Element {
|
||||
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
|
||||
const [courseKey, setCourseKey] = useState('default-course')
|
||||
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
||||
const [trialCourse, setTrialCourse] = useState<StudentCourseOption | null>(null)
|
||||
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
||||
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
||||
@@ -2721,6 +2908,7 @@ export default function App(): React.JSX.Element {
|
||||
const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null)
|
||||
const authSessionRef = useRef<LmsAuthSession | null>(null)
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [launcherSessionEnded, setLauncherSessionEnded] = useState(false)
|
||||
const [studentCoursesLoading, setStudentCoursesLoading] = useState(false)
|
||||
const [curriculumLoading, setCurriculumLoading] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
@@ -2754,9 +2942,13 @@ export default function App(): React.JSX.Element {
|
||||
const [lastWorkspacePlatform, setLastWorkspacePlatform] = useState<PlatformOption | null>(null)
|
||||
const connectedPlatformRef = useRef<PlatformOption | null>(null)
|
||||
const runningPlatformsRef = useRef<PlatformOption[]>([])
|
||||
const platformStatusRequestSequenceRef = useRef(0)
|
||||
const [checkedRemotePlatformStatusIds, setCheckedRemotePlatformStatusIds] = useState<Set<string>>(() => new Set())
|
||||
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
|
||||
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
|
||||
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
|
||||
const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false)
|
||||
const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false)
|
||||
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
|
||||
const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([])
|
||||
const instructorAccount = isInstructorAccount(authSession?.user)
|
||||
@@ -2787,7 +2979,8 @@ export default function App(): React.JSX.Element {
|
||||
}
|
||||
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
|
||||
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
|
||||
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null
|
||||
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId)
|
||||
?? (trialCourse?.id === selectedCourseId ? trialCourse : null)
|
||||
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
|
||||
const studioDisabled = !selectedWeekState
|
||||
const connectedEnvironmentProfile = connectedPlatform
|
||||
@@ -2828,6 +3021,10 @@ export default function App(): React.JSX.Element {
|
||||
clearStoredAuthSession()
|
||||
setAuthSession(null)
|
||||
}, [])
|
||||
useEffect(() => window.launcherAuth?.onSessionExpired?.(() => {
|
||||
clearAuthSession()
|
||||
setLauncherSessionEnded(true)
|
||||
}), [clearAuthSession])
|
||||
useEffect(() => {
|
||||
authSessionRef.current = authSession
|
||||
}, [authSession])
|
||||
@@ -2856,7 +3053,6 @@ export default function App(): React.JSX.Element {
|
||||
?? null
|
||||
const profileSshUser = readNestedString(profile?.config, ['agent', 'sshUser'])
|
||||
const studentSlug = makeStudentSlug(authSession?.user.email)
|
||||
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platform.id}-${studentSlug}-week-${workspaceWeek.week}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
|
||||
const profileAgentUrl = readNestedString(profile?.config, ['agent', 'url'])
|
||||
const host = parseHostFromUrl(profileAgentUrl)
|
||||
?? parseHostFromUrl(readNestedString(profile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL']))
|
||||
@@ -2868,7 +3064,13 @@ export default function App(): React.JSX.Element {
|
||||
host,
|
||||
user: profileSshUser ?? platformSettings.sshUser ?? platformDefaults.sshUser ?? undefined,
|
||||
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
|
||||
workspaceRoot: remoteRuntimeRoot
|
||||
workspaceRoot: makePlatformWorkspaceRoot(
|
||||
platformSettings.workspaceRoot,
|
||||
studentSlug,
|
||||
courseKey,
|
||||
workspaceWeek.week,
|
||||
platform.platformKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => {
|
||||
@@ -2877,7 +3079,7 @@ export default function App(): React.JSX.Element {
|
||||
const modules = (workspaceWeek.modules ?? [])
|
||||
.slice()
|
||||
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
|
||||
.map((module) => ({ moduleName: module.name, folderName: module.folderName }))
|
||||
.map((module) => ({ moduleName: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl }))
|
||||
if (!modules.length) return
|
||||
await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' }))
|
||||
}
|
||||
@@ -3015,17 +3217,19 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
setStudentCoursesLoading(true)
|
||||
try {
|
||||
const courses = await fetchStudentCourses(fetchWithFreshAuth)
|
||||
const { courses, guideCourse } = await fetchStudentCourses(fetchWithFreshAuth)
|
||||
const selectableCourses = guideCourse ? [...courses, guideCourse] : courses
|
||||
|
||||
setStudentCourses(courses)
|
||||
setTrialCourse(guideCourse)
|
||||
setStudentCoursesLoaded(true)
|
||||
setSelectedCourseId((current) => {
|
||||
if (current && courses.some((course) => course.id === current)) {
|
||||
if (current && selectableCourses.some((course) => course.id === current)) {
|
||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||
return current
|
||||
}
|
||||
const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY)
|
||||
if (stored && courses.some((course) => course.id === stored)) {
|
||||
if (stored && selectableCourses.some((course) => course.id === stored)) {
|
||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||
return stored
|
||||
}
|
||||
@@ -3040,6 +3244,7 @@ export default function App(): React.JSX.Element {
|
||||
})
|
||||
} catch {
|
||||
setStudentCourses([])
|
||||
setTrialCourse(null)
|
||||
setStudentCoursesLoaded(true)
|
||||
setCourseSelectionVisible(true)
|
||||
} finally {
|
||||
@@ -3060,6 +3265,7 @@ export default function App(): React.JSX.Element {
|
||||
const loadCurriculum = useCallback(async (): Promise<void> => {
|
||||
if (!authReady || !studentCoursesLoaded || courseSelectionVisible || !selectedCourseId) return
|
||||
|
||||
const curriculumLoadingStartedAt = Date.now()
|
||||
setCurriculumLoading(true)
|
||||
try {
|
||||
const payload = await fetchCurriculum(fetchWithFreshAuth, selectedCourseId)
|
||||
@@ -3103,6 +3309,8 @@ export default function App(): React.JSX.Element {
|
||||
setPlatformOptions([])
|
||||
setSelectedWeekState(null)
|
||||
} finally {
|
||||
const remainingMs = Math.max(0, 500 - (Date.now() - curriculumLoadingStartedAt))
|
||||
if (remainingMs) await new Promise<void>((resolve) => window.setTimeout(resolve, remainingMs))
|
||||
setCurriculumLoading(false)
|
||||
}
|
||||
}, [authReady, authSession?.accessToken, courseSelectionVisible, fetchWithFreshAuth, selectedCourseId, selectedWeekState?.week, studentCoursesLoaded])
|
||||
@@ -3130,23 +3338,63 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
let cancelled = false
|
||||
const refreshPlatformStatuses = async () => {
|
||||
const requestSequence = ++platformStatusRequestSequenceRef.current
|
||||
try {
|
||||
const platforms = await fetchPlatformStatuses(fetchWithFreshAuth, remotePlatformIds)
|
||||
if (cancelled || !platforms.length) return
|
||||
if (cancelled || requestSequence !== platformStatusRequestSequenceRef.current || !platforms.length) return
|
||||
const statusById = new Map(platforms.map((platform) => [platform.id, platform]))
|
||||
const mergePlatformStatus = (platform: PlatformOption): PlatformOption => {
|
||||
const status = statusById.get(platform.id)
|
||||
return status ? { ...platform, ...status } : platform
|
||||
}
|
||||
setPlatformOptions((current) => current.map(mergePlatformStatus))
|
||||
setRunningPlatforms((current) => current
|
||||
setCheckedRemotePlatformStatusIds((current) => {
|
||||
const next = new Set(current)
|
||||
platforms.forEach((platform) => next.add(platform.id))
|
||||
return next
|
||||
})
|
||||
const lostControlPlatformIds = new Set(
|
||||
runningPlatformsRef.current
|
||||
.filter((platform) => {
|
||||
if (platform.platformKey === 'futuredrive-local-pc') return false
|
||||
const nextControl = statusById.get(platform.id)?.control
|
||||
if (!nextControl) return false
|
||||
if (nextControl.status !== 'ACTIVE') return true
|
||||
const currentSessionId = platform.control?.session?.id
|
||||
return Boolean(
|
||||
currentSessionId
|
||||
&& nextControl.session?.id
|
||||
&& nextControl.session.id !== currentSessionId,
|
||||
)
|
||||
})
|
||||
.map((platform) => platform.id),
|
||||
)
|
||||
const remainingRunningPlatforms = runningPlatformsRef.current
|
||||
.filter((platform) => !lostControlPlatformIds.has(platform.id))
|
||||
.map(mergePlatformStatus)
|
||||
.filter((platform) => hasCurrentUserPlatformControl(platform)))
|
||||
|
||||
if (lostControlPlatformIds.size) {
|
||||
setRunningPlatforms(remainingRunningPlatforms)
|
||||
setConnectedPlatform((current) => {
|
||||
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current)
|
||||
return remainingRunningPlatforms[0] ?? null
|
||||
})
|
||||
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null)
|
||||
if (!remainingRunningPlatforms.length) {
|
||||
setLocalPcLayoutOpen(false)
|
||||
setStudioEntered(false)
|
||||
setSessionStatus('ended')
|
||||
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
|
||||
setWorkspaceWeekState(null)
|
||||
}
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
} else {
|
||||
setRunningPlatforms((current) => current.map(mergePlatformStatus))
|
||||
setConnectedPlatform((current) => {
|
||||
if (!current) return current
|
||||
const nextPlatform = mergePlatformStatus(current)
|
||||
return hasCurrentUserPlatformControl(nextPlatform) ? nextPlatform : null
|
||||
return mergePlatformStatus(current)
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Status polling is best-effort; keep the last known platform state.
|
||||
}
|
||||
@@ -3164,10 +3412,15 @@ export default function App(): React.JSX.Element {
|
||||
const restored = restoredStudioSessionRef.current
|
||||
if (!restored || !platformOptions.length) return
|
||||
|
||||
const restoredPlatformById = new Map(restored.runningPlatforms.map((platform) => [platform.id, platform]))
|
||||
const nextRunningPlatforms = restored.runningPlatformIds
|
||||
.map((id) => platformOptions.find((platform) => platform.id === id))
|
||||
.filter((platform): platform is PlatformOption => Boolean(platform))
|
||||
.filter((platform) => hasCurrentUserPlatformControl(platform))
|
||||
.map((id) => {
|
||||
const restoredPlatform = restoredPlatformById.get(id)
|
||||
const currentPlatform = platformOptions.find((platform) => platform.id === id)
|
||||
if (restoredPlatform && currentPlatform) return { ...restoredPlatform, ...currentPlatform }
|
||||
return currentPlatform ?? restoredPlatform ?? null
|
||||
})
|
||||
.filter((platform): platform is PlatformOption => Boolean(platform) && hasCurrentUserPlatformControl(platform))
|
||||
const nextConnectedPlatform = restored.connectedPlatformId
|
||||
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
|
||||
: nextRunningPlatforms[0] ?? null
|
||||
@@ -3201,6 +3454,7 @@ export default function App(): React.JSX.Element {
|
||||
sessionEndedAt,
|
||||
connectedPlatformId: connectedPlatform?.id ?? null,
|
||||
runningPlatformIds: runningPlatforms.map((platform) => platform.id),
|
||||
runningPlatforms,
|
||||
workspaceWeekNumber: workspaceWeekState?.week ?? null
|
||||
})
|
||||
}, [connectedPlatform?.id, localPcLayoutOpen, runningPlatforms, sessionEndedAt, sessionStartedAt, sessionStatus, studioEntered, workspaceWeekState?.week])
|
||||
@@ -3269,15 +3523,19 @@ export default function App(): React.JSX.Element {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authReady, authSession?.accessToken, fetchWithFreshAuth])
|
||||
const isRemotePlatformStatusPending = (platform: PlatformOption): boolean => (
|
||||
platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(platform.id)
|
||||
)
|
||||
const filteredPlatformOptions = platformOptions.filter((platform) => {
|
||||
if (platformFilter === 'available') return platform.state === 'ready'
|
||||
if (platformFilter === 'unavailable') return platform.state !== 'ready'
|
||||
const pending = isRemotePlatformStatusPending(platform)
|
||||
if (platformFilter === 'available') return platform.state === 'ready' && !pending
|
||||
if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending
|
||||
return true
|
||||
})
|
||||
const platformFilterCounts = {
|
||||
all: platformOptions.length,
|
||||
available: platformOptions.filter((platform) => platform.state === 'ready').length,
|
||||
unavailable: platformOptions.filter((platform) => platform.state !== 'ready').length
|
||||
available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length,
|
||||
unavailable: platformOptions.filter((platform) => platform.state !== 'ready' && !isRemotePlatformStatusPending(platform)).length
|
||||
}
|
||||
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
|
||||
const openPlatformSettings = (platform: PlatformOption): void => {
|
||||
@@ -3390,6 +3648,15 @@ export default function App(): React.JSX.Element {
|
||||
].join(' ')
|
||||
const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36'
|
||||
const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE'
|
||||
const controlWarningMatchesConnectedPlatform = Boolean(
|
||||
controlWarningNotice
|
||||
&& controlWarningPlatform?.id === connectedPlatform?.id,
|
||||
)
|
||||
const canExtendConnectedPlatform = Boolean(
|
||||
canReleaseConnectedPlatform
|
||||
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
|
||||
&& (connectedPlatform.control.session?.extensionCount ?? 0) < 1,
|
||||
)
|
||||
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
|
||||
? connectedPlatform?.control?.session?.endsAt ?? null
|
||||
: null
|
||||
@@ -3424,6 +3691,14 @@ export default function App(): React.JSX.Element {
|
||||
setLocalPcLayoutOpen(false)
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
}
|
||||
const searchStudentCourseCatalog = useCallback(
|
||||
(query: string) => searchCourseCatalog(fetchWithFreshAuth, query),
|
||||
[fetchWithFreshAuth],
|
||||
)
|
||||
const enrollStudentInCourse = useCallback(async (courseId: string): Promise<void> => {
|
||||
await enrollInCourse(fetchWithFreshAuth, courseId)
|
||||
await loadStudentCourses({ autoSelectSingle: false })
|
||||
}, [fetchWithFreshAuth, loadStudentCourses])
|
||||
const closeLocalPcWorkspace = (): void => {
|
||||
setLocalPcLayoutOpen(false)
|
||||
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
||||
@@ -3499,12 +3774,16 @@ export default function App(): React.JSX.Element {
|
||||
window.sessionStorage.removeItem(workspaceKey)
|
||||
}
|
||||
|
||||
const connectedPlatformId = connectedPlatformRef.current?.id ?? null
|
||||
const closesConnectedWorkspace = connectedPlatformId === platformToRemove.id
|
||||
setRunningPlatforms((platforms) => {
|
||||
const nextPlatforms = platforms.filter((platform) => platform.id !== platformToRemove.id)
|
||||
const nextConnectedPlatform = nextPlatforms[0] ?? null
|
||||
const nextConnectedPlatform = !closesConnectedWorkspace && connectedPlatformId
|
||||
? nextPlatforms.find((platform) => platform.id === connectedPlatformId) ?? nextPlatforms[0] ?? null
|
||||
: nextPlatforms[0] ?? null
|
||||
setConnectedPlatform(nextConnectedPlatform)
|
||||
setLastWorkspacePlatform(nextConnectedPlatform)
|
||||
setLocalPcLayoutOpen(false)
|
||||
if (closesConnectedWorkspace || !nextPlatforms.length) setLocalPcLayoutOpen(false)
|
||||
setSessionStatus(nextPlatforms.length ? 'running' : 'ended')
|
||||
if (!nextPlatforms.length) {
|
||||
setLastWorkspacePlatform(null)
|
||||
@@ -3549,7 +3828,9 @@ export default function App(): React.JSX.Element {
|
||||
} else {
|
||||
await stopRemotePlatformModules(platformToClose)
|
||||
}
|
||||
if (closeConnectedWorkspace) {
|
||||
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
|
||||
}
|
||||
} finally {
|
||||
const remainingMs = closeConnectedWorkspace ? Math.max(0, 2000 - (Date.now() - loadingStartedAt)) : 0
|
||||
window.setTimeout(() => {
|
||||
@@ -3664,6 +3945,38 @@ export default function App(): React.JSX.Element {
|
||||
}, remainingMs)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtendControl = async (): Promise<void> => {
|
||||
if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return
|
||||
|
||||
setControlExtensionSubmitting(true)
|
||||
try {
|
||||
const result = await extendPlatformControl(fetchWithFreshAuth, connectedPlatform.id)
|
||||
if (!result.session) throw new Error('연장된 제어 시간을 확인하지 못했습니다.')
|
||||
|
||||
const updatePlatform = (platform: PlatformOption): PlatformOption => {
|
||||
if (platform.id !== connectedPlatform.id || !platform.control) return platform
|
||||
return {
|
||||
...platform,
|
||||
control: {
|
||||
...platform.control,
|
||||
warning: false,
|
||||
session: result.session,
|
||||
},
|
||||
}
|
||||
}
|
||||
setPlatformOptions((platforms) => platforms.map(updatePlatform))
|
||||
setRunningPlatforms((platforms) => platforms.map(updatePlatform))
|
||||
setConnectedPlatform((platform) => platform ? updatePlatform(platform) : platform)
|
||||
setControlWarningNotice(null)
|
||||
setControlExtensionConfirmOpen(false)
|
||||
showTitlebarNotice(`${connectedPlatform.title} 제어 시간이 연장되었습니다.`, 'info', true)
|
||||
} catch (error) {
|
||||
showTitlebarNotice(error instanceof Error ? error.message : '제어 시간 연장에 실패했습니다.', 'warning', true)
|
||||
} finally {
|
||||
setControlExtensionSubmitting(false)
|
||||
}
|
||||
}
|
||||
const expireBackgroundPlatformControl = async (platform: PlatformOption): Promise<void> => {
|
||||
await stopRemotePlatformModules(platform)
|
||||
if (authSession?.accessToken) {
|
||||
@@ -3715,6 +4028,8 @@ export default function App(): React.JSX.Element {
|
||||
setLocalPcLayoutOpen(false)
|
||||
setRecentSessionModalOpen(false)
|
||||
setPlatformModalMode('select')
|
||||
platformStatusRequestSequenceRef.current += 1
|
||||
setCheckedRemotePlatformStatusIds(new Set())
|
||||
await loadCurriculum()
|
||||
setPlatformModalOpen(true)
|
||||
}
|
||||
@@ -3836,6 +4151,8 @@ export default function App(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
|
||||
// Do not let an older status poll overwrite this control action result.
|
||||
platformStatusRequestSequenceRef.current += 1
|
||||
const updatePlatform = (platform: PlatformOption): PlatformOption => {
|
||||
if (platform.id !== platformId) return platform
|
||||
if (payload.result === 'GRANTED' || payload.result === 'ALREADY_GRANTED') {
|
||||
@@ -3876,7 +4193,7 @@ export default function App(): React.JSX.Element {
|
||||
state: 'reserved',
|
||||
control: {
|
||||
status: payload.session ? 'IN_USE' : 'AVAILABLE',
|
||||
action: payload.session ? '대기 예약' : '제어권 요청',
|
||||
action: payload.session ? '대기 예약' : '제어요청',
|
||||
warning: false,
|
||||
session: payload.session ?? null,
|
||||
queue: null,
|
||||
@@ -3980,9 +4297,8 @@ export default function App(): React.JSX.Element {
|
||||
const position = payload.queue?.position
|
||||
showTitlebarNotice(`${platform.title} 대기 예약되었습니다${position ? ` (${position}번째)` : ''}`, 'info', true)
|
||||
}
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
} catch {
|
||||
setCurriculumRefreshSignal((current) => current + 1)
|
||||
// Keep the existing curriculum visible when a control request fails.
|
||||
} finally {
|
||||
await waitForMinimumLoading(loadingStartedAt)
|
||||
if (shouldWaitForControlPaint) await waitForNextPaint()
|
||||
@@ -4066,7 +4382,12 @@ export default function App(): React.JSX.Element {
|
||||
: runningPlatforms[0]
|
||||
if (platform) openRunningPlatformWorkspace(platform)
|
||||
}}
|
||||
onDisconnectSession={() => setSessionConfirmAction('disconnect')}
|
||||
onDisconnectSession={(sessionId) => {
|
||||
const platform = sessionId
|
||||
? runningPlatforms.find((current) => current.id === sessionId)
|
||||
: connectedPlatform
|
||||
if (platform) setRunningPlatformCloseTarget(platform)
|
||||
}}
|
||||
onReleaseControl={() => setSessionConfirmAction('release')}
|
||||
/>
|
||||
<main
|
||||
@@ -4089,18 +4410,23 @@ export default function App(): React.JSX.Element {
|
||||
<div className={localPcLayoutOpen ? 'h-full' : 'hidden h-full'}>
|
||||
<LocalPcWorkspaceMockup
|
||||
key={workspacePlatform?.id ?? 'local-pc-workspace'}
|
||||
onClose={closeLocalPcWorkspace}
|
||||
onClose={() => {
|
||||
closeLocalPcWorkspace()
|
||||
setOuterView('curriculum')
|
||||
}}
|
||||
onDisconnectSession={() => setSessionConfirmAction('disconnect')}
|
||||
onReleaseControl={() => setSessionConfirmAction('release')}
|
||||
onRequestControlExtension={() => setControlExtensionConfirmOpen(true)}
|
||||
onTitlebarNotice={showTitlebarNotice}
|
||||
onRegisterStopRunningModules={(handler) => {
|
||||
stopRunningWorkspaceModulesRef.current = handler
|
||||
}}
|
||||
canReleaseControl={canReleaseConnectedPlatform}
|
||||
canExtendControl={canExtendConnectedPlatform}
|
||||
controlEndsAt={connectedPlatformControlEndsAt}
|
||||
theme={ideTheme}
|
||||
title={workspaceWeek.title}
|
||||
platformName={activePlatformName}
|
||||
platformName={workspacePlatform?.title ?? activePlatformName}
|
||||
platformId={workspacePlatform?.id ?? 'local-pc'}
|
||||
platformKey={workspacePlatform?.platformKey ?? 'futuredrive-local-pc'}
|
||||
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
|
||||
@@ -4117,8 +4443,10 @@ export default function App(): React.JSX.Element {
|
||||
weekNumber={workspaceWeek.week}
|
||||
courseKey={courseKey}
|
||||
curriculumWeek={workspaceWeek}
|
||||
publicGuideCourse={publicGuideCourseActive}
|
||||
activeLearningStep={activeLearningStep}
|
||||
allowFutureLearningSteps={publicGuideCourseActive}
|
||||
teacherQaDisabled={publicGuideCourseActive}
|
||||
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
|
||||
onSubmissionRecorded={() => undefined}
|
||||
/>
|
||||
@@ -4153,9 +4481,12 @@ export default function App(): React.JSX.Element {
|
||||
) : courseSelectionVisible ? (
|
||||
<CourseSelectionScreen
|
||||
courses={studentCourses}
|
||||
trialCourse={trialCourse}
|
||||
selectedCourseId={selectedCourseId}
|
||||
hasSelectedCourse={Boolean(selectedCourse)}
|
||||
onSelectCourse={selectStudentCourse}
|
||||
onSearchCourses={searchStudentCourseCatalog}
|
||||
onEnrollCourse={enrollStudentInCourse}
|
||||
onBack={() => setCourseSelectionVisible(false)}
|
||||
/>
|
||||
) : (
|
||||
@@ -4219,6 +4550,7 @@ export default function App(): React.JSX.Element {
|
||||
counts={platformFilterCounts}
|
||||
workspaceWeekTitle={workspaceWeek.title}
|
||||
platformLoading={platformLoading}
|
||||
checkedRemotePlatformStatusIds={checkedRemotePlatformStatusIds}
|
||||
isPlatformUnavailable={isPlatformUnavailable}
|
||||
getPlatformDotClass={getPlatformDotClass}
|
||||
onFilterChange={setPlatformFilter}
|
||||
@@ -4285,6 +4617,11 @@ export default function App(): React.JSX.Element {
|
||||
title="제어 시간 안내"
|
||||
message="제어 시간이 곧 종료됩니다."
|
||||
detail={`남은 시간 ${formatControlRemainingTime(controlWarningNotice.endsAt)}${formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : ''}`}
|
||||
actionLabel={canExtendConnectedPlatform ? '연장' : undefined}
|
||||
onAction={canExtendConnectedPlatform ? () => {
|
||||
setControlWarningNotice(null)
|
||||
setControlExtensionConfirmOpen(true)
|
||||
} : undefined}
|
||||
onConfirm={() => setControlWarningNotice(null)}
|
||||
>
|
||||
{(controlWarningPlatform || controlWarningWeekLabel) && (
|
||||
@@ -4342,6 +4679,30 @@ export default function App(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{controlExtensionConfirmOpen && (
|
||||
<ControlExtensionConfirmModal
|
||||
overlayClassName={fullAppModalOverlayClass}
|
||||
dark={appModalDark}
|
||||
platformName={activePlatformName}
|
||||
submitting={controlExtensionSubmitting}
|
||||
onCancel={() => setControlExtensionConfirmOpen(false)}
|
||||
onConfirm={() => void handleExtendControl()}
|
||||
/>
|
||||
)}
|
||||
{launcherSessionEnded && (
|
||||
<StatusNoticeModal
|
||||
overlayClassName={fullAppModalOverlayClass}
|
||||
dark={appModalDark}
|
||||
tone="warning"
|
||||
title="LMS 세션 종료"
|
||||
message="런처에서 로그아웃되어 LMS 세션이 종료되었습니다."
|
||||
detail="런처에 다시 로그인한 뒤 LMS를 실행해주세요."
|
||||
onConfirm={() => {
|
||||
window.close()
|
||||
window.setTimeout(() => window.location.replace('about:blank'), 100)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{platformLoading && !platformModalOpen && !recentSessionModalOpen && (
|
||||
<PlatformLoadingOverlay
|
||||
title={platformLoadingCopy.title}
|
||||
|
||||
@@ -60,6 +60,8 @@ export function StatusNoticeModal({
|
||||
message,
|
||||
detail,
|
||||
children,
|
||||
actionLabel,
|
||||
onAction,
|
||||
onConfirm
|
||||
}: AppModalTheme & {
|
||||
tone: ModalTone
|
||||
@@ -67,6 +69,8 @@ export function StatusNoticeModal({
|
||||
message: string
|
||||
detail?: string
|
||||
children?: React.ReactNode
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
@@ -84,7 +88,12 @@ export function StatusNoticeModal({
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
{actionLabel && onAction && (
|
||||
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
|
||||
확인
|
||||
</button>
|
||||
@@ -195,6 +204,43 @@ export function SessionConfirmModal({
|
||||
)
|
||||
}
|
||||
|
||||
export function ControlExtensionConfirmModal({
|
||||
overlayClassName,
|
||||
dark,
|
||||
platformName,
|
||||
submitting = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: AppModalTheme & {
|
||||
platformName: string
|
||||
submitting?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className={overlayClassName}>
|
||||
<section className={modalClass(dark)}>
|
||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||
<Clock3 size={15} className="text-[#f59e0b]" />
|
||||
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
|
||||
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
|
||||
{submitting ? '연장 중' : '연장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmStatusModal({
|
||||
overlayClassName,
|
||||
dark,
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function Sidebar({
|
||||
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
|
||||
canReleaseControl?: boolean
|
||||
onOpenSession?: (sessionId?: string) => void
|
||||
onDisconnectSession?: () => void
|
||||
onDisconnectSession?: (sessionId?: string) => void
|
||||
onReleaseControl?: () => void
|
||||
}): React.JSX.Element {
|
||||
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
|
||||
@@ -55,7 +55,7 @@ export default function Sidebar({
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
'flex shrink-0 flex-col rounded-bl-[18px] bg-[#181b20] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out',
|
||||
'flex shrink-0 flex-col rounded-bl-[18px] bg-[#111520] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out',
|
||||
collapsed ? 'w-[68px]' : 'w-[240px]'
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -133,7 +133,7 @@ export default function Sidebar({
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
|
||||
{visibleRunningSessions.map((session, index) => (
|
||||
{visibleRunningSessions.map((session) => (
|
||||
<div key={session.id} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -152,13 +152,13 @@ export default function Sidebar({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{sessionStatus === 'running' && index === 0 && (
|
||||
{sessionStatus === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDisconnectSession?.()
|
||||
onDisconnectSession?.(session.id)
|
||||
}}
|
||||
aria-label="세션 연결종료"
|
||||
title="연결종료"
|
||||
@@ -177,13 +177,13 @@ export default function Sidebar({
|
||||
className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]"
|
||||
>
|
||||
<Activity size={17} />
|
||||
<span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#181b20]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
||||
<span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#111520]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
|
||||
</button>
|
||||
)}
|
||||
{collapsed && sessionPopoverOpen && (
|
||||
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#181b20] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
|
||||
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
|
||||
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
|
||||
{visibleRunningSessions.map((session, index) => (
|
||||
{visibleRunningSessions.map((session) => (
|
||||
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -205,14 +205,14 @@ export default function Sidebar({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{sessionStatus === 'running' && index === 0 && (
|
||||
{sessionStatus === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setSessionPopoverOpen(false)
|
||||
onDisconnectSession?.()
|
||||
onDisconnectSession?.(session.id)
|
||||
}}
|
||||
aria-label="세션 연결종료"
|
||||
title="연결종료"
|
||||
|
||||
Vendored
+38
-12
@@ -36,6 +36,7 @@ interface Window {
|
||||
appUrl: string | null
|
||||
handoffToken: string | null
|
||||
audienceApp: string
|
||||
onSessionExpired?: (listener: () => void) => () => void
|
||||
}
|
||||
lmsBootSplash?: {
|
||||
status: (message: string) => void
|
||||
@@ -56,6 +57,7 @@ interface Window {
|
||||
files: Record<string, string>
|
||||
folders: string[]
|
||||
}>
|
||||
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
|
||||
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
@@ -63,7 +65,7 @@ interface Window {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
modules: {
|
||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
|
||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null; dockerImage?: string | null }>) => Promise<{
|
||||
cacheRoot: string
|
||||
workspaceRoot: string | null
|
||||
modules: Array<{
|
||||
@@ -75,6 +77,7 @@ interface Window {
|
||||
sourcePath: string
|
||||
workspacePath: string | null
|
||||
templateRepositoryUrl?: string | null
|
||||
dockerImage?: string | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
@@ -99,12 +102,12 @@ interface Window {
|
||||
}
|
||||
}>
|
||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
||||
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
|
||||
}
|
||||
@@ -208,6 +211,15 @@ interface Window {
|
||||
localWorkspace: {
|
||||
moduleFiles: Window['moduleFiles']
|
||||
modules: Window['modules']
|
||||
nav2: {
|
||||
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => Promise<{ ok: boolean; message: string }>
|
||||
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => Promise<{ ok: boolean; message: string }>
|
||||
clearRuntimeObstacles: () => Promise<{ ok: boolean; message: string }>
|
||||
}
|
||||
worldLayout: {
|
||||
read: (workspaceRoot?: string) => Promise<WorldLayout>
|
||||
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
|
||||
}
|
||||
visualization: Window['visualization']
|
||||
git: Window['git']
|
||||
terminal: Window['terminal']
|
||||
@@ -215,6 +227,7 @@ interface Window {
|
||||
remoteWorkspace: {
|
||||
moduleFiles: {
|
||||
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
|
||||
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
|
||||
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']>
|
||||
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']>
|
||||
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']>
|
||||
@@ -240,12 +253,12 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
|
||||
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
|
||||
}
|
||||
git: {
|
||||
@@ -259,14 +272,27 @@ interface Window {
|
||||
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
|
||||
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
|
||||
write: (data: string, sessionId?: string) => void
|
||||
stop: (sessionId?: string) => void
|
||||
onData: (callback: (data: string) => void, sessionId?: string) => () => void
|
||||
onSuppressData: (callback: (data: string) => void, sessionId?: string) => () => void
|
||||
write: (target: RemoteWorkspaceTarget, data: string, sessionId?: string) => void
|
||||
stop: (target: RemoteWorkspaceTarget, sessionId?: string) => void
|
||||
onData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
|
||||
onSuppressData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WorldLayoutObject = {
|
||||
id: string
|
||||
type: 'pillar' | 'wall' | 'box'
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
size: [number, number, number]
|
||||
}
|
||||
|
||||
type WorldLayout = {
|
||||
version: 1
|
||||
objects: WorldLayoutObject[]
|
||||
}
|
||||
|
||||
type RemoteWorkspaceTarget = {
|
||||
host: string
|
||||
user?: string | null
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { CheckCircle2, LoaderCircle, Search, Sparkles, UserPlus } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
import type { CourseCatalogOption } from './curriculumApi'
|
||||
|
||||
type CourseOption = {
|
||||
id: string
|
||||
title: string
|
||||
@@ -15,72 +18,131 @@ type CourseOption = {
|
||||
|
||||
type CourseSelectionScreenProps = {
|
||||
courses: CourseOption[]
|
||||
trialCourse?: CourseOption | null
|
||||
selectedCourseId: string | null
|
||||
hasSelectedCourse: boolean
|
||||
onSelectCourse: (courseId: string) => void
|
||||
onSearchCourses: (query: string) => Promise<CourseCatalogOption[]>
|
||||
onEnrollCourse: (courseId: string) => Promise<void>
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function teacherLabel(course: Pick<CourseOption, 'teacher' | 'organizationName'>): string {
|
||||
return `${course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / ${course.teacher?.primaryOrganizationName ?? course.organizationName ?? '소속 미지정'}`
|
||||
}
|
||||
|
||||
export default function CourseSelectionScreen({
|
||||
courses,
|
||||
trialCourse,
|
||||
selectedCourseId,
|
||||
hasSelectedCourse,
|
||||
onSelectCourse,
|
||||
onBack
|
||||
onSearchCourses,
|
||||
onEnrollCourse,
|
||||
onBack,
|
||||
}: CourseSelectionScreenProps): React.JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<CourseCatalogOption[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const [selectedCatalogCourseId, setSelectedCatalogCourseId] = useState<string | null>(null)
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim()
|
||||
if (!keyword) {
|
||||
setSearchResults([])
|
||||
setSelectedCatalogCourseId(null)
|
||||
setSearchError('')
|
||||
setSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
setSearching(true)
|
||||
setSearchError('')
|
||||
void onSearchCourses(keyword)
|
||||
.then((nextResults) => {
|
||||
if (cancelled) return
|
||||
setSearchResults(nextResults)
|
||||
setSelectedCatalogCourseId((current) => nextResults.some((course) => course.id === current) ? current : null)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setSearchResults([])
|
||||
setSelectedCatalogCourseId(null)
|
||||
setSearchError(error instanceof Error ? error.message : '강좌 검색에 실패했습니다.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSearching(false)
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [onSearchCourses, query])
|
||||
|
||||
const selectedCatalogCourse = searchResults.find((course) => course.id === selectedCatalogCourseId) ?? null
|
||||
const submitEnrollment = async (): Promise<void> => {
|
||||
if (!selectedCatalogCourse || enrolling) return
|
||||
setEnrolling(true)
|
||||
setSearchError('')
|
||||
try {
|
||||
await onEnrollCourse(selectedCatalogCourse.id)
|
||||
onSelectCourse(selectedCatalogCourse.id)
|
||||
} catch (error) {
|
||||
setSearchError(error instanceof Error ? error.message : '수강 신청에 실패했습니다.')
|
||||
} finally {
|
||||
setEnrolling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
|
||||
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
|
||||
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">학습을 진행할 수강중인 강좌를 선택해주세요.</p>
|
||||
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.</p>
|
||||
</div>
|
||||
{hasSelectedCourse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]"
|
||||
>
|
||||
뒤로가기
|
||||
</button>
|
||||
<button type="button" onClick={onBack} className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]">뒤로가기</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3">
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="relative block">
|
||||
<Search size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#738096]" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="강좌명 또는 강사명 검색" className="h-12 w-full rounded-lg border border-[#cfd8e6] bg-[#fbfcff] pl-11 pr-4 text-[14px] font-semibold text-[#172033] outline-none transition focus:border-[#6f4df6] focus:bg-white focus:ring-2 focus:ring-[#6f4df6]/15" />
|
||||
</label>
|
||||
{query.trim() ? (
|
||||
<div className="mt-3">
|
||||
{searching ? <div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-[13px] font-bold text-[#64748b]"><LoaderCircle size={17} className="animate-spin" /> 검색 중입니다.</div>
|
||||
: searchResults.length ? <div className="grid gap-2">{searchResults.map((course) => {
|
||||
const selected = course.id === selectedCatalogCourseId
|
||||
return <button key={course.id} type="button" disabled={course.enrolled || enrolling} onClick={() => setSelectedCatalogCourseId(course.id)} className={['rounded-lg border p-3.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', course.enrolled ? 'cursor-default border-[#dce3ee] bg-[#f8fafc] opacity-70' : selected ? 'cursor-pointer border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'cursor-pointer border-[#dce3ee] bg-white hover:border-[#6f4df6] hover:bg-[#fbfaff]'].join(' ')}>
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="truncate text-[15px] font-black text-[#111520]">{course.title}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div>{course.enrolled ? <span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">수강 중</span> : selected ? <CheckCircle2 size={18} className="shrink-0 text-[#6f4df6]" /> : null}</div>
|
||||
</button>
|
||||
})}</div>
|
||||
: <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">검색 결과가 없습니다.</div>}
|
||||
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{searchError}</p> : null}
|
||||
{selectedCatalogCourse ? <div className="mt-3 flex justify-end"><button type="button" onClick={() => void submitEnrollment()} disabled={enrolling} className="inline-flex h-10 items-center gap-2 rounded-md bg-[#6f4df6] px-4 text-[13px] font-black text-white transition hover:bg-[#5f42d7] disabled:cursor-wait disabled:opacity-60">{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />} 수강 신청</button></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">수강 중인 강좌</p><div className="mt-3 grid gap-3">
|
||||
{courses.length ? courses.map((course) => {
|
||||
const selected = course.id === selectedCourseId
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
type="button"
|
||||
className={[
|
||||
'cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
||||
selected
|
||||
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]'
|
||||
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'
|
||||
].join(' ')}
|
||||
onClick={() => onSelectCourse(course.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]">
|
||||
<span className="min-w-0 truncate">{course.title}</span>
|
||||
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
||||
{course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / {course.teacher?.primaryOrganizationName ?? course.organizationName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
return <button key={course.id} type="button" className={['cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selected ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')} onClick={() => onSelectCourse(course.id)}>
|
||||
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{course.title}</span>{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div><span className="shrink-0 rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span></div>
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-12 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
}) : <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</div>}
|
||||
</div></div>
|
||||
{trialCourse ? <div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">둘러보기</p><button type="button" onClick={() => onSelectCourse(trialCourse.id)} className={['mt-3 w-full cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selectedCourseId === trialCourse.id ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')}><div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{trialCourse.title}</span>{selectedCourseId === trialCourse.id ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{trialCourse.teacher?.primaryOrganizationName ?? trialCourse.organizationName ?? 'Autonomy Studio'}</p></div><span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#ded4ff] bg-[#f4f0ff] px-2.5 py-1 text-[11px] font-black text-[#6944d8]"><Sparkles size={12} strokeWidth={2.5} />체험</span></div></button></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ type LearningInfoWeek = {
|
||||
title: string
|
||||
tags: string[]
|
||||
progress: number
|
||||
topics: string[]
|
||||
duration: string
|
||||
detail: {
|
||||
requiresSourceSubmission?: boolean
|
||||
@@ -60,21 +59,7 @@ export default function LearningInfoCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
|
||||
<div className="border-b border-[#edf0f5] px-3.5 py-3">
|
||||
<span className="flex items-center gap-2 text-[12px] font-bold text-[#151827]">
|
||||
<BookOpen size={14} className="text-[#6d7788]" />
|
||||
학습 주제
|
||||
</span>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{week.topics.map((topic, index) => (
|
||||
<div key={topic} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-[12px] font-medium text-[#4f596b]">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-[#ede9ff] text-[10px] font-bold text-[#714ef6]">{index + 1}</span>
|
||||
<span className="min-w-0 truncate">{topic}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[17px] mt-4 overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
|
||||
{[
|
||||
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
|
||||
...(week.detail.requiresSourceSubmission
|
||||
|
||||
@@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from '
|
||||
|
||||
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
||||
|
||||
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> {
|
||||
export type CourseCatalogOption = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string | null
|
||||
organizationName?: string | null
|
||||
enrolled: boolean
|
||||
teacher?: {
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
primaryOrganizationName?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type StudentCourseList = {
|
||||
courses: StudentCourseOption[]
|
||||
guideCourse: StudentCourseOption | null
|
||||
}
|
||||
|
||||
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseList> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
||||
const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null
|
||||
return payload?.courses ?? []
|
||||
const payload = response.ok ? await response.json() as Partial<StudentCourseList> : null
|
||||
return {
|
||||
courses: payload?.courses ?? [],
|
||||
guideCourse: payload?.guideCourse ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
|
||||
if (Array.isArray(payload?.message)) return payload.message.join(' ')
|
||||
return payload?.message || fallback
|
||||
}
|
||||
|
||||
export async function searchCourseCatalog(
|
||||
fetchWithFreshAuth: AuthFetch,
|
||||
query: string,
|
||||
): Promise<CourseCatalogOption[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (query.trim()) params.set('query', query.trim())
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog?${params.toString()}`)
|
||||
if (!response.ok) throw new Error(await responseMessage(response, '강좌 검색에 실패했습니다.'))
|
||||
const payload = await response.json() as { courses?: CourseCatalogOption[] }
|
||||
return payload.courses ?? []
|
||||
}
|
||||
|
||||
export async function enrollInCourse(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<void> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog/${encodeURIComponent(courseId)}/enroll`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error(await responseMessage(response, '수강 신청에 실패했습니다.'))
|
||||
}
|
||||
|
||||
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
||||
|
||||
@@ -65,9 +65,20 @@ export type CurriculumWeek = {
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
defaultParameters?: string[] | null
|
||||
dockerImage?: string | null
|
||||
}>
|
||||
environmentProfiles?: EnvironmentProfile[]
|
||||
maps?: Array<{
|
||||
key: string
|
||||
platformKey: 'NAV2'
|
||||
name: string
|
||||
description: string
|
||||
worldFile: string
|
||||
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
|
||||
custom?: boolean
|
||||
layout?: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export type CurriculumResponse = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertCircle, CheckCircle2, Link2, Lock, Power, Settings } from 'lucide-react'
|
||||
import { AlertCircle, CheckCircle2, Link2, LoaderCircle, Lock, Power, Settings } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { PlatformOption } from './types'
|
||||
@@ -60,6 +60,7 @@ type PlatformOptionCardProps = {
|
||||
Icon: LucideIcon
|
||||
dotClassName: string
|
||||
unavailable: boolean
|
||||
statusPending: boolean
|
||||
displayStatus: string
|
||||
primaryAction: string
|
||||
actionDisabled: boolean
|
||||
@@ -73,6 +74,7 @@ export function PlatformOptionCard({
|
||||
Icon,
|
||||
dotClassName,
|
||||
unavailable,
|
||||
statusPending,
|
||||
displayStatus,
|
||||
primaryAction,
|
||||
actionDisabled,
|
||||
@@ -106,7 +108,9 @@ export function PlatformOptionCard({
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
|
||||
{unavailable
|
||||
{statusPending
|
||||
? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" />
|
||||
: unavailable
|
||||
? <AlertCircle size={13} className="text-[#dc2626]" />
|
||||
: ready
|
||||
? <CheckCircle2 size={13} className="text-[#2fbda0]" />
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function PlatformSelectModal({
|
||||
}: PlatformSelectModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
|
||||
<section className="flex h-[660px] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
|
||||
<section className="flex h-[min(660px,calc(100vh-86px))] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
|
||||
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
|
||||
<div>
|
||||
<span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ type PlatformSelectionPanelProps = {
|
||||
counts: Record<PlatformFilter, number>
|
||||
workspaceWeekTitle: string
|
||||
platformLoading: boolean
|
||||
checkedRemotePlatformStatusIds: ReadonlySet<string>
|
||||
isPlatformUnavailable: (platform: PlatformOption) => boolean
|
||||
getPlatformDotClass: (platform: PlatformOption) => string
|
||||
onFilterChange: (filter: PlatformFilter) => void
|
||||
@@ -36,6 +37,7 @@ export default function PlatformSelectionPanel({
|
||||
counts,
|
||||
workspaceWeekTitle,
|
||||
platformLoading,
|
||||
checkedRemotePlatformStatusIds,
|
||||
isPlatformUnavailable,
|
||||
getPlatformDotClass,
|
||||
onFilterChange,
|
||||
@@ -45,7 +47,7 @@ export default function PlatformSelectionPanel({
|
||||
onOpenSettings
|
||||
}: PlatformSelectionPanelProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['min-h-0 w-1/2 shrink-0 px-6 pb-5 pt-3 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
|
||||
<div className={['h-full min-h-0 w-1/2 shrink-0 overflow-y-auto px-6 pb-5 pt-3 pr-5 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
|
||||
{runningPlatforms.length > 0 && (
|
||||
<div className="mb-4 pr-1">
|
||||
<p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p>
|
||||
@@ -76,24 +78,22 @@ export default function PlatformSelectionPanel({
|
||||
counts={counts}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
<div data-help="platform-list" className={[
|
||||
'grid content-start gap-3 overflow-y-auto pr-1',
|
||||
runningPlatforms.length > 0
|
||||
? 'min-h-[340px] max-h-[340px]'
|
||||
: 'min-h-[470px] max-h-[470px]'
|
||||
].join(' ')}>
|
||||
<div data-help="platform-list" className="grid content-start gap-3">
|
||||
{filteredPlatforms.length ? filteredPlatforms.map((platform) => {
|
||||
const { id, status, action, state, iconKey } = platform
|
||||
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
|
||||
const locked = state === 'locked'
|
||||
const unavailable = isPlatformUnavailable(platform)
|
||||
const actionDisabled = locked || unavailable
|
||||
const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id)
|
||||
const unavailable = !statusPending && isPlatformUnavailable(platform)
|
||||
const actionDisabled = statusPending || locked || unavailable
|
||||
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
|
||||
const displayStatus = unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
|
||||
const displayStatus = statusPending
|
||||
? '확인 중'
|
||||
: unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
|
||||
? '연결 끊김'
|
||||
: status
|
||||
const hasControl = platform.control?.status === 'ACTIVE'
|
||||
const primaryAction = hasControl ? '연결' : action
|
||||
const primaryAction = statusPending ? '확인 중' : hasControl ? '연결' : action
|
||||
const platformActionDisabled = runningInSelectionList || actionDisabled
|
||||
|
||||
return (
|
||||
@@ -101,8 +101,9 @@ export default function PlatformSelectionPanel({
|
||||
key={id}
|
||||
platform={platform}
|
||||
Icon={Icon}
|
||||
dotClassName={getPlatformDotClass(platform)}
|
||||
dotClassName={statusPending ? 'bg-[#94a3b8] shadow-[0_0_6px_rgba(148,163,184,0.45)]' : getPlatformDotClass(platform)}
|
||||
unavailable={unavailable}
|
||||
statusPending={statusPending}
|
||||
displayStatus={displayStatus}
|
||||
primaryAction={primaryAction}
|
||||
actionDisabled={platformActionDisabled}
|
||||
|
||||
@@ -75,6 +75,20 @@ export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, plat
|
||||
if (!response.ok) throw new Error('platform-release failed')
|
||||
}
|
||||
|
||||
export async function extendPlatformControl(
|
||||
fetchWithFreshAuth: AuthFetch,
|
||||
platformId: string,
|
||||
): Promise<PlatformControlActionResponse> {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/extend`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const payload = await response.json().catch(() => null) as PlatformControlActionResponse | { message?: string } | null
|
||||
if (!response.ok) {
|
||||
throw new Error(payload && 'message' in payload && payload.message ? payload.message : '제어 시간 연장에 실패했습니다.')
|
||||
}
|
||||
return payload as PlatformControlActionResponse
|
||||
}
|
||||
|
||||
export async function requestOrCancelPlatformControl(
|
||||
fetchWithFreshAuth: AuthFetch,
|
||||
platform: PlatformOption
|
||||
|
||||
@@ -51,6 +51,7 @@ export type PlatformOption = {
|
||||
startedAt: string
|
||||
endsAt: string
|
||||
warningAt: string
|
||||
extensionCount?: number
|
||||
} | null
|
||||
queue: {
|
||||
id: string
|
||||
@@ -63,7 +64,7 @@ export type PlatformOption = {
|
||||
export type PlatformControlState = NonNullable<PlatformOption['control']>
|
||||
|
||||
export type PlatformControlActionResponse = {
|
||||
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED'
|
||||
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' | 'EXTENDED'
|
||||
platformId?: string
|
||||
session?: PlatformControlState['session']
|
||||
queue?: PlatformControlState['queue']
|
||||
|
||||
@@ -9,6 +9,7 @@ type DeletableModule = {
|
||||
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
|
||||
dark: boolean
|
||||
module: Module
|
||||
allowRepositoryDeletion?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
|
||||
}
|
||||
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
|
||||
export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
dark,
|
||||
module,
|
||||
allowRepositoryDeletion = true,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
|
||||
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
|
||||
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
|
||||
</p>
|
||||
<label
|
||||
{allowRepositoryDeletion && <label
|
||||
className={[
|
||||
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
|
||||
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
|
||||
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</label>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(module, { deleteRepository })}
|
||||
onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
|
||||
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
|
||||
>
|
||||
삭제
|
||||
|
||||
@@ -6,12 +6,10 @@ type ModuleListHeaderProps = {
|
||||
|
||||
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
|
||||
<div className={['grid h-9 grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
|
||||
<span className="text-center">상태</span>
|
||||
<span className="text-center">분류</span>
|
||||
<span>모듈명</span>
|
||||
<span className="text-center">CPU</span>
|
||||
<span className="text-center">메모리</span>
|
||||
<span className="text-center">가동시간</span>
|
||||
<span className="col-span-2 text-center">제어</span>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
||||
module: Module
|
||||
installing: boolean
|
||||
controlling: boolean
|
||||
bulkControlling: boolean
|
||||
onAddModuleTab: (module: Module) => void
|
||||
onOpenSource: (module: Module) => void
|
||||
onOpenParameters: (module: Module) => void
|
||||
@@ -80,6 +81,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
module,
|
||||
installing,
|
||||
controlling,
|
||||
bulkControlling,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onOpenParameters,
|
||||
@@ -92,6 +94,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
}: ModuleListRowProps<Module>): React.JSX.Element {
|
||||
const selectable = module.state !== 'uninstalled'
|
||||
const busy = installing || controlling
|
||||
const parameters = Array.isArray(module.parameters) ? module.parameters : []
|
||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
|
||||
@@ -121,6 +124,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
const installAction = module.state === 'uninstalled'
|
||||
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
|
||||
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -128,7 +132,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
if (selectable) onOpenSource(module)
|
||||
}}
|
||||
className={[
|
||||
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
|
||||
'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
|
||||
'cursor-default',
|
||||
selectable
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||
@@ -158,37 +162,9 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
|
||||
].join(' ')}
|
||||
>
|
||||
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
|
||||
{parameters.length ? parameters.join(' ') : '파라미터 설정'}
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.cpu === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{module.memory === null ? (
|
||||
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
|
||||
{module.runtime}
|
||||
</span>
|
||||
@@ -215,7 +191,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
{controlActions.map(({ label, Icon, tone, action }) => (
|
||||
(() => {
|
||||
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
|
||||
const disabled = busy && !canCancelBuild
|
||||
const disabled = bulkControlling || (busy && !canCancelBuild)
|
||||
return (
|
||||
<button
|
||||
key={`${module.name}-${label}`}
|
||||
@@ -238,7 +214,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
disabled={installActionDisabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (module.state === 'uninstalled') {
|
||||
@@ -248,7 +224,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
onDeleteModule(module)
|
||||
}}
|
||||
aria-label={installAction.label}
|
||||
className={actionButtonClass(dark, installAction.tone, busy)}
|
||||
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
|
||||
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
|
||||
|
||||
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
onOpenWorldEditor: () => void
|
||||
}
|
||||
|
||||
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
|
||||
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
|
||||
onFilterChange,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
onStopAll,
|
||||
onOpenWorldEditor
|
||||
}: ModuleListToolbarProps): React.JSX.Element {
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
const startingAll = installingModuleName === '__start_all__'
|
||||
const stoppingAll = installingModuleName === '__stop_all__'
|
||||
|
||||
const bulkActions = [
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined },
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
|
||||
{
|
||||
label: '전체 설치',
|
||||
Icon: Download,
|
||||
|
||||
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
|
||||
controllingModuleNames: string[]
|
||||
listError: string | null
|
||||
installError: string | null
|
||||
allowRepositoryDeletion?: boolean
|
||||
onAddModuleTab: (module: ModuleRow) => void
|
||||
onOpenSource: (module: ModuleRow) => void
|
||||
onInstallModule: (module: ModuleRow) => void
|
||||
@@ -29,6 +30,7 @@ type ModuleListViewProps = {
|
||||
onInstallAll: () => void
|
||||
onStartAll: () => void
|
||||
onStopAll: () => void
|
||||
onOpenWorldEditor: () => void
|
||||
}
|
||||
|
||||
export default function ModuleListView({
|
||||
@@ -39,6 +41,7 @@ export default function ModuleListView({
|
||||
controllingModuleNames,
|
||||
listError,
|
||||
installError,
|
||||
allowRepositoryDeletion = true,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onInstallModule,
|
||||
@@ -50,7 +53,8 @@ export default function ModuleListView({
|
||||
onRestartModule,
|
||||
onInstallAll,
|
||||
onStartAll,
|
||||
onStopAll
|
||||
onStopAll,
|
||||
onOpenWorldEditor
|
||||
}: ModuleListViewProps): React.JSX.Element {
|
||||
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
|
||||
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
|
||||
@@ -60,6 +64,7 @@ export default function ModuleListView({
|
||||
const runningCount = modules.filter((row) => row.state === 'running').length
|
||||
const stoppedCount = modules.filter((row) => row.state === 'stopped').length
|
||||
const installingAll = installingModuleName === '__all__'
|
||||
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
@@ -102,6 +107,7 @@ export default function ModuleListView({
|
||||
onInstallAll={onInstallAll}
|
||||
onStartAll={onStartAll}
|
||||
onStopAll={onStopAll}
|
||||
onOpenWorldEditor={onOpenWorldEditor}
|
||||
/>
|
||||
<ModuleListHeader dark={dark} />
|
||||
{(listError || installError) && (
|
||||
@@ -122,6 +128,7 @@ export default function ModuleListView({
|
||||
module={row}
|
||||
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
|
||||
controlling={controllingModuleNames.includes(row.name)}
|
||||
bulkControlling={bulkControlling}
|
||||
onAddModuleTab={onAddModuleTab}
|
||||
onOpenSource={onOpenSource}
|
||||
onOpenParameters={(module) => {
|
||||
@@ -148,6 +155,7 @@ export default function ModuleListView({
|
||||
<ModuleDeleteConfirm
|
||||
dark={dark}
|
||||
module={deleteConfirmModule}
|
||||
allowRepositoryDeletion={allowRepositoryDeletion}
|
||||
onCancel={() => setDeleteConfirmModule(null)}
|
||||
onConfirm={(module, options) => {
|
||||
onDeleteModule(module, options)
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
|
||||
}: WorkspaceFilePanelProps): React.JSX.Element {
|
||||
return (
|
||||
<aside
|
||||
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
|
||||
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
|
||||
>
|
||||
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
|
||||
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectRoot}
|
||||
|
||||
@@ -25,6 +25,7 @@ type WorkspaceQaPanelProps = {
|
||||
iconButtonClass: string
|
||||
accessToken: string
|
||||
qaMode: QaMode
|
||||
teacherQaDisabled?: boolean
|
||||
teacherChatFlash: boolean
|
||||
qaMessagesLoading: boolean
|
||||
visibleAiMessages: WorkspaceQaMessage[]
|
||||
@@ -53,6 +54,7 @@ export default function WorkspaceQaPanel({
|
||||
iconButtonClass,
|
||||
accessToken,
|
||||
qaMode,
|
||||
teacherQaDisabled = false,
|
||||
teacherChatFlash,
|
||||
qaMessagesLoading,
|
||||
visibleAiMessages,
|
||||
@@ -73,6 +75,10 @@ export default function WorkspaceQaPanel({
|
||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
|
||||
}, [onModeChange, qaMode, teacherQaDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const closeContextMenu = (): void => setContextMenu(null)
|
||||
@@ -161,16 +167,24 @@ export default function WorkspaceQaPanel({
|
||||
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
||||
{ id: 'teacher' as const, label: '강사', Icon: User }
|
||||
].map(({ id, label, Icon }) => {
|
||||
const disabled = id === 'teacher' && teacherQaDisabled
|
||||
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onModeChange(id)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) onModeChange(id)
|
||||
}}
|
||||
className={[
|
||||
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
teacherTabFlash
|
||||
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
disabled
|
||||
? dark
|
||||
? 'cursor-not-allowed bg-[#202020] text-white/25'
|
||||
: 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]'
|
||||
: teacherTabFlash
|
||||
? dark
|
||||
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
||||
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
||||
@@ -234,7 +248,7 @@ export default function WorkspaceQaPanel({
|
||||
<QaInputBox
|
||||
dark={dark}
|
||||
busy={inputBusy}
|
||||
disabled={inputDisabled}
|
||||
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
|
||||
onStop={onStop}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
|
||||
previewOpen: boolean
|
||||
showControlCountdown: boolean
|
||||
controlCountdownDanger: boolean
|
||||
canExtendControl: boolean
|
||||
controlEndsAt: string | null
|
||||
submitVisible: boolean
|
||||
submitDisabled: boolean
|
||||
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
|
||||
onTogglePreview: () => void
|
||||
onSubmit: () => void
|
||||
onOpenPowerActions: () => void
|
||||
onRequestControlExtension: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
|
||||
previewOpen,
|
||||
showControlCountdown,
|
||||
controlCountdownDanger,
|
||||
canExtendControl,
|
||||
controlEndsAt,
|
||||
submitVisible,
|
||||
submitDisabled,
|
||||
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
|
||||
onTogglePreview,
|
||||
onSubmit,
|
||||
onOpenPowerActions,
|
||||
onRequestControlExtension,
|
||||
onClose
|
||||
}: WorkspaceTopBarProps): React.JSX.Element {
|
||||
return (
|
||||
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
|
||||
<Clock3 size={13} />
|
||||
{formatControlRemainingTime(controlEndsAt)}
|
||||
</span>
|
||||
{controlCountdownDanger && canExtendControl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRequestControlExtension}
|
||||
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
|
||||
>
|
||||
연장
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
|
||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||
|
||||
type WorkspaceVisualizationPanelProps = {
|
||||
dark: boolean
|
||||
@@ -16,11 +17,13 @@ type WorkspaceVisualizationPanelProps = {
|
||||
running: boolean
|
||||
busy: boolean
|
||||
viewerUrl: string
|
||||
nav2Mode: Nav2VisualizationMode
|
||||
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
onToggleLarge: () => void
|
||||
onClose: () => void
|
||||
onStackChange: (stack: VisualizationStack) => void
|
||||
onStart: () => void
|
||||
onStartNav2: (mode: Nav2VisualizationMode) => void
|
||||
onStop: () => void
|
||||
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
|
||||
}
|
||||
@@ -38,14 +41,17 @@ export default function WorkspaceVisualizationPanel({
|
||||
running,
|
||||
busy,
|
||||
viewerUrl,
|
||||
nav2Mode,
|
||||
onDragStart,
|
||||
onToggleLarge,
|
||||
onClose,
|
||||
onStackChange,
|
||||
onStart,
|
||||
onStartNav2,
|
||||
onStop,
|
||||
onResizeStart
|
||||
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
|
||||
const isNav2Visualization = activeStack === 'nav2' || activeStack === 'slam'
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
@@ -127,21 +133,58 @@ export default function WorkspaceVisualizationPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{running ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => {
|
||||
if (running) onStop()
|
||||
else onStart()
|
||||
}}
|
||||
onClick={onStop}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : running ? '종료' : '시작'}
|
||||
{busy ? '처리 중' : '종료'}
|
||||
</button>
|
||||
) : isNav2Visualization ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => onStartNav2('slam')}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
nav2Mode === 'slam' ? 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy && nav2Mode === 'slam' ? '처리 중' : 'SLAM 시작'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={() => onStartNav2('navigation')}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
||||
nav2Mode === 'navigation' ? 'bg-[#2563eb] text-white hover:bg-[#3b82f6]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy && nav2Mode === 'navigation' ? '처리 중' : '주행 시작'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !stacks.length}
|
||||
onClick={onStart}
|
||||
className={[
|
||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? '처리 중' : '시작'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -155,7 +198,7 @@ export default function WorkspaceVisualizationPanel({
|
||||
{!running && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
|
||||
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p>
|
||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '시각화 준비 중입니다.'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
|
||||
|
||||
type WorldObjectType = WorldLayoutObject['type']
|
||||
|
||||
type WorldEditorModalProps = {
|
||||
workspaceRoot: string
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
type TransformMode = 'translate' | 'rotate' | 'scale'
|
||||
|
||||
const objectLabels: Record<WorldObjectType, string> = {
|
||||
pillar: '기둥',
|
||||
wall: '벽',
|
||||
box: '박스'
|
||||
}
|
||||
|
||||
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
|
||||
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
|
||||
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
|
||||
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
|
||||
}
|
||||
|
||||
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
|
||||
const template = defaultsByType[type]
|
||||
return {
|
||||
id: `${type}-${Date.now()}-${index}`,
|
||||
type,
|
||||
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
|
||||
rotation: [...template.rotation],
|
||||
size: [...template.size]
|
||||
}
|
||||
}
|
||||
|
||||
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
|
||||
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
|
||||
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
|
||||
}
|
||||
|
||||
function createMesh(item: WorldLayoutObject): THREE.Mesh {
|
||||
const [width, depth, height] = item.size
|
||||
const geometry = item.type === 'pillar'
|
||||
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
|
||||
: new THREE.BoxGeometry(width, depth, height)
|
||||
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
|
||||
mesh.position.set(item.position[0], item.position[1], item.position[2])
|
||||
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
|
||||
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
mesh.userData.objectId = item.id
|
||||
return mesh
|
||||
}
|
||||
|
||||
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
|
||||
const modelRoots: Record<string, string> = {
|
||||
turtlebot3_world: modelRoot,
|
||||
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
|
||||
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
|
||||
}
|
||||
|
||||
function parseNumbers(value: string | null | undefined, count = 0): number[] {
|
||||
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
|
||||
while (numbers.length < count) numbers.push(0)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function directChild(element: Element | null | undefined, name: string): Element | null {
|
||||
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
|
||||
}
|
||||
|
||||
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
|
||||
return directChild(element, name)?.textContent?.trim() || fallback
|
||||
}
|
||||
|
||||
function applySdfPose(target: THREE.Object3D, value: string): void {
|
||||
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
|
||||
target.position.set(x, y, z)
|
||||
target.rotation.set(roll, pitch, yaw, 'XYZ')
|
||||
}
|
||||
|
||||
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
|
||||
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
|
||||
const ambient = visual.querySelector('ambient')?.textContent
|
||||
const [red, green, blue] = parseNumbers(ambient, 3)
|
||||
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
|
||||
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
|
||||
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
|
||||
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
|
||||
}
|
||||
|
||||
function gazeboAssetPath(uri: string): string {
|
||||
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
|
||||
}
|
||||
|
||||
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
|
||||
const response = await fetch(`${modelRoot}/model.sdf`)
|
||||
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
|
||||
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
|
||||
const model = documentNode.querySelector('model')
|
||||
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
|
||||
|
||||
const colladaLoader = new ColladaLoader()
|
||||
const stlLoader = new STLLoader()
|
||||
const modelGroup = new THREE.Group()
|
||||
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
|
||||
parent.add(modelGroup)
|
||||
|
||||
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
|
||||
const meshNode = directChild(geometry, 'mesh')
|
||||
const uri = childText(meshNode, 'uri')
|
||||
if (!uri) return
|
||||
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
|
||||
const url = gazeboAssetPath(uri)
|
||||
const material = worldMaterial(visual)
|
||||
if (url.endsWith('.stl')) {
|
||||
const geometryValue = await stlLoader.loadAsync(url)
|
||||
geometryValue.computeVertexNormals()
|
||||
const mesh = new THREE.Mesh(geometryValue, material)
|
||||
mesh.scale.set(scale[0], scale[1], scale[2])
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
target.add(mesh)
|
||||
return
|
||||
}
|
||||
const collada = await colladaLoader.loadAsync(url)
|
||||
collada.scene.scale.set(scale[0], scale[1], scale[2])
|
||||
collada.scene.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return
|
||||
child.material = material.clone()
|
||||
child.castShadow = true
|
||||
child.receiveShadow = true
|
||||
})
|
||||
target.add(collada.scene)
|
||||
}
|
||||
|
||||
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
|
||||
const linkGroup = new THREE.Group()
|
||||
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
|
||||
modelGroup.add(linkGroup)
|
||||
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
|
||||
const visualGroup = new THREE.Group()
|
||||
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
|
||||
linkGroup.add(visualGroup)
|
||||
const geometry = directChild(visual, 'geometry')
|
||||
if (!geometry) continue
|
||||
const material = worldMaterial(visual)
|
||||
if (directChild(geometry, 'mesh')) {
|
||||
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
|
||||
continue
|
||||
}
|
||||
const box = directChild(geometry, 'box')
|
||||
const cylinder = directChild(geometry, 'cylinder')
|
||||
if (box) {
|
||||
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
|
||||
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
|
||||
} else if (cylinder) {
|
||||
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
|
||||
const height = Number(childText(cylinder, 'length', '1')) || 1
|
||||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
|
||||
mesh.rotateX(Math.PI / 2)
|
||||
visualGroup.add(mesh)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
||||
const canvasHostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<HTMLIFrameElement>(null)
|
||||
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
|
||||
const selectedIdRef = useRef<string | null>(null)
|
||||
const renderSceneRef = useRef<(() => void) | null>(null)
|
||||
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.localWorkspace.worldLayout.read(workspaceRoot)
|
||||
.then((nextLayout) => {
|
||||
if (!active) return
|
||||
worldRef.current = nextLayout
|
||||
setLayout(nextLayout)
|
||||
setSelectedId(nextLayout.objects[0]?.id ?? null)
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
if (!active) return
|
||||
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
|
||||
})
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => { active = false }
|
||||
}, [workspaceRoot])
|
||||
|
||||
useEffect(() => {
|
||||
worldRef.current = layout
|
||||
}, [layout])
|
||||
|
||||
useEffect(() => {
|
||||
selectedIdRef.current = selectedId
|
||||
renderSceneRef.current?.()
|
||||
sendLayoutToViewer(worldRef.current)
|
||||
}, [selectedId])
|
||||
|
||||
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
|
||||
viewerRef.current?.contentWindow?.postMessage({
|
||||
source: 'autonomy-world-editor',
|
||||
type: 'set-layout',
|
||||
layout: nextLayout,
|
||||
selectedId: selectedIdRef.current,
|
||||
}, '*')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
sendLayoutToViewer(layout)
|
||||
}, [layout])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent): void => {
|
||||
if (event.data?.source !== 'autonomy-world-editor') return
|
||||
if (event.data.type === 'ready') {
|
||||
sendLayoutToViewer(worldRef.current)
|
||||
return
|
||||
}
|
||||
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
|
||||
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
|
||||
const patch = event.data.patch as Partial<WorldLayoutObject>
|
||||
setLayout((current) => {
|
||||
const next = {
|
||||
...current,
|
||||
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
|
||||
}
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const host = canvasHostRef.current
|
||||
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
|
||||
// previous canvas path dormant while the shared viewer is mounted.
|
||||
if (!host || loading || viewerRef.current) return
|
||||
const scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color('#0b1220')
|
||||
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
|
||||
camera.position.set(4, 6, 5)
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.shadowMap.enabled = true
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||
host.replaceChildren(renderer.domElement)
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.target.set(0, 0, 0)
|
||||
controls.enableDamping = true
|
||||
controls.maxPolarAngle = Math.PI / 2.08
|
||||
controls.update()
|
||||
|
||||
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
|
||||
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
|
||||
keyLight.position.set(6, -5, 11)
|
||||
keyLight.castShadow = true
|
||||
scene.add(keyLight)
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(20, 20),
|
||||
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
|
||||
)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
|
||||
|
||||
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
|
||||
const sdfRoot = new THREE.Group()
|
||||
sdfRoot.rotation.x = -Math.PI / 2
|
||||
scene.add(sdfRoot)
|
||||
const objectGroup = new THREE.Group()
|
||||
const selectionGroup = new THREE.Group()
|
||||
sdfRoot.add(objectGroup)
|
||||
scene.add(selectionGroup)
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointer = new THREE.Vector2()
|
||||
|
||||
const renderScene = (): void => {
|
||||
objectGroup.clear()
|
||||
selectionGroup.clear()
|
||||
for (const item of worldRef.current.objects) {
|
||||
const mesh = createMesh(item)
|
||||
objectGroup.add(mesh)
|
||||
if (item.id === selectedIdRef.current) {
|
||||
const outline = new THREE.Box3().setFromObject(mesh)
|
||||
const size = outline.getSize(new THREE.Vector3())
|
||||
const center = outline.getCenter(new THREE.Vector3())
|
||||
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
|
||||
selectionGroup.add(helper)
|
||||
}
|
||||
}
|
||||
}
|
||||
renderSceneRef.current = renderScene
|
||||
renderScene()
|
||||
let disposed = false
|
||||
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
|
||||
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
|
||||
})
|
||||
|
||||
const resize = (): void => {
|
||||
const { width, height } = host.getBoundingClientRect()
|
||||
if (!width || !height) return
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height, false)
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(host)
|
||||
resize()
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
|
||||
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
|
||||
}
|
||||
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
|
||||
|
||||
let frameId = 0
|
||||
const animate = (): void => {
|
||||
controls.update()
|
||||
renderer.render(scene, camera)
|
||||
frameId = window.requestAnimationFrame(animate)
|
||||
}
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
renderSceneRef.current = null
|
||||
window.cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
|
||||
controls.dispose()
|
||||
renderer.dispose()
|
||||
host.replaceChildren()
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const selectTransformMode = (mode: TransformMode): void => {
|
||||
setTransformMode(mode)
|
||||
viewerRef.current?.contentWindow?.postMessage({
|
||||
source: 'autonomy-world-editor',
|
||||
type: 'set-transform-mode',
|
||||
mode,
|
||||
}, '*')
|
||||
}
|
||||
|
||||
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
|
||||
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
|
||||
setLayout((current) => {
|
||||
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
|
||||
if (!selected) return
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return
|
||||
const next = [...selected[key]] as [number, number, number]
|
||||
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
|
||||
updateObject(selected.id, { [key]: next })
|
||||
}
|
||||
const addObject = (type: WorldObjectType): void => {
|
||||
const object = createObject(type, layout.objects.length)
|
||||
setLayout((current) => {
|
||||
const next = { ...current, objects: [...current.objects, object] }
|
||||
worldRef.current = next
|
||||
return next
|
||||
})
|
||||
setSelectedId(object.id)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const removeSelected = (): void => {
|
||||
if (!selected) return
|
||||
const remaining = layout.objects.filter((item) => item.id !== selected.id)
|
||||
const next = { ...layout, objects: remaining }
|
||||
worldRef.current = next
|
||||
setLayout(next)
|
||||
setSelectedId(remaining[0]?.id ?? null)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const resetLayout = (): void => {
|
||||
const next: WorldLayout = { version: 1, objects: [] }
|
||||
worldRef.current = next
|
||||
setLayout(next)
|
||||
setSelectedId(null)
|
||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||
}
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
|
||||
setLayout(saved)
|
||||
onSaved?.()
|
||||
onClose()
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
|
||||
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
|
||||
<div className="border-b border-white/10 px-3 py-3">
|
||||
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
|
||||
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{layout.objects.length ? layout.objects.map((item) => {
|
||||
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
|
||||
const selected = item.id === selectedId
|
||||
return (
|
||||
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
|
||||
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
|
||||
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
|
||||
</button>
|
||||
)
|
||||
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
|
||||
</div>
|
||||
<div className="border-t border-white/10 p-2">
|
||||
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{([
|
||||
['pillar', Cylinder, '기둥'],
|
||||
['wall', PanelsTopLeft, '벽'],
|
||||
['box', Box, '박스'],
|
||||
] as const).map(([type, Icon, label]) => (
|
||||
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
|
||||
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
|
||||
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
|
||||
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
|
||||
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
|
||||
{([
|
||||
['translate', Move3D, '이동'],
|
||||
['rotate', Rotate3D, '회전'],
|
||||
['scale', Maximize2, '크기'],
|
||||
] as const).map(([mode, Icon, label]) => (
|
||||
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
|
||||
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
|
||||
{selected ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
|
||||
{(['position', 'rotation', 'size'] as const).map((key) => {
|
||||
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
|
||||
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
|
||||
})}
|
||||
</div>
|
||||
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
|
||||
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
|
||||
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
|
||||
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
|
||||
</footer>
|
||||
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { AlertTriangle, Check, House, LoaderCircle, Map, PencilRuler, Save, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type WorkspaceMapOption = {
|
||||
key: string
|
||||
platformKey: 'NAV2'
|
||||
name: string
|
||||
description: string
|
||||
worldFile: string
|
||||
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
|
||||
custom?: boolean
|
||||
saved?: boolean
|
||||
savedMapId?: string
|
||||
createdAt?: string
|
||||
sourceMapKey?: string
|
||||
yamlPath?: string
|
||||
pgmPath?: string
|
||||
previewPgm?: string
|
||||
layout?: unknown
|
||||
}
|
||||
|
||||
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
|
||||
{
|
||||
key: 'nav2-turtlebot3-world',
|
||||
platformKey: 'NAV2',
|
||||
name: '기본 월드',
|
||||
description: 'TurtleBot3 기본 장애물 환경',
|
||||
worldFile: 'turtlebot3_world.world',
|
||||
viewerModel: 'turtlebot3_world'
|
||||
},
|
||||
{
|
||||
key: 'nav2-turtlebot3-house',
|
||||
platformKey: 'NAV2',
|
||||
name: '실내 환경',
|
||||
description: '방과 복도로 구성된 TurtleBot3 House',
|
||||
worldFile: 'turtlebot3_house.world',
|
||||
viewerModel: 'turtlebot3_house'
|
||||
},
|
||||
{
|
||||
key: 'nav2-custom',
|
||||
platformKey: 'NAV2',
|
||||
name: '커스텀 맵',
|
||||
description: '장애물을 직접 배치해 학습 환경 구성',
|
||||
worldFile: 'empty_world.world',
|
||||
viewerModel: 'turtlebot3_world',
|
||||
custom: true
|
||||
}
|
||||
]
|
||||
|
||||
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
|
||||
|
||||
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
|
||||
const Icon = map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.previewPgm || !canvasRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
|
||||
let cursor = 0
|
||||
const nextToken = (): string => {
|
||||
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
if (bytes[cursor] === 35) {
|
||||
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
|
||||
return nextToken()
|
||||
}
|
||||
const start = cursor
|
||||
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
return String.fromCharCode(...bytes.slice(start, cursor))
|
||||
}
|
||||
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
|
||||
const width = Number(nextToken())
|
||||
const height = Number(nextToken())
|
||||
const maxValue = Number(nextToken())
|
||||
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
|
||||
|
||||
const targetWidth = 192
|
||||
const targetHeight = 128
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
context.fillStyle = '#d9dce0'
|
||||
context.fillRect(0, 0, targetWidth, targetHeight)
|
||||
const scale = Math.min(targetWidth / width, targetHeight / height)
|
||||
const renderedWidth = Math.max(1, Math.floor(width * scale))
|
||||
const renderedHeight = Math.max(1, Math.floor(height * scale))
|
||||
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
|
||||
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
|
||||
const image = context.createImageData(renderedWidth, renderedHeight)
|
||||
for (let y = 0; y < renderedHeight; y += 1) {
|
||||
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
|
||||
for (let x = 0; x < renderedWidth; x += 1) {
|
||||
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
|
||||
const value = bytes[cursor + sourceY * width + sourceX]
|
||||
const index = (y * renderedWidth + x) * 4
|
||||
image.data[index] = value
|
||||
image.data[index + 1] = value
|
||||
image.data[index + 2] = value
|
||||
image.data[index + 3] = 255
|
||||
}
|
||||
}
|
||||
context.putImageData(image, offsetX, offsetY)
|
||||
} catch {
|
||||
// Keep the fallback map schematic visible when a legacy preview is invalid.
|
||||
}
|
||||
}, [map.previewPgm])
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
|
||||
selected ? 'border-[#6f9cff]' : 'border-white/15'
|
||||
].join(' ')}
|
||||
>
|
||||
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
|
||||
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
|
||||
{!map.previewPgm && (map.custom ? (
|
||||
<>
|
||||
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
|
||||
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
|
||||
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
|
||||
</>
|
||||
) : map.viewerModel === 'turtlebot3_house' ? (
|
||||
<>
|
||||
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
|
||||
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
|
||||
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
|
||||
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
|
||||
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
|
||||
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
|
||||
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
|
||||
</>
|
||||
))}
|
||||
{map.saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
|
||||
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
|
||||
<Icon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type WorldMapSelectorModalProps = {
|
||||
platformKey: string
|
||||
weekNumber: number
|
||||
maps?: WorkspaceMapOption[]
|
||||
selectedMapKey: string
|
||||
onSelect: (map: WorkspaceMapOption) => void
|
||||
onCreate: () => void
|
||||
onDelete?: (map: WorkspaceMapOption) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
|
||||
const maps = platformKey.toUpperCase() === 'NAV2'
|
||||
? (configuredMaps?.length
|
||||
? [...configuredMaps, ...(configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [] : [LOCAL_CUSTOM_MAP])]
|
||||
: NAV2_MAP_OPTIONS)
|
||||
: []
|
||||
const environmentMaps = maps.filter((map) => !map.saved)
|
||||
const savedMaps = maps.filter((map) => map.saved)
|
||||
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
|
||||
const [tab, setTab] = useState<'environment' | 'saved'>(() => selectedMap?.saved ? 'saved' : 'environment')
|
||||
const visibleMaps = tab === 'environment' ? environmentMaps : savedMaps
|
||||
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
|
||||
|
||||
useEffect(() => {
|
||||
setTab(selectedMap?.saved ? 'saved' : 'environment')
|
||||
setPendingMap(selectedMap)
|
||||
}, [selectedMapKey])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
|
||||
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-[16px] font-bold">맵 설정</h2>
|
||||
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
|
||||
</div>
|
||||
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
|
||||
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
|
||||
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
|
||||
{visibleMaps.map((map) => {
|
||||
const pending = map.key === pendingMap?.key
|
||||
const selected = map.key === selectedMapKey
|
||||
return (
|
||||
<button
|
||||
key={map.key}
|
||||
type="button"
|
||||
onClick={() => setPendingMap(map)}
|
||||
className={[
|
||||
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
|
||||
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
|
||||
].join(' ')}
|
||||
>
|
||||
<MapPreview map={map} selected={pending} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-bold">{map.name}</span>
|
||||
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
|
||||
</span>
|
||||
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
||||
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
|
||||
{map.saved && map.key !== selectedMapKey && onDelete && (
|
||||
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
|
||||
<Trash2 className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
|
||||
</div>
|
||||
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
|
||||
{tab === 'environment' && (
|
||||
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
|
||||
<PencilRuler className="size-3.5" />
|
||||
맵 편집
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
|
||||
확인
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type WorldMapRestartConfirmModalProps = {
|
||||
map: WorkspaceMapOption
|
||||
restarting: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function WorldMapRestartConfirmModal({ map, restarting, onCancel, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
|
||||
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
|
||||
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
|
||||
<AlertTriangle className="size-4 text-amber-300" />
|
||||
<h2 className="text-[15px] font-bold">맵 변경</h2>
|
||||
</header>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-[13px] font-semibold">{map.name}으로 변경하시겠습니까?</p>
|
||||
<p className="mt-2 text-[12px] leading-5 text-white/55">실행 중인 모듈을 전체 재시작해야 새 맵이 적용됩니다.</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button type="button" disabled={restarting} onClick={onCancel} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/65 transition hover:bg-white/8 hover:text-white disabled:cursor-default disabled:opacity-45">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" disabled={restarting} onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-60">
|
||||
{restarting && <LoaderCircle className="size-3.5 animate-spin" />}
|
||||
{restarting ? '재시작 중' : '변경 후 재시작'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { ModulePrebuiltArtifact } from './types'
|
||||
|
||||
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
|
||||
|
||||
type RuntimeModuleOverride = {
|
||||
id?: string
|
||||
folderName?: string
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
sortOrder?: number
|
||||
defaultParameters?: string[]
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[]
|
||||
dockerImage?: string | null
|
||||
}
|
||||
|
||||
type RuntimeModuleSet = {
|
||||
target?: 'local' | 'platform'
|
||||
includeUnlisted?: boolean
|
||||
modules?: RuntimeModuleOverride[]
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
|
||||
const config = asRecord(profile?.config)
|
||||
const raw = asRecord(config?.moduleSet)
|
||||
if (!raw) return null
|
||||
|
||||
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
|
||||
if (target && target !== runtimeTarget) return null
|
||||
const modules = Array.isArray(raw.modules)
|
||||
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
|
||||
id: typeof item.id === 'string' ? item.id : undefined,
|
||||
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
|
||||
name: typeof item.name === 'string' ? item.name : undefined,
|
||||
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
||||
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
|
||||
defaultParameters: Array.isArray(item.defaultParameters)
|
||||
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
|
||||
: undefined,
|
||||
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
|
||||
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
|
||||
: undefined,
|
||||
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
|
||||
}))
|
||||
: []
|
||||
|
||||
return {
|
||||
target,
|
||||
includeUnlisted: raw.includeUnlisted !== false,
|
||||
modules,
|
||||
}
|
||||
}
|
||||
|
||||
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
|
||||
return Boolean(
|
||||
(override.id && override.id === module.id)
|
||||
|| (override.folderName && override.folderName === module.folderName)
|
||||
|| (override.name && override.name === module.name)
|
||||
)
|
||||
}
|
||||
|
||||
/** Environment-specific module selection preserves the shared student source repositories. */
|
||||
export function resolveRuntimeModules(
|
||||
modules: CurriculumWeek['modules'],
|
||||
profile: EnvironmentProfile | EnvironmentProfile[] | null,
|
||||
runtimeTarget: 'local' | 'platform',
|
||||
): CurriculumWeek['modules'] {
|
||||
const baseModules = modules ?? []
|
||||
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
|
||||
const moduleSets = profiles
|
||||
.map((item) => readModuleSet(item, runtimeTarget))
|
||||
.filter((item): item is RuntimeModuleSet => item !== null)
|
||||
if (!moduleSets.length) return baseModules
|
||||
|
||||
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
|
||||
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
|
||||
return baseModules
|
||||
.filter((module) => {
|
||||
const override = overrides.find((item) => matchesModule(module, item))
|
||||
return includeUnlisted || Boolean(override?.enabled !== false && override)
|
||||
})
|
||||
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
|
||||
.map((module) => {
|
||||
const override = overrides.find((item) => matchesModule(module, item))
|
||||
if (!override) return module
|
||||
return {
|
||||
...module,
|
||||
sortOrder: override.sortOrder ?? module.sortOrder,
|
||||
defaultParameters: override.defaultParameters ?? module.defaultParameters,
|
||||
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
|
||||
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
}
|
||||
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
|
||||
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
|
||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
|
||||
const safePlatformKey = platformKey
|
||||
.replace(/^futuredrive[-_]/i, '')
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||
.toLowerCase()
|
||||
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
|
||||
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
|
||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
|
||||
return `${normalizedRoot}/${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
|
||||
import type { PlatformOption } from '../platforms/types'
|
||||
|
||||
export type StudioRuntimeSessionState = {
|
||||
localPcLayoutOpen: boolean
|
||||
@@ -8,6 +9,7 @@ export type StudioRuntimeSessionState = {
|
||||
sessionEndedAt: string | null
|
||||
connectedPlatformId: string | null
|
||||
runningPlatformIds: string[]
|
||||
runningPlatforms: PlatformOption[]
|
||||
workspaceWeekNumber: number | null
|
||||
}
|
||||
|
||||
@@ -27,6 +29,15 @@ export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | nul
|
||||
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
|
||||
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
|
||||
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
|
||||
runningPlatforms: Array.isArray(parsed.runningPlatforms)
|
||||
? parsed.runningPlatforms.filter((platform): platform is PlatformOption => (
|
||||
Boolean(platform)
|
||||
&& typeof platform === 'object'
|
||||
&& typeof (platform as PlatformOption).id === 'string'
|
||||
&& typeof (platform as PlatformOption).platformKey === 'string'
|
||||
&& typeof (platform as PlatformOption).title === 'string'
|
||||
))
|
||||
: [],
|
||||
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ModuleRow = {
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
dockerImage?: string | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
|
||||
@@ -62,19 +62,6 @@ export function useRemoteWorkspaceRuntime({
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
}
|
||||
|
||||
const submitResult = await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
|
||||
throw new Error(`[${module.name}] ${submitResult.message}`)
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.remoteWorkspace.modules.install(
|
||||
getRemoteWorkspaceTarget(),
|
||||
@@ -87,23 +74,45 @@ export function useRemoteWorkspaceRuntime({
|
||||
runtimeImages: activeRuntimeImages
|
||||
}
|
||||
)
|
||||
|
||||
const submitResult = remoteWorkspace
|
||||
? await window.remoteWorkspace.git.submit(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
)
|
||||
: await window.localWorkspace.git.submit(
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`Run: ${weekNumber}주차 ${module.name}`,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
|
||||
throw new Error(`[${module.name}] ${submitResult.message}`)
|
||||
}
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
clonePathPreview,
|
||||
courseKey,
|
||||
getFreshAccessToken,
|
||||
getRemoteWorkspaceTarget,
|
||||
remoteWorkspace,
|
||||
saveDirtyFilesForModule,
|
||||
weekNumber
|
||||
])
|
||||
|
||||
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
|
||||
if (remoteWorkspace) {
|
||||
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
|
||||
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
|
||||
for (const module of availableModules) {
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
@@ -112,6 +121,7 @@ export function useRemoteWorkspaceRuntime({
|
||||
moduleMap.set(module.name, {
|
||||
moduleName: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||
sortOrder: module.sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
|
||||
type UseWorkspaceFileActionsOptions = {
|
||||
selectedModule: ModuleRow | null
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
selectedTreePath: string | null
|
||||
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
|
||||
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
|
||||
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
|
||||
export function useWorkspaceFileActions({
|
||||
selectedModule,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
selectedTreePath,
|
||||
setSelectedTreePath,
|
||||
setSelectedExampleTreePath,
|
||||
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
|
||||
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
||||
try {
|
||||
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const nextFiles = result.files
|
||||
const nextFolders = result.folders.length ? result.folders : []
|
||||
|
||||
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
|
||||
if (newFileDraft.kind === 'folder') {
|
||||
const nextFolder = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] create folder failed', error)
|
||||
return
|
||||
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
|
||||
|
||||
const nextFile = `${newFileDraft.parent}/${normalizedName}`
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] create file failed', error)
|
||||
return
|
||||
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
|
||||
|
||||
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
|
||||
if (!selectedModule || file.startsWith('examples/example-code/')) return
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
markEditorFileSaved(file, editorFiles[file] ?? '')
|
||||
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
|
||||
}
|
||||
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
|
||||
}
|
||||
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] rename failed', error)
|
||||
return
|
||||
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
|
||||
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
|
||||
if (!selectedModule) return
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] delete failed', error)
|
||||
return
|
||||
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
|
||||
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
|
||||
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[module-files] copy failed', error)
|
||||
return
|
||||
|
||||
@@ -87,11 +87,23 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
|
||||
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
|
||||
const installOptions = {
|
||||
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
||||
runtimeImages: activeRuntimeImages
|
||||
})
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.install(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName,
|
||||
templateRepositoryUrl,
|
||||
prepared.repositoryUrlWithToken,
|
||||
installOptions,
|
||||
)
|
||||
return
|
||||
}
|
||||
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, installOptions)
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
|
||||
|
||||
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
|
||||
await prepareRemoteRuntimeModule(module)
|
||||
@@ -102,7 +114,8 @@ export function useWorkspaceModuleControl({
|
||||
getModuleRosParams(module),
|
||||
sourceSettings.gpuEnabled,
|
||||
sourceSettings.rosDomainId,
|
||||
activeRuntimeImages
|
||||
activeRuntimeImages,
|
||||
module.dockerImage,
|
||||
)
|
||||
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
@@ -167,7 +180,7 @@ export function useWorkspaceModuleControl({
|
||||
for (const module of targets) {
|
||||
const result = remoteWorkspace
|
||||
? await remoteModuleStart(module)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
await loadAvailableModules({ silent: true })
|
||||
@@ -206,10 +219,11 @@ export function useWorkspaceModuleControl({
|
||||
setModuleInstallError(null)
|
||||
try {
|
||||
if (remoteWorkspace) {
|
||||
for (const module of targets) {
|
||||
const result = await remoteModuleStop(module)
|
||||
const result = await window.remoteWorkspace.modules.stopMany(
|
||||
getRemoteWorkspaceTarget(),
|
||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
}
|
||||
} else {
|
||||
const result = await window.localWorkspace.modules.stopMany(
|
||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||
@@ -227,7 +241,7 @@ export function useWorkspaceModuleControl({
|
||||
} finally {
|
||||
setInstallingModuleName(null)
|
||||
}
|
||||
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
|
||||
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteWorkspace])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
||||
@@ -242,14 +256,14 @@ export function useWorkspaceModuleControl({
|
||||
: action === 'build'
|
||||
? remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage, getModuleRosParams(module))
|
||||
: action === 'restart'
|
||||
? remoteWorkspace
|
||||
? await remoteModuleRestart(module)
|
||||
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||
: remoteWorkspace
|
||||
? await remoteModuleStart(module)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
await loadAvailableModules({ silent: true })
|
||||
const actionLabel = action === 'stop'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { CurriculumWeek } from '../curriculum/types'
|
||||
import type { ModuleRow } from './types'
|
||||
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
|
||||
type UseWorkspaceModuleListResult = {
|
||||
availableModules: ModuleRow[]
|
||||
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
||||
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
|
||||
modulesLoading: boolean
|
||||
moduleListError: string | null
|
||||
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
|
||||
@@ -49,43 +50,104 @@ export function useWorkspaceModuleList({
|
||||
const [modulesLoading, setModulesLoading] = useState(false)
|
||||
const [moduleListError, setModuleListError] = useState<string | null>(null)
|
||||
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
|
||||
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
|
||||
const loadSequenceRef = useRef(0)
|
||||
const moduleLoadInFlightRef = useRef(false)
|
||||
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
|
||||
|
||||
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
|
||||
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
||||
const now = Date.now()
|
||||
return modules.map((module) => {
|
||||
const key = moduleStateKey(module)
|
||||
const pending = pendingModuleStatesRef.current.get(key)
|
||||
if (!pending) return module
|
||||
if (module.state === pending.state || pending.expiresAt <= now) {
|
||||
pendingModuleStatesRef.current.delete(key)
|
||||
return module
|
||||
}
|
||||
return {
|
||||
...module,
|
||||
state: pending.state,
|
||||
runtime: pending.state === 'running' ? '시작 중' : '-',
|
||||
cpu: pending.state === 'running' ? module.cpu : null,
|
||||
memory: pending.state === 'running' ? module.memory : null,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
|
||||
const key = moduleStateKey(module)
|
||||
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + 10_000 })
|
||||
setAvailableModules((current) => current.map((item) => {
|
||||
if (moduleStateKey(item) !== key) return item
|
||||
return {
|
||||
...item,
|
||||
state,
|
||||
runtime: state === 'running' ? '시작 중' : '-',
|
||||
cpu: state === 'running' ? item.cpu : null,
|
||||
memory: state === 'running' ? item.memory : null,
|
||||
}
|
||||
}))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
|
||||
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
|
||||
|
||||
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
|
||||
if (!remoteTarget) return { modules: [] }
|
||||
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
|
||||
if (!remoteTarget) throw new Error('원격 플랫폼 SSH 주소를 확인하지 못했습니다.')
|
||||
const result = await window.remoteWorkspace.modules.list(remoteTarget, moduleInputsRef.current.curriculumModules ?? [])
|
||||
return {
|
||||
modules: result.modules.map((module) => {
|
||||
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
|
||||
const parameters = Array.isArray(rawModule.parameters)
|
||||
? rawModule.parameters
|
||||
: Array.isArray(rawModule.defaultParameters)
|
||||
? rawModule.defaultParameters.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
return { ...module, parameters }
|
||||
}),
|
||||
}
|
||||
}, [curriculumModuleKey, remoteTarget])
|
||||
|
||||
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
|
||||
// Remote inventory probes open SSH sessions. Keep one request in flight so
|
||||
// polling cannot continuously replace a slow response with a newer one.
|
||||
if (moduleLoadInFlightRef.current) return
|
||||
moduleLoadInFlightRef.current = true
|
||||
const requestSequence = ++loadSequenceRef.current
|
||||
if (!silent) setModulesLoading(true)
|
||||
try {
|
||||
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
|
||||
const { curriculumModules: currentCurriculumModules, applyModuleParameterOverrides: applyOverrides } = moduleInputsRef.current
|
||||
if (remoteWorkspace) {
|
||||
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] }))
|
||||
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module]))
|
||||
result.modules = result.modules.map((module) => {
|
||||
const remoteModule = remoteStateByName.get(module.name)
|
||||
return remoteModule?.state === 'running'
|
||||
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
|
||||
: module
|
||||
})
|
||||
}
|
||||
const result = await loadRemoteModules()
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
|
||||
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
setModuleListError(null)
|
||||
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
|
||||
} catch (error) {
|
||||
if (requestSequence !== loadSequenceRef.current) return
|
||||
console.error('[modules] list failed', error)
|
||||
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
|
||||
if (!silent) {
|
||||
setModuleListError(message)
|
||||
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
|
||||
setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setModulesLoading(false)
|
||||
moduleLoadInFlightRef.current = false
|
||||
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
|
||||
}
|
||||
}, [
|
||||
applyModuleParameterOverrides,
|
||||
applyPendingModuleStates,
|
||||
clonePathPreview,
|
||||
curriculumModuleKey,
|
||||
curriculumModules,
|
||||
loadRemoteModules,
|
||||
remoteWorkspace
|
||||
])
|
||||
@@ -99,14 +161,15 @@ export function useWorkspaceModuleList({
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadAvailableModules({ silent: true })
|
||||
}, 5000)
|
||||
}, remoteWorkspace ? 10_000 : 5_000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [loadAvailableModules, pollingActive])
|
||||
}, [loadAvailableModules, pollingActive, remoteWorkspace])
|
||||
|
||||
return {
|
||||
availableModules,
|
||||
setAvailableModules,
|
||||
markModuleState,
|
||||
modulesLoading,
|
||||
moduleListError,
|
||||
loadAvailableModules,
|
||||
|
||||
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
|
||||
moduleName?: string | null
|
||||
moduleFolderName?: string | null
|
||||
clonePathPreview: string
|
||||
remoteWorkspace: boolean
|
||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||
dark: boolean
|
||||
terminalActive: boolean
|
||||
resetKey: string
|
||||
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
|
||||
moduleName,
|
||||
moduleFolderName,
|
||||
clonePathPreview,
|
||||
remoteWorkspace,
|
||||
getRemoteWorkspaceTarget,
|
||||
dark,
|
||||
terminalActive,
|
||||
resetKey,
|
||||
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
|
||||
const terminalRef = useRef<XTerm | null>(null)
|
||||
const terminalFitRef = useRef<FitAddon | null>(null)
|
||||
const terminalSessionIdRef = useRef<string | null>(null)
|
||||
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
|
||||
const terminalSuppressQueueRef = useRef<string[]>([])
|
||||
const terminalOutputBufferRef = useRef<Record<string, string>>({})
|
||||
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
|
||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
||||
|
||||
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
|
||||
|
||||
const fitTerminal = (): void => {
|
||||
terminalFitRef.current?.fit()
|
||||
}
|
||||
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
|
||||
|
||||
void navigator.clipboard.readText()
|
||||
.then((text) => {
|
||||
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||
if (!text) return
|
||||
if (remoteWorkspace) {
|
||||
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), text, terminalSessionId)
|
||||
return
|
||||
}
|
||||
window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||
})
|
||||
.catch((error) => console.error('[terminal] paste failed', error))
|
||||
.finally(() => {
|
||||
@@ -153,8 +165,18 @@ export function useWorkspaceTerminal({
|
||||
terminalRef.current = term
|
||||
terminalFitRef.current = fitAddon
|
||||
|
||||
const terminalApi = window.localWorkspace.terminal
|
||||
const removeDataListener = terminalApi.onData((data) => {
|
||||
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
|
||||
const removeDataListener = remoteWorkspace
|
||||
? terminalApi.onData(remoteTargetResolverRef.current(), (data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
return
|
||||
}
|
||||
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${data}`.slice(-100000)
|
||||
term.write(data)
|
||||
}, terminalSessionId)
|
||||
: terminalApi.onData((data) => {
|
||||
const suppressQueue = terminalSuppressQueueRef.current
|
||||
if (suppressQueue.length && suppressQueue[0] === data) {
|
||||
suppressQueue.shift()
|
||||
@@ -168,10 +190,20 @@ export function useWorkspaceTerminal({
|
||||
term.write(nextData)
|
||||
}
|
||||
}, terminalSessionId)
|
||||
const removeSuppressListener = terminalApi.onSuppressData((data) => {
|
||||
const removeSuppressListener = remoteWorkspace
|
||||
? window.remoteWorkspace.terminal.onSuppressData(remoteTargetResolverRef.current(), (data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||
: window.localWorkspace.terminal.onSuppressData((data) => {
|
||||
terminalSuppressQueueRef.current.push(data)
|
||||
}, terminalSessionId)
|
||||
const inputDisposable = term.onData((data) => {
|
||||
if (remoteWorkspace) {
|
||||
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), data, terminalSessionId)
|
||||
return
|
||||
}
|
||||
window.localWorkspace.terminal.write(data, terminalSessionId)
|
||||
})
|
||||
|
||||
let fallbackPromptTimer: number | null = null
|
||||
let sizeSyncTimer: number | null = null
|
||||
@@ -183,8 +215,24 @@ export function useWorkspaceTerminal({
|
||||
startNestedFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit()
|
||||
if (disposed) return resolvePromise(false)
|
||||
void window.localWorkspace.terminal
|
||||
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
||||
const start = remoteWorkspace
|
||||
? window.remoteWorkspace.terminal.start(
|
||||
remoteTargetResolverRef.current(),
|
||||
moduleName,
|
||||
term.cols,
|
||||
term.rows,
|
||||
terminalSessionId,
|
||||
moduleFolderName ?? undefined,
|
||||
)
|
||||
: window.localWorkspace.terminal.start(
|
||||
moduleName,
|
||||
term.cols,
|
||||
term.rows,
|
||||
terminalSessionId,
|
||||
clonePathPreview,
|
||||
moduleFolderName ?? undefined,
|
||||
)
|
||||
void start
|
||||
.then(resolvePromise)
|
||||
.catch(() => resolvePromise(false))
|
||||
})
|
||||
@@ -195,7 +243,11 @@ export function useWorkspaceTerminal({
|
||||
if (!started) return
|
||||
sizeSyncTimer = window.setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
|
||||
if (remoteWorkspace) {
|
||||
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
|
||||
return
|
||||
}
|
||||
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
|
||||
}, 150)
|
||||
fallbackPromptTimer = window.setTimeout(() => {
|
||||
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
|
||||
@@ -228,14 +280,15 @@ export function useWorkspaceTerminal({
|
||||
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
|
||||
terminalApi.stop(terminalSessionId)
|
||||
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(remoteTargetResolverRef.current(), terminalSessionId)
|
||||
else window.localWorkspace.terminal.stop(terminalSessionId)
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
terminalFitRef.current = null
|
||||
terminalSessionIdRef.current = null
|
||||
terminalSuppressQueueRef.current = []
|
||||
}
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
|
||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
|
||||
|
||||
return {
|
||||
terminalContextMenu,
|
||||
|
||||
@@ -3,12 +3,60 @@ import type { EnvironmentProfile } from '../platforms/types'
|
||||
import type { RuntimeImageArtifact } from './types'
|
||||
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
||||
|
||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||
|
||||
const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
|
||||
id: 'default-nav2-visualization',
|
||||
week: 0,
|
||||
platformKey: 'futuredrive-local-pc',
|
||||
profileType: 'NAV2',
|
||||
name: 'NAV2',
|
||||
config: {
|
||||
type: 'NAV2',
|
||||
target: 'local',
|
||||
viewer: { stack: 'nav2' },
|
||||
bridge: {
|
||||
name: 'nav2-ros-bridge',
|
||||
port: 8765,
|
||||
image: 'slam-ros-bridge:latest',
|
||||
buildContext: 'resources/slam-ros-bridge',
|
||||
env: { ROBOT_STACK: 'nav2' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
||||
const type = profileType.toLowerCase()
|
||||
if (type === 'real_slam') return 'slam'
|
||||
if (type === 'real_navigation') return 'nav2'
|
||||
return type === 'autoware' || type === 'nav2' || type === 'slam' ? type : null
|
||||
}
|
||||
|
||||
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
|
||||
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
|
||||
}
|
||||
|
||||
function isRemoteEnvironmentProfile(profile: EnvironmentProfile | null): boolean {
|
||||
const target = profile?.config?.target
|
||||
return target === 'remote-jetson' || target === 'platform'
|
||||
}
|
||||
|
||||
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
|
||||
const matchingProfiles = profiles.filter((profile) => (
|
||||
remoteWorkspace ? isRemoteEnvironmentProfile(profile) : !isRemoteEnvironmentProfile(profile)
|
||||
))
|
||||
return matchingProfiles.length ? matchingProfiles : profiles
|
||||
}
|
||||
|
||||
type NoticeTone = 'info' | 'warning' | 'error'
|
||||
|
||||
type UseWorkspaceVisualizationOptions = {
|
||||
profiles: EnvironmentProfile[]
|
||||
clonePathPreview: string
|
||||
rosDomainId: string
|
||||
remoteWorkspace: boolean
|
||||
mapKey?: string | null
|
||||
worldLayoutVersion?: number
|
||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||
}
|
||||
|
||||
@@ -19,6 +67,8 @@ type UseWorkspaceVisualizationResult = {
|
||||
visualizationRunning: boolean
|
||||
visualizationBusy: boolean
|
||||
visualizationViewerUrl: string
|
||||
nav2VisualizationMode: Nav2VisualizationMode
|
||||
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
|
||||
activeEnvironmentProfile: EnvironmentProfile | null
|
||||
activeRuntimeImages: RuntimeImageArtifact[]
|
||||
refreshVisualizationStatus: () => Promise<void>
|
||||
@@ -36,20 +86,29 @@ export function useWorkspaceVisualization({
|
||||
profiles,
|
||||
clonePathPreview,
|
||||
rosDomainId,
|
||||
remoteWorkspace,
|
||||
mapKey,
|
||||
worldLayoutVersion,
|
||||
onTitlebarNotice
|
||||
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
|
||||
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
|
||||
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
|
||||
const [visualizationRunning, setVisualizationRunning] = useState(false)
|
||||
const [, setVisualizationStatusMessage] = useState('대기')
|
||||
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
||||
|
||||
const runtimeProfiles = useMemo(() => {
|
||||
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
|
||||
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
|
||||
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
|
||||
}, [profiles, remoteWorkspace])
|
||||
const visualizationStacks = useMemo(() => (
|
||||
profiles
|
||||
.map((profile) => profile.profileType.toLowerCase())
|
||||
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
|
||||
), [profiles])
|
||||
runtimeProfiles
|
||||
.map((profile) => resolveVisualizationStack(profile.profileType))
|
||||
.filter((stack): stack is VisualizationStack => stack !== null)
|
||||
), [runtimeProfiles])
|
||||
|
||||
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
|
||||
const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
|
||||
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
|
||||
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
|
||||
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
|
||||
@@ -62,7 +121,7 @@ export function useWorkspaceVisualization({
|
||||
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
|
||||
? activeEnvironmentAgent.url.replace(/\/+$/, '')
|
||||
: ''
|
||||
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl
|
||||
const visualizationRemote = isRemoteEnvironmentProfile(activeEnvironmentProfile) && activeEnvironmentAgentUrl
|
||||
const visualizationRemoteBridgeHttpUrl = (() => {
|
||||
if (!visualizationRemote) return ''
|
||||
try {
|
||||
@@ -78,16 +137,15 @@ export function useWorkspaceVisualization({
|
||||
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
: 'ws://localhost:8765'
|
||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||
const visualizationViewerUrl = visualizationStack === 'slam'
|
||||
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}`
|
||||
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
|
||||
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRemoteEnvironmentProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
|
||||
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||
|
||||
useEffect(() => {
|
||||
const profileType = profiles[0]?.profileType?.toLowerCase()
|
||||
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
|
||||
setVisualizationStack(profileType)
|
||||
}
|
||||
}, [profiles.map((profile) => profile.id).join('|')])
|
||||
const stack = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
|
||||
if (stack) setVisualizationStack(stack)
|
||||
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
|
||||
}, [runtimeProfiles])
|
||||
|
||||
const refreshVisualizationStatus = async (): Promise<void> => {
|
||||
const status = await window.localWorkspace.visualization.status().catch((error) => ({
|
||||
@@ -107,11 +165,6 @@ export function useWorkspaceVisualization({
|
||||
|
||||
const startVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
if (!activeEnvironmentProfile) {
|
||||
setVisualizationStatusMessage('지원 안함')
|
||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
||||
return
|
||||
}
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('시작 중')
|
||||
try {
|
||||
@@ -155,6 +208,8 @@ export function useWorkspaceVisualization({
|
||||
visualizationRunning,
|
||||
visualizationBusy,
|
||||
visualizationViewerUrl,
|
||||
nav2VisualizationMode,
|
||||
setNav2VisualizationMode,
|
||||
activeEnvironmentProfile,
|
||||
activeRuntimeImages,
|
||||
refreshVisualizationStatus,
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||
if (file.endsWith('.json')) return 'json'
|
||||
if (file.endsWith('.xml')) return 'xml'
|
||||
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
|
||||
if (file.endsWith('.html')) return 'html'
|
||||
if (file.endsWith('.css')) return 'css'
|
||||
if (file.endsWith('.py')) return 'python'
|
||||
|
||||
@@ -18,7 +18,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
sourceType: module.sourceType ?? null,
|
||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||
parameters: [],
|
||||
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
runtime: '-'
|
||||
|
||||
+135
-18
@@ -9,6 +9,8 @@ type ModuleInput = {
|
||||
folderName?: string | null
|
||||
category?: string | null
|
||||
sortOrder?: number | null
|
||||
defaultParameters?: string[] | null
|
||||
dockerImage?: string | null
|
||||
templateRepositoryUrl?: string | null
|
||||
studentRepositoryUrl?: string | null
|
||||
}
|
||||
@@ -61,6 +63,43 @@ function readLaunchState(): { handoffToken: string | null; audienceApp: string;
|
||||
}
|
||||
|
||||
const launchState = readLaunchState()
|
||||
const launcherSessionExpiredListeners = new Set<() => void>()
|
||||
|
||||
function notifyLauncherSessionExpired(): void {
|
||||
for (const listener of launcherSessionExpiredListeners) listener()
|
||||
}
|
||||
|
||||
function watchLauncherSession(): void {
|
||||
if (!launchState.bridge) return
|
||||
|
||||
const controller = new AbortController()
|
||||
void fetch(`${launchState.bridge.url}/api/session/events`, {
|
||||
method: 'POST',
|
||||
headers: { [BRIDGE_HEADER]: launchState.bridge.token },
|
||||
signal: controller.signal,
|
||||
}).then(async (response) => {
|
||||
if (!response.ok || !response.body) return
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) return
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split('\n\n')
|
||||
buffer = events.pop() ?? ''
|
||||
if (events.some((event) => event.startsWith('event: session-expired'))) {
|
||||
notifyLauncherSessionExpired()
|
||||
controller.abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
console.warn('[lms-auth] launcher session event connection failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
async function bridgeRequest<T>(path: string, body?: unknown): Promise<T> {
|
||||
if (!launchState.bridge) throw new Error('런처 로컬 연결 정보가 없습니다. 런처에서 웹 실행을 다시 선택하세요.')
|
||||
@@ -120,6 +159,7 @@ const localModules = {
|
||||
sourcePath: module.sourcePath ?? module.workspacePath ?? '',
|
||||
workspacePath: module.workspacePath ?? null,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
dockerImage: module.dockerImage ?? null,
|
||||
parameters: Array.isArray(module.parameters) ? module.parameters : [],
|
||||
cpu: running ? cpu : null,
|
||||
memory: running ? memory : null,
|
||||
@@ -134,10 +174,12 @@ const localModules = {
|
||||
folderName?: string,
|
||||
templateRepositoryUrl?: string | null,
|
||||
studentRepositoryUrl?: string | null,
|
||||
options?: { runtimeImages?: unknown },
|
||||
) {
|
||||
const result = await bridgeRequest<{ ok: boolean; workspacePath?: string }>('/api/modules/install', {
|
||||
workspaceRoot,
|
||||
module: moduleInput(name, folderName, { templateRepositoryUrl, studentRepositoryUrl }),
|
||||
runtimeImages: options?.runtimeImages,
|
||||
})
|
||||
return {
|
||||
ok: result.ok,
|
||||
@@ -156,21 +198,24 @@ const localModules = {
|
||||
},
|
||||
}
|
||||
},
|
||||
async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
||||
async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
|
||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/start', {
|
||||
workspaceRoot,
|
||||
module: moduleInput(name, folderName),
|
||||
module: moduleInput(name, folderName, { dockerImage }),
|
||||
rosParams,
|
||||
gpuEnabled,
|
||||
rosDomainId,
|
||||
runtimeImages,
|
||||
})
|
||||
},
|
||||
async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
||||
async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null, rosParams?: string) {
|
||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/build', {
|
||||
workspaceRoot,
|
||||
module: moduleInput(name, folderName),
|
||||
module: moduleInput(name, folderName, { dockerImage }),
|
||||
gpuEnabled,
|
||||
rosDomainId,
|
||||
runtimeImages,
|
||||
rosParams,
|
||||
})
|
||||
},
|
||||
async stop(name: string, workspaceRoot?: string, folderName?: string) {
|
||||
@@ -186,9 +231,9 @@ const localModules = {
|
||||
async stopRunning() {
|
||||
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/stop-running', {})
|
||||
},
|
||||
async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) {
|
||||
async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
|
||||
await localModules.stop(name, workspaceRoot, folderName)
|
||||
return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)
|
||||
return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage)
|
||||
},
|
||||
async logs(name: string, workspaceRoot?: string, folderName?: string) {
|
||||
return bridgeRequest<{ ok: boolean; logs: string; message?: string }>('/api/modules/logs', {
|
||||
@@ -211,6 +256,7 @@ function fileRequest<T>(path: string, moduleName: string, workspaceRoot: string
|
||||
|
||||
const localFileApi = {
|
||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/read', moduleName, workspaceRoot, folderName),
|
||||
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => fileRequest<{ dataUrl: string }>('/api/files/image', moduleName, workspaceRoot, folderName, { path }),
|
||||
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/create', moduleName, workspaceRoot, folderName, { path, kind }),
|
||||
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/write', moduleName, workspaceRoot, folderName, { path, content }),
|
||||
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/copy', moduleName, workspaceRoot, folderName, { fromPath, toPath, mode }),
|
||||
@@ -286,6 +332,17 @@ const unavailableVisualizationApi = {
|
||||
stop: async () => ({ ok: true, running: false, stack: null }),
|
||||
}
|
||||
|
||||
const localWorldLayoutApi = {
|
||||
read: (workspaceRoot?: string) => bridgeRequest<WorldLayout>('/api/world-layout/read', { workspaceRoot }),
|
||||
write: (workspaceRoot: string | undefined, layout: WorldLayout) => bridgeRequest<WorldLayout>('/api/world-layout/write', { workspaceRoot, layout }),
|
||||
}
|
||||
|
||||
const unavailableNav2Api = {
|
||||
setSimulatorPose: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
|
||||
addRuntimeObstacle: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
|
||||
clearRuntimeObstacles: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
|
||||
}
|
||||
|
||||
function remoteUnavailable(feature: string): never {
|
||||
throw new Error(`원격 플랫폼 ${feature}은 Platform Gateway와 Agent API 연결이 필요합니다.`)
|
||||
}
|
||||
@@ -301,6 +358,8 @@ function remoteFileRequest<T>(path: string, target: unknown, moduleName: string,
|
||||
const localWorkspace = {
|
||||
moduleFiles: localFileApi,
|
||||
modules: localModules,
|
||||
nav2: unavailableNav2Api,
|
||||
worldLayout: localWorldLayoutApi,
|
||||
visualization: unavailableVisualizationApi,
|
||||
git: localGitApi,
|
||||
terminal: localTerminalApi,
|
||||
@@ -309,6 +368,7 @@ const localWorkspace = {
|
||||
const remoteWorkspace = {
|
||||
moduleFiles: {
|
||||
read: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/files/read', target, moduleName, folderName),
|
||||
image: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest<{ dataUrl: string }>('/api/remote/files/image', target, moduleName, folderName, { path }),
|
||||
create: (target: unknown, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => remoteFileRequest('/api/remote/files/create', target, moduleName, folderName, { path, kind }),
|
||||
write: (target: unknown, moduleName: string, path: string, content: string, folderName?: string) => remoteFileRequest('/api/remote/files/write', target, moduleName, folderName, { path, content }),
|
||||
copy: (target: unknown, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => remoteFileRequest('/api/remote/files/copy', target, moduleName, folderName, { fromPath, toPath, mode }),
|
||||
@@ -316,26 +376,78 @@ const remoteWorkspace = {
|
||||
delete: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest('/api/remote/files/delete', target, moduleName, folderName, { path }),
|
||||
},
|
||||
modules: {
|
||||
list: (target: unknown, modules: ModuleInput[] = []) => remoteBridgeRequest('/api/remote/modules/list', target, { modules }),
|
||||
install: async (target: unknown, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => {
|
||||
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, { templateRepositoryUrl, studentRepositoryUrl })
|
||||
list: async (target: unknown, modules: ModuleInput[] = []) => {
|
||||
const result = await remoteBridgeRequest<{ workspaceRoot: string; modules: unknown[] }>('/api/remote/modules/list', target, { modules })
|
||||
return {
|
||||
...result,
|
||||
modules: result.modules.map((value, index) => {
|
||||
const module = value as Partial<ModuleInput> & {
|
||||
state?: 'running' | 'stopped' | 'uninstalled'
|
||||
workspacePath?: string | null
|
||||
parameters?: string[]
|
||||
cpu?: number | null
|
||||
memory?: number | null
|
||||
runtime?: string
|
||||
}
|
||||
return {
|
||||
...module,
|
||||
state: module.state ?? 'uninstalled',
|
||||
sortOrder: module.sortOrder ?? index + 1,
|
||||
category: module.category ?? '',
|
||||
name: module.name ?? '',
|
||||
folderName: module.folderName ?? module.name ?? '',
|
||||
workspacePath: module.workspacePath ?? null,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
parameters: Array.isArray(module.parameters)
|
||||
? module.parameters
|
||||
: Array.isArray(module.defaultParameters)
|
||||
? module.defaultParameters
|
||||
: [],
|
||||
cpu: module.cpu ?? null,
|
||||
memory: module.memory ?? null,
|
||||
runtime: module.runtime ?? '-',
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
install: async (
|
||||
target: unknown,
|
||||
moduleName: string,
|
||||
folderName?: string,
|
||||
templateRepositoryUrl?: string | null,
|
||||
studentRepositoryUrl?: string | null,
|
||||
options?: { prebuiltArtifacts?: unknown; runtimeImages?: unknown },
|
||||
) => {
|
||||
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, {
|
||||
templateRepositoryUrl,
|
||||
studentRepositoryUrl,
|
||||
prebuiltArtifacts: options?.prebuiltArtifacts,
|
||||
runtimeImages: options?.runtimeImages,
|
||||
})
|
||||
return { ok: result.ok, module: { state: 'stopped' as const, sortOrder: 0, category: '', name: moduleName, folderName: folderName ?? moduleName, sourcePath: result.workspacePath ?? '', workspacePath: result.workspacePath ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } }
|
||||
},
|
||||
start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages }),
|
||||
build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages }),
|
||||
start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
|
||||
build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
|
||||
stop: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName),
|
||||
stopMany: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => {
|
||||
const results = await Promise.all(modules.map((module) => remoteFileRequest<{ ok: boolean }>('/api/remote/modules/stop', target, module.moduleName, module.folderName ?? undefined)))
|
||||
return { ok: results.every((result) => result.ok), message: '모듈 중지 요청을 완료했습니다.' }
|
||||
},
|
||||
stopMany: (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => remoteBridgeRequest<{ ok: boolean; message: string }>('/api/remote/modules/stop-many', target, {
|
||||
modules: modules.map((module) => moduleInput(module.moduleName, module.folderName ?? undefined)),
|
||||
}),
|
||||
stopRunning: () => remoteUnavailable('모듈 일괄 중지'),
|
||||
restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => {
|
||||
restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => {
|
||||
await remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName)
|
||||
return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages })
|
||||
return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage })
|
||||
},
|
||||
logs: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/logs', target, moduleName, folderName),
|
||||
updateParameters: () => remoteUnavailable('모듈 설정'),
|
||||
resetSession: async () => ({ ok: true, message: '플랫폼 세션을 종료했습니다.' }),
|
||||
resetSession: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => {
|
||||
return remoteBridgeRequest<{ ok: boolean; message?: string }>('/api/remote/modules/reset-session', target, {
|
||||
modules: modules.map((module) => ({
|
||||
name: module.moduleName,
|
||||
folderName: module.folderName ?? module.moduleName,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
})),
|
||||
})
|
||||
},
|
||||
},
|
||||
git: {
|
||||
status: (target: unknown, moduleName: string, folderName?: string, selectedFilePath?: string | null) => remoteFileRequest('/api/remote/git/status', target, moduleName, folderName, { selectedFilePath }),
|
||||
@@ -388,7 +500,12 @@ window.launcherAuth = {
|
||||
appUrl: null,
|
||||
handoffToken: launchState.handoffToken,
|
||||
audienceApp: launchState.audienceApp,
|
||||
onSessionExpired: (listener: () => void) => {
|
||||
launcherSessionExpiredListeners.add(listener)
|
||||
return () => launcherSessionExpiredListeners.delete(listener)
|
||||
},
|
||||
}
|
||||
watchLauncherSession()
|
||||
window.lmsBootSplash = { status: () => undefined, done: () => undefined }
|
||||
window.titlebar = {
|
||||
minimize: () => undefined,
|
||||
|
||||
Reference in New Issue
Block a user