auricui-skill 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/install.mjs +66 -0
  4. package/package.json +29 -4
  5. package/skill/SKILL.md +64 -0
  6. package/skill/agents/openai.yaml +4 -0
  7. package/skill/assets/examples/ApprovalCard.tsx +8 -0
  8. package/skill/assets/examples/BasicApp.tsx +38 -0
  9. package/skill/assets/examples/BriefingCard.tsx +8 -0
  10. package/skill/assets/examples/ChatControls.tsx +49 -0
  11. package/skill/assets/examples/ChatPanel.tsx +73 -0
  12. package/skill/assets/examples/Checkbox.tsx +13 -0
  13. package/skill/assets/examples/Combobox.tsx +14 -0
  14. package/skill/assets/examples/Drawer.tsx +10 -0
  15. package/skill/assets/examples/MemoryCard.tsx +9 -0
  16. package/skill/assets/examples/MessageList.tsx +8 -0
  17. package/skill/assets/examples/Theme.tsx +14 -0
  18. package/skill/assets/examples/ToolCall.tsx +8 -0
  19. package/skill/assets/examples/Tree.tsx +15 -0
  20. package/skill/assets/examples/examples.css +11 -0
  21. package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
  22. package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
  23. package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
  24. package/skill/assets/upstream/LICENSE +21 -0
  25. package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
  26. package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
  27. package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
  28. package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
  29. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
  30. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
  31. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
  32. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
  33. package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
  34. package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
  35. package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
  36. package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
  37. package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
  38. package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
  39. package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
  40. package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
  41. package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
  42. package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
  43. package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
  44. package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
  45. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
  46. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
  47. package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
  48. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
  49. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
  50. package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
  51. package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
  52. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
  53. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
  54. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
  55. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
  56. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
  57. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
  58. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
  59. package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
  60. package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
  61. package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
  62. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
  63. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
  64. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
  65. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
  66. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
  67. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
  68. package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
  69. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
  70. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
  71. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
  72. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
  73. package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
  74. package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
  75. package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
  76. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
  77. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
  78. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
  79. package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
  80. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
  81. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
  82. package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
  83. package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
  84. package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
  85. package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
  86. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
  87. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
  88. package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
  89. package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
  90. package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
  91. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
  92. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
  93. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
  94. package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
  95. package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
  96. package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
  97. package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
  98. package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
  99. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
  100. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
  101. package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
  102. package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
  103. package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
  104. package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
  105. package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
  106. package/skill/assets/upstream/apps/desktop/package.json +51 -0
  107. package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
  108. package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
  109. package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
  110. package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
  111. package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
  112. package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
  113. package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
  114. package/skill/assets/upstream/packages/openfilm/package.json +88 -0
  115. package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
  116. package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
  117. package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
  118. package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
  119. package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
  120. package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
  121. package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
  122. package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
  123. package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
  124. package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
  125. package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
  126. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
  127. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
  128. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
  129. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
  130. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
  131. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
  132. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
  133. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
  134. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
  135. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
  136. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
  137. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
  138. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
  139. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
  140. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
  141. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
  142. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
  143. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
  144. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
  145. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
  146. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
  147. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
  148. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
  149. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
  150. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
  151. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
  152. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
  153. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
  154. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
  155. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
  156. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
  157. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
  158. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
  159. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
  160. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
  161. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
  162. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
  163. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
  164. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
  165. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
  166. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
  167. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
  168. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
  169. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
  170. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
  171. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
  172. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
  173. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
  174. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
  175. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
  176. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
  177. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
  178. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
  179. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
  180. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
  181. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
  182. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
  183. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
  184. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
  185. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
  186. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
  187. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
  188. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
  189. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
  190. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
  191. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
  192. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
  193. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
  194. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
  195. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
  196. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
  197. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
  198. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
  199. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
  200. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
  201. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
  202. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
  203. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
  204. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
  205. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
  206. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
  207. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
  208. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
  209. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
  210. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
  211. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
  212. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
  213. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
  214. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
  215. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
  216. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
  217. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
  218. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
  219. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
  220. package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
  221. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
  222. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
  223. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
  224. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
  225. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
  226. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
  227. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
  228. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
  229. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
  230. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
  231. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
  232. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
  233. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
  234. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
  235. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
  236. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
  237. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
  238. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
  239. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
  240. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
  241. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
  242. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
  243. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
  244. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
  245. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
  246. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
  247. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
  248. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
  249. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
  250. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
  251. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
  252. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
  253. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
  254. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
  255. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
  256. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
  257. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
  258. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
  259. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
  260. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
  261. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
  262. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
  263. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
  264. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
  265. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
  266. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
  267. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
  268. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
  269. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
  270. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
  271. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
  272. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
  273. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
  274. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
  275. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
  276. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
  277. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
  278. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
  279. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
  280. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
  281. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
  282. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
  283. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
  284. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
  285. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
  286. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
  287. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
  288. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
  289. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
  290. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
  291. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
  292. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
  293. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
  294. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
  295. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
  296. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
  297. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
  298. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
  299. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
  300. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
  301. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
  302. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
  303. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
  304. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
  305. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
  306. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
  307. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
  308. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
  309. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
  310. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
  311. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
  312. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
  313. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
  314. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
  315. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
  316. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
  317. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
  318. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
  319. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
  320. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
  321. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
  322. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
  323. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
  324. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
  325. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
  326. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
  327. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
  328. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
  329. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
  330. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
  331. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
  332. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
  333. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
  334. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
  335. package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
  336. package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
  337. package/skill/assets/upstream/packages/shared/package.json +45 -0
  338. package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
  339. package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
  340. package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
  341. package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
  342. package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
  343. package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
  344. package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
  345. package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
  346. package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
  347. package/skill/references/agent-components.md +33 -0
  348. package/skill/references/api-index.json +2693 -0
  349. package/skill/references/chat-components.md +65 -0
  350. package/skill/references/components.md +23 -0
  351. package/skill/references/core-components.md +44 -0
  352. package/skill/references/dependencies.md +22 -0
  353. package/skill/references/esm-package.md +48 -0
  354. package/skill/references/integration.md +43 -0
  355. package/skill/references/library-manifest.json +12 -0
  356. package/skill/references/source-manifest.json +325 -0
  357. package/skill/references/studio-components.md +24 -0
  358. package/skill/references/theme-motion.md +49 -0
  359. package/skill/references/troubleshooting.md +27 -0
  360. package/skill/references/verification.md +13 -0
  361. package/skill/scripts/check_dependencies.py +109 -0
  362. package/skill/scripts/inspect_api.py +60 -0
