jbilcke-hf HF staff commited on
Commit
083ce88
β€’
1 Parent(s): 243952e

migrate to TimelineSegment

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. package-lock.json +0 -0
  2. package.json +6 -2
  3. src/app/api/resolve/providers/comfy-comfyicu/index.ts +4 -3
  4. src/app/api/resolve/providers/comfy-huggingface/index.ts +4 -3
  5. src/app/api/resolve/providers/comfy-replicate/index.ts +4 -3
  6. src/app/api/resolve/providers/falai/index.ts +5 -4
  7. src/app/api/resolve/providers/gradio/index.ts +4 -3
  8. src/app/api/resolve/providers/huggingface/index.ts +3 -2
  9. src/app/api/resolve/providers/modelslab/index.ts +4 -3
  10. src/app/api/resolve/providers/replicate/index.ts +3 -3
  11. src/app/api/resolve/providers/stabilityai/index.ts +4 -3
  12. src/app/api/resolve/route.ts +1 -1
  13. src/app/main.tsx +5 -8
  14. src/components/core/tree/README.md +7 -0
  15. src/components/core/tree/chainable-map.ts +28 -0
  16. src/components/core/tree/icons.tsx +129 -0
  17. src/components/core/tree/index.tsx +185 -0
  18. src/components/core/tree/root.tsx +45 -0
  19. src/components/core/tree/roving.tsx +254 -0
  20. src/components/core/tree/tree-state.ts +34 -0
  21. src/components/core/tree/types.ts +38 -0
  22. src/components/core/tree/useTreeNode.ts +196 -0
  23. src/components/editor/Editor.tsx +0 -28
  24. src/components/editors/Editors.tsx +36 -0
  25. src/components/editors/EntityEditor/index.tsx +63 -0
  26. src/components/editors/ProjectEditor/index.tsx +113 -0
  27. src/components/{editor β†’ editors}/ScriptEditor/index.tsx +17 -17
  28. src/components/{editor β†’ editors}/ScriptEditor/styles.css +0 -0
  29. src/components/editors/SegmentEditor/index.tsx +46 -0
  30. src/components/forms/FormDir.tsx +3 -0
  31. src/components/forms/FormField.tsx +5 -1
  32. src/components/forms/FormFile.tsx +4 -1
  33. src/components/forms/FormInput.tsx +7 -1
  34. src/components/forms/FormRadio.tsx +4 -2
  35. src/components/forms/FormSection.tsx +7 -2
  36. src/components/forms/FormSelect.tsx +4 -1
  37. src/components/forms/FormSwitch.tsx +4 -2
  38. src/components/icons/getAppropriateIcon.ts +97 -0
  39. src/components/icons/index.tsx +64 -0
  40. src/components/settings/constants.ts +1 -0
  41. src/components/toolbars/{editor-menu/EditorSideMenu.tsx β†’ editors-menu/EditorsSideMenu.tsx} +11 -8
  42. src/components/toolbars/{editor-menu/EditorSideMenuItem.tsx β†’ editors-menu/EditorsSideMenuItem.tsx} +6 -5
  43. src/components/tree-browsers/model-tree-browser/index.tsx +86 -0
  44. src/components/tree-browsers/model-tree-browser/tree-item-viewer.tsx +19 -0
  45. src/components/tree-browsers/project-tree-browser/index.tsx +57 -0
  46. src/components/tree-browsers/project-tree-browser/tree-item-viewer.tsx +17 -0
  47. src/components/tree-browsers/stores/useCivitaiCollections.ts +25 -0
  48. src/components/tree-browsers/stores/useEntityLibrary.ts +322 -0
  49. src/components/tree-browsers/stores/useFileLibrary.txt +211 -0
  50. src/components/tree-browsers/stores/useProjectLibrary.ts +135 -0
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -12,9 +12,9 @@
12
  "dependencies": {
13
  "@aitube/broadway": "0.0.22",
14
  "@aitube/clap": "0.0.30",
15
- "@aitube/clapper-services": "0.0.5",
16
  "@aitube/engine": "0.0.26",
17
- "@aitube/timeline": "0.0.34",
18
  "@fal-ai/serverless-client": "^0.11.0",
19
  "@gradio/client": "^1.1.1",
20
  "@huggingface/hub": "^0.15.1",
@@ -62,7 +62,9 @@
62
  "cmdk": "^0.2.1",
63
  "fflate": "^0.8.2",
64
  "fluent-ffmpeg": "^2.1.3",
 
65
  "fs-extra": "^11.2.0",
 
66
  "lucide-react": "^0.396.0",
67
  "mlt-xml": "^2.0.2",
68
  "monaco-editor": "^0.50.0",
@@ -98,6 +100,7 @@
98
  },
99
  "devDependencies": {
100
  "@types/fluent-ffmpeg": "^2.1.24",
 
101
  "@types/node": "^20",
102
  "@types/react": "^18",
103
  "@types/react-dom": "^18",
@@ -105,6 +108,7 @@
105
  "eslint": "^8",
106
  "eslint-config-next": "14.2.4",
107
  "postcss": "^8",
 
108
  "tailwindcss": "^3.4.3",
109
  "typescript": "^5"
110
  }
 
12
  "dependencies": {
13
  "@aitube/broadway": "0.0.22",
14
  "@aitube/clap": "0.0.30",
15
+ "@aitube/clapper-services": "0.0.14",
16
  "@aitube/engine": "0.0.26",
17
+ "@aitube/timeline": "0.0.37",
18
  "@fal-ai/serverless-client": "^0.11.0",
19
  "@gradio/client": "^1.1.1",
20
  "@huggingface/hub": "^0.15.1",
 
62
  "cmdk": "^0.2.1",
63
  "fflate": "^0.8.2",
64
  "fluent-ffmpeg": "^2.1.3",
65
+ "framer-motion": "11.1.7",
66
  "fs-extra": "^11.2.0",
67
+ "is-hotkey": "^0.2.0",
68
  "lucide-react": "^0.396.0",
69
  "mlt-xml": "^2.0.2",
70
  "monaco-editor": "^0.50.0",
 
100
  },
101
  "devDependencies": {
102
  "@types/fluent-ffmpeg": "^2.1.24",
103
+ "@types/is-hotkey": "^0.1.10",
104
  "@types/node": "^20",
105
  "@types/react": "^18",
106
  "@types/react-dom": "^18",
 
108
  "eslint": "^8",
109
  "eslint-config-next": "14.2.4",
110
  "postcss": "^8",
111
+ "tailwind-scrollbar": "^3.1.0",
112
  "tailwindcss": "^3.4.3",
113
  "typescript": "^5"
114
  }
src/app/api/resolve/providers/comfy-comfyicu/index.ts CHANGED
@@ -1,8 +1,9 @@
1
 
2
  import { ResolveRequest } from "@aitube/clapper-services"
3
- import { ClapSegment, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
 
4
 
5
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
6
  if (!request.settings.comfyIcuApiKey) {
7
  throw new Error(`Missing API key for "Comfy.icu"`)
8
  }
@@ -10,7 +11,7 @@ export async function resolveSegment(request: ResolveRequest): Promise<ClapSegme
10
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "Comfy.icu". Please open a pull request with (working code) to solve this!`)
11
  }
12
 
13
- const segment: ClapSegment = { ...request.segment }
14
 
15
 
16
  try {
 
1
 
2
  import { ResolveRequest } from "@aitube/clapper-services"
3
+ import { ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
4
+ import { TimelineSegment } from "@aitube/timeline"
5
 
6
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
7
  if (!request.settings.comfyIcuApiKey) {
8
  throw new Error(`Missing API key for "Comfy.icu"`)
9
  }
 
11
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "Comfy.icu". Please open a pull request with (working code) to solve this!`)
12
  }
13
 
14
+ const segment: TimelineSegment = { ...request.segment }
15
 
16
 
17
  try {
src/app/api/resolve/providers/comfy-huggingface/index.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { ResolveRequest } from "@aitube/clapper-services"
2
- import { ClapSegment, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
 
3
 
4
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
5
  if (!request.settings.huggingFaceApiKey) {
6
  throw new Error(`Missing API key for "Hugging Face"`)
7
  }
@@ -9,7 +10,7 @@ export async function resolveSegment(request: ResolveRequest): Promise<ClapSegme
9
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "Comfy Hugging Face". Please open a pull request with (working code) to solve this!`)
10
  }
11
 
12
- const segment: ClapSegment = { ...request.segment }
13
 
14
 
15
  try {
 
1
  import { ResolveRequest } from "@aitube/clapper-services"
2
+ import { ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
3
+ import { TimelineSegment } from "@aitube/timeline"
4
 
5
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
6
  if (!request.settings.huggingFaceApiKey) {
7
  throw new Error(`Missing API key for "Hugging Face"`)
8
  }
 
10
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "Comfy Hugging Face". Please open a pull request with (working code) to solve this!`)
11
  }
12
 
13
+ const segment: TimelineSegment = { ...request.segment }
14
 
15
 
