Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Commit
β’
083ce88
1
Parent(s):
243952e
migrate to TimelineSegment
Browse filesThis view is limited to 50 files because it contains too many changes. Β
See raw diff
- package-lock.json +0 -0
- package.json +6 -2
- src/app/api/resolve/providers/comfy-comfyicu/index.ts +4 -3
- src/app/api/resolve/providers/comfy-huggingface/index.ts +4 -3
- src/app/api/resolve/providers/comfy-replicate/index.ts +4 -3
- src/app/api/resolve/providers/falai/index.ts +5 -4
- src/app/api/resolve/providers/gradio/index.ts +4 -3
- src/app/api/resolve/providers/huggingface/index.ts +3 -2
- src/app/api/resolve/providers/modelslab/index.ts +4 -3
- src/app/api/resolve/providers/replicate/index.ts +3 -3
- src/app/api/resolve/providers/stabilityai/index.ts +4 -3
- src/app/api/resolve/route.ts +1 -1
- src/app/main.tsx +5 -8
- src/components/core/tree/README.md +7 -0
- src/components/core/tree/chainable-map.ts +28 -0
- src/components/core/tree/icons.tsx +129 -0
- src/components/core/tree/index.tsx +185 -0
- src/components/core/tree/root.tsx +45 -0
- src/components/core/tree/roving.tsx +254 -0
- src/components/core/tree/tree-state.ts +34 -0
- src/components/core/tree/types.ts +38 -0
- src/components/core/tree/useTreeNode.ts +196 -0
- src/components/editor/Editor.tsx +0 -28
- src/components/editors/Editors.tsx +36 -0
- src/components/editors/EntityEditor/index.tsx +63 -0
- src/components/editors/ProjectEditor/index.tsx +113 -0
- src/components/{editor β editors}/ScriptEditor/index.tsx +17 -17
- src/components/{editor β editors}/ScriptEditor/styles.css +0 -0
- src/components/editors/SegmentEditor/index.tsx +46 -0
- src/components/forms/FormDir.tsx +3 -0
- src/components/forms/FormField.tsx +5 -1
- src/components/forms/FormFile.tsx +4 -1
- src/components/forms/FormInput.tsx +7 -1
- src/components/forms/FormRadio.tsx +4 -2
- src/components/forms/FormSection.tsx +7 -2
- src/components/forms/FormSelect.tsx +4 -1
- src/components/forms/FormSwitch.tsx +4 -2
- src/components/icons/getAppropriateIcon.ts +97 -0
- src/components/icons/index.tsx +64 -0
- src/components/settings/constants.ts +1 -0
- src/components/toolbars/{editor-menu/EditorSideMenu.tsx β editors-menu/EditorsSideMenu.tsx} +11 -8
- src/components/toolbars/{editor-menu/EditorSideMenuItem.tsx β editors-menu/EditorsSideMenuItem.tsx} +6 -5
- src/components/tree-browsers/model-tree-browser/index.tsx +86 -0
- src/components/tree-browsers/model-tree-browser/tree-item-viewer.tsx +19 -0
- src/components/tree-browsers/project-tree-browser/index.tsx +57 -0
- src/components/tree-browsers/project-tree-browser/tree-item-viewer.tsx +17 -0
- src/components/tree-browsers/stores/useCivitaiCollections.ts +25 -0
- src/components/tree-browsers/stores/useEntityLibrary.ts +322 -0
- src/components/tree-browsers/stores/useFileLibrary.txt +211 -0
- 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.
|
16 |
"@aitube/engine": "0.0.26",
|
17 |
-
"@aitube/timeline": "0.0.
|
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 {
|
|
|
4 |
|
5 |
-
export async function resolveSegment(request: ResolveRequest): Promise<
|
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:
|
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 {
|
|
|
3 |
|
4 |
-
export async function resolveSegment(request: ResolveRequest): Promise<
|
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:
|
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 {
|
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<
|
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:
|
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,
|
5 |
import { FalAiAudioResponse, FalAiImageResponse, FalAiSpeechResponse, FalAiVideoResponse } from './types'
|
6 |
|
7 |
-
|
|
|
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 {
|
2 |
-
|
3 |
import { ResolveRequest } from "@aitube/clapper-services"
|
|
|
|
|
4 |
import { callGradioApi } from "@/lib/hf/callGradioApi"
|
5 |
|
6 |
-
export async function resolveSegment(request: ResolveRequest): Promise<
|
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 {
|
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<
|
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 {
|
|
|
3 |
|
4 |
-
export async function resolveSegment(request: ResolveRequest): Promise<
|
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:
|
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 {
|
4 |
-
|
5 |
import { ResolveRequest } from "@aitube/clapper-services"
|
|
|
6 |
|
7 |
-
export async function resolveSegment(request: ResolveRequest): Promise<
|
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 {
|
2 |
-
|
3 |
import { ResolveRequest } from "@aitube/clapper-services"
|
4 |
import { generateImage } from "./generateImage"
|
5 |
|
6 |
-
|
|
|
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,
|
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 {
|
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(
|
37 |
-
const showAssistant = useUI(
|
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 |
-
<
|
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 {
|
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 =
|
18 |
-
const setStandaloneCodeEditor =
|
19 |
-
const draft =
|
20 |
-
const setDraft =
|
21 |
-
const loadDraftFromClap =
|
22 |
-
const onDidScrollChange =
|
23 |
-
const 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 =
|
27 |
|
28 |
const clap = useTimeline((s: TimelineStore) => s.clap)
|
29 |
|
30 |
useEffect(() => { loadDraftFromClap(clap) }, [clap])
|
31 |
|
32 |
-
const 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 (
|
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 } =
|
71 |
if (!textModel) { return }
|
72 |
|
73 |
codeEditor.setModel(textModel)
|
@@ -97,9 +97,9 @@ export function ScriptEditor() {
|
|
97 |
// setDraft(plainText || "")
|
98 |
}
|
99 |
|
100 |
-
const setMonaco =
|
101 |
-
const setTextModel =
|
102 |
-
const 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(
|
|
|
|
|
|
|
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(`
|
|
|
|
|
|
|
|
|
|
|
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
|
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 |
-
|
|
|
19 |
}}
|
20 |
>
|
21 |
<div className="flex flex-col h-full w-full items-center">
|
22 |
|
23 |
-
<
|
24 |
-
<
|
|
|
|
|
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 {
|
8 |
import { useTheme } from "@/services/ui/useTheme"
|
9 |
|
10 |
-
export function
|
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 =
|
35 |
-
const setView =
|
36 |
|
37 |
const isActive = !unmanaged && view === expectedView
|
38 |
|
@@ -66,7 +66,7 @@ export function EditorSideMenuItem({
|
|
66 |
`group`
|
67 |
)}
|
68 |
style={{
|
69 |
-
background: theme.
|
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()
|