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.
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/bin/install.mjs +66 -0
- package/package.json +29 -4
- package/skill/SKILL.md +64 -0
- package/skill/agents/openai.yaml +4 -0
- package/skill/assets/examples/ApprovalCard.tsx +8 -0
- package/skill/assets/examples/BasicApp.tsx +38 -0
- package/skill/assets/examples/BriefingCard.tsx +8 -0
- package/skill/assets/examples/ChatControls.tsx +49 -0
- package/skill/assets/examples/ChatPanel.tsx +73 -0
- package/skill/assets/examples/Checkbox.tsx +13 -0
- package/skill/assets/examples/Combobox.tsx +14 -0
- package/skill/assets/examples/Drawer.tsx +10 -0
- package/skill/assets/examples/MemoryCard.tsx +9 -0
- package/skill/assets/examples/MessageList.tsx +8 -0
- package/skill/assets/examples/Theme.tsx +14 -0
- package/skill/assets/examples/ToolCall.tsx +8 -0
- package/skill/assets/examples/Tree.tsx +15 -0
- package/skill/assets/examples/examples.css +11 -0
- package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
- package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
- package/skill/assets/upstream/LICENSE +21 -0
- package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
- package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
- package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
- package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
- package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
- package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
- package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
- package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
- package/skill/assets/upstream/apps/desktop/package.json +51 -0
- package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
- package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
- package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
- package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
- package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
- package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
- package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
- package/skill/assets/upstream/packages/openfilm/package.json +88 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
- package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
- package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
- package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
- package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
- package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
- package/skill/assets/upstream/packages/shared/package.json +45 -0
- package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
- package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
- package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
- package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
- package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
- package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
- package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
- package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
- package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
- package/skill/references/agent-components.md +33 -0
- package/skill/references/api-index.json +2693 -0
- package/skill/references/chat-components.md +65 -0
- package/skill/references/components.md +23 -0
- package/skill/references/core-components.md +44 -0
- package/skill/references/dependencies.md +22 -0
- package/skill/references/esm-package.md +48 -0
- package/skill/references/integration.md +43 -0
- package/skill/references/library-manifest.json +12 -0
- package/skill/references/source-manifest.json +325 -0
- package/skill/references/studio-components.md +24 -0
- package/skill/references/theme-motion.md +49 -0
- package/skill/references/troubleshooting.md +27 -0
- package/skill/references/verification.md +13 -0
- package/skill/scripts/check_dependencies.py +109 -0
- package/skill/scripts/inspect_api.py +60 -0
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contents of the Projects popover (see ProjectsLibrary): a compact header (title, search, sort, view, new folder,
|
|
3
|
+
* close), the folders and the projects as cards or a list, with "New project" as the first cell (and, under it, "Open
|
|
4
|
+
* folder…" and "Get from GitHub…": an existing film), then the examples. Inside a folder a breadcrumb leads back to
|
|
5
|
+
* all. Data and actions come from the popover.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { createPortal } from 'react-dom';
|
|
9
|
+
import {
|
|
10
|
+
ArrowLeft, Check, ChevronDown, ChevronRight, Clock3, Folder, FolderInput, FolderOpen, FolderPlus, FolderSearch, Github, LayoutGrid, List, MoreHorizontal, Pencil, Plus, Search, SlidersHorizontal, Trash2, Video, X,
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
import type { Example } from '@/lib/project-sources';
|
|
13
|
+
import { ExamplesSection } from './ProjectSources';
|
|
14
|
+
import { useLanguage, useT, type Language } from '@/i18n';
|
|
15
|
+
import { limitTitleWidth, TITLE_MAX_WIDTH } from '@/lib/title';
|
|
16
|
+
import { api, type Project } from '@/api';
|
|
17
|
+
import { folderCounts, folderPosters, folderTrail, listStarts, parentOf, visibleProjects, type LibrarySort, type ProjectFolder } from '@/lib/project-folders';
|
|
18
|
+
import { opensOnClick, posterRetryDelay, posterTry } from '@/lib/projects-page';
|
|
19
|
+
import { ContextMenu } from './ContextMenu';
|
|
20
|
+
import { ProjectMenu } from './ProjectMenu';
|
|
21
|
+
import { ProjectCoverContext } from './ProjectCover';
|
|
22
|
+
import { Tooltip } from './Tooltip';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A project as the popover lists it: `poster` (an image URL; a gradient when it fails), `duration` (seconds, when
|
|
26
|
+
* Studio knows the film's length), `createdAt` (when its folder was made) and `folderId` (its folder in the list).
|
|
27
|
+
*/
|
|
28
|
+
export type LibraryProject = Project & { poster?: string; duration?: number; createdAt?: number; folderId?: string };
|
|
29
|
+
|
|
30
|
+
type LibraryViewMode = 'cards' | 'list';
|
|
31
|
+
|
|
32
|
+
export function ProjectsView({
|
|
33
|
+
projects,
|
|
34
|
+
creating = false,
|
|
35
|
+
onNewProject,
|
|
36
|
+
onOpenFolder,
|
|
37
|
+
onGetFromGitHub,
|
|
38
|
+
onLocate,
|
|
39
|
+
examples = [],
|
|
40
|
+
examplesCollapsed = false,
|
|
41
|
+
onToggleExamples,
|
|
42
|
+
onOpenExample,
|
|
43
|
+
onOpen,
|
|
44
|
+
onRename,
|
|
45
|
+
onDelete,
|
|
46
|
+
folders,
|
|
47
|
+
onCreateFolder,
|
|
48
|
+
onRenameFolder,
|
|
49
|
+
onDeleteFolder,
|
|
50
|
+
onMoveToFolder,
|
|
51
|
+
onMoveFolder,
|
|
52
|
+
onClose,
|
|
53
|
+
heading,
|
|
54
|
+
}: {
|
|
55
|
+
projects: LibraryProject[];
|
|
56
|
+
/** A project is being created or opened; "New project" does not take a second click meanwhile. */
|
|
57
|
+
creating?: boolean;
|
|
58
|
+
/** "New project": made at once in the folder looked at (null: the top level), named as Studio names it (Untitled, Untitled 2…), and opened. */
|
|
59
|
+
onNewProject: (folderId: string | null) => void;
|
|
60
|
+
/** "Open folder…": a folder anywhere, opened as a project (the app's own dialog, else Studio's folder browser). */
|
|
61
|
+
onOpenFolder: () => void;
|
|
62
|
+
/** "Get from GitHub…": a repository or a folder in one, copied into the library and opened. */
|
|
63
|
+
onGetFromGitHub: () => void;
|
|
64
|
+
/** A project whose folder is gone: pick the folder it is in now. */
|
|
65
|
+
onLocate: (project: LibraryProject) => void;
|
|
66
|
+
/** The example films (none: the section is not shown), folded away or not, and opening one. */
|
|
67
|
+
examples?: Example[];
|
|
68
|
+
examplesCollapsed?: boolean;
|
|
69
|
+
onToggleExamples?: () => void;
|
|
70
|
+
onOpenExample?: (example: Example) => void;
|
|
71
|
+
onOpen: (project: LibraryProject) => void;
|
|
72
|
+
/** A new name for the project (its folder is renamed). Never empty. */
|
|
73
|
+
onRename: (projectId: string, name: string) => void;
|
|
74
|
+
/** Ask to delete these projects (the popover confirms first). */
|
|
75
|
+
onDelete: (projectIds: string[]) => void;
|
|
76
|
+
/** The list's folders. */
|
|
77
|
+
folders: ProjectFolder[];
|
|
78
|
+
/** Make a folder; resolves to it (null: it could not be made). */
|
|
79
|
+
/** A new folder, in `parentId` (null: the top level). */
|
|
80
|
+
onCreateFolder: (name: string, parentId?: string | null) => Promise<ProjectFolder | null>;
|
|
81
|
+
/** A new name for a folder. Never empty. */
|
|
82
|
+
onRenameFolder: (folderId: string, name: string) => void;
|
|
83
|
+
/** Ask to delete a folder and the projects in it (the popover confirms first). */
|
|
84
|
+
onDeleteFolder: (folder: ProjectFolder) => void;
|
|
85
|
+
/** Put a project in a folder, or (null) in none. */
|
|
86
|
+
onMoveToFolder: (projectId: string, folderId: string | null) => void;
|
|
87
|
+
/** a folder into another (null: the top level) */
|
|
88
|
+
onMoveFolder: (folderId: string, parentId: string | null) => void;
|
|
89
|
+
/** Missing: no close button (Studio has no project to go back to). */
|
|
90
|
+
onClose?: () => void;
|
|
91
|
+
/** The title row's left, in place of "Projects" (the launcher's switch between the projects and the examples). */
|
|
92
|
+
heading?: React.ReactNode;
|
|
93
|
+
}) {
|
|
94
|
+
const t = useT();
|
|
95
|
+
/* recently opened first: someone coming back to the list most likely wants the one they just had */
|
|
96
|
+
const [sort, setSort] = React.useState<LibrarySort>('recent');
|
|
97
|
+
const [view, setView] = React.useState<LibraryViewMode>('cards');
|
|
98
|
+
const [query, setQuery] = React.useState('');
|
|
99
|
+
const [selecting, setSelecting] = React.useState(false);
|
|
100
|
+
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
|
|
101
|
+
/** The card being renamed in place. One at a time; clicking the cover still opens the project. */
|
|
102
|
+
const [renamingId, setRenamingId] = React.useState<string | null>(null);
|
|
103
|
+
/* folders: the one being looked at (null = all), the project a folder is being picked for, the folder being named */
|
|
104
|
+
const [folderId, setFolderId] = React.useState<string | null>(null);
|
|
105
|
+
/* what the folder picker moves: a project, or a folder of the list */
|
|
106
|
+
const [moving, setMoving] = React.useState<{ project: LibraryProject } | { folder: ProjectFolder } | null>(null);
|
|
107
|
+
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
|
|
108
|
+
/* the folder looked at was deleted (here or elsewhere): back to all */
|
|
109
|
+
React.useEffect(() => {
|
|
110
|
+
if (folderId && !folders.some((f) => f.id === folderId)) setFolderId(null);
|
|
111
|
+
}, [folderId, folders]);
|
|
112
|
+
|
|
113
|
+
const visible = React.useMemo(
|
|
114
|
+
() => visibleProjects(projects, { query, folderId, sort, folders, titleOf: (p) => titleOf(p, t) }),
|
|
115
|
+
[projects, query, folderId, sort, folders, t],
|
|
116
|
+
);
|
|
117
|
+
/* folder cards only at the top level and not while searching: inside a folder is its content, a search its results */
|
|
118
|
+
/* the folders here: the top level's, or those inside the folder open (a search shows projects only) */
|
|
119
|
+
const showFolders = !query.trim();
|
|
120
|
+
const levelFolders = React.useMemo(() => folders.filter((f) => parentOf(f, folders) === folderId), [folders, folderId]);
|
|
121
|
+
const currentFolder = folders.find((f) => f.id === folderId) ?? null;
|
|
122
|
+
const trail = React.useMemo(() => folderTrail(folders, folderId), [folders, folderId]);
|
|
123
|
+
const counts = React.useMemo(() => folderCounts(projects, folders), [projects, folders]);
|
|
124
|
+
const posters = React.useMemo(() => folderPosters(projects, folders), [projects, folders]);
|
|
125
|
+
|
|
126
|
+
/** A card's open: on a click, a key press or its menu, not on the second click of a double-click (lib/projects-page). */
|
|
127
|
+
const opening = (run: () => void) => (event?: { detail: number }) => { if (!event || opensOnClick(event)) run(); };
|
|
128
|
+
|
|
129
|
+
async function newFolder() {
|
|
130
|
+
const created = await onCreateFolder(t('projects.untitledFolder'), folderId);
|
|
131
|
+
if (created) setRenamingFolderId(created.id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function folderProps(folder: ProjectFolder): FolderItemProps {
|
|
135
|
+
return {
|
|
136
|
+
folder,
|
|
137
|
+
count: counts[folder.id] ?? 0,
|
|
138
|
+
posters: posters[folder.id] ?? [],
|
|
139
|
+
renaming: renamingFolderId === folder.id,
|
|
140
|
+
onOpen: opening(() => setFolderId(folder.id)),
|
|
141
|
+
onStartRename: () => setRenamingFolderId(folder.id),
|
|
142
|
+
onCommitRename: (name: string) => {
|
|
143
|
+
setRenamingFolderId(null);
|
|
144
|
+
if (name && name !== folder.name) onRenameFolder(folder.id, name);
|
|
145
|
+
},
|
|
146
|
+
onCancelRename: () => setRenamingFolderId(null),
|
|
147
|
+
onDelete: () => onDeleteFolder(folder),
|
|
148
|
+
onMove: () => setMoving({ folder }),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const selectableIds = React.useMemo(() => visible.map((project) => project.id), [visible]);
|
|
153
|
+
const selectedCount = selectedIds.size;
|
|
154
|
+
const allVisibleSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id));
|
|
155
|
+
|
|
156
|
+
const exitSelecting = React.useCallback(() => {
|
|
157
|
+
setSelecting(false);
|
|
158
|
+
setSelectedIds(new Set());
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
/* selected projects left the list (deleted): drop them from the selection; none left → leave select mode, its job
|
|
162
|
+
is done and an empty select mode only costs one more click to get back to opening projects */
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
if (!selecting) return;
|
|
165
|
+
const alive = new Set(projects.map((project) => project.id));
|
|
166
|
+
const kept = [...selectedIds].filter((id) => alive.has(id));
|
|
167
|
+
if (kept.length === selectedIds.size) return;
|
|
168
|
+
if (kept.length === 0) {
|
|
169
|
+
exitSelecting();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
setSelectedIds(new Set(kept));
|
|
173
|
+
}, [projects, selecting, selectedIds, exitSelecting]);
|
|
174
|
+
|
|
175
|
+
/* Escape leaves select mode only. On the document, so it comes before the Projects window's own Escape (on the
|
|
176
|
+
window), which a handled Escape (defaultPrevented) does not close */
|
|
177
|
+
React.useEffect(() => {
|
|
178
|
+
if (!selecting) return;
|
|
179
|
+
const onKey = (event: KeyboardEvent) => {
|
|
180
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
exitSelecting();
|
|
183
|
+
};
|
|
184
|
+
document.addEventListener('keydown', onKey);
|
|
185
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
186
|
+
}, [selecting, exitSelecting]);
|
|
187
|
+
|
|
188
|
+
function toggleSelected(id: string) {
|
|
189
|
+
setSelectedIds((current) => {
|
|
190
|
+
const next = new Set(current);
|
|
191
|
+
if (next.has(id)) next.delete(id);
|
|
192
|
+
else next.add(id);
|
|
193
|
+
return next;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toggleSelectAllVisible() {
|
|
198
|
+
setSelectedIds((current) => {
|
|
199
|
+
const next = new Set(current);
|
|
200
|
+
for (const id of selectableIds) {
|
|
201
|
+
if (allVisibleSelected) next.delete(id);
|
|
202
|
+
else next.add(id);
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** "Select" on a card's menu enters select mode with that card already ticked: it is the one being pointed at. */
|
|
209
|
+
function startSelecting(project: LibraryProject) {
|
|
210
|
+
setRenamingId(null);
|
|
211
|
+
setSelecting(true);
|
|
212
|
+
setSelectedIds(new Set([project.id]));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function itemProps(project: LibraryProject): ItemProps {
|
|
216
|
+
return {
|
|
217
|
+
project,
|
|
218
|
+
title: titleOf(project, t),
|
|
219
|
+
selecting,
|
|
220
|
+
selected: selectedIds.has(project.id),
|
|
221
|
+
renaming: renamingId === project.id,
|
|
222
|
+
onToggleSelect: () => toggleSelected(project.id),
|
|
223
|
+
onOpen: opening(() => onOpen(project)),
|
|
224
|
+
onStartRename: () => { if (!selecting) setRenamingId(project.id); },
|
|
225
|
+
onCommitRename: (name: string) => {
|
|
226
|
+
setRenamingId((current) => (current === project.id ? null : current));
|
|
227
|
+
/* empty or unchanged: nothing to do (a project is its folder, and a folder needs a name) */
|
|
228
|
+
if (!name || name === project.name.trim()) return;
|
|
229
|
+
onRename(project.id, name);
|
|
230
|
+
},
|
|
231
|
+
onCancelRename: () => setRenamingId((current) => (current === project.id ? null : current)),
|
|
232
|
+
onStartSelect: () => startSelecting(project),
|
|
233
|
+
onMoveToFolder: () => setMoving({ project }),
|
|
234
|
+
onLocate: () => onLocate(project),
|
|
235
|
+
onDelete: () => onDelete([project.id]),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const starts = listStarts({ folderId, query });
|
|
240
|
+
const showNew = starts.newProject;
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
244
|
+
{/* the header stays out of the scroll area: inside it, the list view's scrollbar would narrow the header */}
|
|
245
|
+
<header className="flex shrink-0 flex-col gap-3 px-6 pb-4 pt-5">
|
|
246
|
+
<div className="flex min-h-8 items-center gap-2">
|
|
247
|
+
{/* inside a folder the title is the way back: "Projects / test", on the same line, so what is in the folder
|
|
248
|
+
starts where the projects do */}
|
|
249
|
+
{currentFolder ? (
|
|
250
|
+
<FolderCrumb
|
|
251
|
+
folder={currentFolder}
|
|
252
|
+
above={trail.slice(0, -1)}
|
|
253
|
+
count={counts[currentFolder.id] ?? 0}
|
|
254
|
+
onGo={setFolderId}
|
|
255
|
+
onRename={(name) => onRenameFolder(currentFolder.id, name)}
|
|
256
|
+
onDelete={() => onDeleteFolder(currentFolder)}
|
|
257
|
+
onMove={() => setMoving({ folder: currentFolder })}
|
|
258
|
+
/>
|
|
259
|
+
) : (
|
|
260
|
+
heading ?? <h2 className="mr-auto text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t('nav.projects')}</h2>
|
|
261
|
+
)}
|
|
262
|
+
{projects.length > 0 ? (
|
|
263
|
+
<>
|
|
264
|
+
<label className="relative w-[240px] shrink-0">
|
|
265
|
+
<span className="sr-only">{t('library.searchPlaceholder')}</span>
|
|
266
|
+
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--text-faint)]" />
|
|
267
|
+
<input
|
|
268
|
+
type="search"
|
|
269
|
+
value={query}
|
|
270
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
271
|
+
placeholder={t('library.searchPlaceholder')}
|
|
272
|
+
className="h-8 w-full rounded-lg bg-[var(--surface-2)] pl-8 pr-3 text-[12.5px] text-[var(--text)] outline-none placeholder:text-[var(--text-faint)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
|
273
|
+
/>
|
|
274
|
+
</label>
|
|
275
|
+
<SortButton sort={sort} onSort={setSort} />
|
|
276
|
+
<div className="flex h-8 shrink-0 items-center gap-0.5 rounded-lg bg-[var(--surface-2)] px-[3px]" aria-label={t('library.viewLabel')}>
|
|
277
|
+
<ViewButton active={view === 'cards'} label={t('library.viewCards')} onClick={() => setView('cards')} icon={<LayoutGrid size={14} />} />
|
|
278
|
+
<ViewButton active={view === 'list'} label={t('library.viewList')} onClick={() => setView('list')} icon={<List size={14} />} />
|
|
279
|
+
</div>
|
|
280
|
+
{/* at the top level or inside a folder: a folder can hold folders */}
|
|
281
|
+
<Tooltip label={t('projects.newFolder')} side="bottom">
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
aria-label={t('projects.newFolder')}
|
|
285
|
+
onClick={() => void newFolder()}
|
|
286
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
287
|
+
>
|
|
288
|
+
<FolderPlus size={15} strokeWidth={1.9} />
|
|
289
|
+
</button>
|
|
290
|
+
</Tooltip>
|
|
291
|
+
</>
|
|
292
|
+
) : null}
|
|
293
|
+
{onClose ? (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={onClose}
|
|
297
|
+
aria-label={t('project.close')}
|
|
298
|
+
className="ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--text-faint)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
299
|
+
>
|
|
300
|
+
<X size={16} strokeWidth={1.75} />
|
|
301
|
+
</button>
|
|
302
|
+
) : null}
|
|
303
|
+
</div>
|
|
304
|
+
</header>
|
|
305
|
+
|
|
306
|
+
{/* the one scroll area; the scrollbar gutter is reserved so cards keep their width either way */}
|
|
307
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
|
|
308
|
+
<div className="w-full flex-1 px-6 pb-6">
|
|
309
|
+
{visible.length === 0 && !showNew && !(showFolders && levelFolders.length > 0) ? (
|
|
310
|
+
<div className="flex min-h-[42vh] items-center justify-center text-[14px] text-[var(--text-muted)]">
|
|
311
|
+
{t('library.noMatches')}
|
|
312
|
+
</div>
|
|
313
|
+
) : view === 'cards' ? (
|
|
314
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 lg:grid-cols-4">
|
|
315
|
+
{showNew ? <NewProjectCard creating={creating} onNewProject={() => onNewProject(folderId)} {...(folderId === null ? { onOpenFolder, onGetFromGitHub } : {})} /> : null}
|
|
316
|
+
{showFolders ? levelFolders.map((folder) => <FolderCard key={`folder:${folder.id}`} {...folderProps(folder)} />) : null}
|
|
317
|
+
{visible.map((project) => <ProjectCard key={project.id} {...itemProps(project)} />)}
|
|
318
|
+
</div>
|
|
319
|
+
) : (
|
|
320
|
+
<div className="flex flex-col divide-y divide-[var(--border)]">
|
|
321
|
+
{showNew ? <NewProjectListRow creating={creating} onNewProject={() => onNewProject(folderId)} {...(folderId === null ? { onOpenFolder, onGetFromGitHub } : {})} /> : null}
|
|
322
|
+
{showFolders ? levelFolders.map((folder) => <FolderListRow key={`folder:${folder.id}`} {...folderProps(folder)} />) : null}
|
|
323
|
+
{visible.map((project) => <ProjectListRow key={project.id} {...itemProps(project)} />)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
{starts.examples && onOpenExample ? (
|
|
327
|
+
<ExamplesSection examples={examples} collapsed={examplesCollapsed} onToggle={() => onToggleExamples?.()} onOpen={onOpenExample} view={view} />
|
|
328
|
+
) : null}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{selecting ? (
|
|
332
|
+
<div className="pointer-events-none sticky bottom-0 z-20 flex justify-center pb-5">
|
|
333
|
+
<div
|
|
334
|
+
className="pointer-events-auto flex items-center gap-1 rounded-2xl border border-[var(--border)] bg-[var(--surface)] py-1.5 pl-4 pr-1.5 shadow-[var(--shadow-lg)]"
|
|
335
|
+
style={{ animation: 'openfilm-rise 0.16s ease-out both' }}
|
|
336
|
+
>
|
|
337
|
+
<span className="text-[13px] font-medium tabular-nums text-[var(--text)]">
|
|
338
|
+
{t('library.selectedCount').replace('{n}', String(selectedCount))}
|
|
339
|
+
</span>
|
|
340
|
+
<span className="mx-2 h-4 w-px bg-[var(--border)]" />
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={toggleSelectAllVisible}
|
|
344
|
+
disabled={selectableIds.length === 0}
|
|
345
|
+
className="h-8 rounded-xl px-2.5 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)] disabled:opacity-40"
|
|
346
|
+
>
|
|
347
|
+
{allVisibleSelected ? t('library.deselectAll') : t('library.selectAll')}
|
|
348
|
+
</button>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => { if (selectedIds.size) onDelete([...selectedIds]); }}
|
|
352
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-xl bg-[var(--err)] px-3 text-[12.5px] font-semibold text-[var(--on-accent)] transition hover:brightness-110"
|
|
353
|
+
>
|
|
354
|
+
<Trash2 size={13} />
|
|
355
|
+
{t('library.deleteSelected')}
|
|
356
|
+
</button>
|
|
357
|
+
<Tooltip label={t('library.cancelSelect')}>
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
onClick={exitSelecting}
|
|
361
|
+
aria-label={t('library.cancelSelect')}
|
|
362
|
+
className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
363
|
+
>
|
|
364
|
+
<X size={15} />
|
|
365
|
+
</button>
|
|
366
|
+
</Tooltip>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
{moving ? (
|
|
373
|
+
<FolderPicker
|
|
374
|
+
title={'project' in moving ? titleOf(moving.project, t) : moving.folder.name}
|
|
375
|
+
folders={folders}
|
|
376
|
+
current={'project' in moving
|
|
377
|
+
? (moving.project.folderId && folders.some((f) => f.id === moving.project.folderId) ? moving.project.folderId : null)
|
|
378
|
+
: parentOf(moving.folder, folders)}
|
|
379
|
+
/* a folder never goes into itself or a folder inside it */
|
|
380
|
+
exclude={'folder' in moving ? new Set([moving.folder.id, ...folders.filter((f) => folderTrail(folders, f.id).some((up) => up.id === moving.folder.id)).map((f) => f.id)]) : new Set()}
|
|
381
|
+
onMove={(fid) => {
|
|
382
|
+
if ('project' in moving) onMoveToFolder(moving.project.id, fid);
|
|
383
|
+
else onMoveFolder(moving.folder.id, fid);
|
|
384
|
+
setMoving(null);
|
|
385
|
+
}}
|
|
386
|
+
onCreate={(name, parentId) => onCreateFolder(name, parentId)}
|
|
387
|
+
onClose={() => setMoving(null)}
|
|
388
|
+
/>
|
|
389
|
+
) : null}
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
interface ItemProps {
|
|
395
|
+
project: LibraryProject;
|
|
396
|
+
/** The name shown ("Untitled" when empty). */
|
|
397
|
+
title: string;
|
|
398
|
+
selecting: boolean;
|
|
399
|
+
selected: boolean;
|
|
400
|
+
renaming: boolean;
|
|
401
|
+
onToggleSelect: () => void;
|
|
402
|
+
onOpen: (event?: { detail: number }) => void;
|
|
403
|
+
onStartRename: () => void;
|
|
404
|
+
onCommitRename: (name: string) => void;
|
|
405
|
+
onCancelRename: () => void;
|
|
406
|
+
onStartSelect: () => void;
|
|
407
|
+
onMoveToFolder: () => void;
|
|
408
|
+
onLocate: () => void;
|
|
409
|
+
onDelete: () => void;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function SelectionCheck({ selected }: { selected: boolean }) {
|
|
413
|
+
return (
|
|
414
|
+
<span
|
|
415
|
+
aria-hidden
|
|
416
|
+
className={`flex h-5 w-5 items-center justify-center rounded-md border transition ${
|
|
417
|
+
selected
|
|
418
|
+
? 'border-[var(--accent)] bg-[var(--accent)] text-[var(--on-accent)]'
|
|
419
|
+
: 'border-white/60 bg-black/35 text-transparent backdrop-blur-sm hover:border-white'
|
|
420
|
+
}`}
|
|
421
|
+
>
|
|
422
|
+
<Check size={12} strokeWidth={2.5} />
|
|
423
|
+
</span>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The tick at the cover's top left: only in select mode. */
|
|
428
|
+
function SelectToggle({
|
|
429
|
+
selecting,
|
|
430
|
+
selected,
|
|
431
|
+
onToggle,
|
|
432
|
+
label,
|
|
433
|
+
}: {
|
|
434
|
+
selecting: boolean;
|
|
435
|
+
selected: boolean;
|
|
436
|
+
onToggle: () => void;
|
|
437
|
+
label: string;
|
|
438
|
+
}) {
|
|
439
|
+
if (!selecting) return null;
|
|
440
|
+
return (
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={(event) => {
|
|
444
|
+
event.stopPropagation();
|
|
445
|
+
onToggle();
|
|
446
|
+
}}
|
|
447
|
+
aria-label={label}
|
|
448
|
+
aria-pressed={selected}
|
|
449
|
+
className="absolute left-2 top-2 z-10 rounded-md"
|
|
450
|
+
>
|
|
451
|
+
<SelectionCheck selected={selected} />
|
|
452
|
+
</button>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ProjectCardTitle({
|
|
457
|
+
display,
|
|
458
|
+
stored,
|
|
459
|
+
renaming,
|
|
460
|
+
size,
|
|
461
|
+
onOpen,
|
|
462
|
+
onCommit,
|
|
463
|
+
onCancel,
|
|
464
|
+
}: {
|
|
465
|
+
display: string;
|
|
466
|
+
stored: string;
|
|
467
|
+
renaming: boolean;
|
|
468
|
+
size: 'card' | 'list';
|
|
469
|
+
onOpen: (event?: { detail: number }) => void;
|
|
470
|
+
onCommit: (title: string) => void;
|
|
471
|
+
onCancel: () => void;
|
|
472
|
+
}) {
|
|
473
|
+
const t = useT();
|
|
474
|
+
const [draft, setDraft] = React.useState(stored);
|
|
475
|
+
const committed = React.useRef(false);
|
|
476
|
+
|
|
477
|
+
React.useEffect(() => {
|
|
478
|
+
if (!renaming) return;
|
|
479
|
+
committed.current = false;
|
|
480
|
+
setDraft(stored);
|
|
481
|
+
}, [renaming, stored]);
|
|
482
|
+
|
|
483
|
+
const textClass =
|
|
484
|
+
size === 'card'
|
|
485
|
+
? 'text-[13px] font-semibold text-[var(--text)]'
|
|
486
|
+
: 'text-[14px] font-semibold text-[var(--text)]';
|
|
487
|
+
|
|
488
|
+
function commit() {
|
|
489
|
+
if (committed.current) return;
|
|
490
|
+
committed.current = true;
|
|
491
|
+
onCommit(limitTitleWidth(draft.replace(/\s+/g, ' ').trim(), TITLE_MAX_WIDTH));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function cancel() {
|
|
495
|
+
if (committed.current) return;
|
|
496
|
+
committed.current = true;
|
|
497
|
+
onCancel();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (renaming) {
|
|
501
|
+
return (
|
|
502
|
+
<input
|
|
503
|
+
autoFocus
|
|
504
|
+
value={draft}
|
|
505
|
+
aria-label={t('projectMenu.rename')}
|
|
506
|
+
placeholder={t('project.untitled')}
|
|
507
|
+
onChange={(event) => {
|
|
508
|
+
const next = event.target.value;
|
|
509
|
+
/* mid-composition (IME) the text is not final: limit it once composition ends */
|
|
510
|
+
if ((event.nativeEvent as InputEvent).isComposing) {
|
|
511
|
+
setDraft(next);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
setDraft(limitTitleWidth(next, TITLE_MAX_WIDTH));
|
|
515
|
+
}}
|
|
516
|
+
onCompositionEnd={(event) => {
|
|
517
|
+
setDraft(limitTitleWidth(event.currentTarget.value, TITLE_MAX_WIDTH));
|
|
518
|
+
}}
|
|
519
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
520
|
+
onBlur={() => commit()}
|
|
521
|
+
onKeyDown={(event) => {
|
|
522
|
+
if (event.key === 'Enter') {
|
|
523
|
+
if (event.nativeEvent.isComposing) return;
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
commit();
|
|
526
|
+
}
|
|
527
|
+
if (event.key === 'Escape') {
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
/* the popover would close on this Escape too */
|
|
530
|
+
event.stopPropagation();
|
|
531
|
+
cancel();
|
|
532
|
+
}
|
|
533
|
+
}}
|
|
534
|
+
onClick={(event) => event.stopPropagation()}
|
|
535
|
+
className={`box-border w-full min-w-0 bg-transparent outline-none placeholder:text-[var(--text-faint)] ${textClass}`}
|
|
536
|
+
/>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<button type="button" onClick={onOpen} className="min-w-0 w-full text-left">
|
|
542
|
+
{/* the folder's name as it is: two spaces stay two (HTML would show one) */}
|
|
543
|
+
<h2 className={`truncate ${textClass}`} style={{ whiteSpace: 'pre' }}>{display}</h2>
|
|
544
|
+
</button>
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
type NewProjectProps = {
|
|
549
|
+
creating: boolean;
|
|
550
|
+
onNewProject: () => void;
|
|
551
|
+
/* starting from a film that exists: at the top level only (what they make is not filed in a folder) */
|
|
552
|
+
onOpenFolder?: () => void;
|
|
553
|
+
onGetFromGitHub?: () => void;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/** The grid's first cell: start a new project, card-sized, so "new ones start here" reads at a glance. */
|
|
557
|
+
function NewProjectCard({ creating, onNewProject, onOpenFolder, onGetFromGitHub }: NewProjectProps) {
|
|
558
|
+
const t = useT();
|
|
559
|
+
return (
|
|
560
|
+
<article className="group min-w-0">
|
|
561
|
+
<button
|
|
562
|
+
type="button"
|
|
563
|
+
onClick={onNewProject}
|
|
564
|
+
disabled={creating}
|
|
565
|
+
aria-busy={creating}
|
|
566
|
+
className="flex aspect-video w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[var(--border-strong)] text-[var(--text-muted)] transition hover:border-[var(--text-faint)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)] disabled:opacity-60"
|
|
567
|
+
>
|
|
568
|
+
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--surface-2)] text-[var(--text)] transition group-hover:scale-105">
|
|
569
|
+
<Plus size={18} strokeWidth={2.2} />
|
|
570
|
+
</span>
|
|
571
|
+
<span className="text-[13px] font-medium">{t('projects.newProject')}</span>
|
|
572
|
+
</button>
|
|
573
|
+
{/* where the other cards have their name: starting from a film that exists */}
|
|
574
|
+
<div className="mt-2 flex">
|
|
575
|
+
{onOpenFolder && onGetFromGitHub ? <ImportMenu creating={creating} onOpenFolder={onOpenFolder} onGetFromGitHub={onGetFromGitHub} className="-ml-1.5 h-7 px-1.5 text-[12.5px]" /> : null}
|
|
576
|
+
</div>
|
|
577
|
+
</article>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** "New project" in the list view: the same entries, as a row. */
|
|
582
|
+
function NewProjectListRow({ creating, onNewProject, onOpenFolder, onGetFromGitHub }: NewProjectProps) {
|
|
583
|
+
const t = useT();
|
|
584
|
+
return (
|
|
585
|
+
<article className="group flex min-w-0 items-center gap-3 py-4 sm:gap-4">
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
onClick={onNewProject}
|
|
589
|
+
disabled={creating}
|
|
590
|
+
aria-busy={creating}
|
|
591
|
+
className="flex aspect-video w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-[var(--border-strong)] text-[var(--text-muted)] transition hover:border-[var(--text-faint)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)] disabled:opacity-60 sm:w-36"
|
|
592
|
+
aria-label={t('projects.newProject')}
|
|
593
|
+
>
|
|
594
|
+
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface-2)] text-[var(--text)] transition group-hover:scale-105">
|
|
595
|
+
<Plus size={16} strokeWidth={2.2} />
|
|
596
|
+
</span>
|
|
597
|
+
</button>
|
|
598
|
+
<div className="min-w-0 flex-1">
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
onClick={onNewProject}
|
|
602
|
+
disabled={creating}
|
|
603
|
+
className="text-left text-[14px] font-medium text-[var(--text)] hover:underline disabled:opacity-60"
|
|
604
|
+
>
|
|
605
|
+
{t('projects.newProject')}
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
{onOpenFolder && onGetFromGitHub ? <ImportMenu creating={creating} onOpenFolder={onOpenFolder} onGetFromGitHub={onGetFromGitHub} className="h-8 px-2.5 text-[12.5px]" /> : null}
|
|
609
|
+
</article>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** "Import from ⌄": the ways to start from a film that exists, a folder on this computer or a repository on GitHub. */
|
|
614
|
+
function ImportMenu({ creating, onOpenFolder, onGetFromGitHub, className }: Required<Omit<NewProjectProps, 'onNewProject'>> & { className: string }) {
|
|
615
|
+
const t = useT();
|
|
616
|
+
const [anchor, setAnchor] = React.useState<{ x: number; y: number; top: number } | null>(null);
|
|
617
|
+
const button = React.useRef<HTMLButtonElement>(null);
|
|
618
|
+
return (
|
|
619
|
+
<>
|
|
620
|
+
<button
|
|
621
|
+
ref={button}
|
|
622
|
+
type="button"
|
|
623
|
+
disabled={creating}
|
|
624
|
+
aria-expanded={anchor != null}
|
|
625
|
+
aria-haspopup="menu"
|
|
626
|
+
onClick={(event) => {
|
|
627
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
628
|
+
setAnchor((cur) => (cur ? null : { x: box.left, y: box.bottom + 4, top: box.top - 4 }));
|
|
629
|
+
}}
|
|
630
|
+
className={`inline-flex shrink-0 items-center gap-1 rounded-md text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)] disabled:opacity-60 ${className}`}
|
|
631
|
+
>
|
|
632
|
+
{t('projects.importFrom')}
|
|
633
|
+
<ChevronDown size={13} />
|
|
634
|
+
</button>
|
|
635
|
+
{anchor ? (
|
|
636
|
+
<ContextMenu
|
|
637
|
+
x={anchor.x}
|
|
638
|
+
y={anchor.y}
|
|
639
|
+
flipTo={anchor.top}
|
|
640
|
+
trigger={button}
|
|
641
|
+
onClose={() => setAnchor(null)}
|
|
642
|
+
items={[
|
|
643
|
+
{ id: 'folder', icon: <FolderOpen size={14} />, label: t('projects.openFolder'), onSelect: onOpenFolder },
|
|
644
|
+
{ id: 'github', icon: <Github size={14} />, label: t('projects.getFromGitHub'), onSelect: onGetFromGitHub },
|
|
645
|
+
]}
|
|
646
|
+
/>
|
|
647
|
+
) : null}
|
|
648
|
+
</>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function ProjectCard({
|
|
653
|
+
project,
|
|
654
|
+
title,
|
|
655
|
+
selecting,
|
|
656
|
+
selected,
|
|
657
|
+
renaming,
|
|
658
|
+
onToggleSelect,
|
|
659
|
+
onOpen,
|
|
660
|
+
onStartRename,
|
|
661
|
+
onCommitRename,
|
|
662
|
+
onCancelRename,
|
|
663
|
+
onStartSelect,
|
|
664
|
+
onMoveToFolder,
|
|
665
|
+
onLocate,
|
|
666
|
+
onDelete,
|
|
667
|
+
}: ItemProps) {
|
|
668
|
+
const t = useT();
|
|
669
|
+
const language = useLanguage();
|
|
670
|
+
const open = selecting ? onToggleSelect : onOpen;
|
|
671
|
+
return (
|
|
672
|
+
<article className="group min-w-0">
|
|
673
|
+
<div className="relative">
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
onClick={open}
|
|
677
|
+
className="relative block aspect-video w-full overflow-hidden rounded-lg bg-[var(--surface-2)] text-left ring-1 ring-[var(--border)] transition duration-200 hover:brightness-[1.04]"
|
|
678
|
+
aria-label={title}
|
|
679
|
+
aria-pressed={selecting ? selected : undefined}
|
|
680
|
+
>
|
|
681
|
+
<PosterMedia project={project} shade />
|
|
682
|
+
{/* chosen: an outline drawn inside the card, over its picture, so the list's edge never cuts it */}
|
|
683
|
+
{selected ? <span aria-hidden className="pointer-events-none absolute inset-0 z-[1] rounded-lg ring-2 ring-inset ring-[var(--accent)]" /> : null}
|
|
684
|
+
{project.missing ? <span aria-hidden className="absolute inset-0 bg-black/45" /> : null}
|
|
685
|
+
{project.duration && !project.missing ? (
|
|
686
|
+
<div className="absolute bottom-2 right-2 text-[10.5px] font-medium text-white/80">
|
|
687
|
+
<span className="flex items-center gap-1 rounded bg-black/40 px-1.5 py-0.5 tabular-nums backdrop-blur-sm">
|
|
688
|
+
<Clock3 size={10} />
|
|
689
|
+
{formatDuration(project.duration)}
|
|
690
|
+
</span>
|
|
691
|
+
</div>
|
|
692
|
+
) : null}
|
|
693
|
+
</button>
|
|
694
|
+
<SelectToggle selecting={selecting} selected={selected} onToggle={onToggleSelect} label={t('library.toggleSelect')} />
|
|
695
|
+
{project.missing && !selecting ? <MissingActions onLocate={onLocate} onRemove={onDelete} /> : null}
|
|
696
|
+
</div>
|
|
697
|
+
|
|
698
|
+
<div className="mt-2 flex min-w-0 items-start gap-1.5">
|
|
699
|
+
<div className="min-w-0 flex-1 text-left">
|
|
700
|
+
<ProjectCardTitle
|
|
701
|
+
display={title}
|
|
702
|
+
stored={project.name}
|
|
703
|
+
renaming={renaming}
|
|
704
|
+
size="card"
|
|
705
|
+
onOpen={open}
|
|
706
|
+
onCommit={onCommitRename}
|
|
707
|
+
onCancel={onCancelRename}
|
|
708
|
+
/>
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
onClick={open}
|
|
712
|
+
className="mt-0.5 flex w-full items-center gap-1.5 text-left text-[11.5px] text-[var(--text-faint)]"
|
|
713
|
+
>
|
|
714
|
+
<Video size={11} />
|
|
715
|
+
{formatDate(project.openedAt, language)}
|
|
716
|
+
{project.missing ? <span className="truncate text-[var(--err)]">· {t('projects.missing')}</span> : null}
|
|
717
|
+
{project.working ? <span className="truncate text-[var(--accent)]">· {t('projects.agentRunning')}</span> : null}
|
|
718
|
+
</button>
|
|
719
|
+
</div>
|
|
720
|
+
{!selecting ? <ItemMenu project={project} onOpen={onOpen} onStartRename={onStartRename} onStartSelect={onStartSelect} onMoveToFolder={onMoveToFolder} onLocate={onLocate} onDelete={onDelete} size={14} /> : null}
|
|
721
|
+
</div>
|
|
722
|
+
</article>
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* On the cover of a project whose folder is gone: "Folder not found", and the two things to do about it: point it at
|
|
728
|
+
* the folder it is in now (it keeps its place, and its history goes with the folder), or take it off the list.
|
|
729
|
+
*/
|
|
730
|
+
function MissingActions({ onLocate, onRemove, compact = false }: { onLocate: () => void; onRemove: () => void; compact?: boolean }) {
|
|
731
|
+
const t = useT();
|
|
732
|
+
const button = 'inline-flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-[12px] font-medium transition';
|
|
733
|
+
return (
|
|
734
|
+
<div className={compact ? 'flex items-center gap-1' : 'pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2 p-2'}>
|
|
735
|
+
{compact ? null : <span className="text-[12px] font-semibold text-white drop-shadow">{t('projects.missing')}</span>}
|
|
736
|
+
<div className="pointer-events-auto flex items-center gap-1.5">
|
|
737
|
+
<button type="button" onClick={onLocate} className={`${button} bg-[var(--surface)] text-[var(--text)] shadow hover:brightness-95`}>
|
|
738
|
+
<FolderSearch size={13} />
|
|
739
|
+
{t('projects.locate')}
|
|
740
|
+
</button>
|
|
741
|
+
<button
|
|
742
|
+
type="button"
|
|
743
|
+
onClick={onRemove}
|
|
744
|
+
className={`${button} ${compact ? 'text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text)]' : 'bg-black/40 text-white backdrop-blur-sm hover:bg-black/55'}`}
|
|
745
|
+
>
|
|
746
|
+
{t('projects.removeFromList')}
|
|
747
|
+
</button>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* A card's or row's "…", with Reveal to show where its folder is. A project whose folder is gone can be located or
|
|
755
|
+
* leave the list: there is nothing to open, rename or reveal.
|
|
756
|
+
*/
|
|
757
|
+
function ItemMenu({
|
|
758
|
+
project,
|
|
759
|
+
onOpen,
|
|
760
|
+
onStartRename,
|
|
761
|
+
onStartSelect,
|
|
762
|
+
onMoveToFolder,
|
|
763
|
+
onLocate,
|
|
764
|
+
onDelete,
|
|
765
|
+
size,
|
|
766
|
+
}: Pick<ItemProps, 'project' | 'onOpen' | 'onStartRename' | 'onStartSelect' | 'onMoveToFolder' | 'onLocate' | 'onDelete'> & { size: number }) {
|
|
767
|
+
const cover = React.useContext(ProjectCoverContext);
|
|
768
|
+
if (project.missing) return <ProjectMenu onLocate={onLocate} onRemove={onDelete} size={size} />;
|
|
769
|
+
return (
|
|
770
|
+
<ProjectMenu
|
|
771
|
+
onOpen={onOpen}
|
|
772
|
+
onRename={onStartRename}
|
|
773
|
+
onSelect={onStartSelect}
|
|
774
|
+
onMoveToFolder={onMoveToFolder}
|
|
775
|
+
onChangeCover={cover ? () => cover.change(project) : undefined}
|
|
776
|
+
onRestoreCover={cover && project.cover ? () => cover.restore(project) : undefined}
|
|
777
|
+
onReveal={() => void api.revealProject(project.id).catch(() => {})}
|
|
778
|
+
onDelete={onDelete}
|
|
779
|
+
size={size}
|
|
780
|
+
/>
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** A row in the list view; the folder's path stands where a description would. */
|
|
785
|
+
function ProjectListRow({
|
|
786
|
+
project,
|
|
787
|
+
title,
|
|
788
|
+
selecting,
|
|
789
|
+
selected,
|
|
790
|
+
renaming,
|
|
791
|
+
onToggleSelect,
|
|
792
|
+
onOpen,
|
|
793
|
+
onStartRename,
|
|
794
|
+
onCommitRename,
|
|
795
|
+
onCancelRename,
|
|
796
|
+
onStartSelect,
|
|
797
|
+
onMoveToFolder,
|
|
798
|
+
onLocate,
|
|
799
|
+
onDelete,
|
|
800
|
+
}: ItemProps) {
|
|
801
|
+
const t = useT();
|
|
802
|
+
const language = useLanguage();
|
|
803
|
+
const open = selecting ? onToggleSelect : onOpen;
|
|
804
|
+
return (
|
|
805
|
+
<article className={`group flex min-w-0 items-center gap-3 py-4 sm:gap-4 ${selected ? 'bg-[var(--bg-hover)]/40' : ''}`}>
|
|
806
|
+
<div className="relative shrink-0">
|
|
807
|
+
<button
|
|
808
|
+
type="button"
|
|
809
|
+
onClick={open}
|
|
810
|
+
className="relative block aspect-video w-28 overflow-hidden rounded-md bg-[var(--surface-2)] ring-1 ring-[var(--border)] sm:w-36"
|
|
811
|
+
aria-label={title}
|
|
812
|
+
>
|
|
813
|
+
<PosterMedia project={project} />
|
|
814
|
+
{selected ? <span aria-hidden className="pointer-events-none absolute inset-0 z-[1] rounded-md ring-2 ring-inset ring-[var(--accent)]" /> : null}
|
|
815
|
+
</button>
|
|
816
|
+
<SelectToggle selecting={selecting} selected={selected} onToggle={onToggleSelect} label={t('library.toggleSelect')} />
|
|
817
|
+
</div>
|
|
818
|
+
<div className="min-w-0 flex-1 text-left">
|
|
819
|
+
<ProjectCardTitle
|
|
820
|
+
display={title}
|
|
821
|
+
stored={project.name}
|
|
822
|
+
renaming={renaming}
|
|
823
|
+
size="list"
|
|
824
|
+
onOpen={open}
|
|
825
|
+
onCommit={onCommitRename}
|
|
826
|
+
onCancel={onCancelRename}
|
|
827
|
+
/>
|
|
828
|
+
<button type="button" onClick={open} className="w-full text-left">
|
|
829
|
+
<p className="mt-1 line-clamp-2 break-all text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
|
830
|
+
{project.path}
|
|
831
|
+
</p>
|
|
832
|
+
<p className="mt-2 flex items-center gap-2 text-[11.5px] text-[var(--text-faint)]">
|
|
833
|
+
{formatDate(project.openedAt, language)}
|
|
834
|
+
{project.missing ? <span className="text-[var(--err)]">· {t('projects.missing')}</span> : null}
|
|
835
|
+
{project.working ? <span className="text-[var(--accent)]">· {t('projects.agentRunning')}</span> : null}
|
|
836
|
+
</p>
|
|
837
|
+
</button>
|
|
838
|
+
</div>
|
|
839
|
+
{project.missing && !selecting ? <MissingActions compact onLocate={onLocate} onRemove={onDelete} /> : null}
|
|
840
|
+
{!selecting ? <ItemMenu project={project} onOpen={onOpen} onStartRename={onStartRename} onStartSelect={onStartSelect} onMoveToFolder={onMoveToFolder} onLocate={onLocate} onDelete={onDelete} size={15} /> : null}
|
|
841
|
+
</article>
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* A folder's picture: a folder with some depth (a back panel with its tab, the front panel over it, a highlight along
|
|
847
|
+
* its top). Not lucide's outline Folder: an outline drawn for 16 px buttons, blown up to a third of a card, looks like
|
|
848
|
+
* a placeholder. Three grays (`--folder-*`) per theme and no hue: the covers around it carry the color.
|
|
849
|
+
*
|
|
850
|
+
* Its width comes from `className`; the height follows at 72:56.
|
|
851
|
+
*/
|
|
852
|
+
function FolderGlyph({ className }: { className?: string }) {
|
|
853
|
+
const id = React.useId();
|
|
854
|
+
return (
|
|
855
|
+
<svg viewBox="0 0 72 56" aria-hidden className={className}>
|
|
856
|
+
<defs>
|
|
857
|
+
<linearGradient id={`${id}-front`} x1="0" y1="0" x2="0" y2="1">
|
|
858
|
+
<stop offset="0" stopColor="var(--folder-front-hi)" />
|
|
859
|
+
<stop offset="0.18" stopColor="var(--folder-front)" />
|
|
860
|
+
<stop offset="1" stopColor="var(--folder-front)" />
|
|
861
|
+
</linearGradient>
|
|
862
|
+
</defs>
|
|
863
|
+
<path
|
|
864
|
+
d="M2 12c0-3.3 2.7-6 6-6h16.6c1.6 0 3.1.6 4.2 1.8l3.4 3.4c.8.8 1.9 1.2 3 1.2H64c3.3 0 6 2.7 6 6V48c0 3.3-2.7 6-6 6H8c-3.3 0-6-2.7-6-6Z"
|
|
865
|
+
fill="var(--folder-back)"
|
|
866
|
+
/>
|
|
867
|
+
<rect x="2" y="20" width="68" height="34" rx="6" fill={`url(#${id}-front)`} />
|
|
868
|
+
</svg>
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
interface FolderItemProps {
|
|
873
|
+
folder: ProjectFolder;
|
|
874
|
+
count: number;
|
|
875
|
+
/** its newest projects, up to four, with their covers when they have one */
|
|
876
|
+
posters: { id: string; poster?: string }[];
|
|
877
|
+
renaming: boolean;
|
|
878
|
+
onOpen: (event?: { detail: number }) => void;
|
|
879
|
+
onStartRename: () => void;
|
|
880
|
+
onCommitRename: (name: string) => void;
|
|
881
|
+
onCancelRename: () => void;
|
|
882
|
+
onDelete: () => void;
|
|
883
|
+
onMove: () => void;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* A folder's face: the covers of its newest projects (as many cells as covers: one fills the face, three put the first
|
|
888
|
+
* beside the other two, so no cell is blank), or the folder picture when it holds none. The card's, and the list row's
|
|
889
|
+
* thumbnail (`small`: tighter margins).
|
|
890
|
+
*/
|
|
891
|
+
function FolderFace({ posters, small = false }: { posters: FolderItemProps['posters']; small?: boolean }) {
|
|
892
|
+
if (!posters.length) {
|
|
893
|
+
return (
|
|
894
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
895
|
+
<FolderGlyph className="w-[34%] transition-transform duration-200 group-hover:-translate-y-0.5" />
|
|
896
|
+
</div>
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
return (
|
|
900
|
+
<div className={`absolute grid ${small ? 'inset-1.5 gap-1' : 'inset-3 gap-1.5'} ${posters.length === 1 ? 'grid-cols-1' : posters.length === 2 ? 'grid-cols-2' : 'grid-cols-2 grid-rows-2'}`}>
|
|
901
|
+
{posters.slice(0, 4).map((cover, i) => (
|
|
902
|
+
<div key={cover.id} className={`relative overflow-hidden ${small ? 'rounded-[3px]' : 'rounded-[5px]'} bg-[var(--surface-3)] ${posters.length === 3 && i === 0 ? 'row-span-2' : ''}`}>
|
|
903
|
+
<MiniPoster id={cover.id} url={cover.poster} />
|
|
904
|
+
</div>
|
|
905
|
+
))}
|
|
906
|
+
</div>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* A folder's 16:9 box drawn as a folder, so it doesn't read as a project: the back's tab (`--folder-back`, as in the
|
|
912
|
+
* folder picture) at the top left, and the front under it, which holds the face and anything over it (`children`).
|
|
913
|
+
* The card's, and the list row's thumbnail (`small`).
|
|
914
|
+
*/
|
|
915
|
+
function FolderFrame({ small = false, children }: { small?: boolean; children: React.ReactNode }) {
|
|
916
|
+
return (
|
|
917
|
+
<>
|
|
918
|
+
<span aria-hidden className={`absolute left-0 top-0 h-1/2 w-[38%] bg-[var(--folder-back)] ${small ? 'rounded-[5px]' : 'rounded-lg'}`} />
|
|
919
|
+
<span
|
|
920
|
+
className={`absolute inset-x-0 bottom-0 overflow-hidden bg-[var(--surface-2)] ring-1 ring-[var(--border)] transition duration-200 group-hover/frame:bg-[var(--surface-3)] group-hover/frame:ring-[var(--border-strong)] ${small ? 'top-[9%] rounded-md' : 'top-[7%] rounded-lg'}`}
|
|
921
|
+
>
|
|
922
|
+
{children}
|
|
923
|
+
</span>
|
|
924
|
+
</>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* A folder card: the project card's size (a 16:9 box and a name under it), drawn as a folder (FolderFrame), its face
|
|
930
|
+
* the covers of its newest projects, so what it holds shows at a glance; an empty one shows the folder picture.
|
|
931
|
+
*/
|
|
932
|
+
function FolderCard({
|
|
933
|
+
folder,
|
|
934
|
+
count,
|
|
935
|
+
posters,
|
|
936
|
+
renaming,
|
|
937
|
+
onOpen,
|
|
938
|
+
onStartRename,
|
|
939
|
+
onCommitRename,
|
|
940
|
+
onCancelRename,
|
|
941
|
+
onDelete,
|
|
942
|
+
onMove,
|
|
943
|
+
}: FolderItemProps) {
|
|
944
|
+
const t = useT();
|
|
945
|
+
const language = useLanguage();
|
|
946
|
+
return (
|
|
947
|
+
<article className="group min-w-0">
|
|
948
|
+
<button
|
|
949
|
+
type="button"
|
|
950
|
+
onClick={onOpen}
|
|
951
|
+
aria-label={folder.name}
|
|
952
|
+
className="group/frame relative block aspect-video w-full text-left"
|
|
953
|
+
>
|
|
954
|
+
<FolderFrame>
|
|
955
|
+
<FolderFace posters={posters} />
|
|
956
|
+
{/* the count: dark glass over covers; on the plain face a dark patch is too heavy, so the face's own style */}
|
|
957
|
+
<span
|
|
958
|
+
className={`absolute bottom-2 right-2 rounded-full px-1.5 py-0.5 text-[10.5px] font-medium tabular-nums ${
|
|
959
|
+
posters.length > 0
|
|
960
|
+
? 'bg-black/45 text-white/90 backdrop-blur-sm'
|
|
961
|
+
: 'border border-[var(--border)] bg-[var(--surface)] text-[var(--text-muted)]'
|
|
962
|
+
}`}
|
|
963
|
+
>
|
|
964
|
+
{count}
|
|
965
|
+
</span>
|
|
966
|
+
</FolderFrame>
|
|
967
|
+
</button>
|
|
968
|
+
{/* the same two lines as a project card (name, then a dated line); no folder icon before the name: the face says
|
|
969
|
+
it is a folder already, and an icon would put this name out of line with the projects'. A click on the name
|
|
970
|
+
opens the folder, as a project's opens the project, so it is renamed from "…" or from the open folder's
|
|
971
|
+
title, not by a double-click (the first click would already have opened it) */}
|
|
972
|
+
<div className="mt-2 flex min-w-0 items-start gap-1.5">
|
|
973
|
+
<div className="min-w-0 flex-1 text-left">
|
|
974
|
+
{renaming ? (
|
|
975
|
+
<InlineNameInput
|
|
976
|
+
initial={folder.name}
|
|
977
|
+
onCommit={onCommitRename}
|
|
978
|
+
onCancel={onCancelRename}
|
|
979
|
+
className="h-6 w-full rounded-md border border-[var(--text)] bg-[var(--surface)] px-1.5 text-[13px] font-semibold text-[var(--text)] outline-none"
|
|
980
|
+
/>
|
|
981
|
+
) : (
|
|
982
|
+
<button
|
|
983
|
+
type="button"
|
|
984
|
+
onClick={onOpen}
|
|
985
|
+
className="block w-full truncate text-left text-[13px] font-semibold text-[var(--text)]"
|
|
986
|
+
>
|
|
987
|
+
{folder.name}
|
|
988
|
+
</button>
|
|
989
|
+
)}
|
|
990
|
+
<button
|
|
991
|
+
type="button"
|
|
992
|
+
onClick={onOpen}
|
|
993
|
+
className="mt-0.5 flex w-full items-center gap-1.5 text-left text-[11.5px] text-[var(--text-faint)]"
|
|
994
|
+
>
|
|
995
|
+
<Clock3 size={11} />
|
|
996
|
+
{formatDate(folder.createdAt, language)}
|
|
997
|
+
</button>
|
|
998
|
+
</div>
|
|
999
|
+
<FolderMenu onRename={onStartRename} onMove={onMove} onDelete={onDelete} />
|
|
1000
|
+
</div>
|
|
1001
|
+
<span className="sr-only">{t('projects.folderAll')}</span>
|
|
1002
|
+
</article>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* One of a folder card's small covers: the project's poster, or (none, or it failed to load) the same face its own
|
|
1008
|
+
* card shows. The cell stays plain while the poster loads.
|
|
1009
|
+
*/
|
|
1010
|
+
function MiniPoster({ id, url }: { id: string; url?: string }) {
|
|
1011
|
+
const [state, setState] = React.useState<'loading' | 'ready' | 'failed'>('loading');
|
|
1012
|
+
if (!url || state === 'failed') return <div className="absolute inset-0 opacity-80" style={{ background: gradientForId(id) }} />;
|
|
1013
|
+
return (
|
|
1014
|
+
<img
|
|
1015
|
+
src={url}
|
|
1016
|
+
alt=""
|
|
1017
|
+
loading="lazy"
|
|
1018
|
+
draggable={false}
|
|
1019
|
+
onLoad={() => setState('ready')}
|
|
1020
|
+
onError={() => setState('failed')}
|
|
1021
|
+
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-200 ${state === 'ready' ? 'opacity-100' : 'opacity-0'}`}
|
|
1022
|
+
/>
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** A folder in the list view; its thumbnail is the project rows' size, so the column of thumbnails lines up. */
|
|
1027
|
+
function FolderListRow({
|
|
1028
|
+
folder,
|
|
1029
|
+
count,
|
|
1030
|
+
posters,
|
|
1031
|
+
renaming,
|
|
1032
|
+
onOpen,
|
|
1033
|
+
onStartRename,
|
|
1034
|
+
onCommitRename,
|
|
1035
|
+
onCancelRename,
|
|
1036
|
+
onDelete,
|
|
1037
|
+
onMove,
|
|
1038
|
+
}: FolderItemProps) {
|
|
1039
|
+
const t = useT();
|
|
1040
|
+
const language = useLanguage();
|
|
1041
|
+
return (
|
|
1042
|
+
<div className="group flex min-w-0 items-center gap-3 py-4 sm:gap-4">
|
|
1043
|
+
<button
|
|
1044
|
+
type="button"
|
|
1045
|
+
onClick={onOpen}
|
|
1046
|
+
className="group/frame relative block aspect-video w-28 shrink-0 sm:w-36"
|
|
1047
|
+
aria-label={folder.name}
|
|
1048
|
+
>
|
|
1049
|
+
<FolderFrame small>
|
|
1050
|
+
<FolderFace posters={posters} small />
|
|
1051
|
+
</FolderFrame>
|
|
1052
|
+
</button>
|
|
1053
|
+
<div className="min-w-0 flex-1">
|
|
1054
|
+
{renaming ? (
|
|
1055
|
+
<InlineNameInput
|
|
1056
|
+
initial={folder.name}
|
|
1057
|
+
onCommit={onCommitRename}
|
|
1058
|
+
onCancel={onCancelRename}
|
|
1059
|
+
className="h-7 w-[240px] rounded-md border border-[var(--text)] bg-[var(--surface)] px-2 text-[14px] font-semibold text-[var(--text)] outline-none"
|
|
1060
|
+
/>
|
|
1061
|
+
) : (
|
|
1062
|
+
<button
|
|
1063
|
+
type="button"
|
|
1064
|
+
onClick={onOpen}
|
|
1065
|
+
className="block max-w-full truncate text-left text-[14px] font-semibold text-[var(--text)]"
|
|
1066
|
+
>
|
|
1067
|
+
{folder.name}
|
|
1068
|
+
</button>
|
|
1069
|
+
)}
|
|
1070
|
+
<p className="mt-1 text-[12.5px] leading-relaxed text-[var(--text-muted)]">
|
|
1071
|
+
{t(count === 1 ? 'projects.folderCountOne' : 'projects.folderCount').replace('{n}', String(count))}
|
|
1072
|
+
</p>
|
|
1073
|
+
<p className="mt-2 text-[11.5px] text-[var(--text-faint)]">
|
|
1074
|
+
{formatDate(folder.createdAt, language)}
|
|
1075
|
+
</p>
|
|
1076
|
+
</div>
|
|
1077
|
+
<FolderMenu onRename={onStartRename} onMove={onMove} onDelete={onDelete} />
|
|
1078
|
+
</div>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** A folder's "…": rename, delete. The project card's menu, in looks and placement (see ProjectMenu). */
|
|
1083
|
+
function FolderMenu({ onRename, onMove, onDelete }: { onRename: () => void; onMove: () => void; onDelete: () => void }) {
|
|
1084
|
+
const t = useT();
|
|
1085
|
+
const [anchor, setAnchor] = React.useState<{ x: number; y: number; top: number } | null>(null);
|
|
1086
|
+
const button = React.useRef<HTMLButtonElement>(null);
|
|
1087
|
+
return (
|
|
1088
|
+
<>
|
|
1089
|
+
<button
|
|
1090
|
+
ref={button}
|
|
1091
|
+
type="button"
|
|
1092
|
+
aria-label={t('projectMenu.more')}
|
|
1093
|
+
aria-expanded={anchor != null}
|
|
1094
|
+
aria-haspopup="menu"
|
|
1095
|
+
onClick={(event) => {
|
|
1096
|
+
event.stopPropagation();
|
|
1097
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
1098
|
+
setAnchor((cur) => (cur ? null : { x: box.right, y: box.bottom + 4, top: box.top - 4 }));
|
|
1099
|
+
}}
|
|
1100
|
+
className="flex h-[26px] w-[26px] shrink-0 items-center justify-center rounded-md text-[var(--text-dim)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
1101
|
+
>
|
|
1102
|
+
<MoreHorizontal size={14} />
|
|
1103
|
+
</button>
|
|
1104
|
+
{anchor ? (
|
|
1105
|
+
<ContextMenu
|
|
1106
|
+
x={anchor.x}
|
|
1107
|
+
y={anchor.y}
|
|
1108
|
+
flipTo={anchor.top}
|
|
1109
|
+
align="end"
|
|
1110
|
+
trigger={button}
|
|
1111
|
+
onClose={() => setAnchor(null)}
|
|
1112
|
+
items={[
|
|
1113
|
+
{ id: 'rename', icon: <Pencil size={14} />, label: t('projects.renameFolder'), onSelect: onRename },
|
|
1114
|
+
{ id: 'move', icon: <FolderInput size={14} />, label: t('projects.moveTo'), onSelect: onMove },
|
|
1115
|
+
{ id: 'sep', separator: true },
|
|
1116
|
+
{ id: 'delete', icon: <Trash2 size={14} />, label: t('projects.deleteFolder'), danger: true, onSelect: onDelete },
|
|
1117
|
+
]}
|
|
1118
|
+
/>
|
|
1119
|
+
) : null}
|
|
1120
|
+
</>
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Inside a folder, in the title's place: "← Projects / Clients / Acme count …", each folder above it a way back to
|
|
1126
|
+
* it, the arrow one level up. Double-click the name to rename.
|
|
1127
|
+
*/
|
|
1128
|
+
function FolderCrumb({
|
|
1129
|
+
folder,
|
|
1130
|
+
above,
|
|
1131
|
+
count,
|
|
1132
|
+
onGo,
|
|
1133
|
+
onRename,
|
|
1134
|
+
onDelete,
|
|
1135
|
+
onMove,
|
|
1136
|
+
}: {
|
|
1137
|
+
folder: ProjectFolder;
|
|
1138
|
+
/** the folders it is in, the outermost first */
|
|
1139
|
+
above: ProjectFolder[];
|
|
1140
|
+
count: number;
|
|
1141
|
+
/** go to a folder of the trail (null: all projects) */
|
|
1142
|
+
onGo: (folderId: string | null) => void;
|
|
1143
|
+
onRename: (name: string) => void;
|
|
1144
|
+
onDelete: () => void;
|
|
1145
|
+
onMove: () => void;
|
|
1146
|
+
}) {
|
|
1147
|
+
const t = useT();
|
|
1148
|
+
const [renaming, setRenaming] = React.useState(false);
|
|
1149
|
+
const step = 'inline-flex h-8 min-w-0 items-center rounded-lg px-1.5 text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]';
|
|
1150
|
+
return (
|
|
1151
|
+
<h2 className="mr-auto flex min-w-0 items-center gap-1 text-[17px] font-semibold tracking-[-0.01em]">
|
|
1152
|
+
<button type="button" onClick={() => onGo(above.length ? above[above.length - 1]!.id : null)} aria-label={t('projects.folderUp')}
|
|
1153
|
+
className="-ml-1.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]">
|
|
1154
|
+
<ArrowLeft size={15} />
|
|
1155
|
+
</button>
|
|
1156
|
+
<button type="button" onClick={() => onGo(null)} className={`${step} shrink-0`}>{t('nav.projects')}</button>
|
|
1157
|
+
{above.map((up) => (
|
|
1158
|
+
<React.Fragment key={up.id}>
|
|
1159
|
+
<span className="shrink-0 font-normal text-[var(--text-faint)]">/</span>
|
|
1160
|
+
<button type="button" onClick={() => onGo(up.id)} className={`${step} max-w-[160px]`}><span className="truncate">{up.name}</span></button>
|
|
1161
|
+
</React.Fragment>
|
|
1162
|
+
))}
|
|
1163
|
+
<span className="shrink-0 font-normal text-[var(--text-faint)]">/</span>
|
|
1164
|
+
<Folder size={16} strokeWidth={1.9} className="shrink-0 text-[var(--text-faint)]" />
|
|
1165
|
+
{renaming ? (
|
|
1166
|
+
<InlineNameInput
|
|
1167
|
+
initial={folder.name}
|
|
1168
|
+
onCommit={(name) => {
|
|
1169
|
+
setRenaming(false);
|
|
1170
|
+
if (name && name !== folder.name) onRename(name);
|
|
1171
|
+
}}
|
|
1172
|
+
onCancel={() => setRenaming(false)}
|
|
1173
|
+
className="h-8 w-[220px] rounded-lg border border-[var(--text)] bg-[var(--surface)] px-2.5 text-[15px] font-semibold text-[var(--text)] outline-none"
|
|
1174
|
+
/>
|
|
1175
|
+
) : (
|
|
1176
|
+
<button
|
|
1177
|
+
type="button"
|
|
1178
|
+
onDoubleClick={() => setRenaming(true)}
|
|
1179
|
+
className="min-w-0 truncate text-[var(--text)]"
|
|
1180
|
+
title={t('projects.renameFolder')}
|
|
1181
|
+
>
|
|
1182
|
+
{folder.name}
|
|
1183
|
+
</button>
|
|
1184
|
+
)}
|
|
1185
|
+
<span className="shrink-0 text-[12.5px] font-normal tabular-nums text-[var(--text-faint)]">{count}</span>
|
|
1186
|
+
<FolderMenu onRename={() => setRenaming(true)} onMove={onMove} onDelete={onDelete} />
|
|
1187
|
+
</h2>
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** A name typed in place: Enter or leaving the field keeps it, Escape cancels (and does not close the popover). */
|
|
1192
|
+
function InlineNameInput({
|
|
1193
|
+
initial,
|
|
1194
|
+
placeholder,
|
|
1195
|
+
onCommit,
|
|
1196
|
+
onCancel,
|
|
1197
|
+
className,
|
|
1198
|
+
}: {
|
|
1199
|
+
initial: string;
|
|
1200
|
+
placeholder?: string;
|
|
1201
|
+
onCommit: (name: string) => void;
|
|
1202
|
+
onCancel: () => void;
|
|
1203
|
+
className?: string;
|
|
1204
|
+
}) {
|
|
1205
|
+
const [value, setValue] = React.useState(initial);
|
|
1206
|
+
const committed = React.useRef(false);
|
|
1207
|
+
const commit = () => {
|
|
1208
|
+
if (committed.current) return;
|
|
1209
|
+
committed.current = true;
|
|
1210
|
+
onCommit(value.trim());
|
|
1211
|
+
};
|
|
1212
|
+
return (
|
|
1213
|
+
<input
|
|
1214
|
+
autoFocus
|
|
1215
|
+
value={value}
|
|
1216
|
+
placeholder={placeholder}
|
|
1217
|
+
maxLength={40}
|
|
1218
|
+
onChange={(event) => setValue(event.target.value)}
|
|
1219
|
+
onKeyDown={(event) => {
|
|
1220
|
+
if (event.key === 'Enter') {
|
|
1221
|
+
if (event.nativeEvent.isComposing) return;
|
|
1222
|
+
commit();
|
|
1223
|
+
} else if (event.key === 'Escape') {
|
|
1224
|
+
event.preventDefault();
|
|
1225
|
+
event.stopPropagation();
|
|
1226
|
+
committed.current = true;
|
|
1227
|
+
onCancel();
|
|
1228
|
+
}
|
|
1229
|
+
}}
|
|
1230
|
+
onBlur={commit}
|
|
1231
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
1232
|
+
onClick={(event) => event.stopPropagation()}
|
|
1233
|
+
className={className ?? 'h-8 w-[160px] rounded-lg border border-[var(--text)] bg-[var(--surface)] px-2.5 text-[12.5px] text-[var(--text)] outline-none placeholder:text-[var(--text-faint)]'}
|
|
1234
|
+
/>
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Where a project, or a folder of the list, goes: the folders as a tree, as a file browser shows them. "Projects" (no
|
|
1240
|
+
* folder) at the top and each folder under the one it is in, opened and closed by its arrow. A click picks a place and
|
|
1241
|
+
* a double click (or Move, or Enter) moves it there; the place it is in now is marked and cannot be picked. New folder
|
|
1242
|
+
* makes one inside the place picked, named in the tree, and picks it. A folder never offers itself or what is in it
|
|
1243
|
+
* (`exclude`).
|
|
1244
|
+
*/
|
|
1245
|
+
function FolderPicker({
|
|
1246
|
+
title,
|
|
1247
|
+
folders,
|
|
1248
|
+
current,
|
|
1249
|
+
exclude,
|
|
1250
|
+
onMove,
|
|
1251
|
+
onCreate,
|
|
1252
|
+
onClose,
|
|
1253
|
+
}: {
|
|
1254
|
+
title: string;
|
|
1255
|
+
folders: ProjectFolder[];
|
|
1256
|
+
current: string | null;
|
|
1257
|
+
exclude: ReadonlySet<string>;
|
|
1258
|
+
onMove: (folderId: string | null) => void;
|
|
1259
|
+
onCreate: (name: string, parentId: string | null) => Promise<ProjectFolder | null>;
|
|
1260
|
+
onClose: () => void;
|
|
1261
|
+
}) {
|
|
1262
|
+
const t = useT();
|
|
1263
|
+
/* null is the project list itself (no folder) */
|
|
1264
|
+
const [picked, setPicked] = React.useState<string | null>(current);
|
|
1265
|
+
const [shut, setShut] = React.useState<ReadonlySet<string>>(new Set());
|
|
1266
|
+
/* a new folder being named, inside this place (undefined: none) */
|
|
1267
|
+
const [naming, setNaming] = React.useState<string | null | undefined>(undefined);
|
|
1268
|
+
const children = React.useCallback((parent: string | null) => folders
|
|
1269
|
+
.filter((f) => parentOf(f, folders) === parent && !exclude.has(f.id))
|
|
1270
|
+
.sort((a, b) => a.name.localeCompare(b.name)), [folders, exclude]);
|
|
1271
|
+
React.useEffect(() => {
|
|
1272
|
+
/* this Escape closes the picker, not the popover under it */
|
|
1273
|
+
const onKey = (event: KeyboardEvent) => {
|
|
1274
|
+
if (event.key !== 'Escape') return;
|
|
1275
|
+
event.stopImmediatePropagation();
|
|
1276
|
+
onClose();
|
|
1277
|
+
};
|
|
1278
|
+
window.addEventListener('keydown', onKey, true);
|
|
1279
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
1280
|
+
}, [onClose]);
|
|
1281
|
+
|
|
1282
|
+
const canMove = picked !== current;
|
|
1283
|
+
const move = (to: string | null) => { if (to !== current) onMove(to); };
|
|
1284
|
+
const toggle = (id: string) => setShut((was) => { const next = new Set(was); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
|
1285
|
+
const startNew = () => {
|
|
1286
|
+
setNaming(picked);
|
|
1287
|
+
if (picked) setShut((was) => { const next = new Set(was); next.delete(picked); return next; });
|
|
1288
|
+
};
|
|
1289
|
+
const named = async (name: string) => {
|
|
1290
|
+
const parent = naming ?? null;
|
|
1291
|
+
setNaming(undefined);
|
|
1292
|
+
if (!name) return;
|
|
1293
|
+
const made = await onCreate(name, parent);
|
|
1294
|
+
if (made) setPicked(made.id);
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
/* a place: its arrow (when it holds folders), its icon, its name, and "Here now" on the place it is in */
|
|
1298
|
+
const place = (id: string | null, depth: number, icon: React.ReactNode, label: string): React.ReactNode => {
|
|
1299
|
+
const kids = children(id);
|
|
1300
|
+
const open = id === null || !shut.has(id);
|
|
1301
|
+
const isPicked = picked === id;
|
|
1302
|
+
const here = current === id;
|
|
1303
|
+
return (
|
|
1304
|
+
<React.Fragment key={id ?? 'top'}>
|
|
1305
|
+
<div
|
|
1306
|
+
role="treeitem"
|
|
1307
|
+
aria-selected={isPicked}
|
|
1308
|
+
aria-expanded={kids.length ? open : undefined}
|
|
1309
|
+
onClick={() => setPicked(id)}
|
|
1310
|
+
onDoubleClick={() => move(id)}
|
|
1311
|
+
style={{ paddingLeft: 6 + depth * 18 }}
|
|
1312
|
+
className={`flex h-8 cursor-default select-none items-center gap-1.5 rounded-md pr-2.5 text-[13px] ${
|
|
1313
|
+
isPicked ? 'bg-[var(--accent)] text-[var(--on-accent)]' : 'text-[var(--text)] hover:bg-[var(--bg-hover)]'
|
|
1314
|
+
}`}
|
|
1315
|
+
>
|
|
1316
|
+
{kids.length && id !== null ? (
|
|
1317
|
+
<button type="button" aria-label={label} onClick={(event) => { event.stopPropagation(); toggle(id); }}
|
|
1318
|
+
className="flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-70 hover:opacity-100">
|
|
1319
|
+
<ChevronRight size={13} className={`transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
1320
|
+
</button>
|
|
1321
|
+
) : <span className="w-4 shrink-0" />}
|
|
1322
|
+
<span className={`shrink-0 ${isPicked ? '' : 'text-[var(--text-dim)]'}`}>{icon}</span>
|
|
1323
|
+
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
1324
|
+
{here ? <span className={`shrink-0 text-[11.5px] ${isPicked ? 'opacity-80' : 'text-[var(--text-faint)]'}`}>{t('projects.hereNow')}</span> : null}
|
|
1325
|
+
</div>
|
|
1326
|
+
{open ? (
|
|
1327
|
+
<>
|
|
1328
|
+
{naming === id ? (
|
|
1329
|
+
<div className="flex h-8 items-center gap-1.5 pr-2.5" style={{ paddingLeft: 6 + (depth + 1) * 18 }}>
|
|
1330
|
+
<span className="w-4 shrink-0" />
|
|
1331
|
+
<Folder size={15} strokeWidth={1.7} className="shrink-0 text-[var(--text-dim)]" />
|
|
1332
|
+
<InlineNameInput
|
|
1333
|
+
initial=""
|
|
1334
|
+
placeholder={t('projects.newFolderName')}
|
|
1335
|
+
onCommit={(name) => void named(name)}
|
|
1336
|
+
onCancel={() => setNaming(undefined)}
|
|
1337
|
+
className="h-6 min-w-0 flex-1 rounded border border-[var(--text)] bg-[var(--surface)] px-1.5 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-faint)]"
|
|
1338
|
+
/>
|
|
1339
|
+
</div>
|
|
1340
|
+
) : null}
|
|
1341
|
+
{kids.map((f) => place(f.id, depth + 1, <Folder size={15} strokeWidth={1.7} />, f.name))}
|
|
1342
|
+
</>
|
|
1343
|
+
) : null}
|
|
1344
|
+
</React.Fragment>
|
|
1345
|
+
);
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
return createPortal(
|
|
1349
|
+
<div
|
|
1350
|
+
role="dialog"
|
|
1351
|
+
aria-modal
|
|
1352
|
+
aria-label={t('projects.moveToTitle').replace('{name}', title)}
|
|
1353
|
+
className="fixed inset-0 z-[10100] flex items-center justify-center bg-black/35 p-4"
|
|
1354
|
+
onMouseDown={(event) => {
|
|
1355
|
+
if (event.target === event.currentTarget) onClose();
|
|
1356
|
+
}}
|
|
1357
|
+
>
|
|
1358
|
+
<div
|
|
1359
|
+
className="flex w-full max-w-[460px] flex-col overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-lg)]"
|
|
1360
|
+
style={{ animation: 'openfilm-pop 0.16s ease-out both' }}
|
|
1361
|
+
onKeyDown={(event) => {
|
|
1362
|
+
if (event.key === 'Enter' && naming === undefined && canMove && !(event.target instanceof HTMLInputElement)) { event.preventDefault(); move(picked); }
|
|
1363
|
+
}}
|
|
1364
|
+
>
|
|
1365
|
+
<div className="flex items-start gap-3 px-5 pb-3 pt-4">
|
|
1366
|
+
<div className="min-w-0 flex-1">
|
|
1367
|
+
<p className="truncate text-[15px] font-semibold text-[var(--text)]">{t('projects.moveToTitle').replace('{name}', title)}</p>
|
|
1368
|
+
<p className="mt-0.5 text-[12.5px] text-[var(--text-muted)]">{t('projects.moveHint')}</p>
|
|
1369
|
+
</div>
|
|
1370
|
+
<button
|
|
1371
|
+
type="button"
|
|
1372
|
+
onClick={onClose}
|
|
1373
|
+
aria-label={t('confirm.cancel')}
|
|
1374
|
+
className="-mr-1.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] transition hover:bg-[var(--bg-hover)] hover:text-[var(--text)]"
|
|
1375
|
+
>
|
|
1376
|
+
<X size={15} />
|
|
1377
|
+
</button>
|
|
1378
|
+
</div>
|
|
1379
|
+
|
|
1380
|
+
<div role="tree" tabIndex={0} className="mx-5 h-[260px] overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 outline-none">
|
|
1381
|
+
{place(null, 0, <LayoutGrid size={15} strokeWidth={1.7} />, t('nav.projects'))}
|
|
1382
|
+
</div>
|
|
1383
|
+
|
|
1384
|
+
<div className="flex items-center gap-2 px-5 py-4">
|
|
1385
|
+
<button type="button" onClick={startNew}
|
|
1386
|
+
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 text-[12.5px] text-[var(--text)] transition hover:bg-[var(--bg-hover)]">
|
|
1387
|
+
<FolderPlus size={14} strokeWidth={1.8} />{t('projects.newFolder')}
|
|
1388
|
+
</button>
|
|
1389
|
+
<span className="flex-1" />
|
|
1390
|
+
<button type="button" onClick={onClose}
|
|
1391
|
+
className="h-8 rounded-lg border border-[var(--border)] px-3 text-[12.5px] text-[var(--text)] transition hover:bg-[var(--bg-hover)]">{t('confirm.cancel')}</button>
|
|
1392
|
+
<button type="button" disabled={!canMove} onClick={() => move(picked)}
|
|
1393
|
+
className="h-8 rounded-lg bg-[var(--text)] px-4 text-[12.5px] font-medium text-[var(--bg)] transition hover:opacity-90 disabled:opacity-40">{t('projects.move')}</button>
|
|
1394
|
+
</div>
|
|
1395
|
+
</div>
|
|
1396
|
+
</div>,
|
|
1397
|
+
document.body,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* The cover, or (none, or it failed to load) a gradient made from the id. While Studio draws it (the first time can
|
|
1403
|
+
* take a while) the card shows its plain face with the film mark, not a black frame that reads as a black film. Studio
|
|
1404
|
+
* answers at once while it draws a film's first poster (so the card holds none of the browser's few connections to
|
|
1405
|
+
* it): the card shows its gradient and asks again a few times.
|
|
1406
|
+
*/
|
|
1407
|
+
function PosterMedia({ project, shade = false }: { project: LibraryProject; shade?: boolean }) {
|
|
1408
|
+
/* the try asked for now (0: the first) and whether it failed, for this poster */
|
|
1409
|
+
const [attempt, setAttempt] = React.useState<{ url: string; n: number; failed: boolean } | null>(null);
|
|
1410
|
+
const [loadedUrl, setLoadedUrl] = React.useState<string | null>(null);
|
|
1411
|
+
const tried = attempt && attempt.url === project.poster ? attempt : { url: project.poster ?? '', n: 0, failed: false };
|
|
1412
|
+
const again = tried.failed ? posterRetryDelay(tried.n) : null;
|
|
1413
|
+
React.useEffect(() => {
|
|
1414
|
+
if (again == null) return undefined;
|
|
1415
|
+
const timer = setTimeout(() => setAttempt({ url: tried.url, n: tried.n + 1, failed: false }), again);
|
|
1416
|
+
return () => clearTimeout(timer);
|
|
1417
|
+
}, [again, tried.url, tried.n]);
|
|
1418
|
+
/* `shade`: darker toward the bottom, under the length badge; only over a picture (on the plain face it is a smudge) */
|
|
1419
|
+
const shadow = shade ? <div className="absolute inset-0 bg-gradient-to-t from-black/55 via-transparent to-transparent opacity-90" /> : null;
|
|
1420
|
+
const face = <div className="absolute inset-0 opacity-80" style={{ background: gradientForId(project.id) }} />;
|
|
1421
|
+
if (!project.poster || (tried.failed && again == null)) return <>{face}{shadow}</>;
|
|
1422
|
+
const src = posterTry(project.poster, tried.n);
|
|
1423
|
+
const ready = loadedUrl === src;
|
|
1424
|
+
return (
|
|
1425
|
+
<>
|
|
1426
|
+
{ready ? null : tried.n > 0 || tried.failed ? face : (
|
|
1427
|
+
<span aria-hidden className="absolute inset-0 flex items-center justify-center text-[var(--text-faint)]">
|
|
1428
|
+
<Video size={22} strokeWidth={1.5} />
|
|
1429
|
+
</span>
|
|
1430
|
+
)}
|
|
1431
|
+
<img
|
|
1432
|
+
src={src}
|
|
1433
|
+
alt=""
|
|
1434
|
+
loading="lazy"
|
|
1435
|
+
decoding="async"
|
|
1436
|
+
draggable={false}
|
|
1437
|
+
onLoad={() => setLoadedUrl(src)}
|
|
1438
|
+
onError={() => setAttempt({ url: tried.url, n: tried.n, failed: true })}
|
|
1439
|
+
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-200 ${ready ? 'opacity-100' : 'opacity-0'}`}
|
|
1440
|
+
/>
|
|
1441
|
+
{ready ? shadow : null}
|
|
1442
|
+
</>
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const SORT_OPTIONS: ReadonlyArray<{ id: LibrarySort; labelKey: string }> = [
|
|
1447
|
+
{ id: 'recent', labelKey: 'library.sortRecent' },
|
|
1448
|
+
{ id: 'newest', labelKey: 'library.sortNewest' },
|
|
1449
|
+
{ id: 'oldest', labelKey: 'library.sortOldest' },
|
|
1450
|
+
{ id: 'duration', labelKey: 'library.sortDuration' },
|
|
1451
|
+
{ id: 'title', labelKey: 'library.sortTitle' },
|
|
1452
|
+
];
|
|
1453
|
+
|
|
1454
|
+
/** The sort button: a small card of options, one ticked. */
|
|
1455
|
+
function SortButton({
|
|
1456
|
+
sort,
|
|
1457
|
+
onSort,
|
|
1458
|
+
}: {
|
|
1459
|
+
sort: LibrarySort;
|
|
1460
|
+
onSort: (next: LibrarySort) => void;
|
|
1461
|
+
}) {
|
|
1462
|
+
const t = useT();
|
|
1463
|
+
const [anchor, setAnchor] = React.useState<{ left: number; top: number } | null>(null);
|
|
1464
|
+
const toggle = (event: React.MouseEvent) => {
|
|
1465
|
+
event.stopPropagation();
|
|
1466
|
+
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1467
|
+
setAnchor((current) => (current ? null : { left: box.left, top: box.bottom + 6 }));
|
|
1468
|
+
};
|
|
1469
|
+
React.useEffect(() => {
|
|
1470
|
+
if (!anchor) return;
|
|
1471
|
+
const close = () => setAnchor(null);
|
|
1472
|
+
const onKey = (event: KeyboardEvent) => {
|
|
1473
|
+
if (event.key !== 'Escape') return;
|
|
1474
|
+
/* this Escape closes the menu, not the popover under it */
|
|
1475
|
+
event.stopImmediatePropagation();
|
|
1476
|
+
setAnchor(null);
|
|
1477
|
+
};
|
|
1478
|
+
window.addEventListener('click', close);
|
|
1479
|
+
window.addEventListener('scroll', close, true);
|
|
1480
|
+
window.addEventListener('keydown', onKey, true);
|
|
1481
|
+
window.addEventListener('resize', close);
|
|
1482
|
+
return () => {
|
|
1483
|
+
window.removeEventListener('click', close);
|
|
1484
|
+
window.removeEventListener('scroll', close, true);
|
|
1485
|
+
window.removeEventListener('keydown', onKey, true);
|
|
1486
|
+
window.removeEventListener('resize', close);
|
|
1487
|
+
};
|
|
1488
|
+
}, [anchor]);
|
|
1489
|
+
const row = (selected: boolean) =>
|
|
1490
|
+
`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[13px] transition hover:bg-[var(--bg-hover)] ${
|
|
1491
|
+
selected ? 'text-[var(--text)]' : 'text-[var(--text-dim)]'
|
|
1492
|
+
}`;
|
|
1493
|
+
|
|
1494
|
+
return (
|
|
1495
|
+
<>
|
|
1496
|
+
<Tooltip label={t('library.sortLabel')}>
|
|
1497
|
+
<button
|
|
1498
|
+
type="button"
|
|
1499
|
+
onClick={toggle}
|
|
1500
|
+
aria-haspopup="dialog"
|
|
1501
|
+
aria-expanded={anchor != null}
|
|
1502
|
+
aria-label={t('library.sortLabel')}
|
|
1503
|
+
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--border)] transition ${
|
|
1504
|
+
anchor != null
|
|
1505
|
+
? 'bg-[var(--bg-active)] text-[var(--text)]'
|
|
1506
|
+
: 'bg-[var(--surface)] text-[var(--text-muted)] hover:text-[var(--text)]'
|
|
1507
|
+
}`}
|
|
1508
|
+
>
|
|
1509
|
+
<SlidersHorizontal size={14} />
|
|
1510
|
+
</button>
|
|
1511
|
+
</Tooltip>
|
|
1512
|
+
{anchor
|
|
1513
|
+
? createPortal(
|
|
1514
|
+
<div
|
|
1515
|
+
role="dialog"
|
|
1516
|
+
className="fixed z-[10060] w-[200px] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface)] pb-1.5 shadow-[var(--shadow-lg)]"
|
|
1517
|
+
style={{ left: anchor.left, top: anchor.top, animation: 'openfilm-rise 0.14s ease-out both' }}
|
|
1518
|
+
onClick={(event) => event.stopPropagation()}
|
|
1519
|
+
>
|
|
1520
|
+
<p className="px-3 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-[0.06em] text-[var(--text-faint)]">{t('library.sortLabel')}</p>
|
|
1521
|
+
{SORT_OPTIONS.map((option) => (
|
|
1522
|
+
<button key={option.id} type="button" onClick={() => onSort(option.id)} className={row(option.id === sort)}>
|
|
1523
|
+
<Check size={14} className={option.id === sort ? 'text-[var(--text)]' : 'text-transparent'} />
|
|
1524
|
+
{t(option.labelKey)}
|
|
1525
|
+
</button>
|
|
1526
|
+
))}
|
|
1527
|
+
</div>,
|
|
1528
|
+
document.body,
|
|
1529
|
+
)
|
|
1530
|
+
: null}
|
|
1531
|
+
</>
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function ViewButton({
|
|
1536
|
+
active,
|
|
1537
|
+
label,
|
|
1538
|
+
onClick,
|
|
1539
|
+
icon,
|
|
1540
|
+
}: {
|
|
1541
|
+
active: boolean;
|
|
1542
|
+
label: string;
|
|
1543
|
+
onClick: () => void;
|
|
1544
|
+
icon: React.ReactNode;
|
|
1545
|
+
}) {
|
|
1546
|
+
return (
|
|
1547
|
+
<Tooltip label={label}>
|
|
1548
|
+
<button
|
|
1549
|
+
type="button"
|
|
1550
|
+
aria-label={label}
|
|
1551
|
+
aria-pressed={active}
|
|
1552
|
+
onClick={onClick}
|
|
1553
|
+
className={`flex h-6 w-8 items-center justify-center rounded-[5px] transition ${
|
|
1554
|
+
active
|
|
1555
|
+
? 'bg-[var(--surface-3)] text-[var(--text)]'
|
|
1556
|
+
: 'text-[var(--text-faint)] hover:text-[var(--text)]'
|
|
1557
|
+
}`}
|
|
1558
|
+
>
|
|
1559
|
+
{icon}
|
|
1560
|
+
</button>
|
|
1561
|
+
</Tooltip>
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function titleOf(project: LibraryProject, t: (key: string) => string): string {
|
|
1566
|
+
return project.name.trim() || t('project.untitled');
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* A card without a cover gets a gradient made from its id, not a random one: people recognize a card by "the blue
|
|
1571
|
+
* one at the top left", so it must be the same color every time.
|
|
1572
|
+
*/
|
|
1573
|
+
function gradientForId(id: string): string {
|
|
1574
|
+
let hash = 0;
|
|
1575
|
+
for (let i = 0; i < id.length; i += 1) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
|
1576
|
+
const hue = hash % 360;
|
|
1577
|
+
const next = (hue + 48 + (hash % 42)) % 360;
|
|
1578
|
+
return `linear-gradient(135deg, hsl(${hue} 58% 34%), hsl(${next} 62% 20%))`;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function formatDuration(seconds: number): string {
|
|
1582
|
+
const total = Math.max(0, Math.round(seconds));
|
|
1583
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** A day in the UI's language: "Oct 7, 2026", "2026年10月7日". */
|
|
1587
|
+
function formatDate(ms: number, language: Language): string {
|
|
1588
|
+
try {
|
|
1589
|
+
return new Intl.DateTimeFormat(language, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(ms));
|
|
1590
|
+
} catch {
|
|
1591
|
+
return '';
|
|
1592
|
+
}
|
|
1593
|
+
}
|