16
  try {
src/app/api/resolve/providers/comfy-replicate/index.ts CHANGED
@@ -1,16 +1,17 @@
1
- import { ClapSegment, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
2
 
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
  import { getComfyWorkflow } from "../comfy/getComfyWorkflow"
5
  import { runWorkflow } from "./runWorkflow"
 
6
 
7
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
8
  if (!request.settings.replicateApiKey) {
9
  throw new Error(`Missing API key for "Replicate.com"`)
10
  }
11
  const workflow = getComfyWorkflow(request)
12
 
13
- const segment: ClapSegment = { ...request.segment }
14
 
15
  try {
16
  segment.assetUrl = await runWorkflow({
 
1
+ import { ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
2
 
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
  import { getComfyWorkflow } from "../comfy/getComfyWorkflow"
5
  import { runWorkflow } from "./runWorkflow"
6
+ import { TimelineSegment } from "@aitube/timeline"
7
 
8
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
9
  if (!request.settings.replicateApiKey) {
10
  throw new Error(`Missing API key for "Replicate.com"`)
11
  }
12
  const workflow = getComfyWorkflow(request)
13
 
14
+ const segment: TimelineSegment = request.segment
15
 
16
  try {
17
  segment.assetUrl = await runWorkflow({
src/app/api/resolve/providers/falai/index.ts CHANGED
@@ -1,10 +1,11 @@
1
  import * as fal from '@fal-ai/serverless-client'
2
-
3
  import { FalAiImageSize, ResolveRequest } from "@aitube/clapper-services"
4
- import { ClapMediaOrientation, ClapSegment, ClapSegmentCategory } from "@aitube/clap"
5
  import { FalAiAudioResponse, FalAiImageResponse, FalAiSpeechResponse, FalAiVideoResponse } from './types'
6
 
7
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
 
8
  if (!request.settings.falAiApiKey) {
9
  throw new Error(`Missing API key for "Fal.ai"`)
10
  }
@@ -13,7 +14,7 @@ export async function resolveSegment(request: ResolveRequest): Promise<ClapSegme
13
  credentials: request.settings.falAiApiKey
14
  })
15
 
16
- const segment = request.segment
17
 
18
 
19
  // for doc see:
 
1
  import * as fal from '@fal-ai/serverless-client'
2
+ import { TimelineSegment } from '@aitube/timeline'
3
  import { FalAiImageSize, ResolveRequest } from "@aitube/clapper-services"
4
+ import { ClapMediaOrientation, ClapSegmentCategory } from "@aitube/clap"
5
  import { FalAiAudioResponse, FalAiImageResponse, FalAiSpeechResponse, FalAiVideoResponse } from './types'
6
 
7
+
8
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
9
  if (!request.settings.falAiApiKey) {
10
  throw new Error(`Missing API key for "Fal.ai"`)
11
  }
 
14
  credentials: request.settings.falAiApiKey
15
  })
16
 
17
+ const segment: TimelineSegment = request.segment
18
 
19
 
20
  // for doc see:
src/app/api/resolve/providers/gradio/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { ClapSegment, ClapSegmentCategory } from "@aitube/clap"
2
-
3
  import { ResolveRequest } from "@aitube/clapper-services"
 
 
4
  import { callGradioApi } from "@/lib/hf/callGradioApi"
5
 
6
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
7
 
8
  const segment = request.segment
9
 
 
1
+ import { ClapSegmentCategory } from "@aitube/clap"
 
2
  import { ResolveRequest } from "@aitube/clapper-services"
3
+ import { TimelineSegment } from "@aitube/timeline"
4
+
5
  import { callGradioApi } from "@/lib/hf/callGradioApi"
6
 
7
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
8
 
9
  const segment = request.segment
10
 
src/app/api/resolve/providers/huggingface/index.ts CHANGED
@@ -1,13 +1,14 @@
1
  import { HfInference, HfInferenceEndpoint } from "@huggingface/inference"
2
 
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
- import { ClapSegment, ClapSegmentCategory } from "@aitube/clap"
5
 
6
  import { generateImage } from "./generateImage"
7
  import { generateVoice } from "./generateVoice"
8
  import { generateVideo } from "./generateVideo"
 
9
 
10
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
11
 
12
  if (!request.settings.huggingFaceApiKey) {
13
  throw new Error(`Missing API key for "Hugging Face"`)
 
1
  import { HfInference, HfInferenceEndpoint } from "@huggingface/inference"
2
 
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
+ import { ClapSegmentCategory } from "@aitube/clap"
5
 
6
  import { generateImage } from "./generateImage"
7
  import { generateVoice } from "./generateVoice"
8
  import { generateVideo } from "./generateVideo"
9
+ import { TimelineSegment } from "@aitube/timeline"
10
 
11
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
12
 
13
  if (!request.settings.huggingFaceApiKey) {
14
  throw new Error(`Missing API key for "Hugging Face"`)
src/app/api/resolve/providers/modelslab/index.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { ResolveRequest } from "@aitube/clapper-services"
2
- import { ClapSegment, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
 
3
 
4
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
5
  if (!request.settings.modelsLabApiKey) {
6
  throw new Error(`Missing API key for "ModelsLab.com"`)
7
  }
@@ -9,7 +10,7 @@ export async function resolveSegment(request: ResolveRequest): Promise<ClapSegme
9
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "ModelsLab". Please open a pull request with (working code) to solve this!`)
10
  }
11
 
12
- const segment: ClapSegment = { ...request.segment }
13
 
14
 
15
  try {
 
1
  import { ResolveRequest } from "@aitube/clapper-services"
2
+ import { ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
3
+ import { TimelineSegment } from "@aitube/timeline"
4
 
5
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
6
  if (!request.settings.modelsLabApiKey) {
7
  throw new Error(`Missing API key for "ModelsLab.com"`)
8
  }
 
10
  throw new Error(`Clapper doesn't support ${request.segment.category} generation for provider "ModelsLab". Please open a pull request with (working code) to solve this!`)
11
  }
12
 
13
+ const segment: TimelineSegment = request.segment
14
 
15
 
16
  try {
src/app/api/resolve/providers/replicate/index.ts CHANGED
@@ -1,10 +1,10 @@
1
  import Replicate from 'replicate'
2
 
3
- import { ClapSegment, ClapSegmentCategory } from "@aitube/clap"
4
-
5
  import { ResolveRequest } from "@aitube/clapper-services"
 
6
 
7
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
8
  if (!request.settings.replicateApiKey) {
9
  throw new Error(`Missing API key for "Replicate.com"`)
10
  }
 
1
  import Replicate from 'replicate'
2
 
3
+ import { ClapSegmentCategory } from "@aitube/clap"
 
4
  import { ResolveRequest } from "@aitube/clapper-services"
5
+ import { TimelineSegment } from '@aitube/timeline'
6
 
7
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
8
  if (!request.settings.replicateApiKey) {
9
  throw new Error(`Missing API key for "Replicate.com"`)
10
  }
src/app/api/resolve/providers/stabilityai/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { ClapSegment, ClapSegmentCategory } from "@aitube/clap"
2
-
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
  import { generateImage } from "./generateImage"
5
 
6
- export async function resolveSegment(request: ResolveRequest): Promise<ClapSegment> {
 
7
  if (!request.settings.stabilityAiApiKey) {
8
  throw new Error(`Missing API key for "Stability.ai"`)
9
  }
 
1
+ import { ClapSegmentCategory } from "@aitube/clap"
2
+ import { TimelineSegment } from "@aitube/timeline"
3
  import { ResolveRequest } from "@aitube/clapper-services"
4
  import { generateImage } from "./generateImage"
5
 
6
+
7
+ export async function resolveSegment(request: ResolveRequest): Promise<TimelineSegment> {
8
  if (!request.settings.stabilityAiApiKey) {
9
  throw new Error(`Missing API key for "Stability.ai"`)
10
  }
src/app/api/resolve/route.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { NextResponse, NextRequest } from "next/server"
2
- import { ClapOutputType, ClapSegment, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
3
 
4
  import {
5
  resolveSegmentUsingHuggingFace,
 
1
  import { NextResponse, NextRequest } from "next/server"
2
+ import { ClapOutputType, ClapSegmentCategory, ClapSegmentStatus, getClapAssetSourceType } from "@aitube/clap"
3
 
4
  import {
5
  resolveSegmentUsingHuggingFace,
src/app/main.tsx CHANGED
@@ -6,6 +6,7 @@ import {
6
  ReflexSplitter,
7
  ReflexElement
8
  } from "react-reflex"
 
9
  import { DndProvider, useDrop } from "react-dnd"
10
  import { HTML5Backend, NativeTypes } from "react-dnd-html5-backend"
11
  import { useTimeline } from "@aitube/timeline"
@@ -22,19 +23,15 @@ import { TopBar } from "@/components/toolbars/top-bar"
22
  import { Timeline } from "@/components/core/timeline"
23
  import { useIO } from "@/services/io/useIO"
24
  import { ChatView } from "@/components/assistant/ChatView"
25
- import { ScriptEditor } from "@/components/editor/ScriptEditor"
26
- import { useSearchParams } from "next/navigation"
27
- import EditorMenu from "@/components/toolbars/editor-menu/EditorSideMenu"
28
- import EditorSideMenu from "@/components/toolbars/editor-menu/EditorSideMenu"
29
- import { Editor } from "@/components/editor/Editor"
30
 
31
  type DroppableThing = { files: File[] }
32
 
33
  function MainContent() {
34
  const ref = useRef<HTMLDivElement>(null)
35
  const isEmpty = useTimeline(s => s.isEmpty)
36
- const showTimeline = useUI((s) => s.showTimeline)
37
- const showAssistant = useUI((s) => s.showAssistant)
38
 
39
  const openFiles = useIO(s => s.openFiles)
40
 
@@ -98,7 +95,7 @@ function MainContent() {
98
  minSize={showTimeline ? 100 : 1}
99
  maxSize={showTimeline ? 1600 : 1}
100
  >
101
- <Editor />
102
  </ReflexElement>
103
  <ReflexSplitter />
104
  <ReflexElement
 
6
  ReflexSplitter,
7
  ReflexElement
8
  } from "react-reflex"
9
+ import { useSearchParams } from "next/navigation"
10
  import { DndProvider, useDrop } from "react-dnd"
11
  import { HTML5Backend, NativeTypes } from "react-dnd-html5-backend"
12
  import { useTimeline } from "@aitube/timeline"
 
23
  import { Timeline } from "@/components/core/timeline"
24
  import { useIO } from "@/services/io/useIO"
25
  import { ChatView } from "@/components/assistant/ChatView"
26
+ import { Editors } from "@/components/editors/Editors"
 
 
 
 
27
 
28
  type DroppableThing = { files: File[] }
29
 
30
  function MainContent() {
31
  const ref = useRef<HTMLDivElement>(null)
32
  const isEmpty = useTimeline(s => s.isEmpty)
33
+ const showTimeline = useUI(s => s.showTimeline)
34
+ const showAssistant = useUI(s => s.showAssistant)
35
 
36
  const openFiles = useIO(s => s.openFiles)
37
 
 
95
  minSize={showTimeline ? 100 : 1}
96
  maxSize={showTimeline ? 1600 : 1}
97
  >
98
+ <Editors />
99
  </ReflexElement>
100
  <ReflexSplitter />
101
  <ReflexElement
src/components/core/tree/README.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ This component is adapted from:
2
+
3
+ https://www.joshuawootonn.com/react-treeview-component
4
+
5
+ See the code here for example usage:
6
+
7
+ https://github.com/joshuawootonn/react-components-from-scratch/blob/main/components/treeview/examples/apple-sidebar.tsx
src/components/core/tree/chainable-map.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class ChainableMap<K, V> {
2
+ map: Map<K, V>
3
+ constructor(map?: ChainableMap<K, V> | null)
4
+ constructor(entries?: readonly (readonly [K, V])[] | null)
5
+ constructor(mapOrEntries?: ChainableMap<K, V> | readonly (readonly [K, V])[] | null) {
6
+ this.map = mapOrEntries instanceof ChainableMap ? new Map(mapOrEntries.map) : new Map(mapOrEntries)
7
+ }
8
+ toMap = (): Map<K, V> => {
9
+ return new Map(Array.from(this.map.entries()))
10
+ }
11
+ get = (key: K): V | undefined => {
12
+ return this.map.get(key)
13
+ }
14
+ set = (key: K, value: V): this => {
15
+ this.map.set(key, value)
16
+ return this
17
+ }
18
+ delete = (key: K): this => {
19
+ this.map.delete(key)
20
+ return this
21
+ }
22
+ toString = (): Record<any, V> => {
23
+ return Object.fromEntries(this.map)
24
+ }
25
+ size = (): number => {
26
+ return this.map.size
27
+ }
28
+ }
src/components/core/tree/icons.tsx ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "framer-motion"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ export function Folder({
6
+ open,
7
+ className
8
+ }: {
9
+ open?: boolean;
10
+ className?: string
11
+ }) {
12
+ if (open) {
13
+ return (
14
+ <svg
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ fill="none"
17
+ viewBox="0 0 24 24"
18
+ strokeWidth="1.6"
19
+ stroke="currentColor"
20
+ className={className}
21
+ >
22
+ <path
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"
26
+ />
27
+ </svg>
28
+ )
29
+ }
30
+
31
+ return (
32
+ <svg
33
+ xmlns="http://www.w3.org/2000/svg"
34
+ fill="none"
35
+ viewBox="0 0 24 24"
36
+ strokeWidth="1.6"
37
+ stroke="currentColor"
38
+ className={className}
39
+ >
40
+ <path
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
44
+ />
45
+ </svg>
46
+ )
47
+ }
48
+
49
+ export function File({
50
+ open,
51
+ className
52
+ }: {
53
+ open?: boolean;
54
+ className?: string
55
+ }) {
56
+ return (
57
+ <svg
58
+ xmlns="http://www.w3.org/2000/svg"
59
+ fill="none"
60
+ viewBox="0 0 24 24"
61
+ strokeWidth="1.6"
62
+ stroke="currentColor"
63
+ className={className}
64
+ >
65
+ <path
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
69
+ />
70
+ </svg>
71
+ )
72
+ }
73
+
74
+ export function Arrow({
75
+ open,
76
+ className
77
+ }: {
78
+ open?: boolean;
79
+ className?: string
80
+ }) {
81
+ return (
82
+ <motion.svg
83
+ xmlns="http://www.w3.org/2000/svg"
84
+ fill="none"
85
+ viewBox="0 0 24 24"
86
+ strokeWidth={2}
87
+ stroke="currentColor"
88
+ className={cn('origin-center', className)}
89
+ initial={false}
90
+ animate={{ rotate: open ? 90 : 0 }}
91
+ style={{ originX: '8px', originY: '8px' }}
92
+ transition={{
93
+ duration: 0.25,
94
+ ease: [0.164, 0.84, 0.43, 1],
95
+ }}
96
+ >
97
+ <path
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ d="M8.25 4.5l7.5 7.5-7.5 7.5"
101
+ />
102
+ </motion.svg>
103
+ )
104
+ }
105
+
106
+ export function Ellipse({
107
+ open,
108
+ className
109
+ }: {
110
+ open?: boolean;
111
+ className?: string
112
+ }) {
113
+ return (
114
+ <svg
115
+ xmlns="http://www.w3.org/2000/svg"
116
+ fill="none"
117
+ viewBox="0 0 24 24"
118
+ strokeWidth={2}
119
+ stroke="currentColor"
120
+ className={cn('origin-center', className)}
121
+ >
122
+ <path
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
126
+ />
127
+ </svg>
128
+ )
129
+ }
src/components/core/tree/index.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // adapted from joshuawootonn/react-components-from-scratch
2
+ import React from "react"
3
+ import { AnimatePresence, motion, MotionConfig } from "framer-motion"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ import { Folder, File, Arrow } from "./icons"
8
+ import { useTreeNode } from "./useTreeNode"
9
+ import { Root } from "./root"
10
+ import { TreeNodeType } from "./types"
11
+
12
+ export function Node<S,T>({ node, showArrows, indentLeaves }: {
13
+ node: TreeNodeType<S,T>
14
+
15
+ // show the little arrows on the left
16
+ showArrows?: boolean
17
+
18
+ // indent leaves (but it takes more space)
19
+ indentLeaves?: boolean
20
+ }) {
21
+ const {
22
+ isOpen,
23
+ isFocusable,
24
+ isSelected,
25
+ isExpanded,
26
+ getTreeNodeProps,
27
+ treeGroupProps,
28
+ } = useTreeNode(node.id, {
29
+ selectionType: 'distinct',
30
+ isFolder: Boolean(node.children?.length),
31
+ isExpanded: Boolean(node.isExpanded),
32
+ data: node,
33
+ })
34
+
35
+ const IconComponent = node.icon!
36
+
37
+ return (
38
+ <li
39
+ {...getTreeNodeProps({
40
+ className: cn(
41
+ 'relative cursor-pointer select-none flex flex-col focus:outline-none group',
42
+ )
43
+ })}
44
+ >
45
+ <MotionConfig
46
+ transition={{
47
+ ease: [0.164, 0.84, 0.43, 1],
48
+ duration: 0.25,
49
+ }}
50
+ >
51
+ <div
52
+ className={cn(
53
+ 'group flex flex-row items-center border-[1.5px] border-transparent space-x-2',
54
+ isFocusable &&
55
+ 'group-focus:border-gray-900/0 focus-within:border-transparent',
56
+ /*
57
+ isSelected
58
+ ? 'bg-gray-700/100 text-gray-200'
59
+ : 'bg-transparent text-gray-400 hover:text-gray-200',
60
+ */
61
+
62
+ "hover:bg-gray-700/20 text-gray-300 hover:text-gray-200 fill-gray-300 hover:fill-gray-200",
63
+
64
+ node.className,
65
+ )}
66
+ >
67
+ {node.children?.length ? (
68
+ <>
69
+ {showArrows ? <Arrow
70
+ className="h-4 w-4 flex-shrink-0"
71
+ open={isOpen}
72
+ /> : null}
73
+ <div className="flex flex-col items-center justify-center h-5 w-5 flex-shrink-0">
74
+ {node.icon
75
+ ? <div className="flex flex-col items-center justify-center w-full h-full scale-125">
76
+ <IconComponent />
77
+ </div>
78
+ : <Folder
79
+ open={isOpen}
80
+ className="w-full h-full"
81
+ />}
82
+ </div>
83
+ </>
84
+ ) : (
85
+ <div
86
+ className={cn(
87
+ `flex flex-col items-center justify-center h-5 w-5 flex-shrink-0`,
88
+ showArrows ? "ml-6" : ""
89
+ )}>
90
+ {node.icon
91
+ ? <div className="flex flex-col items-center justify-center w-full h-full scale-110 mt-0.5">
92
+ <IconComponent />
93
+ </div>
94
+ : <File className="w-full h-full" />}
95
+ </div>
96
+ )}
97
+ <span
98
+ className={cn(
99
+ `font-sans font-light text-base`,
100
+ `text-ellipsis whitespace-nowrap overflow-hidden`,
101
+ `flex-grow`,
102
+ node.className,
103
+ )}>
104
+ {node.label}{
105
+ // Array.isArray(node.children) ? `(${node.children.length ? node.children.length : "empty"})` : ""
106
+ }
107
+ </span>
108
+ </div>
109
+
110
+ <AnimatePresence initial={false}>
111
+ {node.children?.length && isOpen && (
112
+ <motion.ul
113
+ key={node.id + 'ul'}
114
+ initial={{
115
+ height: 0,
116
+ opacity: 0,
117
+ }}
118
+ animate={{
119
+ height: 'auto',
120
+ opacity: 1,
121
+ transition: {
122
+ height: {
123
+ duration: 0.25,
124
+ },
125
+ opacity: {
126
+ duration: 0.2,
127
+ delay: 0.05,
128
+ },
129
+ },
130
+ }}
131
+ exit={{
132
+ height: 0,
133
+ opacity: 0,
134
+ transition: {
135
+ height: {
136
+ duration: 0.25,
137
+ },
138
+ opacity: {
139
+ duration: 0.2,
140
+ },
141
+ },
142
+ }}
143
+ {...treeGroupProps}
144
+ className={cn(
145
+ 'pl-3'
146
+ )}
147
+ >
148
+ <motion.svg
149
+ viewBox="0 0 3 60"
150
+ fill="none"
151
+ preserveAspectRatio="none"
152
+ width={2}
153
+ xmlns="http://www.w3.org/2000/svg"
154
+
155
+ // if you want to display vertical lines, tweak the stroke-gray-900/100
156
+ className="absolute top-[31px] h-[calc(100%-30px)] bottom-0 left-3.5 transform -translate-x-1/2 stroke-gray-900/100 z-[-1]"
157
+ key={node.id + 'line'}
158
+ stroke="currentColor"
159
+ >
160
+ <motion.line
161
+ strokeLinecap="round"
162
+ x1="1"
163
+ x2="1"
164
+ y1="1"
165
+ y2="59"
166
+ strokeWidth={2}
167
+ />
168
+ </motion.svg>
169
+ {node.children.map(node => (
170
+ <Node
171
+ key={node.id}
172
+ node={node}
173
+ showArrows={showArrows}
174
+ indentLeaves={indentLeaves}
175
+ />
176
+ ))}
177
+ </motion.ul>
178
+ )}
179
+ </AnimatePresence>
180
+ </MotionConfig>
181
+ </li>
182
+ )
183
+ }
184
+
185
+ export const Tree = { Root, Node }
src/components/core/tree/root.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // adapted from joshuawootonn/react-components-from-scratch
2
+ import React, { ReactNode, useReducer, useMemo, useCallback } from "react"
3
+
4
+ import { cn } from "@/lib/utils"
5
+ import { RovingTabindexRoot } from "./roving"
6
+ import { treeviewReducer, TreeViewContext } from "./tree-state"
7
+ import { ChainableMap } from "./chainable-map"
8
+
9
+ export function Root<S,T>({ children, onChange, value, label, className }: {
10
+ children: ReactNode | ReactNode[]
11
+ label: string
12
+ className?: string
13
+ value: string | null
14
+ onChange: (id: string | null, nodeType?: S, data?: T) => void
15
+ }) {
16
+
17
+ const [open, dispatch] = useReducer(treeviewReducer, new ChainableMap<string, boolean>())
18
+
19
+ const select = useCallback(
20
+ (selectedId: string | null, nodeType?: any, data?: any) => {
21
+ onChange(selectedId, nodeType as S, data as T)
22
+ },
23
+ [onChange],
24
+ )
25
+
26
+ const providerValue = useMemo(
27
+ () => ({ dispatch, open, select, selectedId: value }),
28
+ [open, select, value],
29
+ )
30
+
31
+ return (
32
+ <TreeViewContext.Provider value={providerValue}>
33
+ <RovingTabindexRoot
34
+ className={cn(`flex flex-col overflow-auto`, className)}
35
+ active={providerValue.selectedId ?? null}
36
+ as="ul"
37
+ aria-label={label}
38
+ aria-multiselectable="false"
39
+ role="tree"
40
+ >
41
+ {children}
42
+ </RovingTabindexRoot>
43
+ </TreeViewContext.Provider>
44
+ )
45
+ }
src/components/core/tree/roving.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // adapted from joshuawootonn/react-components-from-scratch
2
+ import {
3
+ createContext,
4
+ ReactNode,
5
+ useCallback,
6
+ useContext,
7
+ useRef,
8
+ useState,
9
+ FocusEvent,
10
+ MouseEvent,
11
+ KeyboardEvent,
12
+ ComponentPropsWithoutRef,
13
+ ElementType,
14
+ MutableRefObject,
15
+ } from "react"
16
+ import isHotkey from "is-hotkey"
17
+ import { RovingTabindexItem } from "./types"
18
+
19
+
20
+ function focusFirst(candidates: HTMLElement[]) {
21
+ const previousFocus = document.activeElement
22
+ while (document.activeElement === previousFocus && candidates.length > 0) {
23
+ candidates.shift()?.focus()
24
+ }
25
+ }
26
+
27
+ const RovingTabindexContext = createContext<{
28
+ currentRovingTabindexValue: string | null
29
+ setFocusableId: (id: string) => void
30
+ onShiftTab: () => void
31
+ getOrderedItems: () => RovingTabindexItem[]
32
+ elements: MutableRefObject<Map<string, HTMLElement>>
33
+ }
34
+ >({
35
+ currentRovingTabindexValue: null,
36
+ setFocusableId: () => {},
37
+ onShiftTab: () => {},
38
+ getOrderedItems: () => [],
39
+ elements: { current: new Map<string, HTMLElement>() },
40
+ })
41
+
42
+ const NODE_SELECTOR = 'data-roving-tabindex-node'
43
+ const ROOT_SELECTOR = 'data-roving-tabindex-root'
44
+ export const NOT_FOCUSABLE_SELECTOR = 'data-roving-tabindex-not-focusable'
45
+
46
+ type RovingTabindexRootBaseProps<T> = {
47
+ children: ReactNode | ReactNode[]
48
+ active: string | null
49
+ as?: T
50
+ }
51
+
52
+ type RovingTabindexRootProps<T extends ElementType> =
53
+ RovingTabindexRootBaseProps<T> &
54
+ Omit<ComponentPropsWithoutRef<T>, keyof RovingTabindexRootBaseProps<T>>
55
+
56
+ export function RovingTabindexRoot<T extends ElementType>({
57
+ children,
58
+ active,
59
+ as,
60
+ ...props
61
+ }: RovingTabindexRootProps<T>) {
62
+ const Component = as || 'div'
63
+ const [isShiftTabbing, setIsShiftTabbing] = useState(false)
64
+ const [currentRovingTabindexValue, setCurrentRovingTabindexValue] =
65
+ useState<string | null>(null)
66
+ const rootRef = useRef<HTMLDivElement | null>(null)
67
+ const elements = useRef<Map<string, HTMLElement>>(new Map())
68
+
69
+ const getOrderedItems = useCallback(() => {
70
+ if (!rootRef.current) return []
71
+ const domElements = Array.from(
72
+ rootRef.current.querySelectorAll(
73
+ `:where([${NODE_SELECTOR}=true]):not(:where([${NOT_FOCUSABLE_SELECTOR}=true] *))`,
74
+ ),
75
+ )
76
+
77
+ return Array.from(elements.current)
78
+ .sort(
79
+ (a, b) => domElements.indexOf(a[1]) - domElements.indexOf(b[1]),
80
+ )
81
+ .map(([id, element]) => ({ id, element }))
82
+ }, [])
83
+
84
+ return (
85
+ <RovingTabindexContext.Provider
86
+ value={{
87
+ setFocusableId: function (id: string) {
88
+ setCurrentRovingTabindexValue(id)
89
+ },
90
+ onShiftTab: function () {
91
+ setIsShiftTabbing(true)
92
+ },
93
+ currentRovingTabindexValue,
94
+ getOrderedItems,
95
+ elements,
96
+ }}
97
+ >
98
+ <Component
99
+ {...{ [ROOT_SELECTOR]: true }}
100
+ tabIndex={isShiftTabbing ? -1 : 0}
101
+ onFocus={e => {
102
+ if (e.target !== e.currentTarget) return
103
+ if (isShiftTabbing) return
104
+ const orderedItems = getOrderedItems()
105
+ if (orderedItems.length === 0) return
106
+
107
+ const candidates = [
108
+ elements.current.get(currentRovingTabindexValue ?? ''),
109
+ elements.current.get(active ?? ''),
110
+ ...orderedItems.map(i => i.element),
111
+ ].filter(
112
+ (element): element is HTMLElement => element != null,
113
+ )
114
+
115
+ focusFirst(candidates)
116
+ }}
117
+ onBlur={() => setIsShiftTabbing(false)}
118
+ ref={rootRef}
119
+ {...props}
120
+ >
121
+ {children}
122
+ </Component>
123
+ </RovingTabindexContext.Provider>
124
+ )
125
+ }
126
+
127
+ export function getNextFocusableId(
128
+ orderedItems: RovingTabindexItem[],
129
+ id: string,
130
+ ): RovingTabindexItem | undefined {
131
+ const currIndex = orderedItems.findIndex(item => item.id === id)
132
+ return orderedItems.at(
133
+ currIndex === orderedItems.length ? 0 : currIndex + 1,
134
+ )
135
+ }
136
+
137
+ export function getParentFocusableId(
138
+ orderedItems: RovingTabindexItem[],
139
+ id: string,
140
+ ): RovingTabindexItem | undefined {
141
+ const currentElement = orderedItems.find(item => item.id === id)?.element
142
+
143
+ if (currentElement == null) return
144
+
145
+ let possibleParent = currentElement.parentElement
146
+
147
+ while (
148
+ possibleParent != null &&
149
+ possibleParent.getAttribute(NODE_SELECTOR) == null &&
150
+ possibleParent.getAttribute(ROOT_SELECTOR) == null
151
+ ) {
152
+ possibleParent = possibleParent?.parentElement ?? null
153
+ }
154
+
155
+ return orderedItems.find(item => item.element === possibleParent)
156
+ }
157
+
158
+ export function getPrevFocusableId(
159
+ orderedItems: RovingTabindexItem[],
160
+ id: string,
161
+ ): RovingTabindexItem | undefined {
162
+ const currIndex = orderedItems.findIndex(item => item.id === id)
163
+ return orderedItems.at(currIndex === 0 ? -1 : currIndex - 1)
164
+ }
165
+
166
+ export function getFirstFocusableId(
167
+ orderedItems: RovingTabindexItem[],
168
+ ): RovingTabindexItem | undefined {
169
+ return orderedItems.at(0)
170
+ }
171
+
172
+ export function getLastFocusableId(
173
+ orderedItems: RovingTabindexItem[],
174
+ ): RovingTabindexItem | undefined {
175
+ return orderedItems.at(-1)
176
+ }
177
+
178
+ function wrapArray<T>(array: T[], startIndex: number) {
179
+ return array.map((_, index) => array[(startIndex + index) % array.length])
180
+ }
181
+
182
+ export function getNextFocusableIdByTypeahead(
183
+ items: RovingTabindexItem[],
184
+ originalId: string,
185
+ keyPressed: string,
186
+ ) {
187
+ const index = items.findIndex(({ id }) => id === originalId)
188
+ const wrappedItems = wrapArray(items, index)
189
+ let typeaheadMatchIndex: RovingTabindexItem | undefined
190
+
191
+ for (
192
+ let index = 0;
193
+ index < wrappedItems.length - 1 && typeaheadMatchIndex == null;
194
+ index++
195
+ ) {
196
+ const nextItem = wrappedItems.at(index + 1)
197
+
198
+ if (
199
+ nextItem?.element?.textContent?.charAt(0).toLowerCase() ===
200
+ keyPressed.charAt(0).toLowerCase()
201
+ ) {
202
+ typeaheadMatchIndex = nextItem
203
+ }
204
+ }
205
+
206
+ return typeaheadMatchIndex
207
+ }
208
+
209
+ export function useRovingTabindex(id: string) {
210
+ const {
211
+ currentRovingTabindexValue,
212
+ setFocusableId,
213
+ onShiftTab,
214
+ getOrderedItems,
215
+ elements,
216
+ } = useContext(RovingTabindexContext)
217
+
218
+ return {
219
+ getOrderedItems,
220
+ isFocusable: currentRovingTabindexValue === id,
221
+ getRovingProps: <T extends ElementType>(
222
+ props?: ComponentPropsWithoutRef<T>,
223
+ ) => ({
224
+ ...props,
225
+ ref: (element: HTMLElement | null) => {
226
+ if (element) {
227
+ elements.current.set(id, element)
228
+ } else {
229
+ elements.current.delete(id)
230
+ }
231
+ },
232
+ onMouseDown: (e: MouseEvent) => {
233
+ props?.onMouseDown?.(e)
234
+ if (e.target !== e.currentTarget) return
235
+ setFocusableId(id)
236
+ },
237
+ onKeyDown: (e: KeyboardEvent) => {
238
+ props?.onKeyDown?.(e)
239
+ if (e.target !== e.currentTarget) return
240
+ if (isHotkey('shift+tab', e)) {
241
+ onShiftTab()
242
+ return
243
+ }
244
+ },
245
+ onFocus: (e: FocusEvent) => {
246
+ props?.onFocus?.(e)
247
+ if (e.target !== e.currentTarget) return
248
+ setFocusableId(id)
249
+ },
250
+ [NODE_SELECTOR]: true,
251
+ tabIndex: currentRovingTabindexValue === id ? 0 : -1,
252
+ }),
253
+ }
254
+ }
src/components/core/tree/tree-state.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // adapted from joshuawootonn/react-components-from-scratch
2
+ import { createContext, Dispatch } from "react"
3
+
4
+ import { ChainableMap } from "./chainable-map"
5
+ import { OpenState, TreeViewActions, TreeViewActionTypes } from "./types"
6
+
7
+ export const TREE_VIEW_ROOT_ID = 'TREE_VIEW_ROOT_ID'
8
+
9
+ export function treeviewReducer(state: OpenState, action: TreeViewActions): OpenState {
10
+ switch (action.type) {
11
+ case TreeViewActionTypes.OPEN:
12
+ return new ChainableMap(state).set(action.id, true)
13
+
14
+ case TreeViewActionTypes.CLOSE:
15
+ return new ChainableMap(state).set(action.id, false)
16
+
17
+ default:
18
+ throw new Error('Tree Reducer received an unknown action')
19
+ }
20
+ }
21
+
22
+ export type TreeViewContextType= {
23
+ open: OpenState
24
+ dispatch: Dispatch<TreeViewActions>
25
+ selectedId: string | null
26
+ select: (id: string | null, nodeType?: any, data?: any) => void
27
+ }
28
+
29
+ export const TreeViewContext = createContext<TreeViewContextType>({
30
+ open: new ChainableMap<string, boolean>(),
31
+ dispatch: () => {},
32
+ selectedId: null,
33
+ select: () => {},
34
+ })
src/components/core/tree/types.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReactNode } from "react"
2
+
3
+ import { ChainableMap } from "./chainable-map"
4
+ import { IconType } from "react-icons/lib"
5
+
6
+ export type OpenState = ChainableMap<string, boolean>
7
+
8
+ export enum TreeViewActionTypes {
9
+ OPEN = 'OPEN',
10
+ CLOSE = 'CLOSE',
11
+ }
12
+
13
+ export type TreeViewActions =
14
+ | {
15
+ type: TreeViewActionTypes.OPEN
16
+ id: string
17
+ }
18
+ | {
19
+ type: TreeViewActionTypes.CLOSE
20
+ id: string
21
+ }
22
+
23
+ export type RovingTabindexItem = {
24
+ id: string
25
+ element: HTMLElement
26
+ }
27
+
28
+ export type TreeNodeType<S,T> = {
29
+ id: string
30
+ nodeType?: S
31
+ label: ReactNode
32
+ children?: TreeNodeType<S,T>[]
33
+ isFolder?: boolean
34
+ isExpanded?: boolean
35
+ icon?: IconType
36
+ className?: string
37
+ data?: T
38
+ }
src/components/core/tree/useTreeNode.ts ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ useContext,
3
+ FocusEvent,
4
+ MouseEvent,
5
+ KeyboardEvent,
6
+ ComponentPropsWithoutRef,
7
+ ElementType,
8
+ useMemo,
9
+ useEffect,
10
+ useRef,
11
+ } from "react"
12
+ import isHotkey from "is-hotkey"
13
+
14
+ import {
15
+ getFirstFocusableId,
16
+ getLastFocusableId,
17
+ getNextFocusableId,
18
+ getNextFocusableIdByTypeahead,
19
+ getParentFocusableId,
20
+ getPrevFocusableId,
21
+ NOT_FOCUSABLE_SELECTOR,
22
+ useRovingTabindex,
23
+ } from "./roving"
24
+
25
+ import {
26
+ TreeViewContextType,
27
+ TreeViewContext,
28
+ } from "./tree-state"
29
+ import { RovingTabindexItem, TreeViewActionTypes } from "./types"
30
+
31
+ export function useTreeNode<T extends ElementType>(
32
+ id: string,
33
+ options: {
34
+ selectionType: 'followFocus' | 'distinct'
35
+ isFolder: boolean
36
+ isExpanded?: boolean // Add this field to the options
37
+ data?: any
38
+ } = {
39
+ selectionType: 'followFocus',
40
+ isFolder: false,
41
+ isExpanded: false,
42
+ data: undefined,
43
+ },
44
+ ): {
45
+ isOpen: boolean
46
+ open: () => void
47
+ close: () => void
48
+ isFocusable: boolean
49
+ isSelected: boolean
50
+ isExpanded: boolean
51
+ getTreeNodeProps: (props: ComponentPropsWithoutRef<T>) => {
52
+ ref: (current: HTMLElement | null) => void
53
+ tabIndex: number
54
+ ['aria-expanded']: boolean
55
+ ['aria-selected']: boolean
56
+ role: 'treeitem'
57
+ onMouseDown: (event: MouseEvent) => void
58
+ onKeyDown: (event: KeyboardEvent) => void
59
+ onFocus: (event: FocusEvent) => void
60
+ }
61
+ treeGroupProps: {
62
+ role: 'group'
63
+ }
64
+ } {
65
+ const { open, selectedId, select, dispatch } =
66
+ useContext<TreeViewContextType>(TreeViewContext)
67
+
68
+ const { isFocusable, getOrderedItems, getRovingProps } =
69
+ useRovingTabindex(id)
70
+
71
+ const dispatchOnce = useRef(false) // Add a ref to track initial dispatch of the default expander
72
+
73
+ useEffect(() => {
74
+ if (options.isExpanded && !open.get(id) && !dispatchOnce.current) {
75
+ dispatch({ type: TreeViewActionTypes.OPEN, id })
76
+
77
+ // Ensure the action is dispatched only once, otherwise we wouldn't be able to collapse the node
78
+ dispatchOnce.current = true
79
+ }
80
+ }, [id, options.isExpanded, open, dispatch])
81
+
82
+ return useMemo(() => {
83
+ const isOpen = open.get(id) ?? false
84
+
85
+ return {
86
+ isOpen,
87
+ isFocusable,
88
+ isSelected: selectedId === id,
89
+ isExpanded: Boolean(options.isExpanded),
90
+ open: function () {
91
+ dispatch({ type: TreeViewActionTypes.OPEN, id })
92
+ },
93
+ close: function () {
94
+ dispatch({ type: TreeViewActionTypes.CLOSE, id })
95
+ },
96
+ getTreeNodeProps: (props: ComponentPropsWithoutRef<T>) => ({
97
+ ['aria-expanded']: isOpen,
98
+ ['aria-selected']: selectedId === id,
99
+ role: 'treeitem',
100
+ ...getRovingProps<T>({
101
+ ...props,
102
+ [NOT_FOCUSABLE_SELECTOR]: !isOpen,
103
+ onMouseDown: function (e: MouseEvent) {
104
+ e.stopPropagation()
105
+ props?.onMouseDown?.(e)
106
+ if (e.button === 0) {
107
+ if (options.isFolder) {
108
+ isOpen
109
+ ? dispatch({
110
+ type: TreeViewActionTypes.CLOSE,
111
+ id,
112
+ })
113
+ : dispatch({
114
+ type: TreeViewActionTypes.OPEN,
115
+ id,
116
+ })
117
+ } else {
118
+ // openOpen?.()
119
+ }
120
+ select(id, options?.data?.nodeType, options?.data?.data)
121
+ }
122
+ },
123
+ onKeyDown: function (e: KeyboardEvent) {
124
+ e.stopPropagation()
125
+ props.onKeyDown?.(e)
126
+
127
+ let nextItemToFocus: RovingTabindexItem | undefined
128
+ const items = getOrderedItems()
129
+
130
+ if (isHotkey('up', e)) {
131
+ e.preventDefault()
132
+ nextItemToFocus = getPrevFocusableId(items, id)
133
+ } else if (isHotkey('down', e)) {
134
+ e.preventDefault()
135
+ nextItemToFocus = getNextFocusableId(items, id)
136
+ } else if (isHotkey('left', e)) {
137
+ if (isOpen && options.isFolder) {
138
+ dispatch({
139
+ type: TreeViewActionTypes.CLOSE,
140
+ id,
141
+ })
142
+ } else {
143
+ nextItemToFocus = getParentFocusableId(
144
+ items,
145
+ id,
146
+ )
147
+ }
148
+ } else if (isHotkey('right', e)) {
149
+ if (isOpen && options.isFolder) {
150
+ nextItemToFocus = getNextFocusableId(items, id)
151
+ } else {
152
+ dispatch({ type: TreeViewActionTypes.OPEN, id })
153
+ }
154
+ } else if (isHotkey('home', e)) {
155
+ e.preventDefault()
156
+ nextItemToFocus = getFirstFocusableId(items)
157
+ } else if (isHotkey('end', e)) {
158
+ e.preventDefault()
159
+ nextItemToFocus = getLastFocusableId(items)
160
+ } else if (isHotkey('space', e)) {
161
+ e.preventDefault()
162
+ select(id, options?.data?.nodeType, options?.data?.data)
163
+ } else if (/^[a-z]$/i.test(e.key)) {
164
+ nextItemToFocus = getNextFocusableIdByTypeahead(
165
+ items,
166
+ id,
167
+ e.key,
168
+ )
169
+ }
170
+
171
+ if (nextItemToFocus != null) {
172
+ nextItemToFocus.element.focus()
173
+ options.selectionType === 'followFocus' &&
174
+ select(nextItemToFocus.id)
175
+ }
176
+ },
177
+ }),
178
+ }),
179
+ treeGroupProps: {
180
+ role: 'group',
181
+ },
182
+ }
183
+ }, [
184
+ dispatch,
185
+ getOrderedItems,
186
+ getRovingProps,
187
+ id,
188
+ isFocusable,
189
+ open,
190
+ options.isFolder,
191
+ options.selectionType,
192
+ options.isExpanded,
193
+ select,
194
+ selectedId,
195
+ ])
196
+ }
src/components/editor/Editor.tsx DELETED
@@ -1,28 +0,0 @@
1
- import { EditorView } from "@aitube/clapper-services"
2
-
3
- import { useEditor } from "@/services"
4
-
5
- import EditorSideMenu from "../toolbars/editor-menu/EditorSideMenu"
6
-
7
- import { ScriptEditor } from "./ScriptEditor"
8
-
9
- export function Editor() {
10
- const view = useEditor(s => s.view)
11
-
12
- return <ScriptEditor />
13
- /*
14
- this doesn't work yet:
15
- return (
16
- <div className="flex flex-row flex-grow w-full overflow-hidden">
17
- <EditorSideMenu />
18
- <div className="flex flex-row flex-grow w-full overflow-hidden">
19
- {view === EditorView.SCRIPT
20
- ? <ScriptEditor />
21
- : view === EditorView.PROJECT
22
- ? <div>TODO</div>
23
- : <div>TODO</div>}
24
- </div>
25
- </div>
26
- )
27
- */
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/editors/Editors.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EditorView } from "@aitube/clapper-services"
2
+
3
+ import { useEditors } from "@/services"
4
+
5
+ import { EditorsSideMenu } from "../toolbars/editors-menu/EditorsSideMenu"
6
+
7
+ import { ScriptEditor } from "./ScriptEditor"
8
+ import { useTheme } from "@/services/ui/useTheme"
9
+ import { EntityEditor } from "./EntityEditor"
10
+ import { ProjectEditor } from "./ProjectEditor"
11
+ import { SegmentEditor } from "./SegmentEditor"
12
+
13
+ export function Editors() {
14
+ const theme = useTheme()
15
+ const view = useEditors(s => s.view)
16
+
17
+ return (
18
+ <div className="flex flex-row h-full w-full overflow-hidden">
19
+ <EditorsSideMenu />
20
+ <div className="flex flex-row h-full w-full overflow-hidden"
21
+ style={{
22
+ background: theme.editorBgColor || theme.defaultBgColor || '#000000'
23
+ }}>
24
+ {view === EditorView.SCRIPT
25
+ ? <ScriptEditor />
26
+ : view === EditorView.PROJECT
27
+ ? <ProjectEditor />
28
+ : view === EditorView.ENTITY
29
+ ? <EntityEditor />
30
+ : view === EditorView.SEGMENT
31
+ ? <SegmentEditor />
32
+ : <div>TODO</div>}
33
+ </div>
34
+ </div>
35
+ )
36
+ }
src/components/editors/EntityEditor/index.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FormFile } from "@/components/forms/FormFile"
2
+ import { FormInput } from "@/components/forms/FormInput"
3
+ import { FormSection } from "@/components/forms/FormSection"
4
+ import { useEntityEditor } from "@/services"
5
+
6
+ export function EntityEditor() {
7
+ const current = useEntityEditor(s => s.current)
8
+ const setCurrent = useEntityEditor(s => s.setCurrent)
9
+ const history = useEntityEditor(s => s.history)
10
+ const undo = useEntityEditor(s => s.undo)
11
+ const redo = useEntityEditor(s => s.redo)
12
+
13
+ if (!current) {
14
+ return <div>
15
+ No Entity selected
16
+ </div>
17
+ }
18
+
19
+ // TODO: adapt the editor based on the kind of
20
+ // entity (character, location..)
21
+ //
22
+ // I think we can use UI elements of our legacy character editor
23
+ // that I did in a Hugging Face space
24
+ return (
25
+ <FormSection
26
+ label={"Entity editor"}
27
+ className="p-4">
28
+ <label>Visual identity</label>
29
+ {current?.imageId
30
+ ? <img src={current?.imageId}></img>
31
+ : null}
32
+ <FormFile
33
+ label={"Visual identity file"}
34
+ />
35
+ {/*
36
+ <FormInput<string>
37
+ label={"Audio identity"}
38
+ value={current?.audioId.slice(0, 20)}
39
+ />
40
+ */}
41
+ <FormInput<string>
42
+ label={"Label"}
43
+ value={current.label}
44
+ />
45
+ <FormInput<string>
46
+ label={"Description"}
47
+ value={current.description}
48
+ />
49
+ <FormInput<number>
50
+ label={"Age"}
51
+ value={current.age}
52
+ />
53
+ <FormInput<string>
54
+ label={"Gender"}
55
+ value={current.gender}
56
+ />
57
+ <FormInput<string>
58
+ label={"Appearance"}
59
+ value={current.appearance}
60
+ />
61
+ </FormSection>
62
+ )
63
+ }
src/components/editors/ProjectEditor/index.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FormFile } from "@/components/forms/FormFile"
2
+ import { FormInput } from "@/components/forms/FormInput"
3
+ import { FormSection } from "@/components/forms/FormSection"
4
+ import { FormSwitch } from "@/components/forms/FormSwitch"
5
+ import { useProjectEditor } from "@/services"
6
+ import { ClapProject } from "@aitube/clap"
7
+ import { useTimeline } from "@aitube/timeline"
8
+ import { useEffect } from "react"
9
+
10
+ export function ProjectEditor() {
11
+ const clap: ClapProject | undefined = useTimeline(s => s.clap)
12
+
13
+ const current = useProjectEditor(s => s.current)
14
+ const setCurrent = useProjectEditor(s => s.setCurrent)
15
+ const history = useProjectEditor(s => s.history)
16
+ const undo = useProjectEditor(s => s.undo)
17
+ const redo = useProjectEditor(s => s.redo)
18
+
19
+ useEffect(() => {
20
+ setCurrent(clap?.meta)
21
+ }, [clap?.meta])
22
+
23
+ if (!current) {
24
+ return <div>
25
+ Loading..
26
+ </div>
27
+ }
28
+
29
+ // TODO: adapt the editor based on the kind of
30
+ // entity (character, location..)
31
+ //
32
+ // I think we can use UI elements of our legacy character editor
33
+ // that I did in a Hugging Face space
34
+ return (
35
+ <FormSection
36
+ label={"Project Settings"}
37
+ className="p-4">
38
+ <FormInput<string>
39
+ label={"title"}
40
+ value={current.title || ""}
41
+ defaultValue=""
42
+ onChange={title => {
43
+ setCurrent({ ...current, title })
44
+ }}
45
+ />
46
+ <FormInput<string>
47
+ label={"Description"}
48
+ value={current.description || ""}
49
+ defaultValue=""
50
+ onChange={description => {
51
+ setCurrent({ ...current, description })
52
+ }}
53
+ />
54
+ <FormInput<string>
55
+ label={"Synopsis"}
56
+ value={current.synopsis || ""}
57
+ defaultValue=""
58
+ onChange={synopsis => {
59
+ setCurrent({ ...current, synopsis })
60
+ }}
61
+ />
62
+ <FormInput<number>
63
+ label={"Default media width"}
64
+ value={current.width || 1024}
65
+ defaultValue={1024}
66
+ // 4k is 3840Γ—2160
67
+ // but we can't do that yet obviously
68
+ minValue={256}
69
+ maxValue={1024}
70
+ />
71
+ <FormInput<number>
72
+ label={"Default media height"}
73
+ value={current.height || 576}
74
+ defaultValue={576}
75
+ // 4k is 3840Γ—2160
76
+ // but we can't do that yet obviously
77
+ minValue={256}
78
+ maxValue={1024}
79
+ />
80
+ {/*
81
+ for this one we will need some kind of draft mode
82
+ */}
83
+ <FormInput<string>
84
+ label={"Global prompt keywords (\"3D render, comical\"..)"}
85
+ value={Array.isArray(current.extraPositivePrompt) ? (current.extraPositivePrompt.join(", ")) : ""}
86
+ onChange={newKeywords => {
87
+ // const keywords = newKeywords.split(",").map(x => x.trim())
88
+ }}
89
+ />
90
+ <FormInput<string>
91
+ label={"Licence (commercial, public domain...)"}
92
+ value={current.licence || ""}
93
+ onChange={licence => {
94
+ setCurrent({ ...current, licence })
95
+ }}
96
+ />
97
+ <FormSwitch
98
+ label={"Is interactive? (WIP feature)"}
99
+ checked={typeof current.isInteractive === "boolean" ? current.isInteractive : false}
100
+ onCheckedChange={(isInteractive) => {
101
+ setCurrent({ ...current, isInteractive: !isInteractive })
102
+ }}
103
+ />
104
+ <FormSwitch
105
+ label={"Is a loop? (WIP feature)"}
106
+ checked={typeof current.isLoop === "boolean" ? current.isLoop : false}
107
+ onCheckedChange={(isLoop) => {
108
+ setCurrent({ ...current, isLoop: !isLoop })
109
+ }}
110
+ />
111
+ </FormSection>
112
+ )
113
+ }
src/components/{editor β†’ editors}/ScriptEditor/index.tsx RENAMED
@@ -1,35 +1,35 @@
1
  import React, { useEffect, useState } from "react"
2
  import MonacoEditor from "monaco-editor"
3
  import Editor, { Monaco } from "@monaco-editor/react"
 
4
  import { DEFAULT_DURATION_IN_MS_PER_STEP, leftBarTrackScaleWidth, TimelineStore, useTimeline } from "@aitube/timeline"
5
 
6
- import { useEditor } from "@/services/editor/useEditor"
7
  import { useRenderer } from "@/services/renderer"
8
  import { useUI } from "@/services/ui"
9
  import { useTheme } from "@/services/ui/useTheme"
10
  import { themes } from "@/services/ui/theme"
11
 
12
  import "./styles.css"
13
- import { ClapSegmentCategory } from "@aitube/clap"
14
 
15
  export function ScriptEditor() {
16
 
17
- const standaloneCodeEditor = useEditor(s => s.standaloneCodeEditor)
18
- const setStandaloneCodeEditor = useEditor(s => s.setStandaloneCodeEditor)
19
- const draft = useEditor(s => s.draft)
20
- const setDraft = useEditor(s => s.setDraft)
21
- const loadDraftFromClap = useEditor(s => s.loadDraftFromClap)
22
- const onDidScrollChange = useEditor(s => s.onDidScrollChange)
23
- const jumpCursorOnLineClick = useEditor(s => s.jumpCursorOnLineClick)
24
 
25
  // this is an expensive function, we should only call it on blur or on click on a "save button maybe"
26
- const publishDraftToTimeline = useEditor(s => s.publishDraftToTimeline)
27
 
28
  const clap = useTimeline((s: TimelineStore) => s.clap)
29
 
30
  useEffect(() => { loadDraftFromClap(clap) }, [clap])
31
 
32
- const scrollHeight = useEditor(s => s.scrollHeight)
33
 
34
  const scrollX = useTimeline(s => s.scrollX)
35
  const contentWidth = useTimeline(s => s.contentWidth)
@@ -43,7 +43,7 @@ export function ScriptEditor() {
43
  // let's do something basic for now: we disable the
44
  // timeline-to-editor scroll sync when the user is
45
  // hovering the editor
46
- if (useEditor.getState().mouseIsInside) { return }
47
 
48
  if (horizontalTimelineRatio !== standaloneCodeEditor.getScrollTop()) {
49
  standaloneCodeEditor.setScrollPosition({ scrollTop: horizontalTimelineRatio })
@@ -67,7 +67,7 @@ export function ScriptEditor() {
67
  }, [standaloneCodeEditor, horizontalTimelineRatio])
68
 
69
  const onMount = (codeEditor: MonacoEditor.editor.IStandaloneCodeEditor) => {
70
- const { textModel } = useEditor.getState()
71
  if (!textModel) { return }
72
 
73
  codeEditor.setModel(textModel)
@@ -97,9 +97,9 @@ export function ScriptEditor() {
97
  // setDraft(plainText || "")
98
  }
99
 
100
- const setMonaco = useEditor(s => s.setMonaco)
101
- const setTextModel = useEditor(s => s.setTextModel)
102
- const setMouseIsInside = useEditor(s => s.setMouseIsInside)
103
  const themeName = useUI(s => s.themeName)
104
  const editorFontSize = useUI(s => s.editorFontSize)
105
 
@@ -143,7 +143,7 @@ export function ScriptEditor() {
143
 
144
  return (
145
  <div
146
- className="h-full"
147
  onMouseEnter={() => setMouseIsInside(true)}
148
  onMouseLeave={() => setMouseIsInside(false)}
149
  >
 
1
  import React, { useEffect, useState } from "react"
2
  import MonacoEditor from "monaco-editor"
3
  import Editor, { Monaco } from "@monaco-editor/react"
4
+ import { ClapSegmentCategory } from "@aitube/clap"
5
  import { DEFAULT_DURATION_IN_MS_PER_STEP, leftBarTrackScaleWidth, TimelineStore, useTimeline } from "@aitube/timeline"
6
 
7
+ import { useScriptEditor } from "@/services/editors/script-editor/useScriptEditor"
8
  import { useRenderer } from "@/services/renderer"
9
  import { useUI } from "@/services/ui"
10
  import { useTheme } from "@/services/ui/useTheme"
11
  import { themes } from "@/services/ui/theme"
12
 
13
  import "./styles.css"
 
14
 
15
  export function ScriptEditor() {
16
 
17
+ const standaloneCodeEditor = useScriptEditor(s => s.standaloneCodeEditor)
18
+ const setStandaloneCodeEditor = useScriptEditor(s => s.setStandaloneCodeEditor)
19
+ const draft = useScriptEditor(s => s.draft)
20
+ const setDraft = useScriptEditor(s => s.setDraft)
21
+ const loadDraftFromClap = useScriptEditor(s => s.loadDraftFromClap)
22
+ const onDidScrollChange = useScriptEditor(s => s.onDidScrollChange)
23
+ const jumpCursorOnLineClick = useScriptEditor(s => s.jumpCursorOnLineClick)
24
 
25
  // this is an expensive function, we should only call it on blur or on click on a "save button maybe"
26
+ const publishDraftToTimeline = useScriptEditor(s => s.publishDraftToTimeline)
27
 
28
  const clap = useTimeline((s: TimelineStore) => s.clap)
29
 
30
  useEffect(() => { loadDraftFromClap(clap) }, [clap])
31
 
32
+ const scrollHeight = useScriptEditor(s => s.scrollHeight)
33
 
34
  const scrollX = useTimeline(s => s.scrollX)
35
  const contentWidth = useTimeline(s => s.contentWidth)
 
43
  // let's do something basic for now: we disable the
44
  // timeline-to-editor scroll sync when the user is
45
  // hovering the editor
46
+ if (useScriptEditor.getState().mouseIsInside) { return }
47
 
48
  if (horizontalTimelineRatio !== standaloneCodeEditor.getScrollTop()) {
49
  standaloneCodeEditor.setScrollPosition({ scrollTop: horizontalTimelineRatio })
 
67
  }, [standaloneCodeEditor, horizontalTimelineRatio])
68
 
69
  const onMount = (codeEditor: MonacoEditor.editor.IStandaloneCodeEditor) => {
70
+ const { textModel } = useScriptEditor.getState()
71
  if (!textModel) { return }
72
 
73
  codeEditor.setModel(textModel)
 
97
  // setDraft(plainText || "")
98
  }
99
 
100
+ const setMonaco = useScriptEditor(s => s.setMonaco)
101
+ const setTextModel = useScriptEditor(s => s.setTextModel)
102
+ const setMouseIsInside = useScriptEditor(s => s.setMouseIsInside)
103
  const themeName = useUI(s => s.themeName)
104
  const editorFontSize = useUI(s => s.editorFontSize)
105
 
 
143
 
144
  return (
145
  <div
146
+ className="h-full w-full"
147
  onMouseEnter={() => setMouseIsInside(true)}
148
  onMouseLeave={() => setMouseIsInside(false)}
149
  >
src/components/{editor β†’ editors}/ScriptEditor/styles.css RENAMED
File without changes
src/components/editors/SegmentEditor/index.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FormInput } from "@/components/forms/FormInput"
2
+ import { FormSection } from "@/components/forms/FormSection"
3
+ import { useSegmentEditor } from "@/services"
4
+
5
+ export function SegmentEditor() {
6
+ const current = useSegmentEditor(s => s.current)
7
+ const setCurrent = useSegmentEditor(s => s.setCurrent)
8
+ const history = useSegmentEditor(s => s.history)
9
+ const undo = useSegmentEditor(s => s.undo)
10
+ const redo = useSegmentEditor(s => s.redo)
11
+
12
+ if (!current) {
13
+ return <div>
14
+ No segment selected
15
+ </div>
16
+ }
17
+
18
+ return (
19
+ <FormSection
20
+ label={"Project Settings"}
21
+ className="p-4">
22
+ <FormInput<string>
23
+ label={"Label"}
24
+ value={current.label}
25
+ />
26
+ <FormInput<string>
27
+ label={"Prompt"}
28
+ value={current.prompt}
29
+ onChange={(newValue: string) => {
30
+ setCurrent({
31
+ ...current,
32
+ prompt: newValue
33
+ })
34
+ }}
35
+ />
36
+ <div>
37
+ <div>Generation status</div>
38
+ <div>{current.status || "N.A."}</div>
39
+ </div>
40
+ <div>
41
+ <div>Created at</div>
42
+ <div>{current.createdAt || "N.A."}</div>
43
+ </div>
44
+ </FormSection>
45
+ )
46
+ }
src/components/forms/FormDir.tsx CHANGED
@@ -14,6 +14,7 @@ export function FormDir({
14
  onChange,
15
  horizontal,
16
  accept,
 
17
  }: {
18
  label?: string
19
  className?: string
@@ -22,6 +23,7 @@ export function FormDir({
22
  onChange?: (files: File[]) => void
23
  horizontal?: boolean
24
  accept?: string
 
25
  }) {
26
 
27
  const handleChange = useMemo(() => (event: ChangeEvent<HTMLInputElement>) => {
@@ -42,6 +44,7 @@ export function FormDir({
42
  `${label}:`
43
  }
44
  horizontal={horizontal}
 
45
  >
46
  <Input
47
  placeholder={`${placeholder || ""}`}
 
14
  onChange,
15
  horizontal,
16
  accept,
17
+ centered,
18
  }: {
19
  label?: string
20
  className?: string
 
23
  onChange?: (files: File[]) => void
24
  horizontal?: boolean
25
  accept?: string
26
+ centered?: boolean
27
  }) {
28
 
29
  const handleChange = useMemo(() => (event: ChangeEvent<HTMLInputElement>) => {
 
44
  `${label}:`
45
  }
46
  horizontal={horizontal}
47
+ centered={centered}
48
  >
49
  <Input
50
  placeholder={`${placeholder || ""}`}
src/components/forms/FormField.tsx CHANGED
@@ -4,11 +4,12 @@ import { cn } from "@/lib/utils"
4
 
5
  import { FormLabel } from "./FormLabel"
6
 
7
- export function FormField({ label, children, className, horizontal = false }: {
8
  label?: ReactNode
9
  children?: ReactNode
10
  className?: string
11
  horizontal?: boolean
 
12
  }) {
13
  return (
14
  <div className={cn(
@@ -20,6 +21,9 @@ export function FormField({ label, children, className, horizontal = false }: {
20
  <div className={cn(
21
  `flex`,
22
  horizontal ? '' : 'w-full',
 
 
 
23
  className
24
  )}>
25
  {children}
 
4
 
5
  import { FormLabel } from "./FormLabel"
6
 
7
+ export function FormField({ label, children, className, horizontal = false, centered = false }: {
8
  label?: ReactNode
9
  children?: ReactNode
10
  className?: string
11
  horizontal?: boolean
12
+ centered?: boolean
13
  }) {
14
  return (
15
  <div className={cn(
 
21
  <div className={cn(
22
  `flex`,
23
  horizontal ? '' : 'w-full',
24
+ centered ? (
25
+ horizontal ? 'items-center' : 'justify-center'
26
+ ) : '',
27
  className
28
  )}>
29
  {children}
src/components/forms/FormFile.tsx CHANGED
@@ -13,7 +13,8 @@ export function FormFile({
13
  disabled,
14
  onChange,
15
  horizontal,
16
- accept
 
17
  }: {
18
  label?: string
19
  className?: string
@@ -22,6 +23,7 @@ export function FormFile({
22
  onChange?: (files: File[]) => void
23
  horizontal?: boolean
24
  accept?: string
 
25
  }) {
26
  const ref = useRef<HTMLInputElement>(null)
27
 
@@ -43,6 +45,7 @@ export function FormFile({
43
  `${label}:`
44
  }
45
  horizontal={horizontal}
 
46
  >
47
  <Input
48
  ref={ref}
 
13
  disabled,
14
  onChange,
15
  horizontal,
16
+ accept,
17
+ centered,
18
  }: {
19
  label?: string
20
  className?: string
 
23
  onChange?: (files: File[]) => void
24
  horizontal?: boolean
25
  accept?: string
26
+ centered?: boolean
27
  }) {
28
  const ref = useRef<HTMLInputElement>(null)
29
 
 
45
  `${label}:`
46
  }
47
  horizontal={horizontal}
48
+ centered={centered}
49
  >
50
  <Input
51
  ref={ref}
src/components/forms/FormInput.tsx CHANGED
@@ -18,6 +18,7 @@ export function FormInput<T>({
18
  onChange,
19
  horizontal,
20
  type,
 
21
  // ...props
22
  }: {
23
  label?: ReactNode
@@ -31,6 +32,7 @@ export function FormInput<T>({
31
  onChange?: (newValue: T) => void
32
  horizontal?: boolean
33
  type?: HTMLInputTypeAttribute
 
34
  }
35
  // & Omit<ComponentProps<typeof Input>, "value" | "defaultValue" | "placeholder" | "type" | "className" | "disabled" | "onChange">
36
  // & ComponentProps<typeof Input>
@@ -87,11 +89,15 @@ export function FormInput<T>({
87
  <FormField
88
  label={<>{label}:</>}
89
  horizontal={horizontal}
 
90
  >
91
  <Input
92
  ref={ref}
93
  placeholder={`${placeholder || defaultValue || ""}`}
94
- className={cn(`w-full md:w-60 lg:w-64 xl:w-80 font-light text-base`, className)}
 
 
 
95
  disabled={disabled}
96
  onChange={handleChange}
97
  // {...props}
 
18
  onChange,
19
  horizontal,
20
  type,
21
+ centered,
22
  // ...props
23
  }: {
24
  label?: ReactNode
 
32
  onChange?: (newValue: T) => void
33
  horizontal?: boolean
34
  type?: HTMLInputTypeAttribute
35
+ centered?: boolean
36
  }
37
  // & Omit<ComponentProps<typeof Input>, "value" | "defaultValue" | "placeholder" | "type" | "className" | "disabled" | "onChange">
38
  // & ComponentProps<typeof Input>
 
89
  <FormField
90
  label={<>{label}:</>}
91
  horizontal={horizontal}
92
+ centered={centered}
93
  >
94
  <Input
95
  ref={ref}
96
  placeholder={`${placeholder || defaultValue || ""}`}
97
+ className={cn(
98
+ `w-full`,
99
+ `md:w-60 lg:w-64 xl:w-80`,
100
+ `font-light text-base`, className)}
101
  disabled={disabled}
102
  onChange={handleChange}
103
  // {...props}
src/components/forms/FormRadio.tsx CHANGED
@@ -4,18 +4,20 @@ import { cn } from "@/lib/utils"
4
 
5
  import { FormField } from "./FormField"
6
 
7
- export function FormRadio({ label, className, selected, items, horizontal }: {
8
  label?: string
9
  className?: string
10
  selected?: string
11
  items?: { name: string; label: string; disabled?: boolean }[]
12
  horizontal?: boolean
 
13
  }) {
14
  return (
15
  <FormField
16
  label={label}
17
  className={cn(`flex-row space-x-5`, className)}
18
- horizontal={horizontal}>
 
19
  {items?.map(item => (
20
  <div key={item.name} className={cn(
21
  `flex flex-row items-center space-x-2`,
 
4
 
5
  import { FormField } from "./FormField"
6
 
7
+ export function FormRadio({ label, className, selected, items, horizontal, centered }: {
8
  label?: string
9
  className?: string
10
  selected?: string
11
  items?: { name: string; label: string; disabled?: boolean }[]
12
  horizontal?: boolean
13
+ centered?: boolean
14
  }) {
15
  return (
16
  <FormField
17
  label={label}
18
  className={cn(`flex-row space-x-5`, className)}
19
+ horizontal={horizontal}
20
+ centered={centered}>
21
  {items?.map(item => (
22
  <div key={item.name} className={cn(
23
  `flex flex-row items-center space-x-2`,
src/components/forms/FormSection.tsx CHANGED
@@ -9,10 +9,15 @@ export function FormSection({ label, children, className, horizontal }: {
9
  horizontal?: boolean
10
  }) {
11
  return (
12
- <div className={cn(`flex flex-col space-y-4`)}>
 
 
 
 
 
13
  <h2 className="text-4xl font-thin pb-2 text-stone-400">{label}</h2>
14
  <div className={cn(
15
- "flex",
16
  horizontal
17
  ? "flex-row space-x-3 justify-start"
18
  : "flex-col space-y-6"
 
9
  horizontal?: boolean
10
  }) {
11
  return (
12
+ <div className={cn(`
13
+ flex flex-col space-y-4
14
+ h-full w-full
15
+ scrollbar-corner-stone-500 scrollbar scrollbar-thumb-stone-700 scrollbar-track-stone-300
16
+ overflow-y-scroll
17
+ `, className)}>
18
  <h2 className="text-4xl font-thin pb-2 text-stone-400">{label}</h2>
19
  <div className={cn(
20
+ "flex w-full",
21
  horizontal
22
  ? "flex-row space-x-3 justify-start"
23
  : "flex-col space-y-6"
src/components/forms/FormSelect.tsx CHANGED
@@ -14,6 +14,7 @@ export function FormSelect<T>({
14
  items = [],
15
  onSelect,
16
  horizontal,
 
17
  }: {
18
  label?: string
19
  className?: string
@@ -29,6 +30,7 @@ export function FormSelect<T>({
29
  }[]
30
  onSelect?: (value?: T) => void
31
  horizontal?: boolean
 
32
  }) {
33
 
34
  return (
@@ -39,7 +41,8 @@ export function FormSelect<T>({
39
  : `${label}:`
40
  }
41
  className={cn(``, className)}
42
- horizontal={horizontal}>
 
43
  <Select
44
  onValueChange={(newSelectedItemId: string) => {
45
  if (!onSelect) {
 
14
  items = [],
15
  onSelect,
16
  horizontal,
17
+ centered,
18
  }: {
19
  label?: string
20
  className?: string
 
30
  }[]
31
  onSelect?: (value?: T) => void
32
  horizontal?: boolean
33
+ centered?: boolean
34
  }) {
35
 
36
  return (
 
41
  : `${label}:`
42
  }
43
  className={cn(``, className)}
44
+ horizontal={horizontal}
45
+ centered={centered}>
46
  <Select
47
  onValueChange={(newSelectedItemId: string) => {
48
  if (!onSelect) {
src/components/forms/FormSwitch.tsx CHANGED
@@ -5,18 +5,20 @@ import { cn } from "@/lib/utils"
5
  import { FormField } from "./FormField"
6
  import { Switch } from "../ui/switch"
7
 
8
- export function FormSwitch({ label, className, checked, onCheckedChange, horizontal }: {
9
  label?: string
10
  className?: string
11
  checked?: boolean
12
  onCheckedChange: (checked: boolean) => void
13
  horizontal?: boolean
 
14
  }) {
15
  return (
16
  <FormField
17
  label={label}
18
  className={cn(`flex-row space-x-5`, className)}
19
- horizontal={horizontal}>
 
20
  <Switch
21
  checked={checked}
22
  onCheckedChange={(checked) => {
 
5
  import { FormField } from "./FormField"
6
  import { Switch } from "../ui/switch"
7
 
8
+ export function FormSwitch({ label, className, checked, onCheckedChange, horizontal, centered }: {
9
  label?: string
10
  className?: string
11
  checked?: boolean
12
  onCheckedChange: (checked: boolean) => void
13
  horizontal?: boolean
14
+ centered?: boolean
15
  }) {
16
  return (
17
  <FormField
18
  label={label}
19
  className={cn(`flex-row space-x-5`, className)}
20
+ horizontal={horizontal}
21
+ centered={centered}>
22
  <Switch
23
  checked={checked}
24
  onCheckedChange={(checked) => {
src/components/icons/getAppropriateIcon.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconType } from "react-icons/lib"
2
+
3
+ import { icons } from "."
4
+
5
+ export const getAppropriateIcon = (rawText: string, defaultIcon?: IconType): IconType => {
6
+ const text = `${rawText || ""}`.trim().toLowerCase()
7
+
8
+ if (text.includes("downloads")) {
9
+ return icons.downloads
10
+ }
11
+
12
+ if (text.includes("demo scripts") || text.includes("screenplay")) {
13
+ return icons.screenplay
14
+ }
15
+
16
+ if (text.includes("folder") || text.includes("directory")) {
17
+ return defaultIcon || icons.misc
18
+ }
19
+
20
+
21
+ if (text.includes(".clap") || text.includes("clapper")) {
22
+ return icons.project
23
+ }
24
+
25
+ if (text.includes(".jpg") || text.includes("jpeg") || text.includes(".webp") || text.includes(".png")) {
26
+ return icons.imagefile
27
+ }
28
+
29
+ if (text.includes(".mp3")) {
30
+ return icons.soundfile
31
+ }
32
+
33
+ if (text.includes(".mp4")) {
34
+ return icons.videofile
35
+ }
36
+
37
+ if (text.includes(".txt") || text.includes(".md")) {
38
+ return icons.textfile
39
+ }
40
+
41
+ if (
42
+ text.includes("transfer") ||
43
+ text.includes("transform")
44
+ ) {
45
+ return icons.transfer
46
+ }
47
+
48
+ if (
49
+ text.includes("interpolator") ||
50
+ text.includes("interpolate") ||
51
+ text.includes("interpolation")
52
+ ) {
53
+ return icons.interpolate
54
+ }
55
+
56
+ if (
57
+ text.includes("superresolution") ||
58
+ text.includes("resolution") ||
59
+ text.includes("upscaling") ||
60
+ text.includes("upscaler") ||
61
+ text.includes("upscale")
62
+ ) {
63
+ return icons.upscale
64
+ }
65
+
66
+ if (
67
+ text.includes("tts") ||
68
+ text.includes("speech") ||
69
+ text.includes("voice")
70
+ ) {
71
+ return icons.speech
72
+ }
73
+
74
+ if (
75
+ text.includes("video") ||
76
+ text.includes("movie")
77
+ ) {
78
+ return icons.film
79
+ }
80
+
81
+ if (
82
+ text.includes("audio") ||
83
+ text.includes("sound") ||
84
+ text.includes("music")
85
+ ) {
86
+ return icons.sound
87
+ }
88
+
89
+ if (
90
+ text.includes("image") ||
91
+ text.includes("photo")
92
+ ) {
93
+ return icons.image
94
+ }
95
+
96
+ return defaultIcon || icons.misc
97
+ }
src/components/icons/index.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { IconType } from "react-icons/lib"
3
+
4
+ import { CgClapperBoard } from "react-icons/cg"
5
+ import { LuFileAudio2, LuFileImage, LuFileText, LuFileVideo2, LuScrollText, LuTextCursorInput } from "react-icons/lu"
6
+ import { IoIosColorFilter, IoMdCloudOutline } from "react-icons/io"
7
+ import { MdOutlineVideoSettings } from "react-icons/md"
8
+ import { MdOutlineCorporateFare } from "react-icons/md"
9
+ import { MdQueueMusic } from "react-icons/md"
10
+ import { BsPersonVideo2 } from "react-icons/bs"
11
+ import { RiComputerLine, RiFileVideoLine, RiScissors2Line } from "react-icons/ri"
12
+ import { PiFileVideo } from "react-icons/pi"
13
+ import { RiFolderVideoLine } from "react-icons/ri"
14
+ import { MdGroup } from "react-icons/md"
15
+ import { HiOutlineGlobeAlt } from "react-icons/hi2"
16
+ import { HiOutlineShoppingCart } from "react-icons/hi"
17
+ import { MdOutlineLocationOn } from "react-icons/md"
18
+ import { FaPersonFallingBurst } from "react-icons/fa6"
19
+ import { FaPeoplePulling } from "react-icons/fa6"
20
+ import { MdNaturePeople } from "react-icons/md"
21
+ import { BsSoundwave } from "react-icons/bs"
22
+ import { HiOutlineFilm } from "react-icons/hi"
23
+ import { BiUserVoice } from "react-icons/bi"
24
+ import { BiImage } from "react-icons/bi"
25
+ import { MdOutlineHighQuality } from "react-icons/md"
26
+ import { MdOutlineAutoAwesomeMotion } from "react-icons/md"
27
+ import { BiTransfer } from "react-icons/bi"
28
+ import { LiaFileDownloadSolid } from "react-icons/lia"
29
+
30
+
31
+ // icons used for our various model types
32
+ export const icons: Record<string, IconType> = {
33
+ project: CgClapperBoard,
34
+ team: MdGroup,
35
+ computer: RiComputerLine,
36
+ cloud: IoMdCloudOutline,
37
+ downloads: LiaFileDownloadSolid,
38
+ soundfile: LuFileAudio2,
39
+ imagefile: LuFileImage,
40
+ videofile: LuFileVideo2,
41
+ textfile: LuFileText,
42
+ screenplay: LuScrollText,
43
+ community: HiOutlineGlobeAlt,
44
+ vendor: HiOutlineShoppingCart,
45
+ prompt: LuTextCursorInput,
46
+ characters: FaPersonFallingBurst,
47
+ character: BsPersonVideo2,
48
+ transition: RiScissors2Line,
49
+ location: MdOutlineLocationOn,
50
+ misc: MdNaturePeople,
51
+ lora: BsPersonVideo2,
52
+ sound: BsSoundwave,
53
+ film: HiOutlineFilm,
54
+ speech: BiUserVoice,
55
+ image: BiImage,
56
+ transfer: BiTransfer,
57
+ interpolate: MdOutlineAutoAwesomeMotion,
58
+ upscale: MdOutlineHighQuality,
59
+ textToVideo: MdOutlineVideoSettings,
60
+ videoToVideo: IoIosColorFilter,
61
+ textToMusic: MdQueueMusic,
62
+ referenceVideoFolder: RiFolderVideoLine,
63
+ referenceVideoFile: PiFileVideo,
64
+ }
src/components/settings/constants.ts CHANGED
@@ -222,6 +222,7 @@ export const availableModelsForImageGeneration: Partial<Record<ComputeProvider,
222
 
223
  export const availableModelsForImageUpscaling: Partial<Record<ComputeProvider, string[]>> = {
224
  [ComputeProvider.FALAI]: [
 
225
  "fal-ai/ccsr",
226
  ],
227
  [ComputeProvider.STABILITYAI]: [
 
222
 
223
  export const availableModelsForImageUpscaling: Partial<Record<ComputeProvider, string[]>> = {
224
  [ComputeProvider.FALAI]: [
225
+ "fal-ai/aura-sr", // "input": { "image_url": "<url>" }
226
  "fal-ai/ccsr",
227
  ],
228
  [ComputeProvider.STABILITYAI]: [
src/components/toolbars/{editor-menu/EditorSideMenu.tsx β†’ editors-menu/EditorsSideMenu.tsx} RENAMED
@@ -1,27 +1,30 @@
1
  "use client"
2
 
3
  import { LiaCogSolid, LiaTheaterMasksSolid } from "react-icons/lia"
4
- import { MdAccountCircle, MdOutlineAccountTree, MdOutlineHistoryEdu } from "react-icons/md"
5
  import { LuClapperboard } from "react-icons/lu"
6
-
7
- import { useEditor } from "@/services/editor/useEditor"
8
- import { EditorSideMenuItem } from "./EditorSideMenuItem"
9
  import { EditorView } from "@aitube/clapper-services"
 
10
  import { useTheme } from "@/services/ui/useTheme"
 
11
 
12
- export default function EditorSideMenu() {
13
  const theme = useTheme()
14
  return (
15
 
16
  <div className="flex flex-col w-14 h-full items-center justify-between border-r"
17
  style={{
18
- borderRightColor: theme.defaultTextColor || "#eeeeee"
 
19
  }}
20
  >
21
  <div className="flex flex-col h-full w-full items-center">
22
 
23
- <EditorSideMenuItem view={EditorView.PROJECT}><LuClapperboard /></EditorSideMenuItem>
24
- <EditorSideMenuItem view={EditorView.SCRIPT} label="Script editor"><MdOutlineHistoryEdu /></EditorSideMenuItem>
 
 
25
 
26
  {/*<EditorSideMenuItem name="Characters"><LiaTheaterMasksSolid /></EditorSideMenuItem>*/}
27
  {/*<EditorSideMenuItem name="Project"><MdLocalMovies /></EditorSideMenuItem>*/}
 
1
  "use client"
2
 
3
  import { LiaCogSolid, LiaTheaterMasksSolid } from "react-icons/lia"
4
+ import { MdAccountCircle, MdLocalMovies, MdOutlineAccountTree, MdOutlineHistoryEdu } from "react-icons/md"
5
  import { LuClapperboard } from "react-icons/lu"
6
+ import { IoFilmOutline } from "react-icons/io5"
 
 
7
  import { EditorView } from "@aitube/clapper-services"
8
+
9
  import { useTheme } from "@/services/ui/useTheme"
10
+ import { EditorsSideMenuItem } from "./EditorsSideMenuItem"
11
 
12
+ export function EditorsSideMenu() {
13
  const theme = useTheme()
14
  return (
15
 
16
  <div className="flex flex-col w-14 h-full items-center justify-between border-r"
17
  style={{
18
+ backgroundColor: theme.editorMenuBgColor || theme.defaultBgColor || "#eeeeee",
19
+ borderRightColor: theme.editorBorderColor || theme.defaultBorderColor || "#eeeeee"
20
  }}
21
  >
22
  <div className="flex flex-col h-full w-full items-center">
23
 
24
+ <EditorsSideMenuItem view={EditorView.PROJECT}><MdLocalMovies /></EditorsSideMenuItem>
25
+ <EditorsSideMenuItem view={EditorView.SCRIPT} label="Script editor"><MdOutlineHistoryEdu /></EditorsSideMenuItem>
26
+ <EditorsSideMenuItem view={EditorView.ENTITY} label="Entity editor"><LiaTheaterMasksSolid /></EditorsSideMenuItem>
27
+ <EditorsSideMenuItem view={EditorView.SEGMENT} label="Segment editor"><IoFilmOutline /></EditorsSideMenuItem>
28
 
29
  {/*<EditorSideMenuItem name="Characters"><LiaTheaterMasksSolid /></EditorSideMenuItem>*/}
30
  {/*<EditorSideMenuItem name="Project"><MdLocalMovies /></EditorSideMenuItem>*/}
src/components/toolbars/{editor-menu/EditorSideMenuItem.tsx β†’ editors-menu/EditorsSideMenuItem.tsx} RENAMED
@@ -4,10 +4,10 @@ import { EditorView } from "@aitube/clapper-services"
4
  import { cn } from "@/lib/utils"
5
 
6
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
7
- import { useEditor } from "@/services/editor/useEditor"
8
  import { useTheme } from "@/services/ui/useTheme"
9
 
10
- export function EditorSideMenuItem({
11
  children,
12
  view: expectedView,
13
  label,
@@ -31,8 +31,8 @@ export function EditorSideMenuItem({
31
  unmanaged?: boolean
32
  }) {
33
  const theme = useTheme()
34
- const view = useEditor(s => s.view)
35
- const setView = useEditor(s => s.setView)
36
 
37
  const isActive = !unmanaged && view === expectedView
38
 
@@ -66,7 +66,7 @@ export function EditorSideMenuItem({
66
  `group`
67
  )}
68
  style={{
69
- background: theme.editorBgColor || "#eeeeee",
70
  borderColor: isActive ? theme.defaultPrimaryColor || "#ffffff" : "#111827"
71
  }}
72
  onClick={handleClick}>
@@ -74,6 +74,7 @@ export function EditorSideMenuItem({
74
  `flex-col items-center justify-center`,
75
  `text-center text-[28px]`,
76
  `transition-all duration-200 ease-out`,
 
77
  isActive ? `scale-110` : `group-hover:scale-110`
78
  )}>
79
  {children}
 
4
  import { cn } from "@/lib/utils"
5
 
6
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
7
+ import { useEditors } from "@/services/editors/useEditors"
8
  import { useTheme } from "@/services/ui/useTheme"
9
 
10
+ export function EditorsSideMenuItem({
11
  children,
12
  view: expectedView,
13
  label,
 
31
  unmanaged?: boolean
32
  }) {
33
  const theme = useTheme()
34
+ const view = useEditors(s => s.view)
35
+ const setView = useEditors(s => s.setView)
36
 
37
  const isActive = !unmanaged && view === expectedView
38
 
 
66
  `group`
67
  )}
68
  style={{
69
+ // background: theme.editorMenuBgColor || theme.defaultBgColor || "#eeeeee",
70
  borderColor: isActive ? theme.defaultPrimaryColor || "#ffffff" : "#111827"
71
  }}
72
  onClick={handleClick}>
 
74
  `flex-col items-center justify-center`,
75
  `text-center text-[28px]`,
76
  `transition-all duration-200 ease-out`,
77
+ `stroke-1`,
78
  isActive ? `scale-110` : `group-hover:scale-110`
79
  )}>
80
  {children}
src/components/tree-browsers/model-tree-browser/index.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+ import { useEntityLibrary } from "../stores/useEntityLibrary"
7
+ import { LibraryNodeItem, LibraryNodeType } from "../types"
8
+ import { Tree } from "@/components/core/tree"
9
+
10
+ import { isClapEntity, isReplicateCollection } from "../utils/isSomething"
11
+ import { useCivitaiCollections } from "../stores/useCivitaiCollections"
12
+ import { useReplicateCollections } from "../stores/useReplicateCollections"
13
+
14
+ export function ModelTreeBrowser() {
15
+ const libraryTreeRoot = useEntityLibrary(s => s.libraryTreeRoot)
16
+ const selectTreeNode = useEntityLibrary(s => s.selectTreeNode)
17
+ const selectedTreeNodeId = useEntityLibrary(s => s.selectedTreeNodeId)
18
+ const setReplicateCollections = useEntityLibrary(s => s.setReplicateCollections)
19
+ const setCivitaiCollections = useEntityLibrary(s => s.setCivitaiCollections)
20
+
21
+ // TODO: we are forced to do this because the api "endpoint" is a server action
22
+ // however we could rewrite it so that we can pull the collections directly
23
+ // from the Zustand store
24
+
25
+ const newReplicateCollections = useReplicateCollections()
26
+ useEffect(() => {
27
+ setReplicateCollections(newReplicateCollections)
28
+ }, [
29
+ JSON.stringify(newReplicateCollections) // ... yeah, I know, I know..
30
+ ])
31
+
32
+ const newCivitaiCollections = useCivitaiCollections()
33
+ useEffect(() => {
34
+ setCivitaiCollections(newCivitaiCollections)
35
+ }, [
36
+ JSON.stringify(newCivitaiCollections) // ... yeah, I know, I know..
37
+ ])
38
+
39
+
40
+ /**
41
+ * handle click on tree node
42
+ * yes, this is where the magic happens!
43
+ *
44
+ * @param id
45
+ * @param nodeType
46
+ * @param node
47
+ * @returns
48
+ */
49
+ const handleOnChange = async (id: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => {
50
+ console.log(`calling selectTreeNodeById(id)`)
51
+ selectTreeNode(id, nodeType, nodeItem)
52
+
53
+ if (!nodeType || !nodeItem) {
54
+ console.log("tree-browser: clicked on an undefined node")
55
+ return
56
+ }
57
+
58
+ if (isReplicateCollection(nodeType, nodeItem)) {
59
+ // ReplicateCollection
60
+ } else if (isClapEntity(nodeType, nodeItem)) {
61
+ // ClapEntity
62
+ } else {
63
+ console.log(`tree-browser: no action attached to ${nodeType}, so skipping`)
64
+ return
65
+ }
66
+ console.log(`tree-browser: clicked on a ${nodeType}`, nodeItem)
67
+ }
68
+
69
+ return (
70
+
71
+ <div className={cn(
72
+ )}>
73
+ <Tree.Root<LibraryNodeType, LibraryNodeItem>
74
+ value={selectedTreeNodeId}
75
+ onChange={handleOnChange}
76
+
77
+ className="w-full h-full not-prose px-2 pt-8"
78
+ label="Model Library"
79
+ >
80
+ {libraryTreeRoot.map(node => (
81
+ <Tree.Node node={node} key={node.id} />
82
+ ))}
83
+ </Tree.Root>
84
+ </div>
85
+ )
86
+ }
src/components/tree-browsers/model-tree-browser/tree-item-viewer.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEntityLibrary } from "../stores/useEntityLibrary"
4
+
5
+ export function TreeItemViewer() {
6
+ const selectedNodeItem = useEntityLibrary(s => s.selectedNodeItem)
7
+ const selectedNodeType = useEntityLibrary(s => s.selectedNodeType)
8
+
9
+ const nodeType = selectedNodeType
10
+ const data = selectedNodeItem
11
+
12
+ if (!nodeType || !data) { return null }
13
+
14
+ return (
15
+ <div className="pt-8">
16
+ The selected item cannot be preview.
17
+ </div>
18
+ )
19
+ }
src/components/tree-browsers/project-tree-browser/index.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { cn } from "@/lib/utils"
4
+ import { isClapEntity } from "../utils/isSomething"
5
+ import { useProjectLibrary } from "../stores/useProjectLibrary"
6
+ import { LibraryNodeItem, LibraryNodeType } from "../types"
7
+ import { Tree } from "@/components/core/tree"
8
+
9
+ export function ProjectTreeBrowser() {
10
+ const libraryTreeRoot = useProjectLibrary(s => s.libraryTreeRoot)
11
+ const selectTreeNode = useProjectLibrary(s => s.selectTreeNode)
12
+ const selectedTreeNodeId = useProjectLibrary(s => s.selectedTreeNodeId)
13
+
14
+ /**
15
+ * handle click on tree node
16
+ * yes, this is where the magic happens!
17
+ *
18
+ * @param id
19
+ * @param nodeType
20
+ * @param node
21
+ * @returns
22
+ */
23
+ const handleOnChange = async (id: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => {
24
+ console.log(`calling selectTreeNodeById(id)`)
25
+ selectTreeNode(id, nodeType, nodeItem)
26
+
27
+ if (!nodeType || !nodeItem) {
28
+ console.log("tree-browser: clicked on an undefined node")
29
+ return
30
+ }
31
+ if (isClapEntity(nodeType, nodeItem)) {
32
+ // ClapEntity
33
+ } else {
34
+ console.log(`tree-browser: no action attached to ${nodeType}, so skipping`)
35
+ return
36
+ }
37
+ console.log(`tree-browser: clicked on a ${nodeType}`, nodeItem)
38
+ }
39
+
40
+ return (
41
+
42
+ <div className={cn(
43
+ )}>
44
+ <Tree.Root<LibraryNodeType, LibraryNodeItem>
45
+ value={selectedTreeNodeId}
46
+ onChange={handleOnChange}
47
+
48
+ className="w-full h-full not-prose px-2 pt-8"
49
+ label="Project Library"
50
+ >
51
+ {libraryTreeRoot.map(node => (
52
+ <Tree.Node node={node} key={node.id} />
53
+ ))}
54
+ </Tree.Root>
55
+ </div>
56
+ )
57
+ }
src/components/tree-browsers/project-tree-browser/tree-item-viewer.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEntityLibrary } from "../stores/useEntityLibrary"
4
+
5
+ export function TreeItemViewer() {
6
+ const selectedNodeItem = useEntityLibrary(s => s.selectedNodeItem)
7
+ const selectedNodeType = useEntityLibrary(s => s.selectedNodeType)
8
+
9
+ const nodeType = selectedNodeType
10
+ const data = selectedNodeItem
11
+
12
+ if (!nodeType || !data) { return null }
13
+
14
+ return (
15
+ <div>TODO</div>
16
+ )
17
+ }
src/components/tree-browsers/stores/useCivitaiCollections.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useState, useTransition } from "react"
4
+ import { CivitaiCollection } from "../types"
5
+
6
+
7
+ export function useCivitaiCollections(): CivitaiCollection[] {
8
+ const [_pending, startTransition] = useTransition()
9
+ const [collections, setCollections] = useState<CivitaiCollection[]>([])
10
+ // const [models, setModels] = useState<CivitaiModel[]>([])
11
+
12
+ useEffect(() => {
13
+ startTransition(async () => {
14
+ // TODO @Julian: this was something we did in the ligacy
15
+ // Clapper, but I'm not sure we want to support Civitai
16
+ // again just yet, we probably require other arch changes
17
+ // const collections = await listCollections()
18
+ const collections: CivitaiCollection[] = []
19
+ setCollections(collections)
20
+ // setModels(models)
21
+ })
22
+ }, [])
23
+
24
+ return collections
25
+ }
src/components/tree-browsers/stores/useEntityLibrary.ts ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { create } from "zustand"
4
+ import { ClapEntity, UUID } from "@aitube/clap"
5
+ import { CivitaiCollection, LibraryNodeItem, LibraryNodeType, LibraryTreeNode, ReplicateCollection } from "../types"
6
+ import { icons } from "@/components/icons"
7
+ import { getAppropriateIcon } from "@/components/icons/getAppropriateIcon"
8
+
9
+
10
+ // TODO: this isn't the best place for this as this is style,
11
+ // and we are in a state manager
12
+ const libraryClassName = "text-base font-semibold"
13
+
14
+ const collectionClassName = `text-base font-normal`
15
+
16
+ const itemClassName = "text-sm font-light text-gray-200/60 hover:text-gray-200/100"
17
+
18
+ export const useEntityLibrary = create<{
19
+ teamLibraryTreeNodeId: string
20
+ communityLibraryTreeNodeId: string
21
+ civitaiLibraryTreeNodeId: string
22
+ huggingfaceLibraryTreeNodeId: string
23
+ replicateLibraryTreeNodeId: string
24
+ libraryTreeRoot: LibraryTreeNode[]
25
+ init: () => void
26
+
27
+ /**
28
+ * Load Replicate collections (API models) into the tree
29
+ *
30
+ * @param collections
31
+ * @returns
32
+ */
33
+ setReplicateCollections: (collections: ReplicateCollection[]) => void
34
+
35
+ /**
36
+ * Load Replicate collections (LoRA models) into the tree
37
+ *
38
+ * @param collections
39
+ * @returns
40
+ */
41
+ setCivitaiCollections: (collections: CivitaiCollection[]) => void
42
+
43
+ // we support those all selection modes for convenience - please keep them!
44
+ selectedNodeItem?: LibraryNodeItem
45
+ selectedNodeType?: LibraryNodeType
46
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => void
47
+ selectedTreeNodeId: string | null
48
+ }>((set, get) => ({
49
+ localUserLibraryTreeNodeId: "",
50
+ huggingfaceUserLibraryTreeNodeId: "",
51
+ teamLibraryTreeNodeId: "",
52
+ communityLibraryTreeNodeId: "",
53
+ civitaiLibraryTreeNodeId: "",
54
+ huggingfaceLibraryTreeNodeId: "",
55
+ replicateLibraryTreeNodeId: "",
56
+ libraryTreeRoot: [],
57
+ init: () => {
58
+ const teamLibrary: LibraryTreeNode = {
59
+ id: UUID(),
60
+ nodeType: "LIB_NODE_TEAM_COLLECTION",
61
+ label: 'Team models',
62
+ icon: icons.team,
63
+ className: libraryClassName,
64
+ isExpanded: true,
65
+ children: [
66
+ {
67
+ id: UUID(),
68
+ nodeType: "LIB_NODE_GENERIC_EMPTY",
69
+ label: 'A - 2',
70
+ icon: icons.team,
71
+ className: collectionClassName,
72
+ }
73
+ ]
74
+ }
75
+
76
+
77
+ const civitaiLibrary: LibraryTreeNode = {
78
+ id: UUID(),
79
+ nodeType: "LIB_NODE_CIVITAI_COLLECTION",
80
+ label: 'Civitai models',
81
+ icon: icons.community,
82
+ className: libraryClassName,
83
+ children: []
84
+ }
85
+
86
+ const communityLibrary: LibraryTreeNode = {
87
+ id: UUID(),
88
+ nodeType: "LIB_NODE_COMMUNITY_COLLECTION",
89
+ label: 'Community models',
90
+ icon: icons.community,
91
+ className: libraryClassName,
92
+ children: [
93
+ {
94
+ id: UUID(),
95
+ nodeType: "LIB_NODE_GENERIC_EMPTY",
96
+ label: 'A - 2',
97
+ icon: icons.community,
98
+ className: collectionClassName,
99
+ }
100
+ ]
101
+ }
102
+
103
+ const huggingfaceLibrary: LibraryTreeNode = {
104
+ id: UUID(),
105
+ nodeType: "LIB_NODE_HUGGINGFACE_COLLECTION",
106
+ label: 'Hugging Face',
107
+ icon: icons.vendor,
108
+ className: libraryClassName,
109
+ children: []
110
+ }
111
+
112
+ const replicateLibrary: LibraryTreeNode = {
113
+ id: UUID(),
114
+ nodeType: "LIB_NODE_REPLICATE_COLLECTION",
115
+ label: 'Replicate',
116
+ icon: icons.vendor,
117
+ isExpanded: false, // This node is expanded by default
118
+ className: libraryClassName,
119
+ children: []
120
+ }
121
+
122
+ const libraryTreeRoot = [
123
+ // teamLibrary,
124
+ // communityLibrary,
125
+ civitaiLibrary,
126
+ // huggingfaceLibrary,
127
+ replicateLibrary,
128
+ ]
129
+
130
+ set({
131
+ teamLibraryTreeNodeId: teamLibrary.id,
132
+ civitaiLibraryTreeNodeId: civitaiLibrary.id,
133
+ communityLibraryTreeNodeId: communityLibrary.id,
134
+ huggingfaceLibraryTreeNodeId: huggingfaceLibrary.id,
135
+ replicateLibraryTreeNodeId: replicateLibrary.id,
136
+ libraryTreeRoot,
137
+ selectedNodeItem: undefined,
138
+ selectedTreeNodeId: null,
139
+ })
140
+ },
141
+
142
+ setProjectEntities: async (entities: ClapEntity[]) => {
143
+
144
+ const characters: LibraryTreeNode = {
145
+ id: UUID(),
146
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
147
+ data: undefined,
148
+ label: 'Characters',
149
+ icon: icons.characters,
150
+ className: collectionClassName,
151
+ isExpanded: true, // This node is expanded by default
152
+ children: []
153
+ }
154
+
155
+ const locations: LibraryTreeNode = {
156
+ id: UUID(),
157
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
158
+ data: undefined,
159
+ label: 'Locations',
160
+ icon: icons.location,
161
+ className: collectionClassName,
162
+ isExpanded: false, // This node is expanded by default
163
+ children: []
164
+ }
165
+
166
+ const misc: LibraryTreeNode = {
167
+ id: UUID(),
168
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
169
+ data: undefined,
170
+ label: 'Misc',
171
+ icon: icons.misc,
172
+ className: collectionClassName,
173
+ isExpanded: false, // This node is expanded by default
174
+ children: []
175
+ }
176
+
177
+ entities.forEach(entity => {
178
+ const node: LibraryTreeNode = {
179
+ nodeType: "LIB_NODE_REPLICATE_MODEL",
180
+ id: entity.id,
181
+ data: entity,
182
+ label: entity.label,
183
+ icon: icons.misc,
184
+ className: itemClassName,
185
+ }
186
+ if (entity.category === "character") {
187
+ node.icon = icons.character
188
+ characters.children!.push(node)
189
+ } else if (entity.category === "location") {
190
+ node.icon = icons.location
191
+ locations.children!.push(node)
192
+ } else {
193
+ misc.children!.push(node)
194
+ }
195
+ })
196
+ },
197
+
198
+ setReplicateCollections: (collections: ReplicateCollection[]) => {
199
+ const { replicateLibraryTreeNodeId, libraryTreeRoot } = get()
200
+
201
+ set({
202
+ libraryTreeRoot: libraryTreeRoot.map(node => {
203
+ if (node.id !== replicateLibraryTreeNodeId) { return node }
204
+
205
+ return {
206
+ ...node,
207
+
208
+ children:
209
+
210
+ // only keep non-empty models
211
+ collections.filter(c => c.models.length)
212
+
213
+ // only visual or sound oriented models
214
+ .filter(c => {
215
+ const name = c.name.toLowerCase()
216
+
217
+ // ignore captioning models, we don't need this right now
218
+ if (name.includes("to text") || name.includes("to-text")) {
219
+ return false
220
+ }
221
+
222
+ if (
223
+ name.includes("image") ||
224
+ name.includes("video") ||
225
+ name.includes("style") ||
226
+ name.includes("audio") ||
227
+ name.includes("sound") ||
228
+ name.includes("music") ||
229
+ name.includes("speech") ||
230
+ name.includes("voice") ||
231
+ name.includes("resolution") ||
232
+ name.includes("upscale") ||
233
+ name.includes("upscaling") ||
234
+ name.includes("interpolate") ||
235
+ name.includes("interpolation")
236
+ ) {
237
+ return true
238
+ }
239
+ return false
240
+ })
241
+ .map<LibraryTreeNode>(c => ({
242
+ id: UUID(),
243
+ data: c,
244
+ nodeType: "LIB_NODE_REPLICATE_COLLECTION",
245
+ label: c.name,
246
+ icon: getAppropriateIcon(c.name),
247
+ className: collectionClassName, // `${collectionClassName} ${getCollectionColor(c.name)}`,
248
+ isExpanded: false, // This node is expanded by default
249
+ children: c.models.map<LibraryTreeNode>(m => ({
250
+ nodeType: "LIB_NODE_REPLICATE_MODEL",
251
+ id: m.id,
252
+ data: m,
253
+ label: m.label,
254
+ icon: getAppropriateIcon(m.label, getAppropriateIcon(c.name)),
255
+ className: itemClassName, // `${itemClassName} ${getItemColor(m.label, getItemColor(c.name))}`,
256
+ })),
257
+ }))
258
+ }
259
+ })
260
+ })
261
+ },
262
+
263
+ setCivitaiCollections: (collections: CivitaiCollection[]) => {
264
+ const { civitaiLibraryTreeNodeId, libraryTreeRoot } = get()
265
+
266
+ set({
267
+ libraryTreeRoot: libraryTreeRoot.map(node => {
268
+ if (node.id !== civitaiLibraryTreeNodeId) { return node }
269
+
270
+ return {
271
+ ...node,
272
+
273
+ children: collections.map<LibraryTreeNode>(c => ({
274
+ id: UUID(),
275
+ data: c,
276
+ nodeType: "LIB_NODE_CIVITAI_COLLECTION",
277
+ label: c.name,
278
+ icon: getAppropriateIcon(c.name),
279
+ className: collectionClassName, // `${collectionClassName} ${getCollectionColor(c.name)}`,
280
+ isExpanded: false, // This node is expanded by default
281
+ children: c.models.map<LibraryTreeNode>(m => ({
282
+ nodeType: "LIB_NODE_CIVITAI_MODEL",
283
+ id: m.id,
284
+ data: m,
285
+ label: m.label,
286
+ icon: getAppropriateIcon(m.label, getAppropriateIcon(c.name)),
287
+ className: itemClassName, // `${itemClassName} ${getItemColor(m.label, getItemColor(c.name))}`,
288
+ })),
289
+ }))
290
+ }
291
+ })
292
+ })
293
+ },
294
+
295
+ selectedNodeItem: undefined,
296
+ selectEntity: (entity?: ClapEntity) => {
297
+ if (entity) {
298
+ console.log("TODO julian: change this code to search in the entity collections")
299
+ const selectedTreeNode =
300
+ get().libraryTreeRoot
301
+ .find(node => node.data?.id === entity.id)
302
+
303
+ // set({ selectedTreeNode })
304
+ set({ selectedTreeNodeId: selectedTreeNode?.id || null })
305
+ set({ selectedNodeItem: entity })
306
+ } else {
307
+ // set({ selectedTreeNode: undefined })
308
+ set({ selectedTreeNodeId: null })
309
+ set({ selectedNodeItem: undefined })
310
+ }
311
+ },
312
+
313
+ // selectedTreeNode: undefined,
314
+ selectedTreeNodeId: null,
315
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => {
316
+ set({ selectedTreeNodeId: treeNodeId ? treeNodeId : undefined })
317
+ set({ selectedNodeType: nodeType ? nodeType : undefined })
318
+ set({ selectedNodeItem: nodeItem ? nodeItem : undefined })
319
+ },
320
+ }))
321
+
322
+ useEntityLibrary.getState().init()
src/components/tree-browsers/stores/useFileLibrary.txt ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { create } from "zustand"
4
+
5
+ import { ClapEntity, UUID } from "@aitube/clap"
6
+ import { HuggingFaceUserCollection, LibraryNodeItem, LibraryNodeType, LibraryTreeNode, LocalUserCollection } from "../types"
7
+ import { icons } from "@/components/icons"
8
+ import { getAppropriateIcon } from "@/components/icons/getAppropriateIcon"
9
+ import { className } from "@/app/fonts"
10
+ import { getCollectionItemTextColor } from "../utils/getCollectionItemTextColor"
11
+
12
+ // TODO: this isn't the best place for this as this is style,
13
+ // and we are in a state manager
14
+ const libraryClassName = "text-base font-semibold"
15
+
16
+ const collectionClassName = `text-base font-normal`
17
+
18
+ const itemClassName = "text-sm font-light text-gray-200/60 hover:text-gray-200/100"
19
+
20
+ export const useFileLibrary = create<{
21
+ localUserLibraryTreeNodeId: string
22
+ huggingfaceUserLibraryTreeNodeId: string
23
+ libraryTreeRoot: LibraryTreeNode[]
24
+ init: () => void
25
+
26
+ /**
27
+ * Load local user collections (projects, assets) into the tree
28
+ *
29
+ * @param collections
30
+ * @returns
31
+ */
32
+ setLocalUserCollections: (collections: LocalUserCollection[]) => void
33
+
34
+ /**
35
+ * Load Hugging Face user collections (projects, assets) into the tree
36
+ *
37
+ * @param collections
38
+ * @returns
39
+ */
40
+ setHuggingFaceUserCollections: (collections: HuggingFaceUserCollection[]) => void
41
+
42
+ // we support those all selection modes for convenience - please keep them!
43
+ selectedNodeItem?: LibraryNodeItem
44
+ selectedNodeType?: LibraryNodeType
45
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => void
46
+ selectedTreeNodeId: string | null
47
+ }>((set, get) => ({
48
+ localUserLibraryTreeNodeId: "",
49
+ huggingfaceUserLibraryTreeNodeId: "",
50
+ libraryTreeRoot: [],
51
+ init: () => {
52
+
53
+ const localUserLibrary: LibraryTreeNode = {
54
+ id: UUID(),
55
+ nodeType: "LIB_NODE_LOCAL_USER_COLLECTION",
56
+ label: "My Computer",
57
+ icon: icons.computer,
58
+ className: libraryClassName,
59
+ isExpanded: true, // This node is expanded by default
60
+ children: [
61
+ {
62
+ id: UUID(),
63
+ nodeType: "LIB_NODE_GENERIC_EMPTY",
64
+ label: "(No files to display)",
65
+ icon: icons.misc,
66
+ className: `${collectionClassName} text-gray-100/30`,
67
+ isExpanded: false, // This node is expanded by default
68
+ children: []
69
+ }
70
+ ]
71
+ }
72
+
73
+ const huggingfaceUserLibrary: LibraryTreeNode = {
74
+ id: UUID(),
75
+ nodeType: "LIB_NODE_HUGGINGFACE_USER_COLLECTION",
76
+ label: "My HF Cloud",
77
+ icon: icons.cloud,
78
+ className: libraryClassName,
79
+ isExpanded: true, // This node is expanded by default
80
+ children: [
81
+ {
82
+ id: UUID(),
83
+ nodeType: "LIB_NODE_GENERIC_EMPTY",
84
+ label: "(No files to display)",
85
+ icon: icons.misc,
86
+ className: `${collectionClassName} text-gray-100/30`,
87
+ isExpanded: false, // This node is expanded by default
88
+ children: []
89
+ }
90
+ ]
91
+ }
92
+
93
+ const libraryTreeRoot = [
94
+ localUserLibrary,
95
+ huggingfaceUserLibrary,
96
+ ]
97
+
98
+ set({
99
+ localUserLibraryTreeNodeId: localUserLibrary.id,
100
+ huggingfaceUserLibraryTreeNodeId: huggingfaceUserLibrary.id,
101
+ libraryTreeRoot,
102
+ selectedNodeItem: undefined,
103
+ selectedTreeNodeId: null,
104
+ })
105
+ },
106
+
107
+ setLocalUserCollections: (collections: LocalUserCollection[]) => {
108
+ const { localUserLibraryTreeNodeId, libraryTreeRoot } = get()
109
+
110
+ console.log("setLocalUserCollections:", collections)
111
+
112
+ set({
113
+ libraryTreeRoot: libraryTreeRoot.map(node => {
114
+ if (node.id !== localUserLibraryTreeNodeId) { return node }
115
+
116
+ return {
117
+ ...node,
118
+
119
+ children: collections.map<LibraryTreeNode>(c => ({
120
+ id: UUID(),
121
+ nodeType: "LIB_NODE_LOCAL_USER_FOLDER",
122
+ data: c,
123
+ label: c.name, // file directory name
124
+ icon: getAppropriateIcon(c.name),
125
+ className: collectionClassName, // `${collectionClassName} ${getCollectionColor(c.name)}`,
126
+ isExpanded: false, // This node is expanded by default
127
+ children: c.items.map<LibraryTreeNode>(m => ({
128
+ nodeType: "LIB_NODE_LOCAL_USER_FILE",
129
+ id: m.id,
130
+ data: m,
131
+ label: <><span>{
132
+ m.fileName.split(".").slice(0, -1)
133
+ }</span><span className="opacity-50">{
134
+ `.${m.fileName.split(".").pop()}`
135
+ }</span></>,
136
+ icon: getAppropriateIcon(m.fileName, getAppropriateIcon(c.name)),
137
+ className: `${itemClassName} ${
138
+ getCollectionItemTextColor(m.fileName)
139
+ }`, // `${itemClassName} ${getItemColor(m.label, getItemColor(c.name))}`,
140
+ }))
141
+ }))
142
+ }
143
+ })
144
+ })
145
+ },
146
+
147
+ setHuggingFaceUserCollections: (collections: HuggingFaceUserCollection[]) => {
148
+ const { huggingfaceUserLibraryTreeNodeId, libraryTreeRoot } = get()
149
+
150
+ set({
151
+ libraryTreeRoot: libraryTreeRoot.map(node => {
152
+ if (node.id !== huggingfaceUserLibraryTreeNodeId) { return node }
153
+
154
+ return {
155
+ ...node,
156
+
157
+ children: collections.map<LibraryTreeNode>(c => ({
158
+ id: c.id,
159
+ nodeType: "LIB_NODE_HUGGINGFACE_USER_DATASET",
160
+ data: c,
161
+ label: c.name, // file directory name
162
+ icon: getAppropriateIcon(c.name),
163
+ className: collectionClassName, // `${collectionClassName} ${getCollectionColor(c.name)}`,
164
+ isExpanded: false, // This node is expanded by default
165
+ children: c.items.map<LibraryTreeNode>(m => ({
166
+ nodeType: "LIB_NODE_HUGGINGFACE_USER_FILE",
167
+ id: m.id,
168
+ data: m,
169
+ label: <><span>{
170
+ m.fileName.split(".").slice(0, -1)
171
+ }</span><span className="opacity-50">{
172
+ `.${m.fileName.split(".").pop()}`
173
+ }</span></>,
174
+ icon: getAppropriateIcon(m.fileName, getAppropriateIcon(c.name)),
175
+ className: `${itemClassName} ${
176
+ getCollectionItemTextColor(m.fileName)
177
+ }`, // `${itemClassName} ${getItemColor(m.label, getItemColor(c.name))}`,
178
+ }))
179
+ }))
180
+ }
181
+ })
182
+ })
183
+ },
184
+
185
+ selectedNodeItem: undefined,
186
+ selectEntity: (entity?: ClapEntity) => {
187
+ if (entity) {
188
+ console.log("TODO julian: change this code to search in the entity collections")
189
+ const selectedTreeNode =
190
+ get().libraryTreeRoot
191
+ .find(node => node.data?.id === entity.id)
192
+
193
+ // set({ selectedTreeNode })
194
+ set({ selectedTreeNodeId: selectedTreeNode?.id || null })
195
+ set({ selectedNodeItem: entity })
196
+ } else {
197
+ // set({ selectedTreeNode: undefined })
198
+ set({ selectedTreeNodeId: null })
199
+ set({ selectedNodeItem: undefined })
200
+ }
201
+ },
202
+ // selectedTreeNode: undefined,
203
+ selectedTreeNodeId: null,
204
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => {
205
+ set({ selectedTreeNodeId: treeNodeId ? treeNodeId : undefined })
206
+ set({ selectedNodeType: nodeType ? nodeType : undefined })
207
+ set({ selectedNodeItem: nodeItem ? nodeItem : undefined })
208
+ }
209
+ }))
210
+
211
+ useFileLibrary.getState().init()
src/components/tree-browsers/stores/useProjectLibrary.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { create } from "zustand"
4
+ import { ClapEntity, UUID } from "@aitube/clap"
5
+
6
+ import { icons } from "@/components/icons"
7
+
8
+ import { LibraryNodeItem, LibraryNodeType, LibraryTreeNode } from "../types"
9
+
10
+ // TODO: this isn't the best place for this as this is style,
11
+ // and we are in a state manager
12
+ const libraryClassName = "text-base font-semibold"
13
+
14
+ const collectionClassName = `text-base font-normal`
15
+
16
+ const itemClassName = "text-sm font-light text-gray-200/60 hover:text-gray-200/100"
17
+
18
+ export const useProjectLibrary = create<{
19
+ libraryTreeRoot: LibraryTreeNode[]
20
+ init: () => void
21
+ setProjectEntities: (entities: ClapEntity[]) => Promise<void>
22
+ selectedNodeItem?: LibraryNodeItem
23
+ selectedNodeType?: LibraryNodeType
24
+ selectEntity: (entity?: ClapEntity) => void
25
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => void
26
+ selectedTreeNodeId: string | null
27
+ }>((set, get) => ({
28
+ libraryTreeRoot: [],
29
+ init: () => {
30
+
31
+ set({
32
+ libraryTreeRoot: [],
33
+ selectedNodeItem: undefined,
34
+ selectedTreeNodeId: null,
35
+ // selectedTreeNode: undefined,
36
+ })
37
+ },
38
+
39
+ setProjectEntities: async (entities: ClapEntity[]) => {
40
+ const { libraryTreeRoot } = get()
41
+
42
+ const characters: LibraryTreeNode = {
43
+ id: UUID(),
44
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
45
+ data: undefined,
46
+ label: 'Characters',
47
+ icon: icons.characters,
48
+ className: libraryClassName,
49
+ isExpanded: true, // This node is expanded by default
50
+ children: []
51
+ }
52
+
53
+ const locations: LibraryTreeNode = {
54
+ id: UUID(),
55
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
56
+ data: undefined,
57
+ label: 'Locations',
58
+ icon: icons.location,
59
+ className: libraryClassName,
60
+ isExpanded: true, // This node is expanded by default
61
+ children: []
62
+ }
63
+
64
+ const misc: LibraryTreeNode = {
65
+ id: UUID(),
66
+ nodeType: "LIB_NODE_GENERIC_COLLECTION",
67
+ data: undefined,
68
+ label: 'Misc',
69
+ icon: icons.misc,
70
+ className: libraryClassName,
71
+ isExpanded: true, // This node is expanded by default
72
+ children: []
73
+ }
74
+
75
+
76
+ entities.forEach(entity => {
77
+ const node: LibraryTreeNode = {
78
+ nodeType: "LIB_NODE_PROJECT_ENTITY_GENERIC",
79
+ id: entity.id,
80
+ data: entity,
81
+ label: entity.label,
82
+ icon: icons.misc,
83
+ className: collectionClassName,
84
+ }
85
+ if (entity.category === "character") {
86
+ node.icon = icons.character
87
+ node.nodeType = "LIB_NODE_PROJECT_ENTITY_CHARACTER"
88
+ characters.children!.push(node)
89
+ } else if (entity.category === "location") {
90
+ node.icon = icons.location
91
+ node.nodeType = "LIB_NODE_PROJECT_ENTITY_LOCATION"
92
+ locations.children!.push(node)
93
+ } else {
94
+ misc.children!.push(node)
95
+ }
96
+ })
97
+
98
+ set({
99
+ libraryTreeRoot: [
100
+ characters,
101
+ locations,
102
+ misc
103
+ // displaying an empty collection isn't very useful,
104
+ // so let's just clean them out
105
+ ].filter(node => node.children?.length)
106
+ })
107
+ },
108
+
109
+ selectedNodeItem: undefined,
110
+ selectEntity: (entity?: ClapEntity) => {
111
+ if (entity) {
112
+ console.log("TODO julian: change this code to search in the model collections")
113
+ const selectedTreeNode =
114
+ get().libraryTreeRoot
115
+ .find(node => node.data?.id === entity.id)
116
+
117
+ // set({ selectedTreeNode })
118
+ set({ selectedTreeNodeId: selectedTreeNode?.id || null })
119
+ set({ selectedNodeItem: entity })
120
+ } else {
121
+ // set({ selectedTreeNode: undefined })
122
+ set({ selectedTreeNodeId: null })
123
+ set({ selectedNodeItem: undefined })
124
+ }
125
+ },
126
+ // selectedTreeNode: undefined,
127
+ selectedTreeNodeId: null,
128
+ selectTreeNode: (treeNodeId?: string | null, nodeType?: LibraryNodeType, nodeItem?: LibraryNodeItem) => {
129
+ set({ selectedTreeNodeId: treeNodeId ? treeNodeId : undefined })
130
+ set({ selectedNodeType: nodeType ? nodeType : undefined })
131
+ set({ selectedNodeItem: nodeItem ? nodeItem : undefined })
132
+ }
133
+ }))
134
+
135
+ useProjectLibrary.getState().init()