scene-inspector.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. "use client";
  2. import { useState } from "react";
  3. import { ChevronDown, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
  4. import { useEditorStore } from "@/lib/store";
  5. import { useIsMobile } from "@/lib/use-mobile";
  6. import type { SceneObject, Physics, LightType } from "@/lib/types";
  7. function Section({
  8. label,
  9. defaultOpen = true,
  10. children,
  11. }: {
  12. label: string;
  13. defaultOpen?: boolean;
  14. children: React.ReactNode;
  15. }) {
  16. const [open, setOpen] = useState(defaultOpen);
  17. return (
  18. <div className="border-b border-[#1e1e1e]">
  19. <button
  20. onClick={() => setOpen(!open)}
  21. className="w-full flex items-center gap-1.5 px-3 py-2.5 sm:py-2 text-[10px] font-semibold text-[#666] uppercase tracking-wider hover:text-[#999] active:text-[#999] min-h-[44px] sm:min-h-0"
  22. >
  23. {open ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
  24. {label}
  25. </button>
  26. {open && (
  27. <div className="px-3 pb-3 space-y-2.5 sm:space-y-2">{children}</div>
  28. )}
  29. </div>
  30. );
  31. }
  32. function Field({
  33. label,
  34. children,
  35. }: {
  36. label: string;
  37. children: React.ReactNode;
  38. }) {
  39. return (
  40. <div className="flex items-center gap-2">
  41. <label className="text-[10px] text-[#666] w-16 flex-shrink-0">
  42. {label}
  43. </label>
  44. <div className="flex-1">{children}</div>
  45. </div>
  46. );
  47. }
  48. function NumberInput({
  49. value,
  50. onChange,
  51. step = 0.1,
  52. }: {
  53. value: number;
  54. onChange: (v: number) => void;
  55. step?: number;
  56. }) {
  57. return (
  58. <input
  59. type="number"
  60. value={value != null ? Number(value.toFixed(3)) : 0}
  61. onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
  62. step={step}
  63. className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
  64. />
  65. );
  66. }
  67. function Vec3Input({
  68. value,
  69. onChange,
  70. labels = ["X", "Y", "Z"],
  71. }: {
  72. value: [number, number, number];
  73. onChange: (v: [number, number, number]) => void;
  74. labels?: [string, string, string];
  75. }) {
  76. return (
  77. <div className="flex gap-1">
  78. {[0, 1, 2].map((i) => (
  79. <div key={i} className="flex-1">
  80. <div className="text-[8px] text-[#555] mb-0.5">{labels[i]}</div>
  81. <NumberInput
  82. value={value[i] ?? 0}
  83. onChange={(v) => {
  84. const next = [...value] as [number, number, number];
  85. next[i] = v;
  86. onChange(next);
  87. }}
  88. />
  89. </div>
  90. ))}
  91. </div>
  92. );
  93. }
  94. function ColorInput({
  95. value,
  96. onChange,
  97. }: {
  98. value: string;
  99. onChange: (v: string) => void;
  100. }) {
  101. return (
  102. <div className="flex items-center gap-1.5">
  103. <input
  104. type="color"
  105. value={value}
  106. onChange={(e) => onChange(e.target.value)}
  107. className="w-8 h-8 sm:w-6 sm:h-6 rounded border border-[#2a2a2a] cursor-pointer bg-transparent"
  108. />
  109. <input
  110. type="text"
  111. value={value}
  112. onChange={(e) => onChange(e.target.value)}
  113. className="flex-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
  114. />
  115. </div>
  116. );
  117. }
  118. function SelectInput({
  119. value,
  120. onChange,
  121. options,
  122. }: {
  123. value: string;
  124. onChange: (v: string) => void;
  125. options: { value: string; label: string }[];
  126. }) {
  127. return (
  128. <select
  129. value={value}
  130. onChange={(e) => onChange(e.target.value)}
  131. className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555]"
  132. >
  133. {options.map((opt) => (
  134. <option key={opt.value} value={opt.value}>
  135. {opt.label}
  136. </option>
  137. ))}
  138. </select>
  139. );
  140. }
  141. function CheckboxInput({
  142. value,
  143. onChange,
  144. label,
  145. }: {
  146. value: boolean;
  147. onChange: (v: boolean) => void;
  148. label: string;
  149. }) {
  150. return (
  151. <label className="flex items-center gap-2 text-xs sm:text-[11px] text-[#ccc] cursor-pointer py-0.5">
  152. <input
  153. type="checkbox"
  154. checked={value}
  155. onChange={(e) => onChange(e.target.checked)}
  156. className="rounded border-[#2a2a2a] w-4 h-4 sm:w-3.5 sm:h-3.5"
  157. />
  158. {label}
  159. </label>
  160. );
  161. }
  162. function ObjectListItem({ obj }: { obj: SceneObject }) {
  163. const {
  164. selectedObjectId,
  165. selectObject,
  166. removeObject,
  167. toggleObjectVisibility,
  168. } = useEditorStore();
  169. const isSelected = selectedObjectId === obj.id;
  170. const isMobile = useIsMobile();
  171. return (
  172. <div
  173. className={`group flex items-center justify-between px-2 ${isMobile ? "py-2" : "py-1"} rounded cursor-pointer text-[11px] ${
  174. isSelected
  175. ? "bg-white/10 text-white"
  176. : "text-[#888] hover:text-[#ccc] hover:bg-white/5"
  177. }`}
  178. onClick={() => selectObject(obj.id)}
  179. >
  180. <span className="truncate">{obj.name}</span>
  181. <div
  182. className={`${isMobile || isSelected ? "flex" : "hidden group-hover:flex"} items-center gap-1`}
  183. >
  184. <button
  185. onClick={(e) => {
  186. e.stopPropagation();
  187. toggleObjectVisibility(obj.id);
  188. }}
  189. className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-white active:text-white`}
  190. >
  191. {obj.visible ? (
  192. <Eye size={isMobile ? 14 : 10} />
  193. ) : (
  194. <EyeOff size={isMobile ? 14 : 10} />
  195. )}
  196. </button>
  197. <button
  198. onClick={(e) => {
  199. e.stopPropagation();
  200. removeObject(obj.id);
  201. }}
  202. className={`${isMobile ? "p-1.5" : "p-0.5"} text-[#666] hover:text-red-400 active:text-red-400`}
  203. >
  204. <Trash2 size={isMobile ? 14 : 10} />
  205. </button>
  206. </div>
  207. </div>
  208. );
  209. }
  210. function TransformEditor({ obj }: { obj: SceneObject }) {
  211. const updateObjectTransform = useEditorStore((s) => s.updateObjectTransform);
  212. const saveToHistory = useEditorStore((s) => s.saveToHistory);
  213. return (
  214. <Section label="Transform">
  215. <Field label="Position">
  216. <Vec3Input
  217. value={obj.position}
  218. onChange={(v) => {
  219. updateObjectTransform(obj.id, { position: v });
  220. saveToHistory();
  221. }}
  222. />
  223. </Field>
  224. <Field label="Rotation">
  225. <Vec3Input
  226. value={obj.rotation}
  227. onChange={(v) => {
  228. updateObjectTransform(obj.id, { rotation: v });
  229. saveToHistory();
  230. }}
  231. />
  232. </Field>
  233. <Field label="Scale">
  234. <Vec3Input
  235. value={obj.scale}
  236. onChange={(v) => {
  237. updateObjectTransform(obj.id, { scale: v });
  238. saveToHistory();
  239. }}
  240. />
  241. </Field>
  242. </Section>
  243. );
  244. }
  245. function MaterialEditor({ obj }: { obj: SceneObject }) {
  246. const updateObjectMaterial = useEditorStore((s) => s.updateObjectMaterial);
  247. return (
  248. <Section label="Material">
  249. <Field label="Color">
  250. <ColorInput
  251. value={obj.material.color}
  252. onChange={(v) => updateObjectMaterial(obj.id, { color: v })}
  253. />
  254. </Field>
  255. <Field label="Metalness">
  256. <NumberInput
  257. value={obj.material.metalness}
  258. onChange={(v) => updateObjectMaterial(obj.id, { metalness: v })}
  259. step={0.05}
  260. />
  261. </Field>
  262. <Field label="Roughness">
  263. <NumberInput
  264. value={obj.material.roughness}
  265. onChange={(v) => updateObjectMaterial(obj.id, { roughness: v })}
  266. step={0.05}
  267. />
  268. </Field>
  269. <Field label="Emissive">
  270. <ColorInput
  271. value={obj.material.emissive}
  272. onChange={(v) => updateObjectMaterial(obj.id, { emissive: v })}
  273. />
  274. </Field>
  275. <Field label="Emit Int.">
  276. <NumberInput
  277. value={obj.material.emissiveIntensity}
  278. onChange={(v) =>
  279. updateObjectMaterial(obj.id, { emissiveIntensity: v })
  280. }
  281. step={0.1}
  282. />
  283. </Field>
  284. </Section>
  285. );
  286. }
  287. function PhysicsEditor({ obj }: { obj: SceneObject }) {
  288. const updateObjectPhysics = useEditorStore((s) => s.updateObjectPhysics);
  289. return (
  290. <Section label="Physics" defaultOpen={false}>
  291. <Field label="Collider">
  292. <SelectInput
  293. value={obj.physics.colliderType}
  294. onChange={(v) =>
  295. updateObjectPhysics(obj.id, {
  296. colliderType: v as Physics["colliderType"],
  297. })
  298. }
  299. options={[
  300. { value: "none", label: "None" },
  301. { value: "cuboid", label: "Cuboid" },
  302. { value: "ball", label: "Ball" },
  303. { value: "capsule", label: "Capsule" },
  304. ]}
  305. />
  306. </Field>
  307. {obj.physics.colliderType !== "none" && (
  308. <>
  309. <Field label="Mass">
  310. <NumberInput
  311. value={obj.physics.mass}
  312. onChange={(v) => updateObjectPhysics(obj.id, { mass: v })}
  313. />
  314. </Field>
  315. <CheckboxInput
  316. value={obj.physics.isStatic}
  317. onChange={(v) => updateObjectPhysics(obj.id, { isStatic: v })}
  318. label="Static"
  319. />
  320. <Field label="Bounce">
  321. <NumberInput
  322. value={obj.physics.restitution}
  323. onChange={(v) => updateObjectPhysics(obj.id, { restitution: v })}
  324. step={0.05}
  325. />
  326. </Field>
  327. <Field label="Friction">
  328. <NumberInput
  329. value={obj.physics.friction}
  330. onChange={(v) => updateObjectPhysics(obj.id, { friction: v })}
  331. step={0.05}
  332. />
  333. </Field>
  334. </>
  335. )}
  336. </Section>
  337. );
  338. }
  339. function DamageEditor({ obj }: { obj: SceneObject }) {
  340. const updateDamage = useEditorStore((s) => s.updateDamage);
  341. const dmg = obj.damage || { amount: 0, enabled: false };
  342. return (
  343. <Section label="Damage" defaultOpen={false}>
  344. <CheckboxInput
  345. value={dmg.enabled}
  346. onChange={(v) => updateDamage(obj.id, { enabled: v })}
  347. label="Enabled"
  348. />
  349. {dmg.enabled && (
  350. <Field label="Amount">
  351. <NumberInput
  352. value={dmg.amount}
  353. onChange={(v) => updateDamage(obj.id, { amount: v })}
  354. step={1}
  355. />
  356. </Field>
  357. )}
  358. </Section>
  359. );
  360. }
  361. function LightEditor({ obj }: { obj: SceneObject }) {
  362. const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
  363. return (
  364. <Section label="Light">
  365. <Field label="Type">
  366. <SelectInput
  367. value={obj.lightType || "point"}
  368. onChange={(v) =>
  369. updateObjectGeneral(obj.id, { lightType: v as LightType })
  370. }
  371. options={[
  372. { value: "ambient", label: "Ambient" },
  373. { value: "directional", label: "Directional" },
  374. { value: "point", label: "Point" },
  375. { value: "spot", label: "Spot" },
  376. ]}
  377. />
  378. </Field>
  379. <Field label="Intensity">
  380. <NumberInput
  381. value={obj.intensity ?? 1}
  382. onChange={(v) => updateObjectGeneral(obj.id, { intensity: v })}
  383. step={0.1}
  384. />
  385. </Field>
  386. {(obj.lightType === "point" || obj.lightType === "spot") && (
  387. <Field label="Distance">
  388. <NumberInput
  389. value={obj.distance ?? 0}
  390. onChange={(v) => updateObjectGeneral(obj.id, { distance: v })}
  391. />
  392. </Field>
  393. )}
  394. {obj.lightType === "spot" && (
  395. <>
  396. <Field label="Angle">
  397. <NumberInput
  398. value={obj.angle ?? Math.PI / 3}
  399. onChange={(v) => updateObjectGeneral(obj.id, { angle: v })}
  400. step={0.05}
  401. />
  402. </Field>
  403. <Field label="Penumbra">
  404. <NumberInput
  405. value={obj.penumbra ?? 0}
  406. onChange={(v) => updateObjectGeneral(obj.id, { penumbra: v })}
  407. step={0.05}
  408. />
  409. </Field>
  410. </>
  411. )}
  412. </Section>
  413. );
  414. }
  415. function TextInput({
  416. value,
  417. onChange,
  418. placeholder,
  419. }: {
  420. value: string;
  421. onChange: (v: string) => void;
  422. placeholder?: string;
  423. }) {
  424. return (
  425. <input
  426. type="text"
  427. value={value}
  428. onChange={(e) => onChange(e.target.value)}
  429. placeholder={placeholder}
  430. className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-1.5 py-1 sm:py-0.5 text-xs sm:text-[11px] text-[#ccc] outline-none focus:border-[#555] placeholder:text-[#444]"
  431. />
  432. );
  433. }
  434. function ModelUrlEditor({ obj }: { obj: SceneObject }) {
  435. const updateObjectGeneral = useEditorStore((s) => s.updateObjectGeneral);
  436. return (
  437. <Section label="Model">
  438. <Field label="URL">
  439. <TextInput
  440. value={obj.modelUrl || ""}
  441. onChange={(v) => updateObjectGeneral(obj.id, { modelUrl: v })}
  442. placeholder="/models/example.glb"
  443. />
  444. </Field>
  445. <p className="text-[9px] text-[#555] leading-relaxed">
  446. Enter a URL to a .glb or .gltf file. Place files in public/models/ to
  447. use local paths like /models/file.glb
  448. </p>
  449. </Section>
  450. );
  451. }
  452. function CharacterEditor({ obj }: { obj: SceneObject }) {
  453. const updateCharacter = useEditorStore((s) => s.updateCharacter);
  454. const role = obj.character?.role || "";
  455. return (
  456. <Section label="Character" defaultOpen={false}>
  457. <Field label="Role">
  458. <TextInput
  459. value={role}
  460. onChange={(v) => updateCharacter(obj.id, { role: v })}
  461. placeholder="e.g. village guard, merchant"
  462. />
  463. </Field>
  464. </Section>
  465. );
  466. }
  467. function EnvironmentEditor() {
  468. const scenes = useEditorStore((s) => s.scenes);
  469. const activeSceneId = useEditorStore((s) => s.activeSceneId);
  470. const updateEnvironmentSettings = useEditorStore(
  471. (s) => s.updateEnvironmentSettings,
  472. );
  473. const activeScene = scenes.find((s) => s.id === activeSceneId);
  474. if (!activeScene) return null;
  475. const env = activeScene.environmentSettings;
  476. return (
  477. <Section label="Environment" defaultOpen={false}>
  478. <Field label="Preset">
  479. <SelectInput
  480. value={env.preset}
  481. onChange={(v) => updateEnvironmentSettings({ preset: v })}
  482. options={[
  483. { value: "apartment", label: "Apartment" },
  484. { value: "city", label: "City" },
  485. { value: "dawn", label: "Dawn" },
  486. { value: "forest", label: "Forest" },
  487. { value: "lobby", label: "Lobby" },
  488. { value: "night", label: "Night" },
  489. { value: "park", label: "Park" },
  490. { value: "studio", label: "Studio" },
  491. { value: "sunset", label: "Sunset" },
  492. { value: "warehouse", label: "Warehouse" },
  493. ]}
  494. />
  495. </Field>
  496. <Field label="Intensity">
  497. <NumberInput
  498. value={env.intensity}
  499. onChange={(v) => updateEnvironmentSettings({ intensity: v })}
  500. step={0.1}
  501. />
  502. </Field>
  503. <Field label="Blur">
  504. <NumberInput
  505. value={env.blur}
  506. onChange={(v) => updateEnvironmentSettings({ blur: v })}
  507. step={0.1}
  508. />
  509. </Field>
  510. </Section>
  511. );
  512. }
  513. function FogEditor() {
  514. const scenes = useEditorStore((s) => s.scenes);
  515. const activeSceneId = useEditorStore((s) => s.activeSceneId);
  516. const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
  517. const activeScene = scenes.find((s) => s.id === activeSceneId);
  518. if (!activeScene) return null;
  519. const fog = activeScene.sceneSettings.fog;
  520. return (
  521. <Section label="Fog" defaultOpen={false}>
  522. <CheckboxInput
  523. value={fog.enabled}
  524. onChange={(v) => updateSceneSettings({ fog: { ...fog, enabled: v } })}
  525. label="Enabled"
  526. />
  527. {fog.enabled && (
  528. <>
  529. <Field label="Color">
  530. <ColorInput
  531. value={fog.color}
  532. onChange={(v) =>
  533. updateSceneSettings({ fog: { ...fog, color: v } })
  534. }
  535. />
  536. </Field>
  537. <Field label="Near">
  538. <NumberInput
  539. value={fog.near}
  540. onChange={(v) =>
  541. updateSceneSettings({ fog: { ...fog, near: v } })
  542. }
  543. />
  544. </Field>
  545. <Field label="Far">
  546. <NumberInput
  547. value={fog.far}
  548. onChange={(v) => updateSceneSettings({ fog: { ...fog, far: v } })}
  549. />
  550. </Field>
  551. </>
  552. )}
  553. </Section>
  554. );
  555. }
  556. function GridEditor() {
  557. const scenes = useEditorStore((s) => s.scenes);
  558. const activeSceneId = useEditorStore((s) => s.activeSceneId);
  559. const updateSceneSettings = useEditorStore((s) => s.updateSceneSettings);
  560. const activeScene = scenes.find((s) => s.id === activeSceneId);
  561. if (!activeScene) return null;
  562. const grid = activeScene.sceneSettings.grid;
  563. return (
  564. <Section label="Grid" defaultOpen={false}>
  565. <CheckboxInput
  566. value={grid.visible}
  567. onChange={(v) => updateSceneSettings({ grid: { ...grid, visible: v } })}
  568. label="Visible"
  569. />
  570. {grid.visible && (
  571. <>
  572. <Field label="Size">
  573. <NumberInput
  574. value={grid.size}
  575. onChange={(v) =>
  576. updateSceneSettings({ grid: { ...grid, size: v } })
  577. }
  578. step={0.5}
  579. />
  580. </Field>
  581. <Field label="Divisions">
  582. <NumberInput
  583. value={grid.divisions}
  584. onChange={(v) =>
  585. updateSceneSettings({
  586. grid: { ...grid, divisions: Math.max(1, Math.round(v)) },
  587. })
  588. }
  589. step={1}
  590. />
  591. </Field>
  592. <Field label="Fade Dist">
  593. <NumberInput
  594. value={grid.fadeDistance}
  595. onChange={(v) =>
  596. updateSceneSettings({ grid: { ...grid, fadeDistance: v } })
  597. }
  598. step={10}
  599. />
  600. </Field>
  601. <Field label="Fade Str">
  602. <NumberInput
  603. value={grid.fadeStrength}
  604. onChange={(v) =>
  605. updateSceneSettings({ grid: { ...grid, fadeStrength: v } })
  606. }
  607. step={0.1}
  608. />
  609. </Field>
  610. </>
  611. )}
  612. </Section>
  613. );
  614. }
  615. export function SceneInspector() {
  616. const scenes = useEditorStore((s) => s.scenes);
  617. const activeSceneId = useEditorStore((s) => s.activeSceneId);
  618. const selectedObjectId = useEditorStore((s) => s.selectedObjectId);
  619. const activeScene = scenes.find((s) => s.id === activeSceneId);
  620. const objects = activeScene?.objects ?? [];
  621. const selectedObj = selectedObjectId
  622. ? objects.find((o) => o.id === selectedObjectId)
  623. : null;
  624. return (
  625. <div className="flex flex-col h-full">
  626. {/* Object list */}
  627. <Section label="Scene Objects">
  628. <div className="space-y-0.5 max-h-40 overflow-y-auto">
  629. {objects.map((obj) => (
  630. <ObjectListItem key={obj.id} obj={obj} />
  631. ))}
  632. </div>
  633. </Section>
  634. {/* Selected object editors */}
  635. {selectedObj && (
  636. <>
  637. <div className="px-3 py-1.5 border-b border-[#1e1e1e]">
  638. <div className="text-[10px] text-[#555] uppercase tracking-wider">
  639. {selectedObj.name}
  640. </div>
  641. </div>
  642. <TransformEditor obj={selectedObj} />
  643. {selectedObj.type !== "light" &&
  644. selectedObj.type !== "player" &&
  645. selectedObj.type !== "sound" && (
  646. <MaterialEditor obj={selectedObj} />
  647. )}
  648. {selectedObj.type !== "light" && selectedObj.type !== "player" && (
  649. <PhysicsEditor obj={selectedObj} />
  650. )}
  651. {selectedObj.type !== "light" &&
  652. selectedObj.type !== "player" &&
  653. selectedObj.type !== "sound" && <DamageEditor obj={selectedObj} />}
  654. {selectedObj.type === "light" && <LightEditor obj={selectedObj} />}
  655. {(selectedObj.type === "model" ||
  656. selectedObj.type === "character") && (
  657. <ModelUrlEditor obj={selectedObj} />
  658. )}
  659. {selectedObj.type === "character" && (
  660. <CharacterEditor obj={selectedObj} />
  661. )}
  662. </>
  663. )}
  664. {/* Scene-level editors */}
  665. <EnvironmentEditor />
  666. <FogEditor />
  667. <GridEditor />
  668. </div>
  669. );
  670. }