@@ -0,0 +1,1413 @@
1
+ /**
2
+ * Studio's media pane: everything at one top level called All, folders as tiles beside the files, double-click a
3
+ * folder to go in.
4
+ *
5
+ * The top level is assets/ itself plus every web page of the project (its scenes). A person can make folders and
6
+ * drag files into them. Paths are not shown: Reveal in Finder and Copy path are on the right-click menu.
7
+ *
8
+ * Click a file to see it: the viewer plays it and the inspector shows its facts (onSelectFile). Drag it to the
9
+ * timeline to use it. Several are picked as in Finder (⌘-click, ⇧-click, a box dragged over empty space, ⌘A), to move
10
+ * them (dragged onto a folder, or Move to) or delete them together. Any kind of file can be imported; Studio previews
11
+ * and plays only pictures, videos, sounds and pages. Moving or renaming a file also repoints the timeline's clips (the server does it in the same
12
+ * step), so nothing goes missing.
13
+ *
14
+ * The host owns the data and the changes: it passes the listing (`listing`), where pictures and sounds come from
15
+ * (`sources`), and does each change through a callback; the listing it passes next shows the result.
16
+ */
17
+ import React from 'react';
18
+ import {
19
+ ArrowUpDown,
20
+ Bookmark,
21
+ BookmarkPlus,
22
+ Layers,
23
+ Component as ComponentIcon,
24
+ ChevronRight,
25
+ Clapperboard,
26
+ Copy,
27
+ Folder,
28
+ ExternalLink,
29
+ FolderInput,
30
+ FolderSymlink,
31
+ HardDriveDownload,
32
+ ImagePlus,
33
+ Link2,
34
+ Unlink,
35
+ FolderOpen,
36
+ FolderPlus,
37
+ LayoutGrid,
38
+ List,
39
+ ListFilter,
40
+ Pencil,
41
+ Plus,
42
+ Search,
43
+ Trash2,
44
+ Upload,
45
+ AudioLines,
46
+ MessageSquarePlus,
47
+ X,
48
+ } from 'lucide-react';
49
+ import { useT } from '@/i18n';
50
+ import { clearResourceDrag, setResourceDragData } from '@/lib/resource-drag';
51
+ import { STUDIO_REF_TYPE, type StudioRef } from '@/lib/host';
52
+ import {
53
+ formatClipDuration,
54
+ formatFileSize,
55
+ mediaNameMatches,
56
+ renameChangesKind,
57
+ renamedPath,
58
+ resourceKindOf,
59
+ safeName,
60
+ srcHitsAsset,
61
+ type MediaListing,
62
+ type MediaResult,
63
+ type MediaSources,
64
+ type WorkspaceResource,
65
+ type WorkspaceResourceKind,
66
+ type MediaLink,
67
+ type MediaProxy,
68
+ } from '@/lib/workspace-resources';
69
+ import { ConfirmDialog } from './ConfirmDialog';
70
+ import { ContextMenu, type ContextMenuEntry } from './ContextMenu';
71
+ import { KIND_ICON, MediaPreview, useNearViewport } from './MediaPreview';
72
+ import { useMediaImage } from '@/lib/media-queue';
73
+ import { Tooltip } from './Tooltip';
74
+ import { retryImage } from '@/lib/retry-image';
75
+ import { boxBetween, boxHits, boxPick, moveTargets, pickItem, type Box } from '@/lib/media-selection';
76
+ import { barLayout, type BarWords } from '@/lib/media-bar';
77
+ import { byName, folderFaces } from '@/lib/media-folders';
78
+
79
+ type Filter = 'all' | 'video' | 'image' | 'audio' | 'page' | 'other';
80
+ type SortKey = 'name' | 'time' | 'duration' | 'type';
81
+ const FILTERS: readonly Filter[] = ['all', 'video', 'image', 'audio', 'page', 'other'];
82
+ const SORTS: readonly SortKey[] = ['name', 'time', 'duration', 'type'];
83
+ /** Tiles dragged inside the pane, to be moved into a folder: their paths, a line each (a name has no line break). */
84
+ const FOLDER_DRAG = 'application/x-openfilm-media';
85
+ /** Files dragged in from the computer, not a tile of this pane (the browser adds a dragged picture as a file). */
86
+ const fromComputer = (dt: DataTransfer) => dt.types.includes('Files') && !dt.types.includes(FOLDER_DRAG);
87
+ /** The open folder per project, kept across remounts (the layout re-mounts the pane). */
88
+ const OPEN_FOLDER = new Map<string, string>();
89
+
90
+ /** Tool folders, not material: fonts. */
91
+ const HIDDEN_DIRS = [/(^|\/)fonts?(\/|$)/i];
92
+ const hiddenDir = (rel: string) => HIDDEN_DIRS.some((re) => re.test(rel));
93
+
94
+ const filterOf = (f: WorkspaceResource): Filter => (f.kind === 'mg' ? 'page' : f.kind as Filter);
95
+
96
+ /** `layout`: tiles in a grid, or rows of a list (name, length, kind). */
97
+ interface ViewState { sort: SortKey; filter: Filter; layout: 'grid' | 'list' }
98
+ /* v2: the order became "as added" by default. A view kept before it (saved whether chosen or not) keeps its filter and
99
+ layout, not its order */
100
+ const viewKey = (projectId: string) => `openfilm.media.v2.${projectId}`;
101
+ const oldViewKey = (projectId: string) => `openfilm.media.${projectId}`;
102
+ function loadView(projectId: string): ViewState {
103
+ try {
104
+ const kept = window.localStorage.getItem(viewKey(projectId));
105
+ const raw = JSON.parse(kept ?? window.localStorage.getItem(oldViewKey(projectId)) ?? 'null') as Partial<ViewState> | null;
106
+ return {
107
+ sort: kept && SORTS.includes(raw?.sort as SortKey) ? raw!.sort as SortKey : 'time',
108
+ filter: FILTERS.includes(raw?.filter as Filter) ? raw!.filter as Filter : 'all',
109
+ layout: raw?.layout === 'list' ? 'list' : 'grid',
110
+ };
111
+ } catch { return { sort: 'time', filter: 'all', layout: 'grid' }; }
112
+ }
113
+
114
+ type Item = { type: 'folder'; rel: string; name: string; count: number; covers: WorkspaceResource[] } | { type: 'file'; file: WorkspaceResource };
115
+
116
+ /** An import's progress, per file (its index in the import): 0..1, and where it landed once it is in. */
117
+ export type ImportProgress = (file: number, ratio: number, landed?: string) => void;
118
+ /** A file on its way in: a card of its own in the folder it goes to, its progress inside, until the listing has it. */
119
+ type Upload = { id: string; name: string; into: string; kind: WorkspaceResourceKind; ratio: number; preview?: string; landed?: string };
120
+ let uploadSeq = 0;
121
+
122
+ export interface MediaPoolProps {
123
+ projectId: string;
124
+ /** The project's media and pages; null while the first listing loads (the pane stays blank). */
125
+ listing: MediaListing | null;
126
+ sources: MediaSources;
127
+ /** Every clip `src` on the timeline: how many clips a delete would take with it. */
128
+ usedSrcs?: readonly string[];
129
+ /** The file the viewer and inspector are showing (the host owns it, so a click elsewhere can clear it). */
130
+ selectedPath?: string | null;
131
+ onSelectFile: (file: WorkspaceResource | null) => void;
132
+ /**
133
+ * Import files from the computer into `folder` (project-relative: `assets` or `assets/<sub>`), reporting each file's
134
+ * progress 0..1 and, once it is in, where it landed. Reject with an Error whose message the pane shows (else "Upload
135
+ * failed, please retry").
136
+ */
137
+ onImport: (files: File[], folder: string, onProgress: ImportProgress) => Promise<unknown>;
138
+ /** Rename or move a file or folder (project-relative `from` → `to`); its clips follow. */
139
+ onMove: (from: string, to: string) => Promise<MediaResult>;
140
+ /**
141
+ * What a move could not keep true, asked before it: the paths a page or script among what moves makes while it runs
142
+ * (the rest of its links are written again by Studio). Empty: nothing to say.
143
+ */
144
+ onMoveCheck?: (from: string, to: string) => Promise<{ file: string; unseen: string[] }[]>;
145
+ /** Delete a file or folder (project-relative). Its clips were already taken off (`onDropClips`). */
146
+ onDelete: (path: string) => Promise<MediaResult>;
147
+ /** A video's sound made a file of its own beside it (it shows up in the listing like any new file). */
148
+ onExtractAudio?: (path: string) => Promise<MediaResult>;
149
+ /** A picture of the project made its cover in the projects list. */
150
+ onSetCover?: (path: string) => void;
151
+ /** Make a folder (project-relative path, its name already made safe). */
152
+ onNewFolder: (path: string) => Promise<MediaResult>;
153
+ /** Before deleting files, take their clips off the timeline. A string is the reason it could not. */
154
+ onDropClips?: (paths: readonly string[]) => Promise<string | null>;
155
+ /**
156
+ * Show a file or folder in Finder (project-relative; `original`: where a linked one is kept). Missing: the menu has
157
+ * no Reveal in Finder.
158
+ */
159
+ onReveal?: (path: string, original?: boolean) => void;
160
+ /**
161
+ * Media kept on this computer, picked in the system's dialog: files, or a folder (`'no-dialog'` where there is
162
+ * none), then copied into `folder` (cloned where the disk can) or linked where it is kept, a NAS or another disk
163
+ * (`onBring`, resolving where each landed). Without them, Upload sends files picked in the browser.
164
+ */
165
+ onPick?: (title: string, folder: boolean) => Promise<string[] | 'no-dialog'>;
166
+ onBring?: (paths: string[], folder: string, mode: 'copy' | 'link') => Promise<string[]>;
167
+ /** A link pointed at where its media is now (the person picks it), or copied into the project in its place. */
168
+ onRelink?: (path: string, folder: boolean) => Promise<MediaResult>;
169
+ onCollect?: (path: string) => Promise<MediaResult>;
170
+ /** Files kept to use again in any project (lib/saved): the file menu's "Add to Saved". */
171
+ onSave?: (files: WorkspaceResource[]) => void;
172
+ /**
173
+ * Which library the pane shows, the project's media or Saved (the person's own, for every project), and the button
174
+ * beside Upload that switches between them. Both are the same pane: upload, folders, views, filters, sort, rename,
175
+ * move, delete, drag onto the timeline.
176
+ */
177
+ scope?: 'project' | 'saved';
178
+ onScope?: (next: 'project' | 'saved') => void;
179
+ /** Upload may also link media kept elsewhere (a project's); without it, files are copied in only (Saved). */
180
+ links?: boolean;
181
+ /**
182
+ * The app around Studio has a chat (lib/host.ts `refer`): a file as a reference in it. With it the file menu has
183
+ * "Reference in chat", and a dragged file carries the reference too (STUDIO_REF_TYPE), for the chat to take.
184
+ */
185
+ fileRef?: (file: WorkspaceResource) => StudioRef;
186
+ onRefer?: (ref: StudioRef) => void;
187
+ /** Show this file: its folder opened, the tile scrolled into view. `seq` goes up each time. */
188
+ reveal?: { path: string; seq: number } | null;
189
+ /** The file a pill hovered in the app's chat points at: its tile lit while it is. */
190
+ litPath?: string | null;
191
+ /**
192
+ * Put a file on the timeline, at the playhead on the targeted track or at the end of the main one (Enter on the file
193
+ * picked; dragging it there is the usual way).
194
+ */
195
+ onAdd?: (file: WorkspaceResource) => void;
196
+ }
197
+
198
+ export function MediaPool({
199
+ projectId,
200
+ listing: media,
201
+ sources,
202
+ usedSrcs = [],
203
+ selectedPath,
204
+ onSelectFile,
205
+ onImport,
206
+ onMove,
207
+ onMoveCheck,
208
+ onDelete,
209
+ onExtractAudio,
210
+ onSetCover,
211
+ onNewFolder,
212
+ onDropClips,
213
+ onReveal,
214
+ onPick,
215
+ onBring,
216
+ onRelink,
217
+ onCollect,
218
+ onSave,
219
+ scope = 'project',
220
+ onScope,
221
+ links = true,
222
+ fileRef,
223
+ onRefer,
224
+ reveal,
225
+ litPath,
226
+ onAdd,
227
+ }: MediaPoolProps) {
228
+ const t = useT();
229
+ const [view, setView] = React.useState<ViewState>(() => loadView(projectId));
230
+ /** The open folder, relative to assets/ ('' is All). */
231
+ const [at, setAtState] = React.useState(() => OPEN_FOLDER.get(projectId) ?? '');
232
+ const setAt = React.useCallback((rel: string) => { OPEN_FOLDER.set(projectId, rel); setAtState(rel); }, [projectId]);
233
+ const [query, setQuery] = React.useState('');
234
+ /** What is picked: files and folders by their project-relative path (lib/media-selection). */
235
+ const [picked, setPicked] = React.useState<string[]>(() => (selectedPath ? [selectedPath] : []));
236
+ /** The one clicked last: where a ⇧-click's run starts. */
237
+ const anchor = React.useRef<string | null>(selectedPath ?? null);
238
+ const [renaming, setRenaming] = React.useState<string | null>(null);
239
+ const [creating, setCreating] = React.useState(false);
240
+ const [menu, setMenu] = React.useState<{ x: number; y: number; flipTo?: number; items: ContextMenuEntry[]; dense?: boolean } | null>(null);
241
+ const [confirm, setConfirm] = React.useState<{ targets: { path: string; folder: boolean }[]; name: string } | null>(null);
242
+ const [uploads, setUploads] = React.useState<Upload[]>([]);
243
+ const [notice, setNotice] = React.useState<string | null>(null);
244
+ const [dropTarget, setDropTarget] = React.useState<string | null>(null);
245
+ const [osDrag, setOsDrag] = React.useState(false);
246
+ const inputRef = React.useRef<HTMLInputElement>(null);
247
+ const inSaved = scope === 'saved';
248
+ React.useEffect(() => { setQuery(''); }, [scope]);
249
+ /* the top folder: All of the project's media, or Saved */
250
+ const topName = inSaved ? t('saved.title') : t('media.filter_all');
251
+
252
+ React.useEffect(() => { setView(loadView(projectId)); setAtState(OPEN_FOLDER.get(projectId) ?? ''); setPicked([]); }, [projectId]);
253
+ /* the file the viewer shows is the host's: the one clicked last, when it is a file. One shown from elsewhere (a click
254
+ on the timeline let it go, the chat pointed at one) is then all that is picked */
255
+ const sentPath = React.useRef(selectedPath ?? null);
256
+ const showFile = React.useCallback((file: WorkspaceResource | null) => {
257
+ sentPath.current = file?.path ?? null;
258
+ onSelectFile(file);
259
+ }, [onSelectFile]);
260
+ React.useEffect(() => {
261
+ if ((selectedPath ?? null) === sentPath.current) return;
262
+ sentPath.current = selectedPath ?? null;
263
+ setPicked(selectedPath ? [selectedPath] : []);
264
+ anchor.current = selectedPath ?? null;
265
+ }, [selectedPath]);
266
+ React.useEffect(() => {
267
+ try { window.localStorage.setItem(viewKey(projectId), JSON.stringify(view)); } catch { /* per-viewer only */ }
268
+ }, [projectId, view]);
269
+
270
+ const root = media?.dir ?? 'assets';
271
+ const assetRel = React.useCallback((path: string) => (path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path), [root]);
272
+ /** Under assets/ (not one of the project's own pages, which are shown at the top). */
273
+ const inMedia = React.useCallback((path: string) => path.startsWith(`${root}/`), [root]);
274
+ const relDir = (rel: string) => (rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : '');
275
+
276
+ const allFiles = React.useMemo(() => {
277
+ if (!media) return null;
278
+ /* every file, of any kind: a document or a model is material too (the agent can make a film of it); Studio
279
+ plays and previews only pictures, videos, sounds and pages */
280
+ const files = media.files.filter((f) => !hiddenDir(relDir(assetRel(f.path))));
281
+ return [...media.pages, ...files];
282
+ }, [media, assetRel]);
283
+
284
+ /* every folder under assets/ but a tool's (fonts) */
285
+ const folders = React.useMemo(() => (media ? media.dirs.filter((dir) => !hiddenDir(dir)) : []), [media]);
286
+
287
+ const items = React.useMemo((): Item[] | null => {
288
+ if (!allFiles) return null;
289
+ const q = query.trim();
290
+ const order = (a: WorkspaceResource, b: WorkspaceResource) => {
291
+ /* as added: the oldest first, what comes in goes last */
292
+ if (view.sort === 'time') return (a.addedMs ?? a.mtimeMs) - (b.addedMs ?? b.mtimeMs) || byName(a.name, b.name);
293
+ if (view.sort === 'duration') return (b.durationMs ?? -1) - (a.durationMs ?? -1);
294
+ if (view.sort === 'type') return filterOf(a).localeCompare(filterOf(b)) || a.name.localeCompare(b.name);
295
+ return byName(a.name, b.name);
296
+ };
297
+ /* searching or showing one kind looks through every folder at once */
298
+ if (q || view.filter !== 'all') {
299
+ return allFiles
300
+ .filter((f) => (view.filter === 'all' || filterOf(f) === view.filter) && mediaNameMatches(f.name, q))
301
+ .sort(order).map((file) => ({ type: 'file', file }));
302
+ }
303
+ /* the project's pages are at the top; a page put in a folder is in its folder */
304
+ const here = allFiles.filter((f) => (inMedia(f.path) ? relDir(assetRel(f.path)) === at : at === '')).sort(order);
305
+ /* the folders here, what each holds and its cover (lib/media-folders) */
306
+ const sub = folderFaces(allFiles, folders, at, (path) => (inMedia(path) ? assetRel(path) : null)).map((face) => ({ type: 'folder' as const, ...face }));
307
+ return [...sub, ...here.map((file) => ({ type: 'file' as const, file }))];
308
+ }, [allFiles, folders, at, query, view, assetRel, inMedia]);
309
+
310
+ /* a file asked to be shown: its folder opened (pages are at the top), a search or a filter that hides it let go */
311
+ const gridRef = React.useRef<HTMLDivElement>(null);
312
+ React.useEffect(() => {
313
+ if (!reveal) return undefined;
314
+ const file = allFiles?.find((f) => f.path === reveal.path);
315
+ if (!file) return undefined;
316
+ setQuery('');
317
+ setView((v) => (v.filter === 'all' || v.filter === filterOf(file) ? v : { ...v, filter: 'all' }));
318
+ setAt(inMedia(file.path) ? relDir(assetRel(file.path)) : '');
319
+ const frame = requestAnimationFrame(() => {
320
+ gridRef.current?.querySelector(`[data-media-path="${CSS.escape(file.path)}"]`)?.scrollIntoView({ block: 'nearest' });
321
+ });
322
+ return () => cancelAnimationFrame(frame);
323
+ // eslint-disable-next-line react-hooks/exhaustive-deps
324
+ }, [reveal?.seq]);
325
+
326
+ /* the open folder went away (deleted, renamed outside): back to All */
327
+ React.useEffect(() => {
328
+ if (at && media && !media.dirs.includes(at)) setAt('');
329
+ }, [at, media, setAt]);
330
+
331
+ const folderPath = (rel: string) => (rel ? `${root}/${rel}` : root);
332
+ const keyOf = React.useCallback((item: Item) => (item.type === 'folder' ? `${root}/${item.rel}` : item.file.path), [root]);
333
+ /* the items as shown, by key: a ⇧-click's run goes along them */
334
+ const order = React.useMemo(() => (items ?? []).map(keyOf), [items, keyOf]);
335
+ const itemByKey = React.useMemo(() => new Map((items ?? []).map((item) => [keyOf(item), item])), [items, keyOf]);
336
+ /* what is picked and shown: a search or another folder hides the rest, and what is hidden is not acted on */
337
+ const chosen = React.useMemo(() => picked.filter((key) => itemByKey.has(key)), [picked, itemByKey]);
338
+ /** The folder an item is in ('' the top, where the pages are shown). */
339
+ const hereOf = (key: string) => (key.startsWith(`${root}/`) ? relDir(assetRel(key)) : '');
340
+
341
+ const importFiles = async (list: File[], intoRel = at) => {
342
+ if (!list.length) return;
343
+ setNotice(null);
344
+ /* a card each, at once, where it goes: a picture shows itself while it is sent */
345
+ const batch: Upload[] = list.map((file) => {
346
+ const kind = resourceKindOf(file.name);
347
+ return { id: `upload-${++uploadSeq}`, name: file.name, into: intoRel, kind, ratio: 0, ...(kind === 'image' ? { preview: URL.createObjectURL(file) } : {}) };
348
+ });
349
+ setUploads((all) => [...all, ...batch]);
350
+ try {
351
+ /* into the open folder (the top of assets/ unless the person went into one), or the folder they were dropped on */
352
+ await onImport(list, folderPath(intoRel), (i, ratio, landed) => {
353
+ const id = batch[i]?.id;
354
+ setUploads((all) => all.map((u) => (u.id === id ? { ...u, ratio, ...(landed ? { landed } : {}) } : u)));
355
+ });
356
+ } catch (e) {
357
+ setNotice(e instanceof Error && e.message ? e.message : t('assets.uploadFailed'));
358
+ /* what did not get in goes; what did stays until the listing has it */
359
+ dropUploads((u) => batch.some((b) => b.id === u.id) && !u.landed);
360
+ }
361
+ };
362
+ /**
363
+ * Media on this computer, picked in the system's dialog, copied in or linked where it is kept (a NAS): a card each
364
+ * while it comes. Where there is no dialog, an import is the browser's pick.
365
+ */
366
+ const bringFrom = async (mode: 'copy' | 'link', folder: boolean, intoRel = at) => {
367
+ if (!onPick || !onBring) { inputRef.current?.click(); return; }
368
+ setNotice(null);
369
+ const paths = await onPick(t(mode === 'copy' ? 'media.pickImport' : folder ? 'media.pickLinkFolder' : 'media.pickLink'), folder);
370
+ if (paths === 'no-dialog') { if (mode === 'copy' && !folder) inputRef.current?.click(); else setNotice(t('media.noDialog')); return; }
371
+ const batch: Upload[] = paths.map((path) => {
372
+ const name = path.split(/[\\/]/).filter(Boolean).pop() ?? path;
373
+ return { id: `upload-${++uploadSeq}`, name, into: intoRel, kind: folder ? 'other' : resourceKindOf(name), ratio: -1 };
374
+ });
375
+ setUploads((all) => [...all, ...batch]);
376
+ for (const [i, path] of paths.entries()) {
377
+ const id = batch[i]!.id;
378
+ try {
379
+ // eslint-disable-next-line no-await-in-loop
380
+ const [landed] = await onBring([path], folderPath(intoRel), mode);
381
+ setUploads((all) => all.map((u) => (u.id === id ? { ...u, ratio: 1, ...(landed ? { landed } : {}) } : u)));
382
+ } catch (e) {
383
+ setNotice(e instanceof Error && e.message ? e.message : t('assets.uploadFailed'));
384
+ dropUploads((u) => u.id === id);
385
+ }
386
+ }
387
+ };
388
+ /** Take cards away (a picture's preview let go with them). */
389
+ const dropUploads = React.useCallback((gone: (u: Upload) => boolean) => {
390
+ setUploads((all) => {
391
+ if (!all.some(gone)) return all;
392
+ for (const u of all) if (gone(u) && u.preview) URL.revokeObjectURL(u.preview);
393
+ return all.filter((u) => !gone(u));
394
+ });
395
+ }, []);
396
+ const uploadsRef = React.useRef(uploads);
397
+ uploadsRef.current = uploads;
398
+ /* a card gives way to the file once the listing has it (or a while after it landed, if it never shows here); what
399
+ came in is selected and scrolled to, so it is found among many */
400
+ React.useEffect(() => {
401
+ if (!media) return;
402
+ const have = new Set([...media.files.map((f) => f.path), ...media.dirs.map((d) => `${media.dir}/${d}`)]);
403
+ const arrived = uploadsRef.current.flatMap((u) => (u.landed && have.has(u.landed) ? [u.landed] : []));
404
+ dropUploads((u) => Boolean(u.landed && have.has(u.landed)));
405
+ if (!arrived.length) return undefined;
406
+ setPicked(arrived);
407
+ anchor.current = arrived[arrived.length - 1]!;
408
+ const frame = requestAnimationFrame(() => {
409
+ gridRef.current?.querySelector(`[data-media-key="${CSS.escape(arrived[arrived.length - 1]!)}"]`)?.scrollIntoView({ block: 'nearest' });
410
+ });
411
+ return () => cancelAnimationFrame(frame);
412
+ }, [media, dropUploads]);
413
+ const landedCount = uploads.filter((u) => u.landed).length;
414
+ React.useEffect(() => {
415
+ if (!landedCount) return undefined;
416
+ const timer = window.setTimeout(() => dropUploads((u) => Boolean(u.landed)), 5000);
417
+ return () => window.clearTimeout(timer);
418
+ }, [landedCount, dropUploads]);
419
+ React.useEffect(() => () => { for (const u of uploadsRef.current) if (u.preview) URL.revokeObjectURL(u.preview); }, []);
420
+ /** Files on their way into a folder or one inside it: their progress together, for its cover. */
421
+ const progressInto = (rel: string) => {
422
+ const going = uploads.filter((u) => u.into === rel || u.into.startsWith(`${rel}/`));
423
+ return going.length ? going.reduce((sum, u) => sum + Math.max(0, u.ratio), 0) / going.length : undefined;
424
+ };
425
+
426
+ /* read when a move or rename settles: the file picked may have changed while it was on its way */
427
+ const selectedRef = React.useRef(selectedPath);
428
+ selectedRef.current = selectedPath;
429
+ /* a page (or a folder with one) whose code makes paths while it runs: those cannot be rewritten, so it is asked first */
430
+ const [moveAsk, setMoveAsk] = React.useState<{ paths: string[]; intoRel: string; unseen: { file: string; unseen: string[] }[] } | null>(null);
431
+ /** Move files and folders into a folder (`intoRel` under assets/, '' its top), one after another. */
432
+ const moveAll = async (paths: readonly string[], intoRel: string, asked = false) => {
433
+ const into = folderPath(intoRel);
434
+ const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1);
435
+ /* what is there already stays, and a folder never goes into itself */
436
+ const going = paths.filter((path) => hereOf(path) !== intoRel && into !== path && !into.startsWith(`${path}/`));
437
+ if (!going.length) return;
438
+ if (!asked && onMoveCheck) {
439
+ const unseen = (await Promise.all(going.map((path) => onMoveCheck(path, `${into}/${nameOf(path)}`).catch(() => [])))).flat();
440
+ if (unseen.length) { setMoveAsk({ paths: going, intoRel, unseen }); return; }
441
+ }
442
+ const moved: string[] = [];
443
+ let taken: string | null = null;
444
+ let failed = false;
445
+ for (const path of going) {
446
+ // eslint-disable-next-line no-await-in-loop
447
+ const result = await onMove(path, `${into}/${nameOf(path)}`);
448
+ if (result === 'ok') moved.push(path);
449
+ else if (result === 'taken') taken ??= nameOf(path);
450
+ else failed = true;
451
+ }
452
+ if (taken) setNotice(t('media.moveTaken').replace('{name}', taken).replace('{folder}', intoRel ? intoRel.split('/').pop()! : topName));
453
+ else if (failed) setNotice(t('media.moveFailed'));
454
+ if (selectedRef.current && moved.includes(selectedRef.current)) showFile(null);
455
+ setPicked((cur) => cur.filter((key) => !moved.includes(key)));
456
+ };
457
+
458
+ const remove = async (targets: readonly { path: string; folder: boolean }[]) => {
459
+ const gone = targets.flatMap((target) => (target.folder
460
+ ? (media?.files ?? []).filter((f) => f.path.startsWith(`${target.path}/`)).map((f) => f.path)
461
+ : [target.path]));
462
+ const dropped = await onDropClips?.(gone);
463
+ if (dropped) { setNotice(dropped); return; }
464
+ let failed = false;
465
+ for (const target of targets) {
466
+ // eslint-disable-next-line no-await-in-loop
467
+ if (await onDelete(target.path) !== 'ok') failed = true;
468
+ }
469
+ if (failed) setNotice(t('assets.deleteFailed'));
470
+ if (selectedPath && gone.includes(selectedPath)) showFile(null);
471
+ setPicked((cur) => cur.filter((key) => !targets.some((target) => target.path === key)));
472
+ };
473
+ /** Ask to delete these (picked) items. */
474
+ const askDelete = (keys: readonly string[]) => {
475
+ const targets = keys.flatMap((key) => { const item = itemByKey.get(key); return item ? [{ path: key, folder: item.type === 'folder' }] : []; });
476
+ const first = targets[0] ? itemByKey.get(targets[0].path) : undefined;
477
+ if (first) setConfirm({ targets, name: first.type === 'folder' ? first.name : first.file.name });
478
+ };
479
+
480
+ const rename = async (path: string, name: string, folder: boolean) => {
481
+ setRenaming(null);
482
+ const to = renamedPath(path, name, folder);
483
+ if (!to) return;
484
+ /* a file's ending says what it is: the server refuses one that would change it, and this says why */
485
+ if (!folder && renameChangesKind(path, to)) {
486
+ setNotice(t('assets.renameKind').replace('{from}', path.slice(path.lastIndexOf('/') + 1)).replace('{to}', to.slice(to.lastIndexOf('/') + 1)));
487
+ return;
488
+ }
489
+ const result = await onMove(path, to);
490
+ if (result !== 'ok') setNotice(t(result === 'taken' ? 'assets.renameTaken' : 'assets.renameFailed'));
491
+ else if (selectedPath === path) showFile(null);
492
+ };
493
+
494
+ /* a folder just named stays where it was typed until the listing has it: no blink between Enter and the refresh */
495
+ const [pendingFolder, setPendingFolder] = React.useState<string | null>(null);
496
+ const makeFolder = async (name: string) => {
497
+ setCreating(false);
498
+ const safe = safeName(name);
499
+ if (!safe) return;
500
+ setPendingFolder(safe);
501
+ const result = await onNewFolder(`${folderPath(at)}/${safe}`);
502
+ if (result !== 'ok') {
503
+ setPendingFolder(null);
504
+ setNotice(t(result === 'taken' ? 'assets.renameTaken' : 'assets.newFolderFailed'));
505
+ }
506
+ };
507
+ const pendingListed = pendingFolder != null && Boolean(items?.some((item) => item.type === 'folder' && item.name === pendingFolder));
508
+ React.useEffect(() => { if (pendingListed) setPendingFolder(null); }, [pendingListed]);
509
+ /* the listing never brought it (it was made elsewhere than shown): let go after a while */
510
+ React.useEffect(() => {
511
+ if (!pendingFolder) return undefined;
512
+ const timer = window.setTimeout(() => setPendingFolder(null), 5000);
513
+ return () => window.clearTimeout(timer);
514
+ }, [pendingFolder]);
515
+
516
+ const commonEntries = (path: string): ContextMenuEntry[] => [
517
+ ...(onReveal ? [{ id: 'reveal', label: t('projects.revealFolder'), icon: <FolderOpen size={14} />, onSelect: () => onReveal(path) }] : []),
518
+ { id: 'copy', label: t('media.copyPath'), icon: <Copy size={14} />, onSelect: () => { void navigator.clipboard?.writeText(path).catch(() => {}); } },
519
+ ];
520
+ /** What to say of a proxy. */
521
+ const proxyText = (proxy: MediaProxy) => (proxy.state === 'making' ? t('media.proxyMaking').replace('{p}', String(Math.round((proxy.ratio ?? 0) * 100)))
522
+ : proxy.state === 'waiting' ? t('media.proxyWaiting') : proxy.state === 'failed' ? t('media.proxyFailed') : t('media.proxyReady'));
523
+ /** Where a file or folder is linked from (a NAS), if it is a link. */
524
+ const linkOf = (key: string): MediaLink | undefined => {
525
+ const item = itemByKey.get(key);
526
+ return item?.type === 'file' ? item.file.link : item ? media?.linkedDirs?.[item.rel] : undefined;
527
+ };
528
+ /** Copy what links lead to into the project in their place, one after another. */
529
+ const collectAll = async (keys: readonly string[]) => {
530
+ if (!onCollect) return;
531
+ for (const key of keys) {
532
+ // eslint-disable-next-line no-await-in-loop
533
+ if (await onCollect(key) !== 'ok') { setNotice(t('media.collectFailed')); return; }
534
+ }
535
+ };
536
+ /* a link's own: where it is kept, pointed at where that is now, or copied into the project */
537
+ const linkEntries = (key: string, folder: boolean): ContextMenuEntry[] => {
538
+ const link = linkOf(key);
539
+ if (!link) return [];
540
+ return [
541
+ ...(onReveal && !link.missing ? [{ id: 'reveal-original', label: t('media.revealOriginal'), icon: <ExternalLink size={14} />, onSelect: () => onReveal(key, true) }] : []),
542
+ ...(onRelink ? [{ id: 'relink', label: t('media.relink'), icon: <Link2 size={14} />, onSelect: () => { void onRelink(key, folder).then((r) => { if (r !== 'ok') setNotice(t('media.relinkFailed')); }); } }] : []),
543
+ ...(onCollect && !link.missing ? [{ id: 'collect', label: t('media.collect'), note: t('media.collectNote'), icon: <HardDriveDownload size={14} />, onSelect: () => { void collectAll([key]); } }] : []),
544
+ { id: 'link-sep', separator: true } as const,
545
+ ];
546
+ };
547
+ /** Move to: the top or any folder, the one they are all in checked (they are there already). */
548
+ const moveEntry = (keys: readonly string[]): ContextMenuEntry => {
549
+ const heres = new Set(keys.map(hereOf));
550
+ const moving = keys.filter((key) => itemByKey.get(key)?.type === 'folder').map(assetRel);
551
+ return {
552
+ id: 'move-to', label: t('media.moveTo'), icon: <FolderInput size={14} />,
553
+ submenu: moveTargets(folders, moving).map((rel) => {
554
+ const here = heres.size === 1 && heres.has(rel);
555
+ return {
556
+ id: `move:${rel}`, label: rel ? rel.split('/').join(' / ') : topName, checked: here, disabled: here,
557
+ ...(here ? { hint: t('media.moveHere') } : {}),
558
+ onSelect: () => { void moveAll(keys, rel); },
559
+ };
560
+ }),
561
+ };
562
+ };
563
+ const fileMenu = (file: WorkspaceResource): ContextMenuEntry[] => [
564
+ ...(fileRef && onRefer ? [
565
+ { id: 'refer', label: t('host.refer'), icon: <MessageSquarePlus size={14} />, onSelect: () => onRefer(fileRef(file)) },
566
+ { id: 'refer-sep', separator: true } as const,
567
+ ] : []),
568
+ ...commonEntries(file.path),
569
+ ...(onSave ? [{ id: 'save', label: t('saved.add'), icon: <BookmarkPlus size={14} />, onSelect: () => onSave([file]) }] : []),
570
+ { id: 'sep', separator: true } as const,
571
+ ...linkEntries(file.path, false),
572
+ /* web pages are the film's code, written by the person's agent (and named in each other's code): not renamed
573
+ here; any file can be deleted, to the Trash, its clips with it */
574
+ ...(file.kind === 'video' && onExtractAudio ? [{
575
+ id: 'extract-audio', label: t('assets.extractAudio'), icon: <AudioLines size={14} />,
576
+ onSelect: () => { void onExtractAudio(file.path).then((r) => { if (r !== 'ok') setNotice(t('assets.extractAudioFailed')); }); },
577
+ }] : []),
578
+ ...(file.kind === 'image' && onSetCover ? [{ id: 'cover', label: t('projects.setAsCover'), icon: <ImagePlus size={14} />, onSelect: () => onSetCover(file.path) }] : []),
579
+ /* a page of the project is named in other pages' code (not renamed here); one of Saved is its own folder */
580
+ ...(file.kind === 'mg' && !file.bundle ? [] : [{ id: 'rename', label: t('assets.rename'), icon: <Pencil size={14} />, onSelect: () => setRenaming(file.path) }]),
581
+ moveEntry([file.path]),
582
+ { id: 'delete', label: t('assets.delete'), icon: <Trash2 size={14} />, danger: true, onSelect: () => setConfirm({ targets: [{ path: file.path, folder: false }], name: file.name }) },
583
+ ];
584
+ const folderMenu = (rel: string, name: string): ContextMenuEntry[] => [
585
+ ...commonEntries(`${root}/${rel}`),
586
+ { id: 'sep', separator: true },
587
+ ...linkEntries(`${root}/${rel}`, true),
588
+ { id: 'rename', label: t('assets.rename'), icon: <Pencil size={14} />, onSelect: () => setRenaming(`${root}/${rel}`) },
589
+ moveEntry([`${root}/${rel}`]),
590
+ { id: 'delete', label: t('assets.delete'), icon: <Trash2 size={14} />, danger: true, onSelect: () => setConfirm({ targets: [{ path: `${root}/${rel}`, folder: true }], name }) },
591
+ ];
592
+ /** Several picked: what can be done to all of them at once. */
593
+ const manyMenu = (keys: readonly string[]): ContextMenuEntry[] => [
594
+ ...(onCollect && keys.some((key) => linkOf(key) && !linkOf(key)!.missing) ? [{
595
+ id: 'collect', label: t('media.collect'), note: t('media.collectNote'), icon: <HardDriveDownload size={14} />,
596
+ onSelect: () => { void collectAll(keys.filter((key) => linkOf(key) && !linkOf(key)!.missing)); },
597
+ }] : []),
598
+ ...(onSave && keys.some((key) => itemByKey.get(key)?.type === 'file') ? [{
599
+ id: 'save', label: t('saved.add'), icon: <BookmarkPlus size={14} />,
600
+ onSelect: () => onSave(keys.flatMap((key) => { const item = itemByKey.get(key); return item?.type === 'file' ? [item.file] : []; })),
601
+ }] : []),
602
+ moveEntry(keys),
603
+ { id: 'sep', separator: true },
604
+ { id: 'delete', label: t('media.deleteMany').replace('{n}', String(keys.length)), icon: <Trash2 size={14} />, danger: true, onSelect: () => askDelete(keys) },
605
+ ];
606
+ /* what Upload offers: a copy in the project, or a link to where the media is kept (a NAS, another disk); where
607
+ nothing is linked (Saved), Upload picks the files to copy in at once */
608
+ const importMenu: ContextMenuEntry[] = [
609
+ { id: 'import', label: t('media.importFiles'), note: t('media.importFilesNote'), icon: <Upload size={14} />, onSelect: () => { void bringFrom('copy', false); } },
610
+ { id: 'link', label: t('media.linkFiles'), note: t('media.linkFilesNote'), icon: <Link2 size={14} />, onSelect: () => { void bringFrom('link', false); } },
611
+ { id: 'link-folder', label: t('media.linkFolder'), icon: <FolderSymlink size={14} />, onSelect: () => { void bringFrom('link', true); } },
612
+ ];
613
+ const blankMenu: ContextMenuEntry[] = [
614
+ { id: 'new-folder', label: t('assets.newFolder'), icon: <FolderPlus size={14} />, onSelect: () => setCreating(true) },
615
+ { id: 'import', label: t('media.import'), icon: <Plus size={14} />, onSelect: () => (onPick && onBring ? void bringFrom('copy', false) : inputRef.current?.click()) },
616
+ ...(onPick && onBring && links ? [
617
+ { id: 'link', label: t('media.linkFiles'), icon: <Link2 size={14} />, onSelect: () => { void bringFrom('link', false); } },
618
+ { id: 'link-folder', label: t('media.linkFolder'), icon: <FolderSymlink size={14} />, onSelect: () => { void bringFrom('link', true); } },
619
+ ] : []),
620
+ ];
621
+ /** The bar button a menu hangs from: a press on it is not outside the menu, and a click on it closes the menu. */
622
+ const menuFrom = React.useRef<HTMLElement | null>(null);
623
+ const openMenuAt = (e: { clientX: number; clientY: number }, items: ContextMenuEntry[], dense?: boolean) => {
624
+ menuFrom.current = null;
625
+ setMenu({ x: e.clientX, y: e.clientY, items, dense });
626
+ };
627
+
628
+ /* a click picks (lib/media-selection pickItem); the viewer shows the file clicked while it is picked, else the film */
629
+ const press = (item: Item, e?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }) => {
630
+ const key = keyOf(item);
631
+ const mods = { toggle: Boolean(e && (e.metaKey || e.ctrlKey)), range: Boolean(e?.shiftKey) };
632
+ const next = pickItem(chosen, key, order, anchor.current, mods);
633
+ if (!mods.range) anchor.current = key;
634
+ setPicked(next);
635
+ showFile(item.type === 'file' && next.includes(key) ? item.file : null);
636
+ /* the keys are the pane's now (Delete, ⌘A), whatever was clicked */
637
+ if (!gridRef.current?.contains(document.activeElement)) gridRef.current?.focus({ preventScroll: true });
638
+ };
639
+ /* a right-click on one of several picked is about all of them; on anything else, about it alone */
640
+ const menuFor = (item: Item, e: React.MouseEvent) => {
641
+ const key = keyOf(item);
642
+ if (chosen.length > 1 && chosen.includes(key)) { openMenuAt(e, manyMenu(chosen)); return; }
643
+ press(item);
644
+ openMenuAt(e, item.type === 'folder' ? folderMenu(item.rel, item.name) : fileMenu(item.file));
645
+ };
646
+ /** What a drag of this item moves: every one picked when it is one of them. */
647
+ const dragKeys = (key: string) => (chosen.length > 1 && chosen.includes(key) ? chosen : [key]);
648
+
649
+ /* a box dragged over empty space picks what it touches (lib/media-selection); held near the top or bottom, the pane
650
+ scrolls on under it */
651
+ const [marquee, setMarquee] = React.useState<Box | null>(null);
652
+ /** The click that ends a box is not a click on empty space (which lets go of what is picked). */
653
+ const boxed = React.useRef(false);
654
+ const startBox = (e: React.PointerEvent<HTMLDivElement>) => {
655
+ boxed.current = false;
656
+ const grid = e.currentTarget;
657
+ const r0 = grid.getBoundingClientRect();
658
+ if (e.button !== 0 || e.clientX - r0.left >= grid.clientWidth) return;
659
+ if ((e.target as Element).closest('[data-media-path], [data-media-folder], [data-media-new-folder], [data-media-upload], button, input')) return;
660
+ /* no text selected as it goes; the keys are the pane's */
661
+ e.preventDefault();
662
+ grid.focus({ preventScroll: true });
663
+ const at = (x: number, y: number) => { const r = grid.getBoundingClientRect(); return { x: x - r.left + grid.scrollLeft, y: y - r.top + grid.scrollTop }; };
664
+ const start = at(e.clientX, e.clientY);
665
+ const add = e.metaKey || e.ctrlKey || e.shiftKey;
666
+ const before = add ? chosen : [];
667
+ const byKey = itemByKey;
668
+ let pointer = { x: e.clientX, y: e.clientY };
669
+ let live = false;
670
+ let hits: string[] = before;
671
+ const tiles = () => [...grid.querySelectorAll<HTMLElement>('[data-media-key]')].map((el) => {
672
+ const r = el.getBoundingClientRect();
673
+ const o = at(r.left, r.top);
674
+ return { key: el.dataset.mediaKey!, box: { left: o.x, top: o.y, right: o.x + r.width, bottom: o.y + r.height } };
675
+ });
676
+ const update = () => {
677
+ const now = at(pointer.x, pointer.y);
678
+ if (!live && Math.hypot(now.x - start.x, now.y - start.y) < 4) return;
679
+ live = true;
680
+ const box = boxBetween(start, now);
681
+ setMarquee(box);
682
+ hits = boxPick(before, boxHits(tiles(), box), add);
683
+ setPicked(hits);
684
+ };
685
+ let frame = 0;
686
+ const tick = () => {
687
+ const r = grid.getBoundingClientRect();
688
+ const dy = pointer.y < r.top + 24 ? -Math.min(20, Math.ceil((r.top + 24 - pointer.y) / 3))
689
+ : pointer.y > r.bottom - 24 ? Math.min(20, Math.ceil((pointer.y - r.bottom + 24) / 3)) : 0;
690
+ if (live && dy) { grid.scrollTop += dy; update(); }
691
+ frame = requestAnimationFrame(tick);
692
+ };
693
+ frame = requestAnimationFrame(tick);
694
+ const move = (ev: PointerEvent) => { pointer = { x: ev.clientX, y: ev.clientY }; update(); };
695
+ const end = () => {
696
+ cancelAnimationFrame(frame);
697
+ window.removeEventListener('pointermove', move);
698
+ window.removeEventListener('pointerup', end);
699
+ window.removeEventListener('pointercancel', end);
700
+ setMarquee(null);
701
+ if (!live) return;
702
+ boxed.current = true;
703
+ anchor.current = hits.at(-1) ?? null;
704
+ const only = hits.length === 1 ? byKey.get(hits[0]!) : undefined;
705
+ showFile(only?.type === 'file' ? only.file : null);
706
+ };
707
+ window.addEventListener('pointermove', move);
708
+ window.addEventListener('pointerup', end);
709
+ window.addEventListener('pointercancel', end);
710
+ };
711
+ const barMenu = (e: React.MouseEvent<HTMLElement>, items: ContextMenuEntry[]) => {
712
+ const from = e.currentTarget;
713
+ if (menu && menuFrom.current === from) { setMenu(null); menuFrom.current = null; return; }
714
+ menuFrom.current = from;
715
+ const r = from.getBoundingClientRect();
716
+ setMenu({ x: r.left, y: r.bottom + 4, flipTo: r.top - 4, items, dense: true });
717
+ };
718
+
719
+ /* the bar at the pane's width (the whole pane is measured: the bar's own padding changes with it) */
720
+ /* a callback ref: watched from when the element is there, however late it comes */
721
+ const [rootEl, setRootEl] = React.useState<HTMLDivElement | null>(null);
722
+ const [paneWidth, setPaneWidth] = React.useState(0);
723
+ React.useEffect(() => {
724
+ if (!rootEl || typeof ResizeObserver === 'undefined') return undefined;
725
+ const ro = new ResizeObserver(([entry]) => setPaneWidth(Math.round(entry?.contentRect.width ?? 0)));
726
+ ro.observe(rootEl);
727
+ return () => ro.disconnect();
728
+ }, [rootEl]);
729
+ /* what the bar's words measure in this language and font (again once the fonts have loaded) */
730
+ const [fontsTick, setFontsTick] = React.useState(0);
731
+ React.useEffect(() => { void document.fonts?.ready.then(() => setFontsTick((n) => n + 1)); }, []);
732
+ const hint = t('media.searchShort');
733
+ const uploadWord = t('media.upload');
734
+ const words = React.useMemo((): BarWords | null => {
735
+ const ctx = typeof document === 'undefined' ? null : document.createElement('canvas').getContext('2d');
736
+ if (!rootEl || !ctx) return null;
737
+ const family = getComputedStyle(rootEl).fontFamily;
738
+ const measure = (text: string, weight: number) => { ctx.font = `${weight} 12.5px ${family}`; return ctx.measureText(text).width; };
739
+ return { hint: measure(hint, 400), upload: measure(uploadWord, 500) };
740
+ }, [hint, uploadWord, fontsTick, rootEl]);
741
+ /* the fullest bar that fits (lib/media-bar); before the pane is measured, the full one */
742
+ const bar = paneWidth > 0 && words
743
+ ? barLayout(paneWidth, words, 5, Boolean(query))
744
+ : { field: true, uploadWord: true, tight: false, cramped: false };
745
+ const tight = bar.tight;
746
+ const [searchOpen, setSearchOpen] = React.useState(false);
747
+ /* a search opened (from the button, or a field too short to type in) is over the bar; one typed stays open */
748
+ const searching = bar.cramped && (searchOpen || Boolean(query));
749
+
750
+ const crumbs = at ? at.split('/') : [];
751
+ const flat = Boolean(query.trim()) || view.filter !== 'all';
752
+ const rows = view.layout === 'list';
753
+ /* results come from every folder: where a name shows more than once, each says which folder it is in */
754
+ const repeated = React.useMemo(() => {
755
+ const seen = new Map<string, number>();
756
+ if (flat) for (const item of items ?? []) if (item.type === 'file') seen.set(item.file.name, (seen.get(item.file.name) ?? 0) + 1);
757
+ return new Set([...seen].filter(([, n]) => n > 1).map(([name]) => name));
758
+ }, [flat, items]);
759
+
760
+ /* a tile dropped on a folder tile or a crumb moves there. Passing from one part of the tile to another is not
761
+ leaving it (the highlight would flicker) */
762
+ /* a folder takes what is dragged onto it: a file or folder of this pane (moved), or files from the computer (copied
763
+ in, there rather than into the folder open) */
764
+ const dropProps = (intoRel: string) => ({
765
+ onDragOver: (e: React.DragEvent) => {
766
+ const inside = e.dataTransfer.types.includes(FOLDER_DRAG);
767
+ if (!inside && !fromComputer(e.dataTransfer)) return;
768
+ e.preventDefault(); e.stopPropagation();
769
+ e.dataTransfer.dropEffect = inside ? 'move' : 'copy';
770
+ setDropTarget(intoRel);
771
+ },
772
+ onDragLeave: (e: React.DragEvent) => {
773
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
774
+ setDropTarget((cur) => (cur === intoRel ? null : cur));
775
+ },
776
+ onDrop: (e: React.DragEvent) => {
777
+ const paths = e.dataTransfer.getData(FOLDER_DRAG);
778
+ if (paths) {
779
+ e.preventDefault(); e.stopPropagation(); setDropTarget(null);
780
+ void moveAll(paths.split('\n').filter(Boolean), intoRel);
781
+ return;
782
+ }
783
+ if (!fromComputer(e.dataTransfer) || !e.dataTransfer.files.length) return;
784
+ e.preventDefault(); e.stopPropagation(); setDropTarget(null); setOsDrag(false);
785
+ void importFiles([...e.dataTransfer.files], intoRel);
786
+ },
787
+ });
788
+
789
+ const counts = React.useMemo(() => {
790
+ const out: Record<Filter, number> = { all: 0, video: 0, image: 0, audio: 0, page: 0, other: 0 };
791
+ for (const f of allFiles ?? []) { out.all += 1; out[filterOf(f)] += 1; }
792
+ return out;
793
+ }, [allFiles]);
794
+
795
+ return (
796
+ <div
797
+ ref={setRootEl}
798
+ className="relative flex h-full min-h-0 flex-col"
799
+ /* the pane's keys, while they are its own (the timeline's Delete is the timeline's): Delete (or Backspace) asks to
800
+ delete what is picked, as an editor's project panel does; Enter puts the one file picked on the timeline, as
801
+ its "+" does; ⌘A picks everything shown; Esc lets go */
802
+ onKeyDown={(e) => {
803
+ if (renaming || confirm) return;
804
+ if ((e.target as HTMLElement).closest('input, textarea, [contenteditable="true"], button')) return;
805
+ const plain = !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey;
806
+ if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'a' && order.length) {
807
+ e.preventDefault();
808
+ e.stopPropagation();
809
+ setPicked(order);
810
+ return;
811
+ }
812
+ if (!chosen.length) return;
813
+ if (e.key === 'Escape' && plain) { setPicked([]); return; }
814
+ const only = chosen.length === 1 ? itemByKey.get(chosen[0]!) : undefined;
815
+ if (e.key === 'Enter' && onAdd && plain && only?.type === 'file' && canAdd(only.file)) {
816
+ e.preventDefault();
817
+ e.stopPropagation();
818
+ onAdd(only.file);
819
+ return;
820
+ }
821
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return;
822
+ e.preventDefault();
823
+ e.stopPropagation();
824
+ askDelete(chosen);
825
+ }}
826
+ /* files from the computer: a drag of this pane's own tiles is not one, whatever the browser adds to it (a picture
827
+ dragged carries itself as a file) */
828
+ onDragEnter={(e) => { if (fromComputer(e.dataTransfer)) { e.preventDefault(); setOsDrag(true); } }}
829
+ onDragOver={(e) => { if (fromComputer(e.dataTransfer)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } }}
830
+ onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setOsDrag(false); setDropTarget(null); } }}
831
+ onDrop={(e) => {
832
+ if (!fromComputer(e.dataTransfer) || !e.dataTransfer.files.length) return;
833
+ e.preventDefault(); setOsDrag(false); setDropTarget(null);
834
+ void importFiles([...e.dataTransfer.files]);
835
+ }}
836
+ >
837
+ {/* the bar at the pane's width (lib/media-bar): the search field fills the room left; narrowing, Upload is its
838
+ icon first, then the field shortens, then the search is a button that opens over the bar */}
839
+ <div className={`relative flex shrink-0 items-center pb-1.5 pt-2.5 ${tight ? 'gap-0.5 px-2' : 'gap-1 px-2.5'}`}>
840
+ {/* one field throughout: opened over the bar it is restyled, not made again, so it keeps the focus; there, its
841
+ see-through fill is laid on the pane's own, so the buttons under it do not show */}
842
+ {bar.field || searching ? (
843
+ <label className={`flex h-7 min-w-0 items-center gap-1.5 rounded-md bg-[var(--bg-hover)] px-2 text-[12.5px] text-[var(--text-muted)] ${searching ? `absolute top-2.5 z-10 ${tight ? 'inset-x-2' : 'inset-x-2.5'} [background:linear-gradient(var(--bg-hover),var(--bg-hover)),var(--dock-pane)]` : 'flex-1'}`}>
844
+ <Search size={13} className="shrink-0" />
845
+ <input autoFocus={!bar.field} value={query} onChange={(e) => setQuery(e.target.value)} placeholder={hint}
846
+ onFocus={() => { if (bar.cramped) setSearchOpen(true); }}
847
+ onBlur={() => { if (!query) setSearchOpen(false); }}
848
+ onKeyDown={(e) => { if (e.key === 'Escape' && searching) { setQuery(''); setSearchOpen(false); e.currentTarget.blur(); } }}
849
+ className="min-w-0 flex-1 bg-transparent text-[var(--text)] outline-none placeholder:text-[var(--text-faint)]" />
850
+ {query || searching ? (
851
+ <button type="button" aria-label={t('media.clearSearch')} onMouseDown={(e) => e.preventDefault()}
852
+ onClick={() => { setQuery(''); setSearchOpen(false); }} className="shrink-0 hover:text-[var(--text)]"><X size={12} /></button>
853
+ ) : null}
854
+ </label>
855
+ ) : null}
856
+ {bar.field ? null : (
857
+ /* the field down to its icon, still filling the room: clicked, it opens over the bar */
858
+ <Tooltip label={t('media.search')} side="bottom">
859
+ <button type="button" aria-label={t('media.search')} onClick={() => setSearchOpen(true)}
860
+ className="flex h-7 min-w-7 flex-1 items-center justify-center rounded-md bg-[var(--bg-hover)] text-[var(--text-muted)] transition hover:text-[var(--text)]">
861
+ <Search size={13} />
862
+ </button>
863
+ </Tooltip>
864
+ )}
865
+ {/* bringing media in is what this pane is for: the one labeled button, right after the search */}
866
+ <Tooltip label={t('media.import')} side="bottom">
867
+ <button type="button" aria-label={t('media.import')} aria-haspopup={onPick && onBring ? 'menu' : undefined}
868
+ onClick={(e) => (onPick && onBring ? (links ? barMenu(e, importMenu) : void bringFrom('copy', false)) : inputRef.current?.click())}
869
+ className={`flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md bg-[var(--bg-active)] text-[12.5px] font-medium text-[var(--text)] transition hover:bg-[var(--border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--border-strong)] ${bar.uploadWord ? 'px-2.5' : 'w-7'}`}>
870
+ <Upload size={13} strokeWidth={2.2} />
871
+ {bar.uploadWord ? uploadWord : null}
872
+ </button>
873
+ </Tooltip>
874
+ {/* Saved, the person's own library for every project: the pane shows it in place of the project's media, and back */}
875
+ {onScope ? (
876
+ <BarButton label={t(inSaved ? 'saved.back' : 'saved.title')} on={inSaved} onClick={() => onScope(inSaved ? 'project' : 'saved')}>
877
+ <Bookmark size={14} {...(inSaved ? { fill: 'currentColor' } : {})} />
878
+ </BarButton>
879
+ ) : null}
880
+ <BarButton label={t(rows ? 'media.viewGrid' : 'media.viewList')} onClick={() => setView((v) => ({ ...v, layout: rows ? 'grid' : 'list' }))}>
881
+ {rows ? <LayoutGrid size={14} /> : <List size={14} />}
882
+ </BarButton>
883
+ <BarButton label={t('media.filter')} on={view.filter !== 'all'} onClick={(e) => barMenu(e, FILTERS.map((f) => ({
884
+ id: f, label: `${t(`media.filter_${f}`)} ${counts[f]}`, checked: view.filter === f, onSelect: () => setView((v) => ({ ...v, filter: f })),
885
+ })))}><ListFilter size={14} /></BarButton>
886
+ <BarButton label={t('media.sort')} onClick={(e) => barMenu(e, SORTS.map((key) => ({
887
+ id: key, label: t(`media.sort_${key}`), checked: view.sort === key, onSelect: () => setView((v) => ({ ...v, sort: key })),
888
+ })))}><ArrowUpDown size={14} /></BarButton>
889
+ <BarButton label={t('assets.newFolder')} onClick={() => { setQuery(''); setView((v) => ({ ...v, filter: 'all' })); setCreating(true); }}><FolderPlus size={14} /></BarButton>
890
+ {/* any kind of file: what Studio cannot play is still the project's material */}
891
+ <input ref={inputRef} type="file" multiple className="hidden"
892
+ onChange={(e) => { const list = [...(e.target.files ?? [])]; e.target.value = ''; void importFiles(list); }} />
893
+ </div>
894
+
895
+ {/* where you are: All, then the folders you went into (drop a file on one to move it there) */}
896
+ <div className="flex h-7 shrink-0 items-center gap-0.5 overflow-hidden px-2.5 text-[12.5px]">
897
+ {flat ? (
898
+ <span className="truncate text-[var(--text-muted)]">
899
+ {view.filter !== 'all' ? t(`media.filter_${view.filter}`) : t('media.results')}
900
+ </span>
901
+ ) : (
902
+ <>
903
+ <button type="button" onClick={() => setAt('')} {...dropProps('')}
904
+ className={`shrink-0 rounded px-1 ${at ? 'text-[var(--text-muted)] hover:text-[var(--text)]' : 'font-medium text-[var(--text)]'} ${dropTarget === '' ? 'bg-[var(--bg-hover)] ring-1 ring-[var(--accent)]' : ''}`}>
905
+ {topName}
906
+ </button>
907
+ {crumbs.map((name, i) => {
908
+ const rel = crumbs.slice(0, i + 1).join('/');
909
+ const last = i === crumbs.length - 1;
910
+ return (
911
+ <React.Fragment key={rel}>
912
+ <ChevronRight size={12} className="shrink-0 text-[var(--text-faint)]" />
913
+ <button type="button" onClick={() => setAt(rel)} {...dropProps(rel)}
914
+ className={`min-w-0 truncate rounded px-1 ${last ? 'font-medium text-[var(--text)]' : 'text-[var(--text-muted)] hover:text-[var(--text)]'} ${dropTarget === rel ? 'bg-[var(--bg-hover)] ring-1 ring-[var(--accent)]' : ''}`}>
915
+ {name}
916
+ </button>
917
+ </React.Fragment>
918
+ );
919
+ })}
920
+ </>
921
+ )}
922
+ </div>
923
+
924
+ {notice ? (
925
+ <div className="mx-2.5 mb-2 flex shrink-0 items-start gap-2 rounded-md bg-[var(--bg-hover)] px-2 py-1.5 text-[12px] text-[var(--text-muted)]">
926
+ <span className="min-w-0 flex-1">{notice}</span>
927
+ <button type="button" aria-label={t('media.dismiss')} onClick={() => setNotice(null)}><X size={12} /></button>
928
+ </div>
929
+ ) : null}
930
+
931
+ <div
932
+ ref={gridRef}
933
+ tabIndex={-1}
934
+ className="relative min-h-0 flex-1 overflow-y-auto px-2.5 pb-3 pt-1 outline-none"
935
+ onPointerDown={startBox}
936
+ /* a click on empty space (around the tiles, or in the gaps between them) lets go of what is picked */
937
+ onClick={(e) => {
938
+ if (boxed.current) { boxed.current = false; return; }
939
+ if (!(e.target as Element).closest('[data-media-path], [data-media-folder], [data-media-new-folder], button')) { setPicked([]); showFile(null); }
940
+ }}
941
+ onContextMenu={(e) => {
942
+ if ((e.target as Element).closest('[data-media-path], [data-media-folder], [data-media-new-folder], [data-media-upload], button, input, textarea')) return;
943
+ e.preventDefault(); openMenuAt(e, blankMenu);
944
+ }}
945
+ >
946
+ {marquee ? (
947
+ <div className="pointer-events-none absolute z-20 rounded-[2px] border border-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_14%,transparent)]"
948
+ style={{ left: marquee.left, top: marquee.top, width: marquee.right - marquee.left, height: marquee.bottom - marquee.top }} />
949
+ ) : null}
950
+ {items && !items.length && !creating && !(flat ? [] : uploads.filter((u) => u.into === at)).length ? (
951
+ flat ? <p className="px-1 pt-6 text-center text-[12px] text-[var(--text-muted)]">{t('media.noMatch')}</p> : (
952
+ <div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
953
+ <Clapperboard size={22} className="text-[var(--text-faint)]" />
954
+ <div className="text-[13px] font-medium text-[var(--text)]">{t(at ? 'media.emptyFolder' : inSaved ? 'saved.emptyTitle' : 'media.emptyTitle')}</div>
955
+ {at ? null : <p className="max-w-[30ch] text-[12px] leading-relaxed text-[var(--text-muted)]">{t(inSaved ? 'saved.emptyBody' : 'media.emptyBody')}</p>}
956
+ <button type="button" onClick={() => (onPick && onBring ? void bringFrom('copy', false) : inputRef.current?.click())}
957
+ className="mt-1 inline-flex h-7 items-center gap-1.5 rounded-md border border-[var(--border)] px-2.5 text-[12.5px] text-[var(--text-dim)] transition hover:bg-[var(--bg-hover)]">
958
+ <Plus size={13} />{t('media.import')}
959
+ </button>
960
+ </div>
961
+ )
962
+ ) : (
963
+ <div className={rows ? '@container flex flex-col gap-px' : 'grid gap-x-2 gap-y-2.5'} style={rows ? undefined : { gridTemplateColumns: 'repeat(auto-fill, minmax(104px, 1fr))' }}>
964
+ {rows ? (
965
+ <div className="flex h-6 shrink-0 select-none items-center gap-2 px-1.5 text-[11px] text-[var(--text-faint)]">
966
+ <span className="w-[46px] shrink-0" />
967
+ <span className="min-w-0 flex-1 truncate">{t('media.sort_name')}</span>
968
+ <span className={COL_TYPE}>{t('assets.propType')}</span>
969
+ <span className={COL_DIMS}>{t('assets.propDimensions')}</span>
970
+ <span className={COL_SIZE}>{t('assets.propSize')}</span>
971
+ <span className={COL_DUR}>{t('assets.propDuration')}</span>
972
+ </div>
973
+ ) : null}
974
+ {creating ? <NewFolderTile row={rows} label={t('assets.newFolder')} onDone={(name) => void makeFolder(name)} onCancel={() => setCreating(false)} /> : null}
975
+ {pendingFolder && !pendingListed ? <NewFolderTile row={rows} label={pendingFolder} /> : null}
976
+ {(items ?? []).map((item) => item.type === 'folder' ? (
977
+ <FolderTile
978
+ key={`d:${item.rel}`}
979
+ row={rows}
980
+ name={item.name}
981
+ count={item.count}
982
+ covers={item.covers}
983
+ linked={Boolean(media?.linkedDirs?.[item.rel])}
984
+ progress={progressInto(item.rel)}
985
+ sources={sources}
986
+ pickKey={`${root}/${item.rel}`}
987
+ selected={chosen.includes(`${root}/${item.rel}`)}
988
+ target={dropTarget === item.rel}
989
+ renaming={renaming === `${root}/${item.rel}`}
990
+ drop={dropProps(item.rel)}
991
+ onDragStart={(e) => { e.dataTransfer.setData(FOLDER_DRAG, dragKeys(`${root}/${item.rel}`).join('\n')); e.dataTransfer.effectAllowed = 'move'; }}
992
+ onPress={(e) => press(item, e)}
993
+ onOpen={() => { setPicked([]); setAt(item.rel); }}
994
+ onMenu={(e) => menuFor(item, e)}
995
+ onRename={(name) => void rename(`${root}/${item.rel}`, name, true)}
996
+ onCancelRename={() => setRenaming(null)}
997
+ />
998
+ ) : (
999
+ <FileTile
1000
+ key={item.file.path}
1001
+ row={rows}
1002
+ file={item.file}
1003
+ sources={sources}
1004
+ duration={formatClipDuration(item.file.durationMs ?? 0)}
1005
+ where={repeated.has(item.file.name) ? item.file.path.slice(0, Math.max(0, item.file.path.lastIndexOf('/'))) || item.file.path : undefined}
1006
+ selected={chosen.includes(item.file.path)}
1007
+ lit={litPath === item.file.path}
1008
+ {...(item.file.link ? { linkLabel: item.file.link.missing ? t('media.linkMissing') : t('media.linkedFrom').replace('{path}', item.file.link.to) } : {})}
1009
+ {...(item.file.proxy ? { proxyLabel: proxyText(item.file.proxy), proxyMark: t('media.proxy') } : {})}
1010
+ renaming={renaming === item.file.path}
1011
+ {...(fileRef ? { studioRef: () => fileRef(item.file) } : {})}
1012
+ moving={() => dragKeys(item.file.path)}
1013
+ onPress={(e) => press(item, e)}
1014
+ typeText={item.file.bundle ? t(`saved.kind_${item.file.bundle.kind}`) : item.file.kind === 'mg' ? t('assets.kindPage') : extOf(item.file.name).toUpperCase()}
1015
+ library={inSaved}
1016
+ {...(item.file.bundle?.props ? { componentMark: t('saved.component') } : {})}
1017
+ onMenu={(e) => menuFor(item, e)}
1018
+ onRename={(name) => void rename(item.file.path, name, false)}
1019
+ onCancelRename={() => setRenaming(null)}
1020
+ />
1021
+ ))}
1022
+ {flat ? null : uploads.filter((u) => u.into === at).map((u) => <UploadTile key={u.id} upload={u} row={rows} />)}
1023
+ </div>
1024
+ )}
1025
+ </div>
1026
+
1027
+ {/* over a folder, the folder shows where they go */}
1028
+ {osDrag && dropTarget == null ? (
1029
+ <div className="pointer-events-none absolute inset-1 z-30 flex items-center justify-center rounded-lg border-2 border-dashed border-[var(--accent)] bg-[var(--surface)]/80 text-[13px] text-[var(--text)]">
1030
+ {t('media.dropHint')}
1031
+ </div>
1032
+ ) : null}
1033
+ {menu ? <ContextMenu x={menu.x} y={menu.y} flipTo={menu.flipTo} items={menu.items} onClose={() => { setMenu(null); menuFrom.current = null; }} dense={menu.dense} trigger={menuFrom} /> : null}
1034
+ <ConfirmDialog
1035
+ open={moveAsk != null}
1036
+ title={t('media.moveAskTitle')}
1037
+ description={moveAsk ? `${t('media.moveAskBody')}\n\n${moveAsk.unseen.flatMap((u) => u.unseen.map((line) => `${u.file}: ${line}`)).slice(0, 6).join('\n')}` : ''}
1038
+ confirmLabel={t('media.moveAskConfirm')}
1039
+ cancelLabel={t('confirm.cancel')}
1040
+ onCancel={() => setMoveAsk(null)}
1041
+ onConfirm={() => { const ask = moveAsk; setMoveAsk(null); if (ask) void moveAll(ask.paths, ask.intoRel, true); }}
1042
+ />
1043
+ <ConfirmDialog
1044
+ open={confirm != null}
1045
+ title={!confirm ? '' : confirm.targets.length > 1 ? t('media.deleteManyTitle').replace('{n}', String(confirm.targets.length))
1046
+ : confirm.targets[0]?.folder ? t('media.deleteFolderTitle').replace('{name}', confirm.name) : t('assets.deleteConfirmTitle')}
1047
+ description={(() => {
1048
+ if (!confirm) return '';
1049
+ const many = confirm.targets.length > 1;
1050
+ const hits = usedSrcs.filter((src) => confirm.targets.some((x) => srcHitsAsset(src, x.path) || src.startsWith(`${x.path}/`))).length;
1051
+ if (!hits) return t(many ? 'media.deleteManyConfirm' : 'assets.deleteConfirm');
1052
+ if (many) return t(hits === 1 ? 'media.deleteManyAlsoClip' : 'media.deleteManyAlsoClips').replace('{n}', String(hits));
1053
+ return t(hits === 1 ? 'assets.deleteAlsoClip' : 'assets.deleteAlsoClips').replace('{n}', String(hits));
1054
+ })()}
1055
+ confirmLabel={t('assets.delete')}
1056
+ cancelLabel={t('confirm.cancel')}
1057
+ danger
1058
+ onCancel={() => setConfirm(null)}
1059
+ onConfirm={() => { const asked = confirm; setConfirm(null); if (asked) void remove(asked.targets); }}
1060
+ />
1061
+ </div>
1062
+ );
1063
+ }
1064
+
1065
+ function BarButton({ label, on, onClick, children }: { label: string; on?: boolean; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; children: React.ReactNode }) {
1066
+ return (
1067
+ <Tooltip label={label} side="bottom">
1068
+ <button type="button" aria-label={label} onClick={onClick}
1069
+ /* two states, on and off: hovered, only the ground lights; an on button keeps its color */
1070
+ className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition hover:bg-[var(--bg-hover)] ${on ? 'text-[var(--accent)]' : 'text-[var(--text-dim)] hover:text-[var(--text)]'}`}>
1071
+ {children}
1072
+ </button>
1073
+ </Tooltip>
1074
+ );
1075
+ }
1076
+
1077
+ function NameInput({ value, onDone, onCancel }: { value: string; onDone: (name: string) => void; onCancel: () => void }) {
1078
+ return (
1079
+ <input
1080
+ autoFocus
1081
+ defaultValue={value}
1082
+ onFocus={(e) => { const dot = value.lastIndexOf('.'); e.currentTarget.setSelectionRange(0, dot > 0 ? dot : value.length); }}
1083
+ onClick={(e) => e.stopPropagation()}
1084
+ onDoubleClick={(e) => e.stopPropagation()}
1085
+ onKeyDown={(e) => { if (e.key === 'Enter') onDone(e.currentTarget.value); if (e.key === 'Escape') onCancel(); }}
1086
+ onBlur={(e) => onDone(e.currentTarget.value)}
1087
+ className="w-full min-w-0 rounded bg-[var(--bg-hover)] px-1 text-[12px] text-[var(--text)] outline-none ring-1 ring-[var(--accent)]"
1088
+ />
1089
+ );
1090
+ }
1091
+
1092
+ const TILE_BOX = 'relative aspect-video overflow-hidden rounded-md ring-2 transition';
1093
+ /** A row of the list view, and its small picture. */
1094
+ const ROW = 'group flex h-9 min-w-0 cursor-default select-none items-center gap-2 rounded-md px-1.5';
1095
+ const ROW_THUMB = 'relative aspect-video h-[26px] shrink-0 overflow-hidden rounded-[4px] bg-[var(--bg-hover)] ring-1 ring-[var(--border)]';
1096
+ /* the list's columns of facts, right of the name, as many as the pane is wide for (a container query on the list):
1097
+ the length always (a folder's count there), the size, the kind and the picture's size as it widens */
1098
+ const COL = 'shrink-0 truncate text-right text-[11px] tabular-nums text-[var(--text-faint)]';
1099
+ const COL_TYPE = `${COL} hidden w-11 @min-[340px]:block`;
1100
+ const COL_DIMS = `${COL} hidden w-[76px] @min-[240px]:block`;
1101
+ const COL_SIZE = `${COL} hidden w-14 @min-[410px]:block`;
1102
+ const COL_DUR = `${COL} w-12`;
1103
+ /** A file name's ending, without the dot (`png`), or '' when it has none. */
1104
+ const extOf = (name: string) => /\.([^./\s]{1,8})$/.exec(name)?.[1] ?? '';
1105
+
1106
+ /** Whether a file can be a clip (a video, a still, a sound, a page; not a document). */
1107
+ const canAdd = (file: WorkspaceResource) => file.kind === 'video' || file.kind === 'image' || file.kind === 'audio' || file.kind === 'mg';
1108
+ /** A name as it is on disk: two spaces stay two (HTML would show one), so what the search is typed from is the name. */
1109
+ const KEEP_SPACES: React.CSSProperties = { whiteSpace: 'pre' };
1110
+
1111
+ /**
1112
+ * An empty folder's mark. Never a drag's target (pointer-events none), and drawn the same while a drag is over it:
1113
+ * an element swapped under the pointer mid-drag makes the browser leave and re-enter the tile on every move, so the
1114
+ * highlight flickers and the drop is lost.
1115
+ */
1116
+ function FolderGlyph({ open }: { open?: boolean }) {
1117
+ return (
1118
+ <span className="pointer-events-none absolute inset-0 flex items-center justify-center">
1119
+ <Folder size={30} strokeWidth={1.4} className={open ? 'text-[var(--accent)]' : 'text-[var(--text-muted)]'} />
1120
+ </span>
1121
+ );
1122
+ }
1123
+
1124
+ /**
1125
+ * A folder as an album: the media it holds tiled on its cover (one, two, three or four), on two cards stacked behind,
1126
+ * so it reads as a set at a glance. An empty folder shows the folder mark.
1127
+ */
1128
+ function FolderTile(p: {
1129
+ name: string; count: number; covers: readonly WorkspaceResource[]; sources: MediaSources;
1130
+ /** Files on their way into it: their progress together, a bar along its cover. */
1131
+ progress?: number | undefined;
1132
+ /** A link to a folder kept elsewhere (a NAS). */
1133
+ linked?: boolean;
1134
+ /** Its key in what is picked (its path). */
1135
+ pickKey: string;
1136
+ selected: boolean; target: boolean; renaming: boolean;
1137
+ drop: { onDragOver: (e: React.DragEvent) => void; onDragLeave: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void };
1138
+ onDragStart: (e: React.DragEvent) => void;
1139
+ onPress: (e: React.MouseEvent) => void; onOpen: () => void; onMenu: (e: React.MouseEvent) => void;
1140
+ onRename: (name: string) => void; onCancelRename: () => void;
1141
+ /** A row of the list view, not a tile. */
1142
+ row?: boolean;
1143
+ }) {
1144
+ const on = p.target || p.selected;
1145
+ const cells = p.covers.length;
1146
+ if (p.row) {
1147
+ return (
1148
+ <div className={`${ROW} ${on ? 'bg-[var(--bg-active)]' : 'hover:bg-[var(--bg-hover)]'} ${p.target ? 'ring-1 ring-inset ring-[var(--accent)]' : ''}`}
1149
+ data-media-folder="" data-media-key={p.pickKey} draggable={!p.renaming} onDragStart={p.onDragStart}
1150
+ onClick={p.onPress} onDoubleClick={p.onOpen} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); p.onMenu(e); }} {...p.drop}>
1151
+ <span className={ROW_THUMB}>
1152
+ <span className="pointer-events-none absolute inset-0 flex items-center justify-center">
1153
+ {p.target ? <FolderOpen size={15} className="text-[var(--accent)]" /> : p.linked ? <FolderSymlink size={15} className="text-[var(--text-muted)]" /> : <Folder size={15} className="text-[var(--text-muted)]" />}
1154
+ </span>
1155
+ {p.progress != null ? <UploadBar ratio={p.progress} /> : null}
1156
+ </span>
1157
+ <span className="min-w-0 flex-1">
1158
+ {p.renaming ? <NameInput value={p.name} onDone={p.onRename} onCancel={p.onCancelRename} />
1159
+ : <span className={`block truncate text-[12.5px] font-medium ${on ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}`} style={KEEP_SPACES}>{p.name}</span>}
1160
+ </span>
1161
+ <span className={COL_TYPE} />
1162
+ <span className={COL_DIMS} />
1163
+ <span className={COL_SIZE} />
1164
+ <span className={COL_DUR}>{p.count ? String(p.count) : ''}</span>
1165
+ </div>
1166
+ );
1167
+ }
1168
+ const grid = cells >= 4 ? 'grid-cols-2 grid-rows-2' : cells === 3 ? 'grid-cols-2 grid-rows-2' : cells === 2 ? 'grid-cols-2 grid-rows-1' : 'grid-cols-1 grid-rows-1';
1169
+ return (
1170
+ <div className="group min-w-0 cursor-default select-none" data-media-folder="" data-media-key={p.pickKey} draggable={!p.renaming} onDragStart={p.onDragStart}
1171
+ onClick={p.onPress} onDoubleClick={p.onOpen} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); p.onMenu(e); }} {...p.drop}>
1172
+ <div className="relative pt-[7px]">
1173
+ {/* the stack: two cards peeking out above the cover */}
1174
+ <span aria-hidden className="absolute inset-x-[14%] top-0 h-3 rounded-t-md bg-[var(--fill-tsp,rgba(127,127,127,0.18))] opacity-60" />
1175
+ <span aria-hidden className="absolute inset-x-[7%] top-[3px] h-3 rounded-t-md bg-[var(--fill-tsp,rgba(127,127,127,0.18))]" />
1176
+ <div className={`${TILE_BOX} bg-[var(--bg-hover)] shadow-[0_1px_0_rgba(255,255,255,0.06)_inset] ${on ? 'ring-[var(--accent)]' : 'ring-transparent group-hover:ring-[var(--border)]'} ${p.target ? 'scale-[1.03]' : ''}`}>
1177
+ {cells ? (
1178
+ <div className={`pointer-events-none absolute inset-0 grid gap-px bg-black/40 ${grid}`}>
1179
+ {p.covers.map((file, i) => (
1180
+ <span key={file.path} className={`relative overflow-hidden bg-[var(--bg-hover)] ${cells === 3 && i === 0 ? 'row-span-2' : ''}`}>
1181
+ {file.kind === 'mg' ? <PagePoster file={file} sources={p.sources} /> : <MediaPreview file={file} sources={p.sources} />}
1182
+ </span>
1183
+ ))}
1184
+ </div>
1185
+ ) : <FolderGlyph open={p.target} />}
1186
+ <span className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/55 to-transparent" />
1187
+ {p.progress != null ? <UploadBar ratio={p.progress} /> : null}
1188
+ <span className="pointer-events-none absolute bottom-1 left-1.5 flex items-center gap-1 text-[10.5px] font-medium text-white/90">
1189
+ {p.target ? <FolderOpen size={11} /> : p.linked ? <FolderSymlink size={11} /> : <Folder size={11} />}
1190
+ <span className="tabular-nums">{p.count ? String(p.count) : ''}</span>
1191
+ </span>
1192
+ </div>
1193
+ </div>
1194
+ <div className="mt-1 min-w-0 px-0.5">
1195
+ {p.renaming ? <NameInput value={p.name} onDone={p.onRename} onCancel={p.onCancelRename} />
1196
+ : <div className={`truncate text-[12px] font-medium ${on ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}`} style={KEEP_SPACES}>{p.name}</div>}
1197
+ </div>
1198
+ </div>
1199
+ );
1200
+ }
1201
+
1202
+ /** The folder being named; without `onDone`, one just named and on its way (its name, not a field). */
1203
+ function NewFolderTile({ label, onDone, onCancel, row = false }: { label: string; onDone?: (name: string) => void; onCancel?: () => void; row?: boolean }) {
1204
+ if (row) {
1205
+ return (
1206
+ <div className={`${ROW} bg-[var(--bg-hover)]`} data-media-new-folder="">
1207
+ <span className={`${ROW_THUMB} ring-[var(--accent)]`}>
1208
+ <span className="pointer-events-none absolute inset-0 flex items-center justify-center"><Folder size={15} className="text-[var(--text-muted)]" /></span>
1209
+ </span>
1210
+ <span className="min-w-0 flex-1">
1211
+ {onDone ? <NameInput value={label} onDone={onDone} onCancel={onCancel ?? (() => {})} />
1212
+ : <span className="block truncate text-[12.5px] font-medium text-[var(--text)]" style={KEEP_SPACES}>{label}</span>}
1213
+ </span>
1214
+ </div>
1215
+ );
1216
+ }
1217
+ return (
1218
+ <div className="min-w-0" data-media-new-folder="">
1219
+ <div className="relative pt-[7px]">
1220
+ <span aria-hidden className="absolute inset-x-[14%] top-0 h-3 rounded-t-md bg-[var(--fill-tsp,rgba(127,127,127,0.18))] opacity-60" />
1221
+ <span aria-hidden className="absolute inset-x-[7%] top-[3px] h-3 rounded-t-md bg-[var(--fill-tsp,rgba(127,127,127,0.18))]" />
1222
+ <div className={`${TILE_BOX} bg-[var(--bg-hover)] ring-[var(--accent)]`}><FolderGlyph /></div>
1223
+ </div>
1224
+ <div className="mt-1 min-w-0 px-0.5">
1225
+ {onDone ? <NameInput value={label} onDone={onDone} onCancel={onCancel ?? (() => {})} />
1226
+ : <div className="truncate text-[12px] font-medium text-[var(--text)]" style={KEEP_SPACES}>{label}</div>}
1227
+ </div>
1228
+ </div>
1229
+ );
1230
+ }
1231
+
1232
+ function FileTile(p: {
1233
+ file: WorkspaceResource; sources: MediaSources; duration: string;
1234
+ /** Its folder, shown under the name (search results where the name repeats). */
1235
+ where?: string | undefined;
1236
+ selected: boolean; renaming: boolean;
1237
+ /** What a pill hovered in the app's chat points at. */
1238
+ lit?: boolean;
1239
+ /** The file as a reference in the app's chat, carried by a drag beside the file itself. */
1240
+ studioRef?: () => StudioRef;
1241
+ /** What a drag of it moves into a folder: it, or every one picked with it. */
1242
+ moving: () => readonly string[];
1243
+ onPress: (e?: React.MouseEvent) => void; onMenu: (e: React.MouseEvent) => void; onRename: (name: string) => void; onCancelRename: () => void;
1244
+ /** A link to media kept elsewhere: what to say of it (where it is kept, or that it cannot be reached). */
1245
+ linkLabel?: string;
1246
+ /** Heavy footage's proxy: what to say of it (being made, ready…), and its mark once ready. */
1247
+ proxyLabel?: string;
1248
+ proxyMark?: string;
1249
+ /** What kind of file it is, short (`PNG`, `Page`): the list view's Type. */
1250
+ typeText: string;
1251
+ /** A row of the list view, not a tile. */
1252
+ row?: boolean;
1253
+ /** In Saved: a drag of it says its place there (its files are brought into the project as it lands). */
1254
+ library?: boolean;
1255
+ /** A bundle of Saved that offers properties: the word that marks it a component. */
1256
+ componentMark?: string;
1257
+ }) {
1258
+ const bundle = p.file.bundle;
1259
+ return (
1260
+ <div
1261
+ className={p.row
1262
+ ? `${ROW} outline-none ${p.lit ? 'ring-1 ring-inset ring-[var(--tl-accent)]' : ''} ${p.selected ? 'bg-[var(--bg-active)]' : 'hover:bg-[var(--bg-hover)]'}`
1263
+ : 'group min-w-0 cursor-default select-none outline-none'}
1264
+ data-media-path={p.file.path}
1265
+ data-media-key={p.file.path}
1266
+ data-chat-lit={p.lit ? '' : undefined}
1267
+ title={p.where ? p.file.path : p.file.name}
1268
+ draggable={!p.renaming}
1269
+ /* a drag picks nothing: picking opens the inspector, and the pane laid out again under the pointer (or hidden for
1270
+ room) would lose the drag */
1271
+ onDragStart={(e) => {
1272
+ setResourceDragData(e.dataTransfer, p.library ? { ...p.file, saved: p.file.path } : p.file);
1273
+ if (p.studioRef) e.dataTransfer.setData(STUDIO_REF_TYPE, JSON.stringify([p.studioRef()]));
1274
+ /* any file can go into a folder of this pane: a page's links are written again as it moves (Studio) */
1275
+ e.dataTransfer.setData(FOLDER_DRAG, p.moving().join('\n'));
1276
+ e.dataTransfer.effectAllowed = 'copyMove';
1277
+ }}
1278
+ onDragEnd={() => clearResourceDrag()}
1279
+ /* focusable, so the keys pressed after picking it are the pane's (Delete asks to delete it) */
1280
+ tabIndex={-1}
1281
+ onClick={(e) => { e.currentTarget.focus({ preventScroll: true }); p.onPress(e); }}
1282
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); p.onMenu(e); }}
1283
+ >
1284
+ {p.row ? (
1285
+ <>
1286
+ <span className={`${ROW_THUMB} pointer-events-none`}>
1287
+ {p.file.kind === 'mg' ? <PagePoster file={p.file} sources={p.sources} /> : <MediaPreview file={p.file} sources={p.sources} />}
1288
+ {p.file.proxy && p.file.proxy.state !== 'ready' && p.file.proxy.state !== 'failed' ? <UploadBar ratio={p.file.proxy.state === 'making' ? p.file.proxy.ratio ?? 0 : -1} /> : null}
1289
+ </span>
1290
+ <span className="flex min-w-0 flex-1 flex-col">
1291
+ {p.renaming ? <NameInput value={p.file.name} onDone={p.onRename} onCancel={p.onCancelRename} />
1292
+ : (
1293
+ <span className="flex min-w-0 items-center gap-1" title={p.linkLabel}>
1294
+ {p.file.link ? (p.file.link.missing ? <Unlink size={12} className="shrink-0 text-[var(--danger,#e5484d)]" /> : <Link2 size={12} className="shrink-0 text-[var(--text-faint)]" />) : null}
1295
+ <span className={`block truncate text-[12.5px] ${p.file.link?.missing ? 'text-[var(--text-faint)]' : p.selected ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}`} style={KEEP_SPACES}>{p.file.name}</span>
1296
+ </span>
1297
+ )}
1298
+ {p.where ? <span className="block truncate text-left text-[11px] text-[var(--text-faint)]" dir="rtl"><bdi>{p.where}</bdi></span> : null}
1299
+ </span>
1300
+ <span className={COL_TYPE}>{p.typeText}</span>
1301
+ <span className={COL_DIMS}>{p.file.w && p.file.h ? `${p.file.w} × ${p.file.h}` : ''}</span>
1302
+ <span className={COL_SIZE}>{p.file.size ? formatFileSize(p.file.size) : ''}</span>
1303
+ <span className={COL_DUR}>{p.duration}</span>
1304
+ </>
1305
+ ) : <>
1306
+ {/* the same room above as a folder's stacked cards, so covers and names line up across a row */}
1307
+ <div className="pt-[7px]">
1308
+ <div className={`${TILE_BOX} bg-[var(--bg-hover)] ${p.lit ? 'ring-[var(--tl-accent)] shadow-[0_0_10px_1px_color-mix(in_srgb,var(--tl-accent)_55%,transparent)]' : p.selected ? 'ring-[var(--accent)]' : 'ring-transparent group-hover:ring-[var(--border)]'}`}>
1309
+ {p.file.kind === 'mg' ? <PagePoster file={p.file} sources={p.sources} /> : <MediaPreview file={p.file} sources={p.sources} />}
1310
+ {/* a link to media kept elsewhere: marked; one that cannot be reached (a NAS that is off) says so over it */}
1311
+ {p.file.link?.missing ? (
1312
+ <span className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-1 bg-black/65 text-[10.5px] text-white/85" title={p.linkLabel}>
1313
+ <Unlink size={16} />{p.linkLabel}
1314
+ </span>
1315
+ ) : p.file.link ? (
1316
+ <span className="absolute bottom-1 left-1 z-10 flex h-4 w-4 items-center justify-center rounded bg-black/65 text-white" title={p.linkLabel}><Link2 size={10} /></span>
1317
+ ) : null}
1318
+ {/* heavy footage's proxy: its making along the bottom, then a mark */}
1319
+ {p.file.proxy?.state === 'ready' ? (
1320
+ <span className="absolute right-1 top-1 z-10 rounded bg-black/65 px-1 text-[9.5px] font-semibold uppercase leading-[15px] tracking-wide text-white/90" title={p.proxyLabel}>{p.proxyMark}</span>
1321
+ ) : p.file.proxy && p.file.proxy.state !== 'failed' ? (
1322
+ <span className="absolute inset-x-0 bottom-0 z-10 h-1" title={p.proxyLabel}><UploadBar ratio={p.file.proxy.state === 'making' ? p.file.proxy.ratio ?? 0 : -1} /></span>
1323
+ ) : null}
1324
+ {/* a bundle of Saved: a component, and how many clips a group holds */}
1325
+ {p.componentMark ? (
1326
+ <span className="absolute left-1 top-1 z-10 flex items-center gap-0.5 rounded bg-black/65 px-1 text-[9.5px] font-semibold leading-[15px] text-white/90"><ComponentIcon size={9} />{p.componentMark}</span>
1327
+ ) : null}
1328
+ {bundle?.kind === 'group' && bundle.count ? (
1329
+ <span className="absolute bottom-1 left-1 z-10 flex items-center gap-0.5 rounded bg-black/65 px-1 text-[10.5px] tabular-nums leading-[16px] text-white"><Layers size={10} />{bundle.count}</span>
1330
+ ) : null}
1331
+ {p.duration ? <span className="absolute bottom-1 right-1 z-10 rounded bg-black/65 px-1 text-[10.5px] tabular-nums leading-[16px] text-white">{p.duration}</span> : null}
1332
+ </div>
1333
+ </div>
1334
+ <div className="mt-1 min-w-0 px-0.5">
1335
+ {p.renaming ? <NameInput value={p.file.name} onDone={p.onRename} onCancel={p.onCancelRename} />
1336
+ : <div className={`truncate text-[12px] ${p.selected ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'}`} style={KEEP_SPACES}>{p.file.name}</div>}
1337
+ {p.where ? <div className="truncate text-left text-[11px] text-[var(--text-faint)]" dir="rtl"><bdi>{p.where}</bdi></div> : null}
1338
+ </div>
1339
+ </>}
1340
+ </div>
1341
+ );
1342
+ }
1343
+
1344
+ /** How much of a file is in: a bar along the bottom of its card. */
1345
+ function UploadBar({ ratio }: { ratio: number }) {
1346
+ /* below 0: how much is in is not known (a copy made on this computer): the bar pulses */
1347
+ return (
1348
+ <span className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-1 bg-black/35">
1349
+ <span className={`block h-full bg-[var(--accent)] transition-[width] duration-200 ${ratio < 0 ? 'animate-pulse' : ''}`} style={{ width: `${ratio < 0 ? 100 : Math.round(ratio * 100)}%` }} />
1350
+ </span>
1351
+ );
1352
+ }
1353
+
1354
+ /** A file on its way in, where it will be: a picture shows itself, anything else its icon; its progress inside. */
1355
+ function UploadTile({ upload, row }: { upload: Upload; row: boolean }) {
1356
+ const pct = upload.ratio < 0 ? '' : `${Math.round(upload.ratio * 100)}%`;
1357
+ const Icon = KIND_ICON[upload.kind];
1358
+ const picture = upload.preview
1359
+ ? <img src={upload.preview} alt="" className="absolute inset-0 h-full w-full object-contain opacity-45" />
1360
+ : <span className="absolute inset-0 flex items-center justify-center"><Icon size={row ? 14 : 24} className="text-[var(--text-faint)]" /></span>;
1361
+ if (row) {
1362
+ return (
1363
+ <div className={ROW} data-media-upload="">
1364
+ <span className={ROW_THUMB}>{picture}<UploadBar ratio={upload.ratio} /></span>
1365
+ <span className="block min-w-0 flex-1 truncate text-[12.5px] text-[var(--text-muted)]" style={KEEP_SPACES}>{upload.name}</span>
1366
+ <span className={COL_DUR}>{pct}</span>
1367
+ </div>
1368
+ );
1369
+ }
1370
+ return (
1371
+ <div className="min-w-0 cursor-default select-none" data-media-upload="">
1372
+ <div className="pt-[7px]">
1373
+ <div className={`${TILE_BOX} bg-[var(--bg-hover)] ring-transparent`}>
1374
+ {picture}
1375
+ <span className="absolute inset-0 flex items-center justify-center text-[12px] font-medium tabular-nums text-white [text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">{pct}</span>
1376
+ <UploadBar ratio={upload.ratio} />
1377
+ </div>
1378
+ </div>
1379
+ <div className="mt-1 min-w-0 px-0.5">
1380
+ <div className="truncate text-[12px] text-[var(--text-muted)]" style={KEEP_SPACES}>{upload.name}</div>
1381
+ </div>
1382
+ </div>
1383
+ );
1384
+ }
1385
+
1386
+ /**
1387
+ * A web page's cover: its poster (the middle frame: a title that fades in is black on its first), else the scene
1388
+ * mark. The mark shows until the poster has loaded, so a page whose poster can't be drawn (it throws) never shows the
1389
+ * browser's broken picture while it is asked again.
1390
+ */
1391
+ function PagePoster({ file, sources }: { file: WorkspaceResource; sources: MediaSources }) {
1392
+ /* kept by the address they are about (it changes with the file and what it loads): a reset after the fact would
1393
+ undo a poster that came from the cache and loaded before it ran. Once one has loaded, it stays shown while the
1394
+ next is asked for (the browser keeps the last picture until the new one is in), so a change never flickers */
1395
+ const [loaded, setLoaded] = React.useState<string | null>(null);
1396
+ const [failed, setFailed] = React.useState<string | null>(null);
1397
+ const own = sources.pagePoster?.(file);
1398
+ /* drawn in the headless browser: asked for in turn with Studio's other media (lib/media-queue), once near the screen */
1399
+ const [nearRef, near] = useNearViewport<HTMLSpanElement>();
1400
+ const turn = useMediaImage(own && failed !== own ? own : null, near);
1401
+ const src = turn && failed !== turn ? turn : null;
1402
+ const shown = src != null && loaded != null;
1403
+ return (
1404
+ <span ref={nearRef} className="absolute inset-0">
1405
+ {shown ? null : <span className="absolute inset-0 flex items-center justify-center"><Clapperboard size={20} className="text-[var(--text-muted)]" /></span>}
1406
+ {src ? (
1407
+ <img src={src} alt="" onLoad={() => setLoaded(src)}
1408
+ onError={(e) => { setLoaded(null); retryImage(e.currentTarget, () => setFailed(src)); }}
1409
+ className={`absolute inset-0 h-full w-full object-cover ${shown ? '' : 'invisible'}`} />
1410
+ ) : null}
1411
+ </span>
1412
+ );
1413
+ }