diff --git a/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp b/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp
deleted file mode 100644
index 93249331..00000000
Binary files a/frontend/apps/artcraft/app/public/resources/images/floating-cubes.webp and /dev/null differ
diff --git a/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp b/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp
deleted file mode 100644
index 9e3c0892..00000000
Binary files a/frontend/apps/artcraft/app/public/resources/images/room-of-items.webp and /dev/null differ
diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg
deleted file mode 100644
index e28a3ff4..00000000
--- a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-black.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg b/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg
deleted file mode 100644
index 7906a3eb..00000000
--- a/frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/apps/artcraft/app/public/resources/pose/7.vpd b/frontend/apps/artcraft/app/public/resources/pose/7.vpd
deleted file mode 100644
index b55ef36d..00000000
--- a/frontend/apps/artcraft/app/public/resources/pose/7.vpd
+++ /dev/null
@@ -1,305 +0,0 @@
-Vocaloid Pose Data file
-
-–̐lŒ`.osm; // �eƒtƒ@ƒCƒ‹–¼
-60; // ‘�ƒ|�[ƒYƒ{�[ƒ“�”
-
-Bone0{‘S‚Ă̐e
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone1{ƒZƒ“ƒ^�[
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone2{�㔼�g
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone3{Žñ
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000286,0.002004,-0.028579,0.999590; // Quaternion x,y,z,w
-}
-
-Bone4{һ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.003903,-0.047796,-0.030391,0.998387; // Quaternion x,y,z,w
-}
-
-Bone5{‰º”¼�g
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000950,0.006664,-0.095038,0.995451; // Quaternion x,y,z,w
-}
-
-Bone6{�¶Œ¨P
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone7{�¶Œ¨
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.003159,-0.000000,0.105260,0.994440; // Quaternion x,y,z,w
-}
-
-Bone8{�¶˜r
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.034463,0.034156,-0.396750,0.916644; // Quaternion x,y,z,w
-}
-
-Bone9{�¶˜r�€
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone10{�¶‚Ђ¶
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.014712,-0.013995,0.076793,0.996840; // Quaternion x,y,z,w
-}
-
-Bone11{�¶Ž蝀
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone12{�¶ŽèŽñ
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.028156,-0.026783,0.146962,0.988378; // Quaternion x,y,z,w
-}
-
-Bone13{�¶�eŽw‚P
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.002261,-0.000573,-0.306446,0.951885; // Quaternion x,y,z,w
-}
-
-Bone14{�¶�eŽw‚Q
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.047385,-0.118953,0.062146,0.989819; // Quaternion x,y,z,w
-}
-
-Bone15{�¶�lŽw‚P
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.020755,0.017947,0.064537,0.997538; // Quaternion x,y,z,w
-}
-
-Bone16{�¶�lŽw‚Q
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.024997,0.999687; // Quaternion x,y,z,w
-}
-
-Bone17{�¶�lŽw‚R
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.029995,0.999550; // Quaternion x,y,z,w
-}
-
-Bone18{�¶’†Žw‚P
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,-0.064954,0.997888; // Quaternion x,y,z,w
-}
-
-Bone19{�¶’†Žw‚Q
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,-0.054972,0.998488; // Quaternion x,y,z,w
-}
-
-Bone20{�¶’†Žw‚R
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,0.104807,0.994492; // Quaternion x,y,z,w
-}
-
-Bone21{�¶–òŽw‚P
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.232843,0.972514; // Quaternion x,y,z,w
-}
-
-Bone22{�¶–òŽw‚Q
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.188859,0.982004; // Quaternion x,y,z,w
-}
-
-Bone23{�¶–òŽw‚R
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,0.124675,0.992197; // Quaternion x,y,z,w
-}
-
-Bone24{�¶�¬Žw‚P
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,-0.094857,0.995490; // Quaternion x,y,z,w
-}
-
-Bone25{�¶�¬Žw‚Q
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.496880,0.867819; // Quaternion x,y,z,w
-}
-
-Bone26{�¶�¬Žw‚R
- 0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,0.010000,0.999949; // Quaternion x,y,z,w
-}
-
-Bone27{‰EŒ¨P
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone28{‰EŒ¨
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.003159,0.000000,-0.105260,0.994440; // Quaternion x,y,z,w
-}
-
-Bone29{‰E˜r
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.034463,-0.034156,0.396750,0.916644; // Quaternion x,y,z,w
-}
-
-Bone30{‰E˜r�€
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone31{‰E‚Ђ¶
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.014712,0.013995,-0.076793,0.996840; // Quaternion x,y,z,w
-}
-
-Bone32{‰EŽ蝀
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone33{‰EŽèŽñ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.028156,0.026783,-0.146962,0.988378; // Quaternion x,y,z,w
-}
-
-Bone34{‰E�eŽw‚P
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.002261,0.000573,0.306446,0.951885; // Quaternion x,y,z,w
-}
-
-Bone35{‰E�eŽw‚Q
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.047385,0.118953,-0.062146,0.989819; // Quaternion x,y,z,w
-}
-
-Bone36{‰E�lŽw‚P
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.020755,-0.017947,-0.064537,0.997538; // Quaternion x,y,z,w
-}
-
-Bone37{‰E�lŽw‚Q
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.024997,0.999687; // Quaternion x,y,z,w
-}
-
-Bone38{‰E�lŽw‚R
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,-0.000000,-0.029995,0.999550; // Quaternion x,y,z,w
-}
-
-Bone39{‰E’†Žw‚P
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,0.064954,0.997888; // Quaternion x,y,z,w
-}
-
-Bone40{‰E’†Žw‚Q
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,0.054972,0.998488; // Quaternion x,y,z,w
-}
-
-Bone41{‰E’†Žw‚R
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,-0.104807,0.994492; // Quaternion x,y,z,w
-}
-
-Bone42{‰E–òŽw‚P
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.232843,0.972514; // Quaternion x,y,z,w
-}
-
-Bone43{‰E–òŽw‚Q
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.188859,0.982004; // Quaternion x,y,z,w
-}
-
-Bone44{‰E–òŽw‚R
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,-0.000000,-0.124675,0.992197; // Quaternion x,y,z,w
-}
-
-Bone45{‰E�¬Žw‚P
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,0.094857,0.995490; // Quaternion x,y,z,w
-}
-
-Bone46{‰E�¬Žw‚Q
- -0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.496880,0.867819; // Quaternion x,y,z,w
-}
-
-Bone47{‰E�¬Žw‚R
- -0.000000,0.000000,0.000000; // trans x,y,z
- -0.000000,0.000000,-0.010000,0.999949; // Quaternion x,y,z,w
-}
-
-Bone48{�¶‘«
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone49{�¶‚Ђ´
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone50{�¶‘«Žñ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone51{‰E‘«
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone52{‰E‚Ђ´
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone53{‰E‘«Žñ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone54{�¶‚ܐæ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone55{‰E‚ܐæ
- 0.000000,0.000000,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone56{�¶‘«‚h‚j
- -0.132786,0.336300,0.033801; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone57{‰E‘«‚h‚j
- 0.148176,0.000000,0.001482; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone58{�¶‚ܐæ‚h‚j
- -0.419457,-0.807707,-0.002797; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
-Bone59{‰E‚ܐæ‚h‚j
- 0.069849,-0.366887,0.000000; // trans x,y,z
- 0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
-}
-
diff --git a/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_staticE.wav b/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_staticE.wav
deleted file mode 100644
index f6507d64..00000000
Binary files a/frontend/apps/artcraft/app/public/resources/sound/smrpg_enemy_staticE.wav and /dev/null differ
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts
index a089e67b..d01d39a6 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/BetaKeysApi.ts
@@ -1,6 +1,6 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { BetaKey } from "~/models";
-import type { Pagination } from "@storyteller/ui-pagescene";
+import type { Pagination } from "~/types/artcraft-web";
export class BetaKeysApi extends ApiManager {
public CreateBetaKey({
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts
index 9dde488f..921b734f 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/MediaFilesApi.ts
@@ -3,7 +3,7 @@ import type {
MediaInfo,
Pagination,
PaginationInfinite,
-} from "@storyteller/ui-pagescene";
+} from "~/types/artcraft-web";
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import type { Property } from "csstype";
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts
index 7421c087..a4d73974 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/PromptsApi.ts
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
-import type { Prompts } from "@storyteller/ui-pagescene";
+import type { Prompts } from "~/types/artcraft-web";
export class PromptsApi extends ApiManager {
public GetPromptsByToken({
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts
index 10a20b7d..d5e1ae43 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/TtsApi.ts
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
-import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene";
+import { GenerateTtsAudioErrorType } from "~/types/artcraft-web";
import { Visibility } from "~/enums";
export interface GenerateTtsAudioRequest {
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts
index 369b2bd5..bdf1c8e4 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/UserBookmarksApi.ts
@@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
-import type { UserBookmarkBatch } from "@storyteller/ui-pagescene";
+import type { UserBookmarkBatch } from "~/types/artcraft-web";
export class UserBookmarksApi extends ApiManager {
public CreateUserBookmark({
diff --git a/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts
index 9cc192a2..7146602d 100644
--- a/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts
+++ b/frontend/apps/artcraft/app/src/Classes/ApiManager/WeightsApi.ts
@@ -1,7 +1,7 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import { Weight } from "~/models";
-import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene";
+import type { Pagination, PaginationInfinite } from "~/types/artcraft-web";
import {
FilterEngineCategories,
FilterMediaClasses,
diff --git a/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx b/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx
deleted file mode 100644
index 2fd3308a..00000000
--- a/frontend/apps/artcraft/app/src/components/signaled/ErrorDialog.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { faTriangleExclamation } from "@fortawesome/pro-solid-svg-icons";
-import { useShallow } from "zustand/shallow";
-
-import { Modal } from "@storyteller/ui-modal";
-import { Button } from "@storyteller/ui-button";
-import { usePageSceneStore } from "@storyteller/ui-pagescene";
-
-export function ErrorDialog() {
- const { showErrorDialog, errorDialogTitle, errorDialogMessage, setShowErrorDialog } =
- usePageSceneStore(
- useShallow((s) => ({
- showErrorDialog: s.showErrorDialog,
- errorDialogTitle: s.errorDialogTitle,
- errorDialogMessage: s.errorDialogMessage,
- setShowErrorDialog: s.setShowErrorDialog,
- })),
- );
-
- return (
- setShowErrorDialog(false)}
- showClose={false}
- >
-
- {errorDialogMessage}
-
- setShowErrorDialog(false)}
- variant="secondary"
- >
- Close
-
-
-
-
- );
-}
diff --git a/frontend/apps/artcraft/app/src/components/signaled/index.ts b/frontend/apps/artcraft/app/src/components/signaled/index.ts
index 5ff38720..18ca8cf1 100644
--- a/frontend/apps/artcraft/app/src/components/signaled/index.ts
+++ b/frontend/apps/artcraft/app/src/components/signaled/index.ts
@@ -1,3 +1,2 @@
-export * from "./ErrorDialog";
export * from "./TopBar";
diff --git a/frontend/apps/artcraft/app/src/config/appMenu.tsx b/frontend/apps/artcraft/app/src/config/appMenu.tsx
index 99dc5045..c77b3f0a 100644
--- a/frontend/apps/artcraft/app/src/config/appMenu.tsx
+++ b/frontend/apps/artcraft/app/src/config/appMenu.tsx
@@ -6,24 +6,14 @@ import {
faImage,
faMusic,
} from "@fortawesome/pro-solid-svg-icons";
-import { useExperimentalStore } from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
export type AppId =
| "IMAGE"
| "VIDEO"
| "AUDIO"
- | "EDIT"
- | "VIDEO_FRAME_EXTRACTOR"
- | "VIDEO_WATERMARK_REMOVAL"
- | "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
- | "ANGLES"
- | "STORYBOARD"
- | "BACKGROUND_CHANGE"
- | "VIDEO_EDITOR"
- | "MOODBOARD"
| "SETTINGS";
export interface AppDescriptor {
@@ -35,8 +25,7 @@ export interface AppDescriptor {
large?: boolean;
}
-// The five modalities. Everything else stays reachable in code (goToApp)
-// but is deliberately absent from the nav.
+// The five modalities.
export const APP_DESCRIPTORS: AppDescriptor[] = [
{
id: "IMAGE",
@@ -72,24 +61,11 @@ export const goToApp = (action?: string) => {
"IMAGE",
"VIDEO",
"AUDIO",
- "VIDEO_FRAME_EXTRACTOR",
- "VIDEO_WATERMARK_REMOVAL",
- "IMAGE_WATERMARK_REMOVAL",
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
- "ANGLES",
- "STORYBOARD",
- "BACKGROUND_CHANGE",
- "VIDEO_EDITOR",
- "MOODBOARD",
"SETTINGS",
].includes(action)
) {
- if (action === "STORYBOARD") {
- const { enabled, storyboardPageEnabled } =
- useExperimentalStore.getState();
- if (!enabled || !storyboardPageEnabled) return;
- }
useTabStore.getState().setActiveTab(action as TabId);
}
};
diff --git a/frontend/apps/artcraft/app/src/enums/AssetFilterOption.ts b/frontend/apps/artcraft/app/src/enums/AssetFilterOption.ts
index 86a6daeb..9e7dd8a2 100644
--- a/frontend/apps/artcraft/app/src/enums/AssetFilterOption.ts
+++ b/frontend/apps/artcraft/app/src/enums/AssetFilterOption.ts
@@ -1 +1 @@
-export { AssetFilterOption } from "@storyteller/ui-pagescene";
+export { AssetFilterOption } from "~/types/artcraft-web";
diff --git a/frontend/apps/artcraft/app/src/enums/AssetType.ts b/frontend/apps/artcraft/app/src/enums/AssetType.ts
index 09662af2..360c1f9d 100644
--- a/frontend/apps/artcraft/app/src/enums/AssetType.ts
+++ b/frontend/apps/artcraft/app/src/enums/AssetType.ts
@@ -1,4 +1 @@
-// Single source of truth lives in @storyteller/ui-pagescene; this
-// re-export keeps the artcraft `~/enums` surface stable while the
-// 3D scene lib owns the canonical enum identity.
-export { AssetType } from "@storyteller/ui-pagescene";
+export { AssetType } from "~/types/artcraft-web";
diff --git a/frontend/apps/artcraft/app/src/enums/ClipGroup.ts b/frontend/apps/artcraft/app/src/enums/ClipGroup.ts
index 7a27917a..0a2f55d0 100644
--- a/frontend/apps/artcraft/app/src/enums/ClipGroup.ts
+++ b/frontend/apps/artcraft/app/src/enums/ClipGroup.ts
@@ -1 +1 @@
-export { ClipGroup } from "@storyteller/ui-pagescene";
+export { ClipGroup } from "~/types/artcraft-web";
diff --git a/frontend/apps/artcraft/app/src/enums/QueryFilters.ts b/frontend/apps/artcraft/app/src/enums/QueryFilters.ts
index 8b74468c..e57a742e 100644
--- a/frontend/apps/artcraft/app/src/enums/QueryFilters.ts
+++ b/frontend/apps/artcraft/app/src/enums/QueryFilters.ts
@@ -1,7 +1,5 @@
-// Single source of truth lives in @storyteller/ui-pagescene; this
-// re-export keeps the artcraft `~/enums` surface stable.
export {
FilterEngineCategories,
FilterMediaClasses,
FilterMediaType,
-} from "@storyteller/ui-pagescene";
+} from "~/types/artcraft-web";
diff --git a/frontend/apps/artcraft/app/src/enums/toasts.ts b/frontend/apps/artcraft/app/src/enums/toasts.ts
index e18c896e..2457ae7c 100644
--- a/frontend/apps/artcraft/app/src/enums/toasts.ts
+++ b/frontend/apps/artcraft/app/src/enums/toasts.ts
@@ -1,2 +1 @@
-// Single source of truth lives in @storyteller/ui-pagescene.
-export { ToastTypes } from "@storyteller/ui-pagescene";
+export { ToastTypes } from "~/types/artcraft-web";
diff --git a/frontend/apps/artcraft/app/src/pages/MainApp.tsx b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
index f27f6544..c66b5555 100644
--- a/frontend/apps/artcraft/app/src/pages/MainApp.tsx
+++ b/frontend/apps/artcraft/app/src/pages/MainApp.tsx
@@ -8,7 +8,6 @@ import * as gpu from "detect-gpu";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
-import { ErrorDialog } from "~/components";
import { toast, Toaster } from "@storyteller/ui-toaster";
import {
PricingModal,
@@ -29,7 +28,6 @@ import {
useGenerationFailedEvent,
useTextToImageGenerationCompleteEvent,
} from "@storyteller/tauri-events";
-import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@@ -40,16 +38,8 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import CreateAudio from "./PageAudio/CreateAudio";
-import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
-import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
-import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
-import { Angles } from "./PageAngles";
-import { Storyboard } from "./PageStoryboard";
-import { PageBackgroundChange } from "./PageBackgroundChange";
-import { PageVideoEditor } from "./PageVideoEditor";
-import { PageMoodboard } from "./PageMoodboard";
import { PageSettings } from "./PageSettings";
import {
topNavMediaId,
@@ -115,7 +105,6 @@ export const MainApp = () => {
-
{currentReminderModalProps && (
@@ -145,7 +134,6 @@ export const MainApp = () => {
const TabBody = () => {
const tabStore = useTabStore();
- const storyboardPageEnabled = useStoryboardPageEnabled();
// Every page is a fragment whose top-level children may use
// position: fixed; the wrapping
scopes them so they don't
@@ -172,24 +160,6 @@ const TabBody = () => {
);
- case "VIDEO_FRAME_EXTRACTOR":
- return (
-
-
-
- );
- case "VIDEO_WATERMARK_REMOVAL":
- return (
-
-
-
- );
- case "IMAGE_WATERMARK_REMOVAL":
- return (
-
-
-
- );
case "IMAGE_TO_3D_OBJECT":
return (
@@ -202,46 +172,12 @@ const TabBody = () => {
);
- case "ANGLES":
- return (
-
- );
- case "STORYBOARD":
- return storyboardPageEnabled ? (
-
-
-
- ) : null;
- case "BACKGROUND_CHANGE":
- return (
-
- );
- case "VIDEO_EDITOR":
- return (
-
- );
case "SETTINGS":
return (
);
- case "MOODBOARD":
- // The TopBar is fixed and 40px tall, so the board sits below it and fills
- // the rest of the viewport. overflow-hidden keeps sub-pixel 100vh
- // rounding from spawning page scrollbars (which w-screen ancestors would
- // turn into a horizontal scrollbar via the Windows scrollbar gutter).
- return (
-
- );
default:
return null;
}
diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx
deleted file mode 100644
index 7de01a0f..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageAngles/Angles.tsx
+++ /dev/null
@@ -1,775 +0,0 @@
-import { useState, useRef, useEffect, useCallback, useMemo } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faImages,
- faDownload,
- faCrosshairs,
- faChevronUp,
- faChevronDown,
- faChevronLeft,
- faChevronRight,
-} from "@fortawesome/pro-solid-svg-icons";
-import { Button, GenerateButton } from "@storyteller/ui-button";
-import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
-import {
- MediaUploadApi,
- MediaFilesApi,
- downloadFileFromUrl,
-} from "@storyteller/api";
-import toast from "react-hot-toast";
-import { v4 as uuidv4 } from "uuid";
-
-import { UploadEntryCard } from "../../components/media/UploadEntryCard";
-import {
- useAnglesStore,
- ROTATION_VALUES,
- TILT_VALUES,
- ZOOM_VALUES,
- rotationToDisplay,
- displayToRotation,
- DISPLAY_ROTATION_VALUES,
-} from "./AnglesStore";
-import { OrbitSphere, snapToNearest } from "./OrbitSphere";
-import { twMerge } from "tailwind-merge";
-import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
-import { SliderV2 } from "@storyteller/ui-sliderv2";
-// import { Switch } from "@headlessui/react";
-import { GenerateImage } from "@storyteller/tauri-api";
-import {
- ClassyModelSelector,
- useAnglesPageModelList,
- ModelPage,
- useSelectedModel,
- useSelectedProviderForModel,
-} from "@storyteller/ui-model-selector";
-import {
- CostCalculatorButton,
- useCostBreakdownModalStore,
-} from "@storyteller/ui-pricing-modal";
-import { HelpMenuButton } from "@storyteller/ui-help-menu";
-import {
- HistoryStack,
- type ImageBundle,
- type BaseSelectorImage,
-} from "@storyteller/ui-pagedraw";
-
-const ANGLES_MODEL_PAGE = ModelPage.Angles;
-
-// ─── Main Angles Component ─────────────────────────────────────────────────────
-
-export const Angles = () => {
- const anglesModelList = useAnglesPageModelList();
- const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
- const [selectedGalleryImages, setSelectedGalleryImages] = useState(
- [],
- );
- const [windowSize, setWindowSize] = useState({
- width: window.innerWidth,
- height: window.innerHeight,
- });
-
- const fileInputRef = useRef(null);
-
- // Model selector
- const selectedModel = useSelectedModel(ANGLES_MODEL_PAGE);
- const selectedProvider = useSelectedProviderForModel(
- ANGLES_MODEL_PAGE,
- selectedModel?.id,
- );
-
- // Cost estimation
- const anglesCredits = useCostBreakdownModalStore(
- (s) => s.estimatedCreditsByPage[ANGLES_MODEL_PAGE],
- );
-
- // State selectors (only re-render when specific values change)
- const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
- const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
- const sourceThumbnailUrlTemplate = useAnglesStore(
- (s) => s.sourceThumbnailUrlTemplate,
- );
- const imageDimensions = useAnglesStore((s) => s.imageDimensions);
- const angleConfig = useAnglesStore((s) => s.angleConfig);
- // const generateFromBestAngles = useAnglesStore(
- // (s) => s.generateFromBestAngles,
- // );
- const generatedAngles = useAnglesStore((s) => s.generatedAngles);
- const activeAngleId = useAnglesStore((s) => s.activeAngleId);
- const isProcessing = useAnglesStore((s) => s.isProcessing);
- const isLoadingImage = useAnglesStore((s) => s.isLoadingImage);
-
- // Actions (stable references — never cause re-renders)
- const setSourceImage = useAnglesStore((s) => s.setSourceImage);
- const setImageDimensions = useAnglesStore((s) => s.setImageDimensions);
- const setRotation = useAnglesStore((s) => s.setRotation);
- const setTilt = useAnglesStore((s) => s.setTilt);
- const setZoom = useAnglesStore((s) => s.setZoom);
- // const setGenerateFromBestAngles = useAnglesStore(
- // (s) => s.setGenerateFromBestAngles,
- // );
- const setActiveAngle = useAnglesStore((s) => s.setActiveAngle);
- const setIsLoadingImage = useAnglesStore((s) => s.setIsLoadingImage);
- const resetSource = useAnglesStore((s) => s.resetSource);
-
- // Currently displayed image (active generated angle, or source)
- const activeAngle = useMemo(
- () => generatedAngles.find((a) => a.id === activeAngleId) ?? null,
- [generatedAngles, activeAngleId],
- );
-
- // The token of whatever is currently selected (for HistoryStack highlight)
- const selectedImageToken = activeAngleId ?? sourceMediaToken ?? undefined;
-
- // Event listener is at module level in AnglesStore.ts — persists across navigations.
-
- // Derive history bundles from store (no local state needed)
- const historyBundles = useMemo(
- () =>
- generatedAngles.map((angle) => ({
- images: [
- {
- url: angle.imageUrl,
- mediaToken: angle.id,
- thumbnailUrlTemplate: angle.thumbnailUrlTemplate,
- fullImageUrl: angle.imageUrl,
- },
- ],
- })),
- [generatedAngles],
- );
-
- // Derive pending placeholders from store
- const pendingSubscriberIds = useAnglesStore((s) => s.pendingSubscriberIds);
- const pendingGenerations = useMemo(
- () => pendingSubscriberIds.map((id) => ({ id, count: 1 })),
- [pendingSubscriberIds],
- );
-
- // Window resize handler (debounced to avoid excessive re-renders)
- useEffect(() => {
- let timeoutId: ReturnType;
- const handleResize = () => {
- clearTimeout(timeoutId);
- timeoutId = setTimeout(() => {
- setWindowSize({ width: window.innerWidth, height: window.innerHeight });
- }, 150);
- };
- window.addEventListener("resize", handleResize);
- return () => {
- window.removeEventListener("resize", handleResize);
- clearTimeout(timeoutId);
- };
- }, []);
-
- // ─── Local file upload → MediaUploadApi → media token ───────────────────────
- const handleLocalImageSelect = useCallback(
- async (files: FileList) => {
- const file = files[0];
- if (!file || !file.type.startsWith("image/")) return;
-
- setIsLoadingImage(true);
-
- try {
- const objectUrl = URL.createObjectURL(file);
-
- // Load dimensions
- await new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- resolve();
- };
- img.onerror = () => reject(new Error("Failed to load image"));
- img.src = objectUrl;
- });
-
- // Upload to backend for media token
- const mediaUploadApi = new MediaUploadApi();
- const uploadResult = await mediaUploadApi.UploadImage({
- blob: file,
- fileName: file.name,
- uuid: uuidv4(),
- });
-
- if (!uploadResult?.success || !uploadResult.data) {
- throw new Error("Upload failed — no media token returned");
- }
-
- const mediaToken = uploadResult.data;
-
- // Fetch CDN URL + thumbnail template from backend
- let cdnUrl = objectUrl;
- let thumbnailUrlTemplate: string | undefined;
- try {
- const api = new MediaFilesApi();
- const mediaFile = await api.GetMediaFileByToken({
- mediaFileToken: mediaToken,
- });
- if (mediaFile.success && mediaFile.data) {
- const links = mediaFile.data.media_links as
- | {
- cdn_url?: string;
- thumbnail_template?: string;
- maybe_thumbnail_template?: string;
- }
- | undefined;
- cdnUrl =
- links?.cdn_url ||
- (mediaFile.data as any).public_bucket_url ||
- objectUrl;
- thumbnailUrlTemplate =
- links?.thumbnail_template || links?.maybe_thumbnail_template;
- }
- } catch {
- // Fall back to blob URL if fetching CDN info fails
- }
-
- setSourceImage(cdnUrl, mediaToken, thumbnailUrlTemplate);
- setIsLoadingImage(false);
- } catch (error) {
- console.error("Error processing image:", error);
- toast.error("Failed to process image");
- setIsLoadingImage(false);
- }
- },
- [setSourceImage, setImageDimensions, setIsLoadingImage],
- );
-
- const handleImageSelect = useCallback((id: string) => {
- setSelectedGalleryImages((prev) => {
- if (prev.includes(id)) return prev.filter((x) => x !== id);
- return [id];
- });
- }, []);
-
- // ─── Gallery select → media token from item.id ──────────────────────────────
- const handleGallerySelect = useCallback(
- async (selectedItems: GalleryItem[]) => {
- const item = selectedItems[0];
- if (!item || !item.fullImage) {
- toast.error("No image selected");
- return;
- }
-
- const mediaToken = item.id; // item.id is a media token
- const imageUrl = item.fullImage;
-
- setIsGalleryModalOpen(false);
- setSelectedGalleryImages([]);
- setIsLoadingImage(true);
-
- try {
- await new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => {
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- resolve();
- };
- img.onerror = () => reject(new Error("Failed to load image"));
- img.src = imageUrl;
- });
-
- setSourceImage(imageUrl, mediaToken);
- setIsLoadingImage(false);
- } catch (error) {
- console.error("Error processing gallery image:", error);
- toast.error("Failed to process image");
- setIsLoadingImage(false);
- }
- },
- [setSourceImage, setImageDimensions, setIsLoadingImage],
- );
-
- // ─── Generate with subscriber pattern ───────────────────────────────────────
- const handleGenerate = useCallback(async () => {
- const state = useAnglesStore.getState();
- if (!state.sourceMediaToken) return;
-
- const subscriberId = uuidv4();
- state.startGeneration(subscriberId);
-
- window.__storeTaskEnqueueMeta?.({
- prompt: "",
- modelType: (selectedModel as any)?.tauriId || "flux_2_lora_angles",
- timestamp: Date.now(),
- });
-
- try {
- await GenerateImage({
- model: selectedModel ?? "flux_2_lora_angles",
- provider: selectedProvider ?? undefined,
- image_media_tokens: [state.sourceMediaToken],
- prompt: "",
- adjust_horizontal_angle: state.angleConfig.rotation,
- adjust_vertical_angle: state.angleConfig.tilt,
- adjust_zoom: state.angleConfig.zoom,
- frontend_subscriber_id: subscriberId,
- });
- } catch (error) {
- console.error("Error generating angle:", error);
- toast.error("Failed to generate angle");
- useAnglesStore.getState().removePendingSubscriber(subscriberId);
- }
- }, [selectedModel, selectedProvider]);
-
- const handleDownload = useCallback(async () => {
- if (!activeAngle) {
- toast.error("No image to download");
- return;
- }
- try {
- await downloadFileFromUrl(activeAngle.imageUrl);
- toast.success("Image saved to Downloads folder");
- } catch (error) {
- console.error("Download failed:", error);
- toast.error("Failed to download image");
- }
- }, [activeAngle]);
-
- // Called when user releases the sphere drag — values are already snapped
- const handleSphereDragEnd = useCallback(
- (snappedRotation: number, snappedTilt: number) => {
- setRotation(snappedRotation);
- setTilt(snappedTilt);
- },
- [setRotation, setTilt],
- );
-
- const showUploadScreen = !sourceImageUrl && !isLoadingImage;
-
- const imageContainerStyle = useMemo(() => {
- if (!imageDimensions) {
- return { width: "600px", height: "450px" };
- }
-
- const horizontalPadding = 128 + 80;
- const verticalPadding = 56 + 64 + 160;
-
- const availableWidth = windowSize.width - horizontalPadding;
- const availableHeight = windowSize.height - verticalPadding;
-
- const maxWidth = Math.min(availableWidth, 1200);
- const maxHeight = Math.max(availableHeight, 200);
- const imageAspect = imageDimensions.width / imageDimensions.height;
-
- let width = maxWidth;
- let height = width / imageAspect;
-
- if (height > maxHeight) {
- height = maxHeight;
- width = height * imageAspect;
- }
-
- width = Math.max(width, 200);
- height = Math.max(height, 150);
-
- return { width: `${width}px`, height: `${height}px` };
- }, [imageDimensions, windowSize.width, windowSize.height]);
-
- const handleFileInputChange = useCallback(
- (e: React.ChangeEvent) => {
- if (e.target.files) {
- handleLocalImageSelect(e.target.files);
- e.target.value = "";
- }
- },
- [handleLocalImageSelect],
- );
-
- const handleOpenGallery = useCallback(() => {
- setIsGalleryModalOpen(true);
- }, []);
-
- const handleCloseGallery = useCallback(() => {
- setIsGalleryModalOpen(false);
- setSelectedGalleryImages([]);
- }, []);
-
- const handleChangeImage = useCallback(() => {
- resetSource();
- useAnglesStore.getState().clearGeneratedAngles();
- }, [resetSource]);
-
- // Slider handlers that snap to allowed values
- const handleRotationSlider = useCallback(
- (value: number) => {
- const snapped = snapToNearest(value, DISPLAY_ROTATION_VALUES);
- setRotation(displayToRotation(snapped));
- },
- [setRotation],
- );
-
- const handleTiltSlider = useCallback(
- (value: number) => {
- setTilt(snapToNearest(value, TILT_VALUES));
- },
- [setTilt],
- );
-
- const handleZoomSlider = useCallback(
- (value: number) => {
- setZoom(snapToNearest(value, ZOOM_VALUES));
- },
- [setZoom],
- );
-
- // Arrow step handlers for orbit sphere
- const handleRotationStep = useCallback(
- (direction: 1 | -1) => {
- const idx = ROTATION_VALUES.indexOf(angleConfig.rotation);
- const next =
- (idx + direction + ROTATION_VALUES.length) % ROTATION_VALUES.length;
- setRotation(ROTATION_VALUES[next]);
- },
- [angleConfig.rotation, setRotation],
- );
-
- const handleTiltStep = useCallback(
- (direction: 1 | -1) => {
- const idx = TILT_VALUES.indexOf(angleConfig.tilt);
- const next = Math.max(
- 0,
- Math.min(TILT_VALUES.length - 1, idx + direction),
- );
- setTilt(TILT_VALUES[next]);
- },
- [angleConfig.tilt, setTilt],
- );
-
- // ─── HistoryStack data ──────────────────────────────────────────────────────
- // Source image as the first entry so user can return to original
- const allBundles = useMemo(() => {
- if (!sourceImageUrl || !sourceMediaToken) return historyBundles;
- const sourceBundle: ImageBundle = {
- images: [
- {
- url: sourceImageUrl,
- mediaToken: sourceMediaToken,
- thumbnailUrlTemplate: sourceThumbnailUrlTemplate ?? undefined,
- fullImageUrl: sourceImageUrl,
- },
- ],
- };
- return [sourceBundle, ...historyBundles];
- }, [
- sourceImageUrl,
- sourceMediaToken,
- sourceThumbnailUrlTemplate,
- historyBundles,
- ]);
-
- const handleHistoryImageSelect = useCallback(
- (image: BaseSelectorImage) => {
- // If selecting the source image, clear active angle to show source
- if (image.mediaToken === useAnglesStore.getState().sourceMediaToken) {
- setActiveAngle(null);
- } else {
- setActiveAngle(image.mediaToken);
- }
- },
- [setActiveAngle],
- );
-
- const handleHistoryImageRemove = useCallback((image: BaseSelectorImage) => {
- useAnglesStore.getState().removeGeneratedAngle(image.mediaToken);
- }, []);
-
- const handleHistoryClear = useCallback(() => {
- useAnglesStore.getState().clearGeneratedAngles();
- }, []);
-
- return (
- <>
-
- {showUploadScreen ? (
- /* ──── Upload screen ──── */
-
-
-
-
- {isLoadingImage && (
-
-
-
- )}
-
-
-
- ) : (
- /* ──── Editor layout — floating panels over full-bleed canvas ──── */
-
-
-
- {/* Full-bleed image display */}
-
-
- {activeAngle ? (
-
- ) : sourceImageUrl ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {/* ── Floating top toolbar ── */}
-
-
-
- Change Image
-
- {activeAngle && (
-
- Download
-
- )}
-
-
-
- {/* ── History stack (right side, like PageEdit) ── */}
-
-
-
-
- {/* ── Floating bottom angle controls ── */}
-
-
- {/* Orbit sphere */}
-
- handleTiltStep(1)}
- className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
- >
-
-
- handleTiltStep(-1)}
- className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
- >
-
-
- handleRotationStep(-1)}
- className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
- >
-
-
- handleRotationStep(1)}
- className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
- >
-
-
-
-
-
- {/* Divider */}
-
-
- {/* Sliders group */}
-
-
-
- Rotation
-
-
-
-
-
- {rotationToDisplay(angleConfig.rotation)}°
-
-
-
-
-
- Tilt
-
-
-
-
-
- {angleConfig.tilt}°
-
-
-
-
-
- Zoom
-
-
-
-
-
- {angleConfig.zoom}
-
-
-
-
- {/* Divider */}
-
-
- {/* Toggle + Generate */}
-
- {/*
-
-
- Best 12 angles
-
-
-
-
-
- */}
-
-
-
- Generate
-
-
-
-
-
- {/* ── Cost calculator + Help (bottom-right) ── */}
-
-
-
-
-
- )}
-
-
-
- >
- );
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts b/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts
deleted file mode 100644
index 32031c9c..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageAngles/AnglesStore.ts
+++ /dev/null
@@ -1,288 +0,0 @@
-import { create } from "zustand";
-import { listen } from "@tauri-apps/api/event";
-
-export interface GeneratedAngle {
- id: string;
- imageUrl: string;
- thumbnailUrlTemplate?: string;
- rotation: number;
- tilt: number;
- zoom: number;
- timestamp: number;
-}
-
-export interface ImageDimensions {
- width: number;
- height: number;
-}
-
-export interface AngleConfig {
- rotation: number; // 0, 45, 90, 135, 180, 225, 270, 315
- tilt: number; // -30, 0, 30, 60
- zoom: number; // 0, 5, 10
-}
-
-// Snapped value options (backend values)
-export const ROTATION_VALUES = [0, 45, 90, 135, 180, 225, 270, 315];
-export const TILT_VALUES = [-30, 0, 30, 60];
-export const ZOOM_VALUES = [0, 5, 10];
-
-// ─── Display conversion helpers ──────────────────────────────────────────────
-// Backend uses 0–315 for rotation; UI displays −180 to +180 centered at 0.
-
-/** Convert backend rotation (0–315) to display value (−180 to +180). */
-export const rotationToDisplay = (backend: number): number =>
- backend > 180 ? backend - 360 : backend;
-
-/** Convert display rotation (−180 to +180) to backend value (0–315). */
-export const displayToRotation = (display: number): number =>
- display < 0 ? display + 360 : display;
-
-/** Snap targets for the display-range rotation slider. */
-export const DISPLAY_ROTATION_VALUES = [-180, -135, -90, -45, 0, 45, 90, 135, 180];
-
-interface AnglesState {
- // Source image
- sourceImageUrl: string | null;
- sourceMediaToken: string | null;
- sourceThumbnailUrlTemplate: string | null;
- imageDimensions: ImageDimensions | null;
-
- // Angle config
- angleConfig: AngleConfig;
- generateFromBestAngles: boolean;
-
- // Generated results
- generatedAngles: GeneratedAngle[];
- activeAngleId: string | null;
-
- // Processing
- isProcessing: boolean;
- pendingSubscriberIds: string[];
- isLoadingImage: boolean;
-
- // Actions
- setSourceImage: (url: string, mediaToken: string | null, thumbnailUrlTemplate?: string | null) => void;
- setImageDimensions: (dims: ImageDimensions | null) => void;
- setRotation: (value: number) => void;
- setTilt: (value: number) => void;
- setZoom: (value: number) => void;
- setGenerateFromBestAngles: (value: boolean) => void;
- addGeneratedAngle: (angle: GeneratedAngle) => void;
- setActiveAngle: (id: string | null) => void;
- getActiveAngle: () => GeneratedAngle | null;
- removeGeneratedAngle: (id: string) => void;
- clearGeneratedAngles: () => void;
- removePendingSubscriber: (subscriberId: string) => void;
- setIsLoadingImage: (value: boolean) => void;
- startGeneration: (subscriberId: string) => void;
- completeGeneration: (
- subscriberId: string,
- images: Array<{
- cdn_url: string;
- media_token: string;
- maybe_thumbnail_template?: string;
- }>,
- ) => void;
- resetSource: () => void;
- clearAll: () => void;
-}
-
-const DEFAULT_CONFIG: AngleConfig = {
- rotation: 0,
- tilt: 0,
- zoom: 0,
-};
-
-export const useAnglesStore = create((set, get) => ({
- sourceImageUrl: null,
- sourceMediaToken: null,
- sourceThumbnailUrlTemplate: null,
- imageDimensions: null,
- angleConfig: { ...DEFAULT_CONFIG },
- generateFromBestAngles: false,
- generatedAngles: [],
- activeAngleId: null,
- isProcessing: false,
- pendingSubscriberIds: [],
- isLoadingImage: false,
-
- setSourceImage: (url, mediaToken, thumbnailUrlTemplate) => {
- set({ sourceImageUrl: url, sourceMediaToken: mediaToken, sourceThumbnailUrlTemplate: thumbnailUrlTemplate ?? null });
- },
-
- setImageDimensions: (dims) => {
- set({ imageDimensions: dims });
- },
-
- setRotation: (value) => {
- set((state) => ({
- angleConfig: { ...state.angleConfig, rotation: value },
- }));
- },
-
- setTilt: (value) => {
- set((state) => ({
- angleConfig: { ...state.angleConfig, tilt: value },
- }));
- },
-
- setZoom: (value) => {
- set((state) => ({
- angleConfig: { ...state.angleConfig, zoom: value },
- }));
- },
-
- setGenerateFromBestAngles: (value) => {
- set({ generateFromBestAngles: value });
- },
-
- addGeneratedAngle: (angle) => {
- set((state) => ({
- generatedAngles: [...state.generatedAngles, angle],
- activeAngleId: angle.id,
- }));
- },
-
- setActiveAngle: (id) => {
- set({ activeAngleId: id });
- },
-
- getActiveAngle: () => {
- const state = get();
- return (
- state.generatedAngles.find((a) => a.id === state.activeAngleId) ?? null
- );
- },
-
- removeGeneratedAngle: (id) => {
- set((s) => {
- const filtered = s.generatedAngles.filter((a) => a.id !== id);
- return {
- generatedAngles: filtered,
- activeAngleId: s.activeAngleId === id ? null : s.activeAngleId,
- };
- });
- },
-
- clearGeneratedAngles: () => {
- set({
- generatedAngles: [],
- activeAngleId: null,
- isProcessing: false,
- pendingSubscriberIds: [],
- });
- },
-
- removePendingSubscriber: (subscriberId) => {
- set((s) => {
- const remaining = s.pendingSubscriberIds.filter(
- (id) => id !== subscriberId,
- );
- return {
- pendingSubscriberIds: remaining,
- isProcessing: remaining.length > 0,
- };
- });
- },
-
- setIsLoadingImage: (value) => {
- set({ isLoadingImage: value });
- },
-
- startGeneration: (subscriberId) => {
- set((s) => ({
- isProcessing: true,
- pendingSubscriberIds: [...s.pendingSubscriberIds, subscriberId],
- }));
- },
-
- completeGeneration: (subscriberId, images) => {
- const state = get();
- if (!state.pendingSubscriberIds.includes(subscriberId)) return;
-
- const newAngles: GeneratedAngle[] = images.map((img) => ({
- id: img.media_token,
- imageUrl: img.cdn_url,
- thumbnailUrlTemplate: img.maybe_thumbnail_template,
- rotation: state.angleConfig.rotation,
- tilt: state.angleConfig.tilt,
- zoom: state.angleConfig.zoom,
- timestamp: Date.now(),
- }));
-
- const remaining = state.pendingSubscriberIds.filter(
- (id) => id !== subscriberId,
- );
- set((s) => ({
- generatedAngles: [...s.generatedAngles, ...newAngles],
- activeAngleId: newAngles[0]?.id ?? s.activeAngleId,
- isProcessing: remaining.length > 0,
- pendingSubscriberIds: remaining,
- }));
- },
-
- resetSource: () => {
- set({
- sourceImageUrl: null,
- sourceMediaToken: null,
- sourceThumbnailUrlTemplate: null,
- imageDimensions: null,
- angleConfig: { ...DEFAULT_CONFIG },
- generateFromBestAngles: false,
- isProcessing: false,
- pendingSubscriberIds: [],
- isLoadingImage: false,
- });
- },
-
- clearAll: () => {
- set({
- sourceImageUrl: null,
- sourceMediaToken: null,
- sourceThumbnailUrlTemplate: null,
- imageDimensions: null,
- angleConfig: { ...DEFAULT_CONFIG },
- generateFromBestAngles: false,
- generatedAngles: [],
- activeAngleId: null,
- isProcessing: false,
- pendingSubscriberIds: [],
- isLoadingImage: false,
- });
- },
-}));
-
-// ─── Module-level event listener ────────────────────────────────────────────────
-// Persists across page navigations so generation results are never lost.
-// Angle models route through ImageGeneration task type, which fires
-// text_to_image_generation_complete_event (not image_edit_complete_event).
-listen<{
- status: string;
- data: {
- generated_images: Array<{
- media_token: string;
- cdn_url: string;
- maybe_thumbnail_template?: string;
- }>;
- maybe_frontend_subscriber_id?: string;
- };
-}>("text_to_image_generation_complete_event", (wrappedEvent) => {
- const event = wrappedEvent.payload.data;
- const state = useAnglesStore.getState();
- if (state.pendingSubscriberIds.length === 0) return;
-
- const subscriberId = event.maybe_frontend_subscriber_id;
- if (subscriberId && !state.pendingSubscriberIds.includes(subscriberId))
- return;
-
- const resolvedId = subscriberId ?? state.pendingSubscriberIds[0];
-
- const images = event.generated_images.map((img) => ({
- cdn_url: img.cdn_url,
- media_token: img.media_token,
- maybe_thumbnail_template: img.maybe_thumbnail_template,
- }));
-
- state.completeGeneration(resolvedId, images);
-});
diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx b/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx
deleted file mode 100644
index f5723481..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageAngles/OrbitSphere.tsx
+++ /dev/null
@@ -1,729 +0,0 @@
-import { useRef, useEffect, useCallback, memo } from "react";
-import { ROTATION_VALUES, TILT_VALUES } from "./AnglesStore";
-
-// ─── Types ────────────────────────────────────────────────────────────────────
-
-export interface OrbitSphereProps {
- rotation: number;
- tilt: number;
- zoom: number;
- onDragEnd: (rotation: number, tilt: number) => void;
-}
-
-// ─── Helpers ─────────────────────────────────────────────────────────────────
-
-/** Snap a raw value to the nearest element in an array of allowed values. */
-export const snapToNearest = (
- value: number,
- allowedValues: number[],
-): number => {
- let closest = allowedValues[0];
- let minDist = Math.abs(value - closest);
- for (const v of allowedValues) {
- const dist = Math.abs(value - v);
- if (dist < minDist) {
- minDist = dist;
- closest = v;
- }
- }
- return closest;
-};
-
-// ─── Face descriptor used by the painter's-algorithm renderer ────────────────
-
-interface Face {
- pts: { x: number; y: number; depth: number }[];
- fillColor: string;
- edgeColor: string;
- edgeWidth: number;
- /** Lens front cap — draws concentric glass rings. */
- isFront: boolean;
- /** Camera body back face — draws the LCD screen inset. */
- isBack: boolean;
- /** Camera body front face — draws the red recording dot. */
- isBodyFront: boolean;
- avgDepth: number;
- /** Targeting-laser line from lens tip to sphere centre. */
- isLine?: boolean;
-}
-
-// ─── OrbitSphere Component ───────────────────────────────────────────────────
-
-export const OrbitSphere = memo(
- ({ rotation, tilt, onDragEnd }: OrbitSphereProps) => {
- const canvasRef = useRef(null);
- const isDragging = useRef(false);
- const lastPos = useRef({ x: 0, y: 0 });
- const liveRotation = useRef(rotation);
- const liveTilt = useRef(tilt);
- const rafId = useRef(null);
-
- // Keep live refs in sync when props change (from sliders / snap)
- useEffect(() => {
- if (!isDragging.current) {
- liveRotation.current = rotation;
- liveTilt.current = tilt;
- }
- }, [rotation, tilt]);
-
- // ─── Core draw function ──────────────────────────────────────────────────
-
- const drawSphere = useCallback(
- (renderRotation: number, renderTilt: number, dragging = false) => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
-
- // HiDPI setup
- const displayW = canvas.clientWidth;
- const displayH = canvas.clientHeight;
- const dpr = window.devicePixelRatio || 1;
- canvas.width = displayW * dpr;
- canvas.height = displayH * dpr;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-
- const w = displayW;
- const h = displayH;
- const cx = w / 2;
- const cy = h / 2;
- const radius = Math.min(w, h) * 0.4;
-
- ctx.clearRect(0, 0, w, h);
-
- // Subtle radial outer glow
- const outerGlow = ctx.createRadialGradient(
- cx,
- cy,
- radius * 0.8,
- cx,
- cy,
- radius * 1.3,
- );
- outerGlow.addColorStop(0, "rgba(255, 255, 255, 0.02)");
- outerGlow.addColorStop(1, "rgba(255, 255, 255, 0)");
- ctx.fillStyle = outerGlow;
- ctx.fillRect(0, 0, w, h);
-
- const rotRad = (renderRotation * Math.PI) / 180;
- const tiltRad = (renderTilt * Math.PI) / 180;
-
- // ── Perspective projection ──────────────────────────────────────────
- const project = (
- x3d: number,
- y3d: number,
- z3d: number,
- ): { x: number; y: number; depth: number } => {
- const perspective = 6;
- const scale = perspective / (perspective - z3d);
- return {
- x: cx + x3d * radius * scale,
- y: cy + y3d * radius * scale,
- depth: z3d,
- };
- };
-
- // ── Wireframe sphere ────────────────────────────────────────────────
- const wireAlpha = dragging ? 0.12 : 0.06;
- ctx.strokeStyle = `rgba(255, 255, 255, ${wireAlpha})`;
- ctx.lineWidth = 0.7;
-
- // Meridians
- for (let i = 0; i < 12; i++) {
- const angle = (i * Math.PI) / 6 + rotRad;
- ctx.beginPath();
- for (let j = 0; j <= 40; j++) {
- const phi = (j / 40) * Math.PI * 2;
- const p = project(
- Math.cos(angle) * Math.sin(phi),
- Math.cos(phi),
- Math.sin(angle) * Math.sin(phi),
- );
- if (j === 0) ctx.moveTo(p.x, p.y);
- else ctx.lineTo(p.x, p.y);
- }
- ctx.stroke();
- }
-
- // Parallels
- for (let i = 1; i < 6; i++) {
- const phi = (i * Math.PI) / 6;
- ctx.beginPath();
- for (let j = 0; j <= 40; j++) {
- const angle = (j / 40) * Math.PI * 2 + rotRad;
- const p = project(
- Math.cos(angle) * Math.sin(phi),
- Math.cos(phi),
- Math.sin(angle) * Math.sin(phi),
- );
- if (j === 0) ctx.moveTo(p.x, p.y);
- else ctx.lineTo(p.x, p.y);
- }
- ctx.stroke();
- }
-
- // ── 3D camera position & orientation ───────────────────────────────
- const camPosX = Math.sin(rotRad) * Math.cos(tiltRad);
- const camPosY = -Math.sin(tiltRad);
- const camPosZ = Math.cos(rotRad) * Math.cos(tiltRad);
-
- const centerScreen = project(0, 0, 0);
-
- // Direction vector pointing from camera towards origin
- const dirX = -camPosX;
- const dirY = -camPosY;
- const dirZ = -camPosZ;
-
- // Camera-local right axis (world-up crossed with dir)
- let rightX = dirZ;
- const rightY = 0;
- let rightZ = -dirX;
- const rightLen = Math.sqrt(rightX * rightX + rightZ * rightZ) || 1;
- rightX /= rightLen;
- rightZ /= rightLen;
-
- // Camera-local up axis
- let upX = rightY * dirZ - rightZ * dirY;
- let upY = rightZ * dirX - rightX * dirZ;
- let upZ = rightX * dirY - rightY * dirX;
- const upLen = Math.sqrt(upX * upX + upY * upY + upZ * upZ) || 1;
- upX /= upLen;
- upY /= upLen;
- upZ /= upLen;
-
- // ── Camera body dimensions ─────────────────────────────────────────
- const bodyW = 0.28;
- const bodyH = 0.19;
- const bodyD = 0.22;
-
- // ── Geometry helpers ───────────────────────────────────────────────
-
- /** Build 8 corners of an axis-aligned box in camera-local space. */
- const makeBox = (
- bx: number,
- by: number,
- bz: number,
- hw: number,
- hh: number,
- hd: number,
- ) => [
- {
- x: bx + rightX * hw + upX * hh,
- y: by + rightY * hw + upY * hh,
- z: bz + rightZ * hw + upZ * hh,
- },
- {
- x: bx - rightX * hw + upX * hh,
- y: by - rightY * hw + upY * hh,
- z: bz - rightZ * hw + upZ * hh,
- },
- {
- x: bx - rightX * hw - upX * hh,
- y: by - rightY * hw - upY * hh,
- z: bz - rightZ * hw - upZ * hh,
- },
- {
- x: bx + rightX * hw - upX * hh,
- y: by + rightY * hw - upY * hh,
- z: bz + rightZ * hw - upZ * hh,
- },
- {
- x: bx + dirX * hd + rightX * hw + upX * hh,
- y: by + dirY * hd + rightY * hw + upY * hh,
- z: bz + dirZ * hd + rightZ * hw + upZ * hh,
- },
- {
- x: bx + dirX * hd - rightX * hw + upX * hh,
- y: by + dirY * hd - rightY * hw + upY * hh,
- z: bz + dirZ * hd - rightZ * hw + upZ * hh,
- },
- {
- x: bx + dirX * hd - rightX * hw - upX * hh,
- y: by + dirY * hd - rightY * hw - upY * hh,
- z: bz + dirZ * hd - rightZ * hw - upZ * hh,
- },
- {
- x: bx + dirX * hd + rightX * hw - upX * hh,
- y: by + dirY * hd + rightY * hw - upY * hh,
- z: bz + dirZ * hd + rightZ * hw - upZ * hh,
- },
- ];
-
- // ── Build camera geometry ──────────────────────────────────────────
- const bodyCorners3D = makeBox(
- camPosX,
- camPosY,
- camPosZ,
- bodyW,
- bodyH,
- bodyD,
- );
-
- const lensLen = 0.14;
- const lensR = 0.1;
- const lensCx = camPosX + dirX * bodyD;
- const lensCy = camPosY + dirY * bodyD;
- const lensCz = camPosZ + dirZ * bodyD;
-
- const vfCorners3D = makeBox(
- camPosX + dirX * -0.03 + upX * (bodyH + 0.05),
- camPosY + dirY * -0.03 + upY * (bodyH + 0.05),
- camPosZ + dirZ * -0.03 + upZ * (bodyH + 0.05),
- 0.09,
- 0.05,
- 0.1,
- );
- const gripCorners3D = makeBox(
- camPosX + rightX * (bodyW + 0.03),
- camPosY + rightY * (bodyW + 0.03),
- camPosZ + rightZ * (bodyW + 0.03),
- 0.04,
- bodyH * 0.85,
- bodyD * 0.7,
- );
-
- // Project box corners to screen
- const bodyCorners = bodyCorners3D.map((c) => project(c.x, c.y, c.z));
- const vfCorners = vfCorners3D.map((c) => project(c.x, c.y, c.z));
- const gripCorners = gripCorners3D.map((c) => project(c.x, c.y, c.z));
-
- // Box face winding: [back, front, top, bottom, left, right]
- const BOX_FACES: [number, number, number, number][] = [
- [0, 1, 2, 3],
- [4, 5, 6, 7],
- [0, 1, 5, 4],
- [3, 2, 6, 7],
- [1, 2, 6, 5],
- [0, 3, 7, 4],
- ];
-
- // Edge colours
- const edgeGlow = "rgba(180, 200, 255, 0.35)";
- const edgeGlowBright = "rgba(180, 200, 255, 0.55)";
-
- // ── Face collector ─────────────────────────────────────────────────
- const allFaces: Face[] = [];
-
- const addBoxFaces = (
- corners: { x: number; y: number; depth: number }[],
- fillBase: string,
- edge: string,
- edgeW: number,
- markBack = false,
- markBodyFront = false,
- ) => {
- for (let i = 0; i < 6; i++) {
- const pts = BOX_FACES[i].map((idx) => corners[idx]);
- allFaces.push({
- pts,
- fillColor: fillBase,
- edgeColor: edge,
- edgeWidth: edgeW,
- isFront: false,
- isBack: markBack && i === 0,
- isBodyFront: markBodyFront && i === 1,
- avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
- });
- }
- };
-
- const addCylinder = (
- bx: number,
- by: number,
- bz: number,
- r: number,
- len: number,
- fillBase: string,
- edge: string,
- edgeW: number,
- markFrontCap: boolean,
- ) => {
- const NUM_SEGS = 20;
- const basePts: { x: number; y: number; z: number }[] = [];
- const frontPts: { x: number; y: number; z: number }[] = [];
-
- for (let i = 0; i < NUM_SEGS; i++) {
- const a = (i / NUM_SEGS) * Math.PI * 2;
- const lx = Math.cos(a) * r;
- const ly = Math.sin(a) * r;
- basePts.push({
- x: bx + rightX * lx + upX * ly,
- y: by + rightY * lx + upY * ly,
- z: bz + rightZ * lx + upZ * ly,
- });
- frontPts.push({
- x: bx + dirX * len + rightX * lx + upX * ly,
- y: by + dirY * len + rightY * lx + upY * ly,
- z: bz + dirZ * len + rightZ * lx + upZ * ly,
- });
- }
-
- // Side quads
- for (let i = 0; i < NUM_SEGS; i++) {
- const ni = (i + 1) % NUM_SEGS;
- const pts = [
- basePts[i],
- basePts[ni],
- frontPts[ni],
- frontPts[i],
- ].map((pt) => project(pt.x, pt.y, pt.z));
- allFaces.push({
- pts,
- fillColor: fillBase,
- edgeColor: edge,
- edgeWidth: edgeW,
- isFront: false,
- isBack: false,
- isBodyFront: false,
- avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
- });
- }
-
- // Front cap
- const capPts = frontPts.map((p) => project(p.x, p.y, p.z));
- allFaces.push({
- pts: capPts,
- fillColor: fillBase,
- edgeColor: edge,
- edgeWidth: edgeW,
- isFront: markFrontCap,
- isBack: false,
- isBodyFront: false,
- avgDepth: capPts.reduce((s, p) => s + p.depth, 0) / capPts.length,
- });
- };
-
- // ── Add all camera parts ───────────────────────────────────────────
- addBoxFaces(
- bodyCorners,
- "rgba(35, 38, 45, 0.92)",
- edgeGlow,
- 1.0,
- true,
- true,
- );
- addBoxFaces(
- gripCorners,
- "rgba(30, 33, 40, 0.9)",
- edgeGlow,
- 0.7,
- false,
- false,
- );
- addCylinder(
- lensCx,
- lensCy,
- lensCz,
- lensR,
- lensLen,
- "rgba(28, 30, 38, 0.95)",
- edgeGlowBright,
- 0.8,
- true,
- );
- addBoxFaces(
- vfCorners,
- "rgba(40, 43, 50, 0.9)",
- edgeGlow,
- 0.8,
- false,
- false,
- );
-
- // Targeting laser from lens tip to sphere centre
- const lensTip = project(
- lensCx + dirX * lensLen,
- lensCy + dirY * lensLen,
- lensCz + dirZ * lensLen,
- );
- allFaces.push({
- pts: [lensTip, centerScreen],
- fillColor: "",
- edgeColor: "",
- edgeWidth: 0,
- isFront: false,
- isBack: false,
- isBodyFront: false,
- avgDepth: (lensTip.depth + centerScreen.depth) / 2,
- isLine: true,
- });
-
- // ── Painter's sort & draw ─────────────────────────────────────────
- allFaces.sort((a, b) => a.avgDepth - b.avgDepth);
- ctx.lineJoin = "round";
-
- for (const face of allFaces) {
- // ── Targeting laser ───────────────────────────────────────────
- if (face.isLine) {
- ctx.save();
- ctx.strokeStyle = "rgba(180, 210, 255, 0.7)";
- ctx.lineWidth = 1.5;
- ctx.shadowColor = "rgba(160, 200, 255, 0.9)";
- ctx.shadowBlur = 4;
- ctx.beginPath();
- ctx.moveTo(face.pts[0].x, face.pts[0].y);
- ctx.lineTo(face.pts[1].x, face.pts[1].y);
- ctx.stroke();
- ctx.restore();
- continue;
- }
-
- // ── Fill + glow edges ─────────────────────────────────────────
- ctx.beginPath();
- ctx.moveTo(face.pts[0].x, face.pts[0].y);
- for (let i = 1; i < face.pts.length; i++)
- ctx.lineTo(face.pts[i].x, face.pts[i].y);
- ctx.closePath();
- ctx.fillStyle = face.fillColor;
- ctx.fill();
-
- ctx.save();
- ctx.shadowColor = "rgba(160, 190, 255, 0.4)";
- ctx.shadowBlur = 3;
- ctx.strokeStyle = face.edgeColor;
- ctx.lineWidth = face.edgeWidth;
- ctx.stroke();
- ctx.restore();
-
- ctx.strokeStyle = face.edgeColor;
- ctx.lineWidth = face.edgeWidth * 0.5;
- ctx.stroke();
-
- // ── Back LCD screen inset ─────────────────────────────────────
- if (face.isBack) {
- const scx =
- (face.pts[0].x + face.pts[1].x + face.pts[2].x + face.pts[3].x) /
- 4;
- const scy =
- (face.pts[0].y + face.pts[1].y + face.pts[2].y + face.pts[3].y) /
- 4;
- const screenPts = face.pts.map((p) => ({
- x: scx + (p.x - scx) * 0.82,
- y: scy + (p.y - scy) * 0.75,
- }));
-
- ctx.beginPath();
- ctx.moveTo(screenPts[0].x, screenPts[0].y);
- for (let i = 1; i < screenPts.length; i++)
- ctx.lineTo(screenPts[i].x, screenPts[i].y);
- ctx.closePath();
- ctx.fillStyle = "rgba(18, 20, 26, 0.98)";
- ctx.fill();
- ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
- ctx.lineWidth = 1;
- ctx.stroke();
-
- // Subtle specular highlight on top-left corner
- ctx.beginPath();
- ctx.moveTo(screenPts[0].x, screenPts[0].y);
- ctx.lineTo(screenPts[1].x, screenPts[1].y);
- ctx.lineTo(screenPts[2].x, screenPts[2].y);
- ctx.closePath();
- ctx.fillStyle = "rgba(255, 255, 255, 0.02)";
- ctx.fill();
- }
-
- // ── Lens glass rings & gradient ───────────────────────────────
- if (face.isFront) {
- // Projects a concentric ring of lens-local radius rScale onto the screen.
- const getCirclePts = (rScale: number) => {
- const pts = [];
- for (let i = 0; i < 20; i++) {
- const a = (i / 20) * Math.PI * 2;
- const lx = Math.cos(a) * lensR * rScale;
- const ly = Math.sin(a) * lensR * rScale;
- pts.push(
- project(
- lensCx + dirX * lensLen + rightX * lx + upX * ly,
- lensCy + dirY * lensLen + rightY * lx + upY * ly,
- lensCz + dirZ * lensLen + rightZ * lx + upZ * ly,
- ),
- );
- }
- return pts;
- };
-
- const drawRing = (pts: { x: number; y: number }[]) => {
- ctx.beginPath();
- ctx.moveTo(pts[0].x, pts[0].y);
- for (let i = 1; i < pts.length; i++)
- ctx.lineTo(pts[i].x, pts[i].y);
- ctx.closePath();
- };
-
- // Mid ring
- ctx.strokeStyle = "rgba(180, 200, 255, 0.45)";
- ctx.lineWidth = 1.0;
- drawRing(getCirclePts(0.7));
- ctx.stroke();
-
- // Inner ring
- ctx.strokeStyle = "rgba(160, 190, 255, 0.3)";
- ctx.lineWidth = 0.6;
- drawRing(getCirclePts(0.4));
- ctx.stroke();
-
- // Glass gradient fill
- const lfc = project(
- lensCx + dirX * lensLen,
- lensCy + dirY * lensLen,
- lensCz + dirZ * lensLen,
- );
- const lensGrad = ctx.createRadialGradient(
- lfc.x,
- lfc.y,
- 0,
- lfc.x,
- lfc.y,
- 8,
- );
- lensGrad.addColorStop(0, "rgba(100, 160, 255, 0.4)");
- lensGrad.addColorStop(0.6, "rgba(60, 100, 200, 0.2)");
- lensGrad.addColorStop(1, "rgba(40, 70, 150, 0.05)");
- ctx.fillStyle = lensGrad;
- drawRing(getCirclePts(0.85));
- ctx.fill();
-
- // Outer glow ring
- ctx.save();
- ctx.shadowColor = "rgba(160, 190, 255, 0.6)";
- ctx.shadowBlur = 6;
- ctx.strokeStyle = "rgba(180, 200, 255, 0.4)";
- ctx.lineWidth = 1.2;
- drawRing(getCirclePts(0.95));
- ctx.stroke();
- ctx.restore();
- }
-
- // ── Red recording dot on body front ───────────────────────────
- if (face.isBodyFront) {
- const dot3D = {
- x:
- camPosX +
- dirX * bodyD +
- rightX * bodyW * 0.6 +
- upX * bodyH * 0.6,
- y:
- camPosY +
- dirY * bodyD +
- rightY * bodyW * 0.6 +
- upY * bodyH * 0.6,
- z:
- camPosZ +
- dirZ * bodyD +
- rightZ * bodyW * 0.6 +
- upZ * bodyH * 0.6,
- };
- const dot = project(dot3D.x, dot3D.y, dot3D.z);
-
- const glow = ctx.createRadialGradient(
- dot.x,
- dot.y,
- 0,
- dot.x,
- dot.y,
- 5,
- );
- glow.addColorStop(0, "rgba(255, 40, 40, 0.8)");
- glow.addColorStop(0.5, "rgba(255, 40, 40, 0.2)");
- glow.addColorStop(1, "rgba(255, 40, 40, 0)");
- ctx.fillStyle = glow;
- ctx.beginPath();
- ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2);
- ctx.fill();
-
- ctx.fillStyle = "rgba(255, 60, 60, 1)";
- ctx.beginPath();
- ctx.arc(dot.x, dot.y, 1.8, 0, Math.PI * 2);
- ctx.fill();
- }
- }
-
- // ── Centre crosshair ─────────────────────────────────────────────
- ctx.lineJoin = "miter";
- ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
- ctx.lineWidth = 0.8;
- const crossSize = 5;
- ctx.beginPath();
- ctx.moveTo(centerScreen.x - crossSize, centerScreen.y);
- ctx.lineTo(centerScreen.x + crossSize, centerScreen.y);
- ctx.moveTo(centerScreen.x, centerScreen.y - crossSize);
- ctx.lineTo(centerScreen.x, centerScreen.y + crossSize);
- ctx.stroke();
- },
- [],
- );
-
- // Redraw on prop changes (not during drag — drag has its own rAF loop)
- useEffect(() => {
- if (!isDragging.current) drawSphere(rotation, tilt);
- }, [rotation, tilt, drawSphere]);
-
- // Keep stable refs so mouse handlers are never stale
- const drawSphereRef = useRef(drawSphere);
- drawSphereRef.current = drawSphere;
- const onDragEndRef = useRef(onDragEnd);
- onDragEndRef.current = onDragEnd;
-
- // Window-level mouse listeners so dragging continues outside the canvas
- useEffect(() => {
- const onMove = (e: MouseEvent) => {
- if (!isDragging.current) return;
- const dx = e.clientX - lastPos.current.x;
- const dy = e.clientY - lastPos.current.y;
- lastPos.current = { x: e.clientX, y: e.clientY };
-
- liveRotation.current += dx * 0.8;
- liveTilt.current = Math.max(
- -30,
- Math.min(60, liveTilt.current - dy * 0.8),
- );
-
- if (rafId.current !== null) cancelAnimationFrame(rafId.current);
- rafId.current = requestAnimationFrame(() => {
- drawSphereRef.current(liveRotation.current, liveTilt.current, true);
- rafId.current = null;
- });
- };
-
- const onUp = () => {
- if (!isDragging.current) return;
- isDragging.current = false;
-
- let rawRot = liveRotation.current % 360;
- if (rawRot < 0) rawRot += 360;
- const snappedRot = snapToNearest(rawRot, ROTATION_VALUES);
- const snappedTilt = snapToNearest(liveTilt.current, TILT_VALUES);
-
- liveRotation.current = snappedRot;
- liveTilt.current = snappedTilt;
-
- drawSphereRef.current(snappedRot, snappedTilt);
- onDragEndRef.current(snappedRot, snappedTilt);
- };
-
- window.addEventListener("mousemove", onMove);
- window.addEventListener("mouseup", onUp);
- return () => {
- window.removeEventListener("mousemove", onMove);
- window.removeEventListener("mouseup", onUp);
- };
- }, []);
-
- const handleMouseDown = (e: React.MouseEvent) => {
- isDragging.current = true;
- lastPos.current = { x: e.clientX, y: e.clientY };
- };
-
- return (
-
- );
- },
-);
-
-OrbitSphere.displayName = "OrbitSphere";
diff --git a/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts b/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts
deleted file mode 100644
index 13a7558c..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageAngles/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Angles } from "./Angles";
-export { useAnglesStore } from "./AnglesStore";
diff --git a/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/PageBackgroundChange.tsx b/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/PageBackgroundChange.tsx
deleted file mode 100644
index 5e08160f..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/PageBackgroundChange.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faSparkles } from "@fortawesome/pro-solid-svg-icons";
-import { toast } from "@storyteller/ui-toaster";
-import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
-import {
- PromptBoxVFX,
- SubTabStrip,
- TruchetPattern,
- VFXResultCard,
- VFXShowcaseView,
- newIdempotencyToken,
- submitVFXJob,
- useVFXStore,
- VFX_NOT_AVAILABLE_ERROR,
- VFX_SHOWCASE,
-} from "@storyteller/ui-vfx";
-
-export const PageBackgroundChange = () => {
- const subTab = useVFXStore((s) => s.subTab);
- const setSubTab = useVFXStore((s) => s.setSubTab);
- const history = useVFXStore((s) => s.history);
- const startResult = useVFXStore((s) => s.startResult);
- const failResult = useVFXStore((s) => s.failResult);
- const dismissResult = useVFXStore((s) => s.dismissResult);
- const loadFromShowcase = useVFXStore((s) => s.loadFromShowcase);
- const selectedShowcaseId = useVFXStore((s) => s.selectedShowcaseId);
- const source = useVFXStore((s) => s.source);
- const mask = useVFXStore((s) => s.mask);
- const reference = useVFXStore((s) => s.reference);
- const prompt = useVFXStore((s) => s.prompt);
- const resolution = useVFXStore((s) => s.resolution);
-
- const promptBoxRef = useRef(null);
- const [promptBoxHeight, setPromptBoxHeight] = useState(96);
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- useEffect(() => {
- const el = promptBoxRef.current;
- if (!el || typeof ResizeObserver === "undefined") return;
- const update = () => setPromptBoxHeight(el.offsetHeight);
- update();
- const ro = new ResizeObserver(update);
- ro.observe(el);
- return () => ro.disconnect();
- }, []);
-
- const handleSubmit = useCallback(async () => {
- if (!source || !reference || isSubmitting) return;
- setIsSubmitting(true);
- const id = startResult();
- const trimmed = prompt.trim();
- const result = await submitVFXJob({
- source_video_media_token: source.mediaToken,
- reference_image_media_token: reference.mediaToken,
- prompt: trimmed.length > 0 ? trimmed : null,
- uuid_idempotency_token: newIdempotencyToken(),
- });
- setIsSubmitting(false);
-
- if (!result.success) {
- const isExpected = result.error_code_str === VFX_NOT_AVAILABLE_ERROR;
- const message = isExpected
- ? "Background change backend coming soon. Your inputs are saved."
- : result.error_message || "Failed to submit background change job.";
- failResult(id, message);
- if (isExpected) toast(message);
- else toast.error(message);
- }
- }, [source, reference, prompt, isSubmitting, startResult, failResult]);
-
- const handleTryShowcase = useCallback(() => {
- const entry = VFX_SHOWCASE.find((e) => e.id === selectedShowcaseId);
- if (!entry) return;
- loadFromShowcase({
- prompt: entry.prompt,
- resolution: entry.resolution,
- source: {
- id: entry.source.mediaToken,
- url: entry.source.url,
- mediaToken: entry.source.mediaToken,
- },
- mask: entry.mask
- ? {
- id: entry.mask.mediaToken,
- url: entry.mask.url,
- mediaToken: entry.mask.mediaToken,
- }
- : undefined,
- reference: entry.reference
- ? {
- id: entry.reference.mediaToken,
- url: entry.reference.url,
- mediaToken: entry.reference.mediaToken,
- }
- : undefined,
- });
- toast("Loaded showcase. Edit and Generate.");
- }, [selectedShowcaseId, loadFromShowcase]);
-
- return (
-
-
-
- {subTab === "showcase" ? (
-
-
-
- ) : history.length === 0 ? (
-
- ) : (
-
-
- {history.map((r) => (
- dismissResult(r.id)}
- />
- ))}
-
-
- )}
-
-
-
-
-
-
toast.error(msg)}
- />
-
-
-
- );
-};
-
-interface EmptyStateProps {
- title: string;
- subtitle: string;
-}
-
-const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
-
-
-
-
-
{title}
-
{subtitle}
-
-);
-
-export default PageBackgroundChange;
diff --git a/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/index.ts b/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/index.ts
deleted file mode 100644
index 6530419c..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageBackgroundChange/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { PageBackgroundChange } from "./PageBackgroundChange";
diff --git a/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx b/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx
deleted file mode 100644
index 2b34bdfe..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/ImageWatermarkRemover.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faImages,
- faDroplet,
- faArrowRotateRight,
-} from "@fortawesome/pro-solid-svg-icons";
-import { Button } from "@storyteller/ui-button";
-import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
-import { downloadFileFromUrl } from "@storyteller/api";
-import { UploadEntryCard } from "../../components/media/UploadEntryCard";
-import toast from "react-hot-toast";
-
-export const ImageWatermarkRemover = () => {
- const [imageUrl, setImageUrl] = useState("");
- const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
- const [selectedGalleryImages, setSelectedGalleryImages] = useState(
- [],
- );
- const [isProcessing, setIsProcessing] = useState(false);
- const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
- const [imageDimensions, setImageDimensions] = useState<{
- width: number;
- height: number;
- } | null>(null);
-
- const imageRef = useRef(null);
-
- useEffect(() => {
- return () => {
- if (imageUrl && imageUrl.startsWith("blob:")) {
- URL.revokeObjectURL(imageUrl);
- }
- };
- }, [imageUrl]);
-
- const handleLocalImageSelect = (files: FileList) => {
- const file = files[0];
- if (file && file.type.startsWith("image/")) {
- if (imageUrl && imageUrl.startsWith("blob:")) {
- URL.revokeObjectURL(imageUrl);
- }
-
- const url = URL.createObjectURL(file);
- setImageUrl(url);
- setImageDimensions(null);
- }
- };
-
- const handleImageSelect = (id: string) => {
- setSelectedGalleryImages((prev) => {
- if (prev.includes(id)) return prev.filter((x) => x !== id);
- const maxSelections = 1;
- if (prev.length >= maxSelections) {
- return maxSelections === 1 ? [id] : prev;
- }
- return [...prev, id];
- });
- };
-
- const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
- const item = selectedItems[0];
- if (!item || !item.fullImage) {
- toast.error("No image selected");
- return;
- }
-
- if (isLoadingFromGallery) {
- return;
- }
-
- setIsLoadingFromGallery(true);
-
- if (imageUrl && imageUrl.startsWith("blob:")) {
- URL.revokeObjectURL(imageUrl);
- }
-
- setImageUrl(item.fullImage);
- setImageDimensions(null);
-
- setIsGalleryModalOpen(false);
- setSelectedGalleryImages([]);
- setIsLoadingFromGallery(false);
- };
-
- const handleRemoveWatermark = async () => {
- if (!imageUrl) {
- toast.error("Please select an image first");
- return;
- }
-
- setIsProcessing(true);
- toast.loading("Processing watermark removal...", {
- id: "watermark-removal",
- });
-
- try {
- await new Promise((resolve) => setTimeout(resolve, 2000));
- toast.success("Watermark removal completed!", {
- id: "watermark-removal",
- });
- } catch (error) {
- toast.error("Failed to remove watermark", { id: "watermark-removal" });
- console.error("Error removing watermark:", error);
- } finally {
- setIsProcessing(false);
- }
- };
-
- return (
- <>
-
-
-
- {!imageUrl ? (
-
-
- setIsGalleryModalOpen(true)}
- />
-
-
- ) : (
-
-
-
-
{
- setImageUrl("");
- setImageDimensions(null);
- }}
- className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
- >
- Switch Image
-
-
-
{
- const img = e.currentTarget;
- setImageDimensions({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- }}
- />
-
-
-
-
-
-
- {isProcessing ? "Processing..." : "Remove Watermark"}
-
-
-
-
-
-
-
- Image Information
-
- {imageDimensions ? (
-
-
-
- Resolution
-
-
- {imageDimensions.width} × {imageDimensions.height}
-
-
-
-
- Aspect Ratio
-
-
- {(
- imageDimensions.width / imageDimensions.height
- ).toFixed(2)}
- :1
-
-
-
- ) : (
-
Loading...
- )}
-
-
-
- )}
-
-
-
-
- {
- if (!isLoadingFromGallery) {
- setIsGalleryModalOpen(false);
- setSelectedGalleryImages([]);
- }
- }}
- mode="select"
- selectedItemIds={selectedGalleryImages}
- onSelectItem={handleImageSelect}
- maxSelections={1}
- onUseSelected={handleGallerySelect}
- onDownloadClicked={downloadFileFromUrl}
- forceFilter="image"
- />
- >
- );
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/index.ts b/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/index.ts
deleted file mode 100644
index d1a42702..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageImageWatermarkRemover/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { ImageWatermarkRemover } from "./ImageWatermarkRemover";
-
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx
deleted file mode 100644
index 60376a18..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/PageMoodboard.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect } from "react";
-import {
- MoodboardWorkspace,
- dispatchGalleryMoodboardDrop,
-} from "@storyteller/ui-moodboard";
-import {
- GalleryItem,
- onImageDrop,
- removeImageDropListener,
-} from "@storyteller/ui-gallery-modal";
-import { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
-
-// Desktop moodboard tab. The shared workspace fills its parent; MainApp's
-// TabBody wraps this in a sized container.
-export const PageMoodboard = () => {
- useGalleryDropBridge();
- return ;
-};
-
-// Bridges gallery-modal's onImageDrop into the moodboard's board-drop event, so
-// dragging a library image onto the grid/canvas adds it. Mounted only while the
-// moodboard tab is active, so it doesn't race the 2D/3D tab drop handlers.
-const useGalleryDropBridge = () => {
- useEffect(() => {
- const handler = onImageDrop(
- (item: GalleryItem, position: { x: number; y: number }) => {
- dispatchGalleryMoodboardDrop(item, position);
- },
- );
- return () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- if (handler) removeImageDropListener(handler as any);
- };
- }, []);
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/desktopMoodboardAdapter.tsx b/frontend/apps/artcraft/app/src/pages/PageMoodboard/desktopMoodboardAdapter.tsx
deleted file mode 100644
index 17338105..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/desktopMoodboardAdapter.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useState } from "react";
-import toast from "react-hot-toast";
-import { UploaderStates } from "@storyteller/common";
-import { UploadImageMedia } from "@storyteller/api";
-import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
-import { usePromptImageStore, RefImage } from "@storyteller/ui-promptbox";
-import type {
- MoodboardAdapter,
- MoodboardReference,
- MoodboardLibraryPickerProps,
-} from "@storyteller/ui-moodboard";
-import { useTabStore } from "../Stores/TabState";
-
-// Desktop (Tauri) implementation of the moodboard's platform seams.
-
-const uploadImage = async (file: File): Promise => {
- let token: string | null = null;
- try {
- await UploadImageMedia({
- title: file.name || "Moodboard image",
- assetFile: file,
- progressCallback: (state) => {
- if (state.status === UploaderStates.success && state.data) {
- token = String(state.data);
- }
- },
- });
- } catch (err) {
- console.error("[Moodboard] upload failed", err);
- }
- return token;
-};
-
-const sendToGeneration = (refs: MoodboardReference[]): void => {
- const newRefs: RefImage[] = refs.map((r) => ({
- id: r.id,
- url: r.url,
- file: new File([], r.id),
- mediaToken: r.mediaToken,
- }));
- const store = usePromptImageStore.getState();
- store.setReferenceImages(dedupeByToken([...store.referenceImages, ...newRefs]));
- void useTabStore.getState().setActiveTab("IMAGE");
- toast.success(
- `${refs.length} reference${refs.length > 1 ? "s" : ""} added to the prompt`,
- );
-};
-
-const DesktopLibraryPicker = ({
- open,
- onClose,
- onPick,
-}: MoodboardLibraryPickerProps) => {
- const [selectedIds, setSelectedIds] = useState([]);
- const close = () => {
- setSelectedIds([]);
- onClose();
- };
- return (
-
- setSelectedIds((prev) =>
- prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
- )
- }
- onUseSelected={(items: GalleryItem[]) => {
- onPick(
- items.map((it) => ({
- url: it.fullImage || it.thumbnail || "",
- mediaToken: it.id ?? null,
- kind: it.mediaClass === "video" ? "video" : "image",
- })),
- );
- setSelectedIds([]);
- }}
- forceFilter="image"
- />
- );
-};
-
-export const desktopMoodboardAdapter: MoodboardAdapter = {
- uploadImage,
- sendToGeneration,
- renderLibraryPicker: (props) => ,
-};
-
-const dedupeByToken = (refs: RefImage[]): RefImage[] => {
- const seen = new Set();
- const out: RefImage[] = [];
- for (const r of refs) {
- if (r.mediaToken) {
- if (seen.has(r.mediaToken)) continue;
- seen.add(r.mediaToken);
- }
- out.push(r);
- }
- return out;
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageMoodboard/index.ts b/frontend/apps/artcraft/app/src/pages/PageMoodboard/index.ts
deleted file mode 100644
index 4cb4346f..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageMoodboard/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { PageMoodboard } from "./PageMoodboard";
-export { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
diff --git a/frontend/apps/artcraft/app/src/pages/PageSettings/PageSettings.tsx b/frontend/apps/artcraft/app/src/pages/PageSettings/PageSettings.tsx
index 88bab73b..c4c1c1c9 100644
--- a/frontend/apps/artcraft/app/src/pages/PageSettings/PageSettings.tsx
+++ b/frontend/apps/artcraft/app/src/pages/PageSettings/PageSettings.tsx
@@ -1,6 +1,5 @@
import { SettingsContent } from "@storyteller/ui-settings-modal";
import { goToApp } from "~/config/appMenu";
-import { useStoryboardStore } from "~/pages/PageStoryboard";
import { setLogoutStates } from "~/signals/authentication/utilities";
// Settings as a full page (opened from the titlebar gear) instead of a modal.
@@ -14,10 +13,6 @@ export const PageSettings = () => {
setLogoutStates();
goToApp("IMAGE");
}}
- onStoryboardPageDisable={() => {
- useStoryboardStore.getState().reset();
- goToApp("IMAGE");
- }}
/>
);
diff --git a/frontend/apps/artcraft/app/src/pages/PageStoryboard/Storyboard.tsx b/frontend/apps/artcraft/app/src/pages/PageStoryboard/Storyboard.tsx
deleted file mode 100644
index 7577987d..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageStoryboard/Storyboard.tsx
+++ /dev/null
@@ -1,1276 +0,0 @@
-import { useState, useRef, useCallback, useMemo, useEffect } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faPlus,
- faTrash,
- faUpload,
- faPhotoFilm,
- faPlay,
- faPause,
- faBackwardStep,
- faForwardStep,
- faBackwardFast,
- faForwardFast,
- faPencil,
- faEraser,
- faRotateLeft,
- faXmark,
- faCheck,
-} from "@fortawesome/pro-solid-svg-icons";
-import { Button } from "@storyteller/ui-button";
-import { twMerge } from "tailwind-merge";
-import { useStoryboardStore, type Board } from "./StoryboardStore";
-
-const PIXELS_PER_SECOND = 80;
-const MIN_BLOCK_WIDTH = 40;
-const MIN_DURATION = 0.5;
-const CANVAS_W = 1280;
-const CANVAS_H = 720;
-const MAX_UNDO = 20;
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-interface BoardTiming {
- board: Board;
- startTime: number;
-}
-
-const buildBoardTimings = (boards: Board[]): BoardTiming[] => {
- let t = 0;
- return boards.map((board) => {
- const startTime = t;
- t += board.duration;
- return { board, startTime };
- });
-};
-
-const formatTime = (s: number): string => {
- const clamped = Math.max(0, s);
- const mins = Math.floor(clamped / 60);
- const secs = (clamped % 60).toFixed(1);
- return mins > 0 ? `${mins}:${secs.padStart(4, "0")}` : `${secs}s`;
-};
-
-// ─── EmptyState ───────────────────────────────────────────────────────────────
-
-const EmptyState = ({ onAdd }: { onAdd: () => void }) => (
-
-
-
-
-
-
-
No shots yet
-
Add your first shot to get started
-
-
- Add your first shot
-
-
-
-);
-
-// ─── MetadataEditor ───────────────────────────────────────────────────────────
-
-interface MetadataEditorProps {
- board: Board;
- onUpdate: (patch: Partial>) => void;
- onDelete: () => void;
- onUpload: () => void;
- onSketch: () => void;
-}
-
-const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: MetadataEditorProps) => (
-
-);
-
-// ─── MainPreview ──────────────────────────────────────────────────────────────
-
-const MainPreview = ({
- board,
- onUploadClick,
-}: {
- board: Board | null;
- onUploadClick: () => void;
-}) => (
-
- {board === null ? (
-
Select a shot
- ) : board.imageDataUrl ? (
- <>
-
- {board.title && (
-
- )}
- >
- ) : (
-
-
- Click to upload an image
-
- )}
-
-);
-
-// ─── SketchCanvas ─────────────────────────────────────────────────────────────
-
-type SketchTool = "pencil" | "eraser";
-
-interface SketchCanvasProps {
- initialImageDataUrl: string | null;
- onSave: (dataUrl: string) => void;
- onExit: () => void;
-}
-
-const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps) => {
- const canvasRef = useRef(null);
- const isDrawingRef = useRef(false);
- const prevPosRef = useRef<{ x: number; y: number } | null>(null);
- const [tool, setTool] = useState("pencil");
- const [color, setColor] = useState("#1a1a1a");
- const [brushSize, setBrushSize] = useState(4);
- const [undoStack, setUndoStack] = useState([]);
-
- // Initialize canvas once on mount: fill white, draw reference image if present
- useEffect(() => {
- const canvas = canvasRef.current;
- const ctx = canvas?.getContext("2d");
- if (!canvas || !ctx) return;
-
- ctx.fillStyle = "#ffffff";
- ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
-
- if (initialImageDataUrl) {
- const img = new Image();
- img.onload = () => {
- const scale = Math.min(CANVAS_W / img.width, CANVAS_H / img.height);
- const x = (CANVAS_W - img.width * scale) / 2;
- const y = (CANVAS_H - img.height * scale) / 2;
- ctx.drawImage(img, x, y, img.width * scale, img.height * scale);
- };
- img.src = initialImageDataUrl;
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []); // intentionally runs once — we own the canvas state after mount
-
- const getCoords = (e: React.PointerEvent) => {
- const canvas = canvasRef.current!;
- const rect = canvas.getBoundingClientRect();
- return {
- x: ((e.clientX - rect.left) / rect.width) * CANVAS_W,
- y: ((e.clientY - rect.top) / rect.height) * CANVAS_H,
- };
- };
-
- const applyCtxSettings = (ctx: CanvasRenderingContext2D) => {
- ctx.lineCap = "round";
- ctx.lineJoin = "round";
- ctx.globalAlpha = 1;
- if (tool === "eraser") {
- ctx.globalCompositeOperation = "destination-out";
- ctx.lineWidth = brushSize * 3;
- } else {
- ctx.globalCompositeOperation = "source-over";
- ctx.strokeStyle = color;
- ctx.fillStyle = color;
- ctx.lineWidth = brushSize;
- }
- };
-
- const snapshotForUndo = () => {
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
- const snap = ctx.getImageData(0, 0, CANVAS_W, CANVAS_H);
- setUndoStack((prev) => [...prev.slice(-(MAX_UNDO - 1)), snap]);
- };
-
- const handlePointerDown = (e: React.PointerEvent) => {
- e.currentTarget.setPointerCapture(e.pointerId);
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
-
- snapshotForUndo();
- isDrawingRef.current = true;
- const pos = getCoords(e);
- prevPosRef.current = pos;
-
- // Draw a dot at click point
- applyCtxSettings(ctx);
- ctx.beginPath();
- ctx.arc(pos.x, pos.y, ctx.lineWidth / 2, 0, Math.PI * 2);
- ctx.fill();
- };
-
- const handlePointerMove = (e: React.PointerEvent) => {
- if (!isDrawingRef.current || !prevPosRef.current) return;
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
-
- const pos = getCoords(e);
- const mid = {
- x: (prevPosRef.current.x + pos.x) / 2,
- y: (prevPosRef.current.y + pos.y) / 2,
- };
-
- applyCtxSettings(ctx);
- ctx.beginPath();
- ctx.moveTo(prevPosRef.current.x, prevPosRef.current.y);
- ctx.quadraticCurveTo(prevPosRef.current.x, prevPosRef.current.y, mid.x, mid.y);
- ctx.stroke();
-
- prevPosRef.current = pos;
- };
-
- const handlePointerUp = (e: React.PointerEvent) => {
- if (!isDrawingRef.current) return;
- isDrawingRef.current = false;
- prevPosRef.current = null;
- e.currentTarget.releasePointerCapture(e.pointerId);
- const dataUrl = canvasRef.current?.toDataURL("image/png");
- if (dataUrl) onSave(dataUrl);
- };
-
- const handleUndo = () => {
- if (undoStack.length === 0) return;
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
- const snap = undoStack[undoStack.length - 1];
- ctx.putImageData(snap, 0, 0);
- setUndoStack((prev) => prev.slice(0, -1));
- const dataUrl = canvasRef.current?.toDataURL("image/png");
- if (dataUrl) onSave(dataUrl);
- };
-
- const handleClear = () => {
- const ctx = canvasRef.current?.getContext("2d");
- if (!ctx) return;
- snapshotForUndo();
- ctx.globalCompositeOperation = "source-over";
- ctx.fillStyle = "#ffffff";
- ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
- const dataUrl = canvasRef.current?.toDataURL("image/png");
- if (dataUrl) onSave(dataUrl);
- };
-
- const toolBtn = (t: SketchTool, icon: typeof faPencil, label: string) => (
- setTool(t)}
- title={label}
- className={twMerge(
- "flex h-8 w-8 items-center justify-center rounded-md transition-colors",
- tool === t
- ? "bg-primary text-white"
- : "text-base-fg/60 hover:bg-white/10 hover:text-base-fg",
- )}
- >
-
-
- );
-
- return (
-
- {/* Toolbar */}
-
- {toolBtn("pencil", faPencil, "Pencil")}
- {toolBtn("eraser", faEraser, "Eraser")}
-
-
-
- {/* Color picker */}
-
-
- setColor(e.target.value)}
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
- />
-
-
- {/* Brush size */}
-
- Size
- setBrushSize(Number(e.target.value))}
- className="w-20 accent-primary"
- />
- {brushSize}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Done
-
-
-
- {/* Canvas area */}
-
-
-
-
- );
-};
-
-// ─── ThumbnailItem ────────────────────────────────────────────────────────────
-
-interface ThumbnailItemProps {
- board: Board;
- index: number;
- isSelected: boolean;
- isDragOver: boolean;
- onSelect: () => void;
- onDragStart: (e: React.DragEvent, index: number) => void;
- onDragOver: (e: React.DragEvent, index: number) => void;
- onDrop: (e: React.DragEvent, index: number) => void;
- onDragEnd: () => void;
-}
-
-const ThumbnailItem = ({
- board,
- index,
- isSelected,
- isDragOver,
- onSelect,
- onDragStart,
- onDragOver,
- onDrop,
- onDragEnd,
-}: ThumbnailItemProps) => (
- onDragStart(e, index)}
- onDragOver={(e) => {
- e.preventDefault();
- onDragOver(e, index);
- }}
- onDrop={(e) => onDrop(e, index)}
- onDragEnd={onDragEnd}
- onClick={onSelect}
- className={twMerge(
- "relative flex h-[60px] w-[100px] shrink-0 cursor-pointer overflow-hidden rounded-md border-2 transition-all select-none",
- isSelected
- ? "border-primary ring-2 ring-primary/30"
- : "border-transparent hover:border-white/20",
- isDragOver && "border-primary/60 opacity-60",
- )}
- >
- {board.imageDataUrl ? (
-
- ) : (
-
-
-
- )}
-
- {board.shotNumber}
-
-
-);
-
-// ─── Filmstrip ────────────────────────────────────────────────────────────────
-
-interface FilmstripProps {
- boards: Board[];
- selectedBoardId: string | null;
- dragOverIndex: number | null;
- onAdd: () => void;
- onSelect: (id: string) => void;
- onDragStart: (e: React.DragEvent, index: number) => void;
- onDragOver: (e: React.DragEvent, index: number) => void;
- onDrop: (e: React.DragEvent, index: number) => void;
- onDragEnd: () => void;
-}
-
-const Filmstrip = ({
- boards,
- selectedBoardId,
- dragOverIndex,
- onAdd,
- onSelect,
- onDragStart,
- onDragOver,
- onDrop,
- onDragEnd,
-}: FilmstripProps) => (
-
-
-
-
-
- {boards.map((board, index) => (
- onSelect(board.id)}
- onDragStart={onDragStart}
- onDragOver={onDragOver}
- onDrop={onDrop}
- onDragEnd={onDragEnd}
- />
- ))}
-
-);
-
-// ─── Timeline ─────────────────────────────────────────────────────────────────
-
-interface TimelineDragState {
- boardId: string;
- boardIndex: number;
- caretX: number;
- insertionIndex: number;
-}
-
-interface TimelineProps {
- boards: Board[];
- selectedBoardId: string | null;
- currentTimeSeconds: number;
- onSelectBoard: (id: string, startTime: number) => void;
- onUpdateDuration: (id: string, duration: number) => void;
- onSeek: (time: number) => void;
- onReorder: (fromIndex: number, toIndex: number) => void;
-}
-
-const Timeline = ({
- boards,
- selectedBoardId,
- currentTimeSeconds,
- onSelectBoard,
- onUpdateDuration,
- onSeek,
- onReorder,
-}: TimelineProps) => {
- const laneRef = useRef(null);
- const resizeRef = useRef<{ id: string; startX: number; startDuration: number } | null>(null);
- // Store full drag data in a ref so onUp can read it without going through a setState updater
- const activeDragRef = useRef<{
- boardId: string;
- boardIndex: number;
- insertionIndex: number;
- caretX: number;
- } | null>(null);
- const [dragState, setDragState] = useState(null);
-
- const boardTimings: BoardTiming[] = useMemo(() => buildBoardTimings(boards), [boards]);
- const totalDuration = boardTimings.reduce((s, { board }) => s + board.duration, 0);
- const totalWidth = Math.max(totalDuration * PIXELS_PER_SECOND, 300);
-
- // ── Resize handlers ──────────────────────────────────────────────────────
-
- const handleResizePointerDown = useCallback(
- (e: React.PointerEvent, id: string, currentDuration: number) => {
- e.stopPropagation();
- e.preventDefault();
- resizeRef.current = { id, startX: e.clientX, startDuration: currentDuration };
-
- const onMove = (ev: PointerEvent) => {
- if (!resizeRef.current) return;
- const delta = ev.clientX - resizeRef.current.startX;
- const newDuration = Math.max(
- MIN_DURATION,
- parseFloat((resizeRef.current.startDuration + delta / PIXELS_PER_SECOND).toFixed(1)),
- );
- onUpdateDuration(resizeRef.current.id, newDuration);
- };
-
- const onUp = () => {
- resizeRef.current = null;
- window.removeEventListener("pointermove", onMove);
- window.removeEventListener("pointerup", onUp);
- };
-
- window.addEventListener("pointermove", onMove);
- window.addEventListener("pointerup", onUp);
- },
- [onUpdateDuration],
- );
-
- // ── Drag-to-rearrange handlers ───────────────────────────────────────────
-
- const computeInsertionFromPointerX = useCallback(
- (pointerX: number, fromIndex: number): { caretX: number; insertionIndex: number } => {
- let insertionIndex = boardTimings.length;
- let caretX = totalWidth;
-
- for (let i = 0; i < boardTimings.length; i++) {
- if (i === fromIndex) continue;
- const { startTime, board } = boardTimings[i];
- const midX = (startTime + board.duration / 2) * PIXELS_PER_SECOND;
- if (pointerX < midX) {
- insertionIndex = i;
- caretX = startTime * PIXELS_PER_SECOND;
- break;
- } else {
- insertionIndex = i + 1;
- caretX = (startTime + board.duration) * PIXELS_PER_SECOND;
- }
- }
-
- return { caretX, insertionIndex };
- },
- [boardTimings, totalWidth],
- );
-
- const handleBoardPointerDown = useCallback(
- (e: React.PointerEvent, boardId: string, boardIndex: number) => {
- // Don't initiate drag if clicking the resize handle
- if ((e.target as HTMLElement).dataset.resize) return;
- e.stopPropagation();
- e.preventDefault();
-
- activeDragRef.current = { boardId, boardIndex, insertionIndex: boardIndex, caretX: 0 };
-
- const onMove = (ev: PointerEvent) => {
- if (!activeDragRef.current || !laneRef.current) return;
- const rect = laneRef.current.getBoundingClientRect();
- const scrollLeft = laneRef.current.scrollLeft;
- const pointerX = ev.clientX - rect.left + scrollLeft;
- const { caretX, insertionIndex } = computeInsertionFromPointerX(
- pointerX,
- activeDragRef.current.boardIndex,
- );
- activeDragRef.current = { ...activeDragRef.current, caretX, insertionIndex };
- setDragState({ boardId, boardIndex, caretX, insertionIndex });
- };
-
- const onUp = () => {
- // Read from ref synchronously — setState updaters run async so can't rely on them
- if (activeDragRef.current) {
- const { boardIndex: fromIdx, insertionIndex } = activeDragRef.current;
- // If moving forward, subtract 1 because the item is removed first before inserting
- const toIndex = insertionIndex > fromIdx ? insertionIndex - 1 : insertionIndex;
- if (toIndex !== fromIdx) {
- onReorder(fromIdx, toIndex);
- }
- activeDragRef.current = null;
- }
- setDragState(null);
- window.removeEventListener("pointermove", onMove);
- window.removeEventListener("pointerup", onUp);
- };
-
- window.addEventListener("pointermove", onMove);
- window.addEventListener("pointerup", onUp);
- },
- [computeInsertionFromPointerX, onReorder],
- );
-
- // ── Seek by clicking empty lane area ─────────────────────────────────────
-
- const handleLanePointerDown = useCallback(
- (e: React.PointerEvent) => {
- if (!laneRef.current) return;
- const rect = laneRef.current.getBoundingClientRect();
- const scrollLeft = laneRef.current.scrollLeft;
- const x = e.clientX - rect.left + scrollLeft;
- const time = Math.min(Math.max(0, x / PIXELS_PER_SECOND), totalDuration);
- onSeek(time);
-
- const hit = boardTimings.find(
- ({ board, startTime }) =>
- x >= startTime * PIXELS_PER_SECOND &&
- x < (startTime + board.duration) * PIXELS_PER_SECOND,
- );
- if (hit) onSelectBoard(hit.board.id, hit.startTime);
- },
- [boardTimings, totalDuration, onSeek, onSelectBoard],
- );
-
- const playheadLeft = currentTimeSeconds * PIXELS_PER_SECOND;
-
- return (
-
- {/* Header row */}
-
- {formatTime(currentTimeSeconds)}
- Timeline
- {formatTime(totalDuration)}
-
-
- {/* Lane */}
-
-
- {/* Ruler ticks */}
- {Array.from({ length: Math.ceil(totalDuration) + 1 }, (_, i) => (
-
- ))}
-
- {/* Board blocks */}
- {boardTimings.map(({ board, startTime }, idx) => {
- const blockWidth = Math.max(MIN_BLOCK_WIDTH, board.duration * PIXELS_PER_SECOND);
- const isSelected = board.id === selectedBoardId;
- const isDragging = dragState?.boardId === board.id;
-
- return (
-
handleBoardPointerDown(e, board.id, idx)}
- >
- {board.imageDataUrl && (
-
- )}
-
-
- {board.shotNumber}
- {board.title ? ` · ${board.title}` : ""}
-
- {board.dialogue && (
-
- "{board.dialogue}"
-
- )}
- {board.duration.toFixed(1)}s
-
-
- {/* Resize handle */}
-
handleResizePointerDown(e, board.id, board.duration)}
- title="Drag to resize duration"
- />
-
- );
- })}
-
- {/* Drag insertion caret */}
- {dragState && (
-
- {/* Downward-pointing triangle at top */}
-
-
- )}
-
- {/* Playhead */}
-
-
-
-
- );
-};
-
-// ─── PlaybackControls ─────────────────────────────────────────────────────────
-
-interface PlaybackControlsProps {
- isPlaying: boolean;
- currentTimeSeconds: number;
- totalDuration: number;
- currentBoardNumber: number | null;
- totalBoards: number;
- onPlay: () => void;
- onPause: () => void;
- onPrevBoard: () => void;
- onNextBoard: () => void;
- onPrevScene: () => void;
- onNextScene: () => void;
-}
-
-const PlaybackControls = ({
- isPlaying,
- currentTimeSeconds,
- totalDuration,
- currentBoardNumber,
- totalBoards,
- onPlay,
- onPause,
- onPrevBoard,
- onNextBoard,
- onPrevScene,
- onNextScene,
-}: PlaybackControlsProps) => {
- const transportBtnClass =
- "flex h-9 w-9 items-center justify-center rounded-md text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95";
-
- return (
-
- {/* Left stats */}
-
- {formatTime(currentTimeSeconds)}
-
- {currentBoardNumber !== null ? `Shot ${currentBoardNumber}` : "—"}
-
-
-
- {/* Transport buttons — centered */}
-
-
-
-
-
-
-
-
- {/* Play / Pause */}
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Right stats */}
-
- {formatTime(totalDuration)}
- {totalBoards} shot{totalBoards !== 1 ? "s" : ""}
-
-
- );
-};
-
-// ─── Storyboard ───────────────────────────────────────────────────────────────
-
-export const Storyboard = () => {
- const boards = useStoryboardStore((s) => s.boards);
- const selectedBoardId = useStoryboardStore((s) => s.selectedBoardId);
- const addBoard = useStoryboardStore((s) => s.addBoard);
- const deleteBoard = useStoryboardStore((s) => s.deleteBoard);
- const selectBoard = useStoryboardStore((s) => s.selectBoard);
- const updateBoard = useStoryboardStore((s) => s.updateBoard);
- const reorderBoards = useStoryboardStore((s) => s.reorderBoards);
-
- const fileInputRef = useRef
(null);
- const dragIndexRef = useRef(null);
- const [dragOverIndex, setDragOverIndex] = useState(null);
- const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
- const [isPlaying, setIsPlaying] = useState(false);
- const [isSketchMode, setIsSketchMode] = useState(false);
-
- // Refs for playback rAF loop (avoid stale closures)
- const currentTimeRef = useRef(0);
- const boardsRef = useRef([]);
- const rafRef = useRef(null);
- const lastTimestampRef = useRef(null);
-
- // Keep refs in sync — currentTimeRef is updated manually in every handler and in the rAF
- // tick itself, so we do NOT sync it here (that would race with and reset manual updates).
- useEffect(() => { boardsRef.current = boards; }, [boards]);
-
- // selectedBoardId ref so the rAF tick can check current selection without a stale closure
- const selectedBoardIdRef = useRef(selectedBoardId);
- useEffect(() => { selectedBoardIdRef.current = selectedBoardId; }, [selectedBoardId]);
-
- const boardTimings = useMemo(() => buildBoardTimings(boards), [boards]);
- const totalDuration = boardTimings.reduce((s, { board }) => s + board.duration, 0);
- const selectedBoard = boards.find((b) => b.id === selectedBoardId) ?? null;
-
- // ── Playback engine ────────────────────────────────────────────────────────
-
- const stopPlayback = useCallback(() => {
- setIsPlaying(false);
- if (rafRef.current !== null) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- lastTimestampRef.current = null;
- }, []);
-
- useEffect(() => {
- if (!isPlaying) {
- if (rafRef.current !== null) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- lastTimestampRef.current = null;
- return;
- }
-
- const tick = (timestamp: number) => {
- const last = lastTimestampRef.current;
- if (last !== null) {
- const delta = (timestamp - last) / 1000;
- const nextTime = currentTimeRef.current + delta;
- const timings = buildBoardTimings(boardsRef.current);
- const dur = timings.reduce((s, { board }) => s + board.duration, 0);
-
- if (nextTime >= dur) {
- // Reached end — stop
- setCurrentTimeSeconds(dur);
- currentTimeRef.current = dur;
- stopPlayback();
- return;
- }
-
- setCurrentTimeSeconds(nextTime);
- currentTimeRef.current = nextTime;
-
- // Auto-select board under playhead — use ref to avoid stale closure
- const hit = timings.find(
- ({ board, startTime }) =>
- nextTime >= startTime && nextTime < startTime + board.duration,
- );
- if (hit && hit.board.id !== selectedBoardIdRef.current) {
- selectBoard(hit.board.id);
- selectedBoardIdRef.current = hit.board.id;
- }
- }
- lastTimestampRef.current = timestamp;
- rafRef.current = requestAnimationFrame(tick);
- };
-
- rafRef.current = requestAnimationFrame(tick);
- return () => {
- if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isPlaying]);
-
- // ── Playback control actions ───────────────────────────────────────────────
-
- const handlePlay = useCallback(() => {
- // If at the end, restart from beginning
- if (currentTimeRef.current >= totalDuration && totalDuration > 0) {
- setCurrentTimeSeconds(0);
- currentTimeRef.current = 0;
- }
- setIsPlaying(true);
- }, [totalDuration]);
-
- const handlePause = useCallback(() => stopPlayback(), [stopPlayback]);
-
- const handlePrevBoard = useCallback(() => {
- const timings = buildBoardTimings(boards);
- const curTime = currentTimeRef.current;
- // If we're more than 0.3s into the current board, rewind to its start
- const currentEntry = timings.find(
- ({ startTime, board }) => curTime >= startTime && curTime < startTime + board.duration,
- );
- if (currentEntry && curTime - currentEntry.startTime > 0.3) {
- setCurrentTimeSeconds(currentEntry.startTime);
- currentTimeRef.current = currentEntry.startTime;
- selectBoard(currentEntry.board.id);
- return;
- }
- // Otherwise go to start of previous board
- const idx = timings.findIndex(({ board }) => board.id === selectedBoardId);
- const prevEntry = idx > 0 ? timings[idx - 1] : timings[0];
- if (prevEntry) {
- setCurrentTimeSeconds(prevEntry.startTime);
- currentTimeRef.current = prevEntry.startTime;
- selectBoard(prevEntry.board.id);
- }
- }, [boards, selectedBoardId, selectBoard]);
-
- const handleNextBoard = useCallback(() => {
- const timings = buildBoardTimings(boards);
- const idx = timings.findIndex(({ board }) => board.id === selectedBoardId);
- const nextEntry = idx >= 0 && idx < timings.length - 1 ? timings[idx + 1] : null;
- if (nextEntry) {
- setCurrentTimeSeconds(nextEntry.startTime);
- currentTimeRef.current = nextEntry.startTime;
- selectBoard(nextEntry.board.id);
- }
- }, [boards, selectedBoardId, selectBoard]);
-
- const handlePrevScene = useCallback(() => {
- setCurrentTimeSeconds(0);
- currentTimeRef.current = 0;
- if (boards.length > 0) selectBoard(boards[0].id);
- }, [boards, selectBoard]);
-
- const handleNextScene = useCallback(() => {
- setCurrentTimeSeconds(totalDuration);
- currentTimeRef.current = totalDuration;
- stopPlayback();
- }, [totalDuration, stopPlayback]);
-
- // ── File upload ────────────────────────────────────────────────────────────
-
- const handleUploadClick = useCallback(() => {
- fileInputRef.current?.click();
- }, []);
-
- const handleFileChange = useCallback(
- (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file || !selectedBoardId) return;
- if (!file.type.startsWith("image/")) return;
- const reader = new FileReader();
- reader.onloadend = () => {
- if (typeof reader.result === "string") {
- updateBoard(selectedBoardId, { imageDataUrl: reader.result });
- }
- };
- reader.readAsDataURL(file);
- e.target.value = "";
- },
- [selectedBoardId, updateBoard],
- );
-
- // ── Filmstrip drag handlers ────────────────────────────────────────────────
-
- const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
- dragIndexRef.current = index;
- e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setData("text/plain", String(index));
- }, []);
-
- const handleDragOver = useCallback((_e: React.DragEvent, index: number) => {
- setDragOverIndex(index);
- }, []);
-
- const handleDrop = useCallback(
- (_e: React.DragEvent, toIndex: number) => {
- const fromIndex = dragIndexRef.current;
- if (fromIndex !== null && fromIndex !== toIndex) {
- reorderBoards(fromIndex, toIndex);
- }
- setDragOverIndex(null);
- dragIndexRef.current = null;
- },
- [reorderBoards],
- );
-
- const handleDragEnd = useCallback(() => {
- setDragOverIndex(null);
- dragIndexRef.current = null;
- }, []);
-
- // ── Metadata update / delete ───────────────────────────────────────────────
-
- const handleUpdate = useCallback(
- (patch: Partial>) => {
- if (selectedBoardId) updateBoard(selectedBoardId, patch);
- },
- [selectedBoardId, updateBoard],
- );
-
- const handleDelete = useCallback(() => {
- if (selectedBoardId) deleteBoard(selectedBoardId);
- }, [selectedBoardId, deleteBoard]);
-
- // ── Timeline callbacks ────────────────────────────────────────────────────
-
- const handleTimelineSelect = useCallback(
- (id: string, startTime: number) => {
- selectBoard(id);
- setCurrentTimeSeconds(startTime);
- currentTimeRef.current = startTime;
- },
- [selectBoard],
- );
-
- const handleSeek = useCallback((time: number) => {
- setCurrentTimeSeconds(time);
- currentTimeRef.current = time;
- }, []);
-
- const handleUpdateDuration = useCallback(
- (id: string, duration: number) => updateBoard(id, { duration }),
- [updateBoard],
- );
-
- // ── Sketch mode ────────────────────────────────────────────────────────────
-
- const handleSketchOpen = useCallback(() => setIsSketchMode(true), []);
- const handleSketchExit = useCallback(() => setIsSketchMode(false), []);
- const handleSketchSave = useCallback(
- (dataUrl: string) => {
- if (selectedBoardId) updateBoard(selectedBoardId, { imageDataUrl: dataUrl });
- },
- [selectedBoardId, updateBoard],
- );
-
- // Auto-exit sketch mode if selected board changes (otherwise canvas would hold stale drawing)
- useEffect(() => {
- if (isSketchMode) setIsSketchMode(false);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedBoardId]);
-
- // ── Derived values for PlaybackControls ──────────────────────────────────
-
- const currentBoardNumber = useMemo(() => {
- const hit = boardTimings.find(
- ({ startTime, board }) =>
- currentTimeSeconds >= startTime &&
- currentTimeSeconds < startTime + board.duration,
- );
- return hit?.board.shotNumber ?? null;
- }, [boardTimings, currentTimeSeconds]);
-
- if (boards.length === 0) {
- return ;
- }
-
- return (
-
-
-
- {/* Top section: metadata panel + main preview (or sketch canvas) */}
-
- {selectedBoard ? (
-
- ) : (
-
- )}
- {isSketchMode && selectedBoard ? (
-
- ) : (
-
- )}
-
-
- {/* Filmstrip */}
-
-
- {/* Timeline */}
-
-
- {/* Playback controls */}
-
-
- );
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageStoryboard/StoryboardStore.ts b/frontend/apps/artcraft/app/src/pages/PageStoryboard/StoryboardStore.ts
deleted file mode 100644
index 1507fd9b..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageStoryboard/StoryboardStore.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { create } from "zustand";
-import { v4 as uuidv4 } from "uuid";
-
-export interface Board {
- id: string;
- imageDataUrl: string | null;
- title: string;
- shotNumber: number;
- dialogue: string;
- action: string;
- notes: string;
- duration: number;
-}
-
-interface StoryboardState {
- boards: Board[];
- selectedBoardId: string | null;
-
- addBoard: () => void;
- deleteBoard: (id: string) => void;
- selectBoard: (id: string) => void;
- updateBoard: (id: string, patch: Partial>) => void;
- reorderBoards: (fromIndex: number, toIndex: number) => void;
- reset: () => void;
-}
-
-const syncShotNumbers = (boards: Board[]): Board[] =>
- boards.map((b, i) => ({ ...b, shotNumber: i + 1 }));
-
-export const useStoryboardStore = create((set, get) => ({
- boards: [],
- selectedBoardId: null,
-
- addBoard: () => {
- const newBoard: Board = {
- id: uuidv4(),
- imageDataUrl: null,
- title: "",
- shotNumber: get().boards.length + 1,
- dialogue: "",
- action: "",
- notes: "",
- duration: 3,
- };
- set((state) => ({
- boards: [...state.boards, newBoard],
- selectedBoardId: newBoard.id,
- }));
- },
-
- deleteBoard: (id) => {
- set((state) => {
- const idx = state.boards.findIndex((b) => b.id === id);
- const remaining = syncShotNumbers(state.boards.filter((b) => b.id !== id));
- const nextSelected = remaining[idx] ?? remaining[idx - 1] ?? null;
- return {
- boards: remaining,
- selectedBoardId: nextSelected?.id ?? null,
- };
- });
- },
-
- selectBoard: (id) => {
- set({ selectedBoardId: id });
- },
-
- updateBoard: (id, patch) => {
- set((state) => ({
- boards: state.boards.map((b) => (b.id === id ? { ...b, ...patch } : b)),
- }));
- },
-
- reorderBoards: (fromIndex, toIndex) => {
- set((state) => {
- const arr = [...state.boards];
- const [moved] = arr.splice(fromIndex, 1);
- arr.splice(toIndex, 0, moved);
- return { boards: syncShotNumbers(arr) };
- });
- },
-
- reset: () => {
- set({ boards: [], selectedBoardId: null });
- },
-}));
diff --git a/frontend/apps/artcraft/app/src/pages/PageStoryboard/index.ts b/frontend/apps/artcraft/app/src/pages/PageStoryboard/index.ts
deleted file mode 100644
index 0830a593..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageStoryboard/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Storyboard } from "./Storyboard";
-export { useStoryboardStore } from "./StoryboardStore";
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx
deleted file mode 100644
index 98bf6b1b..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/asset-gallery-adapter.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import {
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactElement,
-} from "react";
-import type {
- AssetGalleryAdapter,
- MediaKind,
- MediaPickerSelection,
-} from "@storyteller/ui-video-editor";
-import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
-
-// Tauri AssetGalleryAdapter — wraps @storyteller/ui-gallery-modal in a
-// promise-shaped openPicker, matching the webapp's pattern verbatim.
-// The gallery modal is session-agnostic — it talks to the same HTTPS
-// `/v1/session` endpoint as the webapp, so it works in the Tauri
-// WebView with no Tauri-specific wiring.
-
-type Resolver = (selections: MediaPickerSelection[]) => void;
-
-interface GalleryControlState {
- open: boolean;
- filter: string | undefined;
-}
-
-interface AssetGalleryHostBundle {
- adapter: AssetGalleryAdapter;
- // Render this alongside . The host doesn't need to wire
- // any props — state is owned by the hook.
- modal: ReactElement;
-}
-
-function kindsToFilter(kinds: MediaKind[]): string | undefined {
- if (kinds.length === 1) return kinds[0];
- return undefined;
-}
-
-function mediaClassToKind(mediaClass: string | undefined): MediaKind {
- if (mediaClass === "video") return "video";
- if (mediaClass === "audio") return "audio";
- return "image";
-}
-
-export function useTauriAssetGalleryAdapter(): AssetGalleryHostBundle {
- const [state, setState] = useState({
- open: false,
- filter: undefined,
- });
- // GalleryModal at mode="select" only registers user clicks when an
- // onSelectItem handler is wired; without selectedItemIds tracking
- // the "Use selected" button hands back an empty array.
- const [selectedItemIds, setSelectedItemIds] = useState([]);
- const resolverRef = useRef(null);
-
- // Settle any pending resolver (resolve-empty). Used when a second
- // openPicker arrives before the first one resolved, or when the
- // hook unmounts mid-flow. Without this the first promise hangs
- // forever and the caller's UI stays stuck in its "processing" state.
- const settlePending = (selections: MediaPickerSelection[]) => {
- const resolver = resolverRef.current;
- resolverRef.current = null;
- resolver?.(selections);
- };
-
- // Unmount cleanup — fail any in-flight picker rather than leak it.
- useEffect(() => {
- return () => {
- settlePending([]);
- };
- // settlePending closes over the ref; safe to omit from deps.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const adapter = useMemo(
- () => ({
- openPicker({ kinds }) {
- return new Promise((resolve) => {
- settlePending([]);
- setSelectedItemIds([]);
- resolverRef.current = resolve;
- setState({ open: true, filter: kindsToFilter(kinds) });
- });
- },
- }),
- [],
- );
-
- const handleSelectItem = (id: string) => {
- setSelectedItemIds((prev) =>
- prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id],
- );
- };
-
- const handleUseSelected = (items: GalleryItem[]) => {
- const selections: MediaPickerSelection[] = items.map((item) => ({
- handle: { id: item.id, kind: mediaClassToKind(item.mediaClass) },
- name: item.label,
- }));
- setState({ open: false, filter: undefined });
- setSelectedItemIds([]);
- settlePending(selections);
- };
-
- const handleClose = () => {
- setState({ open: false, filter: undefined });
- setSelectedItemIds([]);
- settlePending([]);
- };
-
- const modal = (
-
- );
-
- return { adapter, modal };
-}
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts
deleted file mode 100644
index 479794a1..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/auth-user-adapter.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { AuthUser, AuthUserAdapter } from "@storyteller/ui-video-editor";
-import type { UserInfo } from "~/models";
-import { authentication } from "~/signals/authentication/authentication";
-
-// Tauri AuthUserAdapter — wraps the Preact signal that holds the
-// signed-in user. currentUser() reads the signal's value; subscribe()
-// re-derives the projected AuthUser and notifies only on actual change.
-//
-// currentUser() must return a stable reference across calls while the
-// underlying UserInfo identity is unchanged — React's
-// useSyncExternalStore loop sees a new snapshot on every render
-// otherwise and infinite-loops. The cache lives at module scope and
-// is **only mutated by snapshot()**. Subscribers each maintain their
-// own `previous` baseline so the cache can't be observed in a way
-// that races between concurrent subscribers (the bug a webapp version
-// of this file had earlier, which silently dropped sign-in/sign-out
-// notifications to the second-and-later useAuthUser consumer when one
-// ran before the others).
-
-function projectUser(user: UserInfo | undefined): AuthUser | null {
- if (!user) return null;
- return { id: user.user_token, displayName: user.display_name };
-}
-
-function authUsersEqual(a: AuthUser | null, b: AuthUser | null): boolean {
- if (a === b) return true;
- if (a === null || b === null) return false;
- return a.id === b.id && a.displayName === b.displayName;
-}
-
-let cachedSource: UserInfo | undefined = undefined;
-let cachedResult: AuthUser | null = null;
-
-function snapshot(): AuthUser | null {
- const source = authentication.userInfo.value;
- if (source === cachedSource) return cachedResult;
- cachedSource = source;
- cachedResult = projectUser(source);
- return cachedResult;
-}
-
-export const tauriAuthUserAdapter: AuthUserAdapter = {
- currentUser() {
- return snapshot();
- },
- subscribe(listener) {
- // Per-listener `previous` — closed-over, not shared.
- let previous = projectUser(authentication.userInfo.value);
- return authentication.userInfo.subscribe((value) => {
- const next = projectUser(value);
- if (authUsersEqual(previous, next)) return;
- previous = next;
- listener(next);
- });
- },
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts
deleted file mode 100644
index 9d449500..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/export-sink-adapter.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import type {
- ExportArtifact,
- ExportSinkAdapter,
-} from "@storyteller/ui-video-editor";
-import { kindFromMime } from "@storyteller/ui-video-editor";
-import { downloadUrlToPath } from "@storyteller/api";
-import { downloadDir } from "@tauri-apps/api/path";
-import { uploadByKind } from "./upload-by-kind";
-
-// Tauri ExportSinkAdapter.
-//
-// Two destinations the user controls from the export popover:
-// - saveToDisk: downloadUrlToPath writes the file to the OS Downloads
-// directory.
-// - saveToLibrary: UploadNewVideo / UploadAudio / UploadImage mirrors
-// the render into the user's Artcraft media library.
-//
-// Progress events fire per destination so the lib's popover can render
-// inline status rows; accept() resolves only after every requested
-// destination settled. In-flight Set dedupes concurrent invocations.
-//
-// Return value: the absolute path of the file written to disk when
-// saveToDisk succeeded, otherwise `null`. Matches the
-// ExportSinkAdapter contract — the webapp adapter always returns
-// `null` because the browser can't observe the chosen download path.
-
-const inFlight = new Set();
-
-async function ensureSavePath(
- _url: string,
- filename: string,
-): Promise {
- const dir = await downloadDir();
- return `${dir}/${filename}`;
-}
-
-async function uploadToLibrary(artifact: ExportArtifact): Promise {
- const baseTitle = artifact.filename.replace(/\.[^.]+$/, "");
- await uploadByKind({
- kind: kindFromMime(artifact.mime),
- blob: artifact.blob,
- fileName: artifact.filename,
- title: baseTitle,
- });
-}
-
-export const tauriExportSinkAdapter: ExportSinkAdapter = {
- async accept(artifact, options) {
- const saveToDisk = options?.saveToDisk ?? true;
- const saveToLibrary = options?.saveToLibrary ?? false;
- const onProgress = options?.onProgress;
-
- if (!saveToDisk && !saveToLibrary) {
- return null;
- }
-
- const downloadUrl = URL.createObjectURL(artifact.blob);
-
- if (inFlight.has(artifact.filename)) {
- if (saveToDisk) {
- onProgress?.({
- destination: "disk",
- status: "error",
- error: "Already saving this export",
- });
- }
- if (saveToLibrary) {
- onProgress?.({
- destination: "library",
- status: "error",
- error: "Already uploading this export",
- });
- }
- setTimeout(() => URL.revokeObjectURL(downloadUrl), 60_000);
- return null;
- }
- inFlight.add(artifact.filename);
-
- let savedPath: string | null = null;
- try {
- if (saveToDisk) {
- onProgress?.({ destination: "disk", status: "pending" });
- try {
- const targetPath = await ensureSavePath(downloadUrl, artifact.filename);
- if (targetPath) {
- await downloadUrlToPath(downloadUrl, targetPath);
- savedPath = targetPath;
- onProgress?.({ destination: "disk", status: "success" });
- } else {
- // User cancelled the dialog — treat as error so the row
- // doesn't look like a silent success.
- onProgress?.({
- destination: "disk",
- status: "error",
- error: "Save dialog cancelled",
- });
- }
- } catch (error) {
- console.error("Export disk save failed:", error);
- onProgress?.({
- destination: "disk",
- status: "error",
- error: error instanceof Error ? error.message : "Disk save failed",
- });
- }
- }
-
- if (saveToLibrary) {
- onProgress?.({ destination: "library", status: "uploading" });
- try {
- await uploadToLibrary(artifact);
- onProgress?.({ destination: "library", status: "success" });
- } catch (error) {
- console.error("Export library upload failed:", error);
- onProgress?.({
- destination: "library",
- status: "error",
- error: error instanceof Error ? error.message : "Upload failed",
- });
- }
- }
- } finally {
- inFlight.delete(artifact.filename);
- URL.revokeObjectURL(downloadUrl);
- }
-
- return savedPath;
- },
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts
deleted file mode 100644
index f3361d2d..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/media-source-adapter.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import type {
- MediaHandle,
- MediaKind,
- MediaSourceAdapter,
- ResolvedMedia,
-} from "@storyteller/ui-video-editor";
-import { kindFromMime } from "@storyteller/ui-video-editor";
-import { MediaFilesApi } from "@storyteller/api";
-import { uploadByKind } from "./upload-by-kind";
-
-// Tauri MediaSourceAdapter — same shape as the webapp's adapter.
-// Uploads route through MediaUploadApi (HTTPS), resolveMedia hits
-// MediaFilesApi.GetMediaFileByToken, deleteHandle calls
-// DeleteMediaFileByToken to roll back failed-mid-pipeline uploads.
-//
-// Tauri-specific drag-drops (OS-level paths through
-// appWindow.onDragDropEvent) are converted to File objects in the
-// PageVideoEditor's drop-bridge component before being fed into
-// processMediaAssets — so this adapter never sees a Tauri-shaped
-// payload, only standard Files. That keeps the adapter contract
-// portable between webapp and desktop.
-
-type ApiMediaFileClass = "video" | "audio" | "image" | "unknown" | string;
-
-const filesApi = new MediaFilesApi();
-
-function kindFromMediaClass(mediaClass: ApiMediaFileClass | null): MediaKind {
- if (mediaClass === "video") return "video";
- if (mediaClass === "audio") return "audio";
- return "image";
-}
-
-const EXT_TO_MIME: Record = {
- mp4: "video/mp4",
- webm: "video/webm",
- mov: "video/quicktime",
- m4v: "video/x-m4v",
- mkv: "video/x-matroska",
- avi: "video/x-msvideo",
- mp3: "audio/mpeg",
- wav: "audio/wav",
- ogg: "audio/ogg",
- oga: "audio/ogg",
- m4a: "audio/mp4",
- aac: "audio/aac",
- flac: "audio/flac",
- png: "image/png",
- jpg: "image/jpeg",
- jpeg: "image/jpeg",
- gif: "image/gif",
- webp: "image/webp",
- svg: "image/svg+xml",
- bmp: "image/bmp",
- avif: "image/avif",
-};
-
-function extensionOf(path: string | null | undefined): string | null {
- if (!path) return null;
- const trimmed = path.split(/[?#]/)[0];
- const dot = trimmed.lastIndexOf(".");
- if (dot === -1 || dot === trimmed.length - 1) return null;
- return trimmed.slice(dot + 1).toLowerCase();
-}
-
-function mimeForMediaFile({
- maybeOriginalFilename,
- cdnUrl,
- publicBucketPath,
- mediaClass,
-}: {
- maybeOriginalFilename: string | null;
- cdnUrl: string;
- publicBucketPath: string;
- mediaClass: ApiMediaFileClass | null;
-}): string {
- const candidates = [maybeOriginalFilename, cdnUrl, publicBucketPath];
- for (const candidate of candidates) {
- const ext = extensionOf(candidate);
- if (ext && EXT_TO_MIME[ext]) return EXT_TO_MIME[ext];
- }
- if (mediaClass === "video") return "video/mp4";
- if (mediaClass === "audio") return "audio/mpeg";
- if (mediaClass === "image") return "image/png";
- return "application/octet-stream";
-}
-
-export const tauriMediaSourceAdapter: MediaSourceAdapter = {
- async uploadLocalFile(file: File): Promise {
- const kind = kindFromMime(file.type);
- const id = await uploadByKind({
- kind,
- blob: file,
- fileName: file.name,
- });
- return { id, kind };
- },
-
- async resolveMedia(handle: MediaHandle): Promise {
- const response = await filesApi.GetMediaFileByToken({
- mediaFileToken: handle.id,
- });
- if (!response.success || !response.data) {
- throw new Error(
- response.errorMessage || `Failed to resolve media ${handle.id}`,
- );
- }
- const media = response.data;
- return {
- url: media.media_links.cdn_url,
- mime: mimeForMediaFile({
- maybeOriginalFilename: media.maybe_original_filename,
- cdnUrl: media.media_links.cdn_url,
- publicBucketPath: media.public_bucket_path,
- mediaClass: media.media_class,
- }),
- durationMs: media.maybe_duration_millis ?? undefined,
- };
- },
-
- releaseResolved() {
- // no-op — HTTP URLs don't need explicit release
- },
-
- async deleteHandle(handle) {
- const response = await filesApi.DeleteMediaFileByToken({
- mediaFileToken: handle.id,
- asMod: false,
- });
- if (!response.success) {
- throw new Error(
- response.errorMessage || `Failed to delete media ${handle.id}`,
- );
- }
- },
-};
-
-export { kindFromMediaClass };
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts
deleted file mode 100644
index a93fb06a..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/toast-adapter.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { ToastAdapter, ToastOptions } from "@storyteller/ui-video-editor";
-import { ToastTypes } from "~/enums";
-import { addToast } from "~/signals/toasts";
-
-// Tauri ToastAdapter — routes lib toasts through the Artcraft desktop
-// app's existing signal-based toast system (signals/toasts.ts +
-// mounted in MainApp).
-//
-// ToastTypes only has SUCCESS / WARNING / ERROR, so `info` collapses to
-// SUCCESS (closest visual fallback). ToastOptions.description is
-// concatenated after a newline because the toast UI is single-line.
-
-function formatMessage(message: string, options?: ToastOptions): string {
- if (!options?.description) return message;
- return `${message}\n${options.description}`;
-}
-
-export const tauriToastAdapter: ToastAdapter = {
- info(message, options) {
- addToast(ToastTypes.SUCCESS, formatMessage(message, options));
- },
- success(message, options) {
- addToast(ToastTypes.SUCCESS, formatMessage(message, options));
- },
- warning(message, options) {
- addToast(ToastTypes.WARNING, formatMessage(message, options));
- },
- error(message, options) {
- addToast(ToastTypes.ERROR, formatMessage(message, options));
- },
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts
deleted file mode 100644
index af113ae4..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/adapters/upload-by-kind.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { MediaKind } from "@storyteller/ui-video-editor";
-import { MediaUploadApi } from "@storyteller/api";
-
-// Single dispatch from MediaKind to the right MediaUploadApi endpoint.
-// Both the media-source adapter (uploadLocalFile) and the export-sink
-// adapter (uploadToLibrary) need this. Factoring it here means a future
-// upload-API change (e.g. a new required field) lands in one place.
-//
-// Mirror of the webapp's helper at
-// apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts —
-// the two adapters could share a workspace lib later but the helper
-// is tiny, so duplication is cheap during initial Tauri integration.
-
-const api = new MediaUploadApi();
-
-export async function uploadByKind({
- kind,
- blob,
- fileName,
- title,
-}: {
- kind: MediaKind;
- blob: Blob;
- fileName: string;
- title?: string;
-}): Promise {
- const uuid = crypto.randomUUID();
- const maybe_title = title ?? fileName;
-
- const response =
- kind === "video"
- ? await api.UploadNewVideo({ uuid, blob, fileName, maybe_title })
- : kind === "audio"
- ? await api.UploadAudio({ uuid, blob, fileName, maybe_title })
- : await api.UploadImage({ uuid, blob, fileName, maybe_title });
-
- if (!response.success || !response.data) {
- throw new Error(response.errorMessage || "Upload failed");
- }
- return response.data;
-}
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx
deleted file mode 100644
index 1a349228..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoEditor/index.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import {
- VideoEditor,
- EditorCore,
- buildDefaultScene,
- createDefaultAdapters,
- ZERO_MEDIA_TIME,
- type TProject,
- type VideoEditorAdapters,
-} from "@storyteller/ui-video-editor";
-import { tauriToastAdapter } from "./adapters/toast-adapter";
-import { tauriAuthUserAdapter } from "./adapters/auth-user-adapter";
-import { tauriMediaSourceAdapter } from "./adapters/media-source-adapter";
-import { tauriExportSinkAdapter } from "./adapters/export-sink-adapter";
-import { useTauriAssetGalleryAdapter } from "./adapters/asset-gallery-adapter";
-
-// Tauri host for the @storyteller/ui-video-editor lib.
-//
-// Bootstraps an in-memory project (the IndexedDB project-storage default
-// still persists across WebView restarts; a Tauri-specific filesystem-
-// backed adapter is a future option but not needed yet) and wires
-// every adapter through Tauri-native infra: addToast signal,
-// authentication.userInfo signal, MediaUploadApi / MediaFilesApi.
-//
-// Mounted inside MainApp's tab switch — see MainApp.tsx case
-// "VIDEO_EDITOR".
-
-function buildBootstrapProject({ id }: { id: string }): TProject {
- const scene = buildDefaultScene({ name: "Main scene", isMain: true });
- const now = new Date();
- return {
- metadata: {
- id,
- name: "Untitled project",
- duration: ZERO_MEDIA_TIME,
- createdAt: now,
- updatedAt: now,
- },
- scenes: [scene],
- currentSceneId: scene.id,
- settings: {
- fps: { numerator: 30, denominator: 1 } as never,
- canvasSize: { width: 1920, height: 1080 },
- background: { type: "color", color: "#000000" },
- },
- version: 1,
- };
-}
-
-export function PageVideoEditor() {
- const [ready, setReady] = useState(false);
-
- const { adapter: assetGalleryAdapter, modal: galleryModal } =
- useTauriAssetGalleryAdapter();
-
- const adapters = useMemo>(
- () => ({
- toast: tauriToastAdapter,
- authUser: tauriAuthUserAdapter,
- mediaSource: tauriMediaSourceAdapter,
- assetGallery: assetGalleryAdapter,
- exportSink: tauriExportSinkAdapter,
- }),
- [assetGalleryAdapter],
- );
-
- // Stable transient id so StrictMode + adapter ref churn don't
- // reset the project on every effect re-run.
- const transientIdRef = useRef(`local-${crypto.randomUUID()}`);
- const resolvedProjectId = transientIdRef.current;
-
- useEffect(() => {
- // Explicit initialize before EditorProvider mounts so the Tauri
- // adapter bundle wins the first-call-wins idempotent semantics.
- EditorCore.initialize({
- adapters: { ...createDefaultAdapters(), ...adapters },
- });
- const editor = EditorCore.getInstance();
-
- if (editor.project.getActive()?.metadata.id !== resolvedProjectId) {
- const project = buildBootstrapProject({ id: resolvedProjectId });
- editor.project.setActiveProject({ project });
- editor.scenes.initializeScenes({
- scenes: project.scenes,
- currentSceneId: project.currentSceneId,
- });
- }
- setReady(true);
- }, [resolvedProjectId, adapters]);
-
- if (!ready) return null;
-
- return (
-
-
- {galleryModal}
-
- );
-}
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx
deleted file mode 100644
index a3661c2f..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/VideoFrameExtractor.tsx
+++ /dev/null
@@ -1,894 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faPlay,
- faPause,
- faVolumeHigh,
- faVolumeMute,
- faPhotoFilm,
- faSparkles,
- faImages,
- faStepBackward,
- faStepForward,
- faArrowRotateRight,
- faSave,
- faVideo,
- faCheck,
-} from "@fortawesome/pro-solid-svg-icons";
-import { Button } from "@storyteller/ui-button";
-import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
-import {
- downloadFileFromUrl,
- MediaUploadApi,
- EIntermediateFile,
-} from "@storyteller/api";
-import toast from "react-hot-toast";
-import { v4 as uuidv4 } from "uuid";
-import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
-import { UploadEntryCard } from "../../components/media/UploadEntryCard";
-import { useTabStore } from "~/pages/Stores/TabState";
-
-export const VideoFrameExtractor = () => {
- const [videoUrl, setVideoUrl] = useState("");
- const [isPlaying, setIsPlaying] = useState(false);
- const [isMuted, setIsMuted] = useState(false);
- const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(0);
- const [volume, setVolume] = useState(1);
- const [startTime, setStartTime] = useState(0);
- const [numFrames, setNumFrames] = useState(10);
- const [frameDistance, setFrameDistance] = useState(100);
- const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
- const [selectedGalleryVideos, setSelectedGalleryVideos] = useState(
- [],
- );
- const [extractedFrames, setExtractedFrames] = useState<
- { id: string; url: string; timestamp: number }[]
- >([]);
- const [isExtracting, setIsExtracting] = useState(false);
- const [savingFrames, setSavingFrames] = useState>(new Set());
- const [savedFrames, setSavedFrames] = useState>(new Set());
- const [convertingFrames, setConvertingFrames] = useState>(
- new Set(),
- );
- const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
-
- const videoRef = useRef(null);
- const progressBarRef = useRef(null);
- const extractedFramesRef = useRef(null);
-
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
-
- const handleTimeUpdate = () => {
- setCurrentTime(video.currentTime);
- if (!video.paused) {
- setStartTime(video.currentTime);
- }
- };
- const handleLoadedMetadata = () => {
- setDuration(video.duration);
- setCurrentTime(0);
- };
- const handleEnded = () => setIsPlaying(false);
- const handlePlay = () => setIsPlaying(true);
- const handlePause = () => setIsPlaying(false);
- const handleVolumeChange = () => {
- setVolume(video.volume);
- setIsMuted(video.muted);
- };
-
- video.addEventListener("timeupdate", handleTimeUpdate);
- video.addEventListener("loadedmetadata", handleLoadedMetadata);
- video.addEventListener("ended", handleEnded);
- video.addEventListener("play", handlePlay);
- video.addEventListener("pause", handlePause);
- video.addEventListener("volumechange", handleVolumeChange);
-
- return () => {
- video.removeEventListener("timeupdate", handleTimeUpdate);
- video.removeEventListener("loadedmetadata", handleLoadedMetadata);
- video.removeEventListener("ended", handleEnded);
- video.removeEventListener("play", handlePlay);
- video.removeEventListener("pause", handlePause);
- video.removeEventListener("volumechange", handleVolumeChange);
- };
- }, [videoUrl]);
-
- useEffect(() => {
- if (videoRef.current) {
- videoRef.current.volume = volume;
- }
- }, [volume]);
-
- useEffect(() => {
- return () => {
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
- };
- }, [videoUrl]);
-
- const handleLocalVideoSelect = (files: FileList) => {
- const file = files[0];
- if (file && file.type.startsWith("video/")) {
- setIsPlaying(false);
- setCurrentTime(0);
- setStartTime(0);
- setDuration(0);
- setExtractedFrames([]);
- setSavingFrames(new Set());
- setSavedFrames(new Set());
- setConvertingFrames(new Set());
-
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
-
- const url = URL.createObjectURL(file);
- setVideoUrl(url);
- }
- };
-
- const handleVideoSelect = (id: string) => {
- setSelectedGalleryVideos((prev) => {
- if (prev.includes(id)) return prev.filter((x) => x !== id);
- const maxSelections = 1;
- if (prev.length >= maxSelections) {
- return maxSelections === 1 ? [id] : prev;
- }
- return [...prev, id];
- });
- };
-
- const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
- const item = selectedItems[0];
- if (!item || !item.fullImage) {
- toast.error("No video selected");
- return;
- }
-
- if (isLoadingFromGallery) {
- return;
- }
-
- setIsLoadingFromGallery(true);
-
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
-
- setIsPlaying(false);
- setCurrentTime(0);
- setStartTime(0);
- setDuration(0);
- setExtractedFrames([]);
- setSavingFrames(new Set());
- setSavedFrames(new Set());
- setConvertingFrames(new Set());
- setVideoUrl(item.fullImage);
-
- setIsGalleryModalOpen(false);
- setSelectedGalleryVideos([]);
- setIsLoadingFromGallery(false);
- };
-
- const togglePlayPause = () => {
- if (!videoRef.current) return;
- if (isPlaying) {
- videoRef.current.pause();
- } else {
- videoRef.current.play().catch((err) => {
- console.error("Play failed:", err);
- });
- }
- };
-
- const toggleMute = () => {
- if (!videoRef.current) return;
- videoRef.current.muted = !isMuted;
- setIsMuted(!isMuted);
- };
-
- const handleProgressBarClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (!videoRef.current || !progressBarRef.current || !duration) return;
- const rect = progressBarRef.current.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const percentage = Math.max(0, Math.min(1, clickX / rect.width));
- const newTime = percentage * duration;
- videoRef.current.currentTime = newTime;
- setStartTime(newTime);
- };
-
- const seekToFrame = (direction: "first" | "last") => {
- if (!videoRef.current || !duration) return;
-
- if (direction === "first") {
- videoRef.current.currentTime = 0;
- setStartTime(0);
- } else {
- videoRef.current.currentTime = duration;
- setStartTime(duration);
- }
- };
-
- const formatTime = (seconds: number) => {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
- };
-
- const formatTimePrecise = (seconds: number) => {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- const millis = Math.floor((seconds % 1) * 1000);
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${millis.toString().padStart(3, "0")}`;
- };
-
- const handleExtract = async () => {
- if (!videoRef.current) return;
-
- setIsExtracting(true);
- setExtractedFrames([]);
- setSavingFrames(new Set());
- setSavedFrames(new Set());
- setConvertingFrames(new Set());
-
- const video = videoRef.current;
-
- // Ensure video metadata is loaded before extraction
- if (video.readyState < 2 || !video.videoWidth || !video.videoHeight) {
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error("Video failed to load metadata"));
- }, 5000);
-
- const handleLoadedData = () => {
- clearTimeout(timeout);
- video.removeEventListener("loadeddata", handleLoadedData);
- resolve();
- };
-
- video.addEventListener("loadeddata", handleLoadedData);
- }).catch((error) => {
- console.error("Video loading error:", error);
- setIsExtracting(false);
- return;
- });
- }
-
- const originalTime = video.currentTime;
- const wasPlaying = !video.paused;
-
- if (wasPlaying) {
- video.pause();
- }
-
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (!ctx) {
- setIsExtracting(false);
- return;
- }
-
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
-
- const frames: { id: string; url: string; timestamp: number }[] = [];
- let corsError = false;
-
- for (let i = 0; i < numFrames; i++) {
- const timestamp = startTime + (i * frameDistance) / 1000;
-
- if (timestamp > duration) break;
-
- video.currentTime = timestamp;
-
- await new Promise((resolve) => {
- const handleSeeked = () => {
- try {
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- const dataUrl = canvas.toDataURL("image/png");
-
- frames.push({
- id: `frame-${i + 1}`,
- url: dataUrl,
- timestamp,
- });
- } catch (error) {
- console.error("Error extracting frame:", error);
- if (error instanceof Error && error.name === "SecurityError") {
- corsError = true;
- }
- }
-
- video.removeEventListener("seeked", handleSeeked);
- resolve();
- };
-
- video.addEventListener("seeked", handleSeeked, { once: true });
- });
-
- if (corsError) break;
- }
-
- video.currentTime = originalTime;
-
- await new Promise((resolve) => {
- const handleRestored = () => {
- video.removeEventListener("seeked", handleRestored);
- resolve();
- };
- video.addEventListener("seeked", handleRestored, { once: true });
- });
-
- if (wasPlaying) {
- video.play();
- }
-
- if (corsError) {
- toast.error(
- "CORS error: Cannot extract frames from this video. Try uploading the video file directly instead.",
- );
- setIsExtracting(false);
- return;
- }
-
- if (frames.length === 0) {
- toast.error("Failed to extract any frames");
- setIsExtracting(false);
- return;
- }
-
- setExtractedFrames(frames);
- setIsExtracting(false);
-
- setTimeout(() => {
- extractedFramesRef.current?.scrollIntoView({
- behavior: "smooth",
- block: "start",
- });
- }, 100);
- };
-
- const handleSaveFrame = async (
- frameId: string,
- frameUrl: string,
- timestamp: number,
- ) => {
- if (savedFrames.has(frameId) || savingFrames.has(frameId)) {
- return;
- }
-
- setSavingFrames((prev) => new Set(prev).add(frameId));
-
- try {
- const response = await fetch(frameUrl);
- const blob = await response.blob();
- const fileName = `frame-${formatTimePrecise(timestamp)}.png`;
-
- const mediaUploadApi = new MediaUploadApi();
- const uploadResponse = await mediaUploadApi.UploadImage({
- uuid: uuidv4(),
- blob: blob,
- fileName: fileName,
- maybe_title: `video-frame-${timestamp}`,
- is_intermediate_system_file: EIntermediateFile.false,
- });
-
- if (uploadResponse.success) {
- setSavedFrames((prev) => new Set(prev).add(frameId));
- setSavingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- toast.success("Frame saved to library!");
- } else {
- setSavingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- toast.error(uploadResponse.errorMessage || "Failed to save frame");
- }
- } catch (error) {
- setSavingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- toast.error("Failed to save frame");
- console.error("Error saving frame:", error);
- }
- };
-
- const handleTurnIntoVideo = async (
- frameId: string,
- frameUrl: string,
- timestamp: number,
- ) => {
- if (convertingFrames.has(frameId)) {
- return;
- }
-
- setConvertingFrames((prev) => new Set(prev).add(frameId));
-
- try {
- const response = await fetch(frameUrl);
- const blob = await response.blob();
- const fileName = `frame-${formatTimePrecise(timestamp)}.png`;
-
- const mediaUploadApi = new MediaUploadApi();
- const uploadResponse = await mediaUploadApi.UploadImage({
- uuid: uuidv4(),
- blob: blob,
- fileName: fileName,
- maybe_title: `video-frame-${timestamp}`,
- is_intermediate_system_file: EIntermediateFile.true,
- });
-
- if (uploadResponse.success && uploadResponse.data) {
- const file = new File([blob], fileName, { type: "image/png" });
-
- const referenceImage: RefImage = {
- id: Math.random().toString(36).substring(7),
- url: frameUrl,
- file: file,
- mediaToken: uploadResponse.data,
- };
-
- usePromptVideoStore.getState().setReferenceImages([referenceImage]);
- useTabStore.getState().setActiveTab("VIDEO");
-
- setConvertingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- } else {
- setConvertingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- toast.error(
- uploadResponse.errorMessage || "Failed to convert to video",
- );
- }
- } catch (error) {
- setConvertingFrames((prev) => {
- const next = new Set(prev);
- next.delete(frameId);
- return next;
- });
- toast.error("Failed to convert frame to video");
- console.error("Error converting frame:", error);
- }
- };
-
- return (
- <>
-
-
-
- {!videoUrl ? (
-
-
- setIsGalleryModalOpen(true)}
- />
-
-
- ) : (
-
-
-
-
{
- setVideoUrl("");
- setStartTime(0);
- setCurrentTime(0);
- setExtractedFrames([]);
- setSavingFrames(new Set());
- setSavedFrames(new Set());
- setConvertingFrames(new Set());
- }}
- className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
- >
- Switch Video
-
-
-
-
-
-
{
- e.stopPropagation();
- const handleMouseMove = (moveEvent: MouseEvent) => {
- if (!progressBarRef.current || !duration) return;
- const rect =
- progressBarRef.current.getBoundingClientRect();
- const moveX = moveEvent.clientX - rect.left;
- const percentage = Math.max(
- 0,
- Math.min(1, moveX / rect.width),
- );
- const newTime = percentage * duration;
- setStartTime(newTime);
- if (videoRef.current) {
- videoRef.current.currentTime = newTime;
- setCurrentTime(newTime);
- }
- };
- const handleMouseUp = () => {
- document.removeEventListener(
- "mousemove",
- handleMouseMove,
- );
- document.removeEventListener(
- "mouseup",
- handleMouseUp,
- );
- };
- document.addEventListener(
- "mousemove",
- handleMouseMove,
- );
- document.addEventListener("mouseup", handleMouseUp);
- }}
- />
-
-
-
-
seekToFrame("first")}
- className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
- title="First Frame"
- >
-
-
-
-
-
-
seekToFrame("last")}
- className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
- title="Last Frame"
- >
-
-
-
-
-
-
-
- setVolume(parseFloat(e.target.value))
- }
- className="w-20 accent-white"
- />
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
-
-
-
-
-
-
-
- {isExtracting ? "Extracting..." : "Extract Frames"}
-
-
-
-
-
-
-
-
- Start Time
-
-
- {formatTimePrecise(startTime)}
-
-
-
-
Adjust via the yellow marker on the video
-
-
-
-
-
-
-
- Video Information
-
-
-
-
- Duration
-
-
- {formatTime(duration)}
-
-
- {videoRef.current && (
-
-
- Resolution
-
-
- {videoRef.current.videoWidth} ×{" "}
- {videoRef.current.videoHeight}
-
-
- )}
-
-
-
-
-
-
-
-
-
- Extraction Settings
-
-
-
-
-
- Number of Frames
-
-
- setNumFrames(
- Math.max(
- 1,
- Math.min(50, parseInt(e.target.value) || 1),
- ),
- )
- }
- className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
- />
-
-
-
-
- Distance (ms)
-
-
- setFrameDistance(
- Math.max(1, parseInt(e.target.value) || 1),
- )
- }
- className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
- />
-
-
-
- Extracting multiple frames helps capture a sharp,
- non-blurred frame.
-
-
-
-
-
-
- {extractedFrames.length > 0 && (
-
-
-
-
-
- Extracted Frames
-
-
- {extractedFrames.length}{" "}
- {extractedFrames.length === 1 ? "Frame" : "Frames"}
-
-
-
- {extractedFrames.map((frame, index) => (
-
-
-
-
-
-
-
- #{index + 1}
-
-
- {formatTimePrecise(frame.timestamp)}
-
-
-
-
- handleSaveFrame(
- frame.id,
- frame.url,
- frame.timestamp,
- )
- }
- disabled={
- savingFrames.has(frame.id) ||
- savedFrames.has(frame.id)
- }
- className="flex-1 px-3 py-1.5 text-xs font-semibold"
- >
- {savingFrames.has(frame.id)
- ? "Saving..."
- : savedFrames.has(frame.id)
- ? "Saved"
- : "Save"}
-
-
- handleTurnIntoVideo(
- frame.id,
- frame.url,
- frame.timestamp,
- )
- }
- disabled={convertingFrames.has(frame.id)}
- className="flex-1 px-3 py-1.5 text-xs font-semibold"
- >
- Turn into Video
-
-
-
-
- ))}
-
-
-
- )}
-
- )}
-
-
-
-
-
{
- if (!isLoadingFromGallery) {
- setIsGalleryModalOpen(false);
- setSelectedGalleryVideos([]);
- }
- }}
- mode="select"
- selectedItemIds={selectedGalleryVideos}
- onSelectItem={handleVideoSelect}
- maxSelections={1}
- onUseSelected={handleGallerySelect}
- onDownloadClicked={downloadFileFromUrl}
- forceFilter="video"
- />
- >
- );
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/index.ts b/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/index.ts
deleted file mode 100644
index 49e7b36a..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoFrameExtractor/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { VideoFrameExtractor } from "./VideoFrameExtractor";
-
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx b/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx
deleted file mode 100644
index 37c9c986..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/VideoWatermarkRemover.tsx
+++ /dev/null
@@ -1,410 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faPlay,
- faPause,
- faVolumeHigh,
- faVolumeMute,
- faImages,
- faStepBackward,
- faStepForward,
- faArrowRotateRight,
- faDroplet,
- faVideo,
-} from "@fortawesome/pro-solid-svg-icons";
-import { Button } from "@storyteller/ui-button";
-import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
-import { downloadFileFromUrl } from "@storyteller/api";
-import { UploadEntryCard } from "../../components/media/UploadEntryCard";
-import toast from "react-hot-toast";
-
-export const VideoWatermarkRemover = () => {
- const [videoUrl, setVideoUrl] = useState("");
- const [isPlaying, setIsPlaying] = useState(false);
- const [isMuted, setIsMuted] = useState(false);
- const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(0);
- const [volume, setVolume] = useState(1);
- const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
- const [selectedGalleryVideos, setSelectedGalleryVideos] = useState(
- [],
- );
- const [isProcessing, setIsProcessing] = useState(false);
- const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
-
- const videoRef = useRef(null);
- const progressBarRef = useRef(null);
-
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
-
- const handleTimeUpdate = () => {
- setCurrentTime(video.currentTime);
- };
- const handleLoadedMetadata = () => {
- setDuration(video.duration);
- setCurrentTime(0);
- };
- const handleEnded = () => setIsPlaying(false);
- const handlePlay = () => setIsPlaying(true);
- const handlePause = () => setIsPlaying(false);
- const handleVolumeChange = () => {
- setVolume(video.volume);
- setIsMuted(video.muted);
- };
-
- video.addEventListener("timeupdate", handleTimeUpdate);
- video.addEventListener("loadedmetadata", handleLoadedMetadata);
- video.addEventListener("ended", handleEnded);
- video.addEventListener("play", handlePlay);
- video.addEventListener("pause", handlePause);
- video.addEventListener("volumechange", handleVolumeChange);
-
- return () => {
- video.removeEventListener("timeupdate", handleTimeUpdate);
- video.removeEventListener("loadedmetadata", handleLoadedMetadata);
- video.removeEventListener("ended", handleEnded);
- video.removeEventListener("play", handlePlay);
- video.removeEventListener("pause", handlePause);
- video.removeEventListener("volumechange", handleVolumeChange);
- };
- }, [videoUrl]);
-
- useEffect(() => {
- if (videoRef.current) {
- videoRef.current.volume = volume;
- }
- }, [volume]);
-
- useEffect(() => {
- if (videoRef.current && videoUrl) {
- videoRef.current.load();
- }
- }, [videoUrl]);
-
- useEffect(() => {
- return () => {
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
- };
- }, [videoUrl]);
-
- const handleLocalVideoSelect = (files: FileList) => {
- const file = files[0];
- if (file && file.type.startsWith("video/")) {
- setIsPlaying(false);
- setCurrentTime(0);
- setDuration(0);
-
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
-
- const url = URL.createObjectURL(file);
- setVideoUrl(url);
- }
- };
-
- const handleVideoSelect = (id: string) => {
- setSelectedGalleryVideos((prev) => {
- if (prev.includes(id)) return prev.filter((x) => x !== id);
- const maxSelections = 1;
- if (prev.length >= maxSelections) {
- return maxSelections === 1 ? [id] : prev;
- }
- return [...prev, id];
- });
- };
-
- const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
- const item = selectedItems[0];
- if (!item || !item.fullImage) {
- toast.error("No video selected");
- return;
- }
-
- if (isLoadingFromGallery) {
- return;
- }
-
- setIsLoadingFromGallery(true);
-
- if (videoUrl && videoUrl.startsWith("blob:")) {
- URL.revokeObjectURL(videoUrl);
- }
-
- setIsPlaying(false);
- setCurrentTime(0);
- setDuration(0);
- setVideoUrl(item.fullImage);
-
- setIsGalleryModalOpen(false);
- setSelectedGalleryVideos([]);
- setIsLoadingFromGallery(false);
- };
-
- const togglePlayPause = () => {
- if (!videoRef.current) return;
- if (isPlaying) {
- videoRef.current.pause();
- } else {
- videoRef.current.play().catch((err) => {
- console.error("Play failed:", err);
- });
- }
- };
-
- const toggleMute = () => {
- if (!videoRef.current) return;
- videoRef.current.muted = !isMuted;
- setIsMuted(!isMuted);
- };
-
- const handleProgressBarClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (!videoRef.current || !progressBarRef.current || !duration) return;
- const rect = progressBarRef.current.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const percentage = Math.max(0, Math.min(1, clickX / rect.width));
- const newTime = percentage * duration;
- videoRef.current.currentTime = newTime;
- };
-
- const seekToFrame = (direction: "first" | "last") => {
- if (!videoRef.current || !duration) return;
-
- if (direction === "first") {
- videoRef.current.currentTime = 0;
- } else {
- videoRef.current.currentTime = duration;
- }
- };
-
- const formatTime = (seconds: number) => {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
- };
-
- const handleRemoveWatermark = async () => {
- if (!videoRef.current || !videoUrl) {
- toast.error("Please select a video first");
- return;
- }
-
- setIsProcessing(true);
- toast.loading("Processing watermark removal...", {
- id: "watermark-removal",
- });
-
- try {
- await new Promise((resolve) => setTimeout(resolve, 2000));
- toast.success("Watermark removal completed!", {
- id: "watermark-removal",
- });
- } catch (error) {
- toast.error("Failed to remove watermark", { id: "watermark-removal" });
- console.error("Error removing watermark:", error);
- } finally {
- setIsProcessing(false);
- }
- };
-
- return (
- <>
-
-
-
- {!videoUrl ? (
-
-
- setIsGalleryModalOpen(true)}
- />
-
-
- ) : (
-
-
-
-
{
- setVideoUrl("");
- setCurrentTime(0);
- }}
- className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
- >
- Switch Video
-
-
-
-
-
-
-
seekToFrame("first")}
- className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
- title="First Frame"
- >
-
-
-
-
-
-
seekToFrame("last")}
- className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
- title="Last Frame"
- >
-
-
-
-
-
-
-
- setVolume(parseFloat(e.target.value))
- }
- className="w-20 accent-white"
- />
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
-
-
-
-
-
-
-
- {isProcessing ? "Processing..." : "Remove Watermark"}
-
-
-
-
-
-
-
- Video Information
-
-
-
-
- Duration
-
-
- {formatTime(duration)}
-
-
- {videoRef.current && (
-
-
- Resolution
-
-
- {videoRef.current.videoWidth} ×{" "}
- {videoRef.current.videoHeight}
-
-
- )}
-
-
-
-
- )}
-
-
-
-
- {
- if (!isLoadingFromGallery) {
- setIsGalleryModalOpen(false);
- setSelectedGalleryVideos([]);
- }
- }}
- mode="select"
- selectedItemIds={selectedGalleryVideos}
- onSelectItem={handleVideoSelect}
- maxSelections={1}
- onUseSelected={handleGallerySelect}
- onDownloadClicked={downloadFileFromUrl}
- forceFilter="video"
- />
- >
- );
-};
diff --git a/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/index.ts b/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/index.ts
deleted file mode 100644
index 8d2dcdf3..00000000
--- a/frontend/apps/artcraft/app/src/pages/PageVideoWatermarkRemover/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { VideoWatermarkRemover } from "./VideoWatermarkRemover";
-
diff --git a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts
index 601ae082..fad7e7ec 100644
--- a/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts
+++ b/frontend/apps/artcraft/app/src/pages/Stores/TabState.ts
@@ -1,20 +1,11 @@
import { create } from "zustand";
export type TabId =
- | "VIDEO"
- | "EDIT"
| "IMAGE"
+ | "VIDEO"
| "AUDIO"
- | "VIDEO_FRAME_EXTRACTOR"
- | "VIDEO_WATERMARK_REMOVAL"
- | "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
- | "ANGLES"
- | "STORYBOARD"
- | "BACKGROUND_CHANGE"
- | "VIDEO_EDITOR"
- | "MOODBOARD"
| "SETTINGS";
const DEFAULT_TAB: TabId = "IMAGE";
diff --git a/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/backgroundLoadedMedia.ts b/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/backgroundLoadedMedia.ts
index ab6d6ad2..52dd1c1e 100644
--- a/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/backgroundLoadedMedia.ts
+++ b/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/backgroundLoadedMedia.ts
@@ -1,7 +1,7 @@
import { signal } from "@preact/signals-core";
import deepEqual from "deep-equal";
-import type { AudioMediaItem, MediaInfo } from "@storyteller/ui-pagescene";
+import type { AudioMediaItem, MediaInfo } from "~/types/artcraft-web";
import { remapResponseToAudioMediaItems } from "./utilities";
// Signals and signal functions for background loading User's Movies
diff --git a/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/utilities.ts b/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/utilities.ts
index 087c3ef4..a3e35d59 100644
--- a/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/utilities.ts
+++ b/frontend/apps/artcraft/app/src/signals/backgroundLoadedMedia/utilities.ts
@@ -1,4 +1,4 @@
-import type { MediaInfo, AudioMediaItem } from "@storyteller/ui-pagescene";
+import type { MediaInfo, AudioMediaItem } from "~/types/artcraft-web";
import { AssetType } from "~/enums";
export const remapResponseToAudioMediaItems = (
diff --git a/frontend/libs/components/pagescene/src/lib/models/assets.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/assets.ts
similarity index 92%
rename from frontend/libs/components/pagescene/src/lib/models/assets.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/assets.ts
index 91bce424..7bec6535 100644
--- a/frontend/libs/components/pagescene/src/lib/models/assets.ts
+++ b/frontend/apps/artcraft/app/src/types/artcraft-web/assets.ts
@@ -1,4 +1,4 @@
-import { AssetType } from "../enums";
+import { AssetType } from "./enums";
export interface MediaItem {
version: number;
diff --git a/frontend/libs/components/pagescene/src/lib/enums/AssetFilterOption.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/AssetFilterOption.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/AssetFilterOption.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/AssetFilterOption.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/AssetType.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/AssetType.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/AssetType.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/AssetType.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/ClipGroup.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/ClipGroup.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/ClipGroup.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/ClipGroup.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/FilterEngineCategories.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterEngineCategories.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/FilterEngineCategories.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterEngineCategories.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/FilterMediaClasses.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterMediaClasses.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/FilterMediaClasses.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterMediaClasses.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/FilterMediaType.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterMediaType.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/FilterMediaType.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/FilterMediaType.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/GenerateTtsAudioErrorType.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/GenerateTtsAudioErrorType.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/GenerateTtsAudioErrorType.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/GenerateTtsAudioErrorType.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/MediaFileClass.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/MediaFileClass.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/MediaFileClass.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/MediaFileClass.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/MediaFileType.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/MediaFileType.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/MediaFileType.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/MediaFileType.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/ToastTypes.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/ToastTypes.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/ToastTypes.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/ToastTypes.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/WeightCategory.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/WeightCategory.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/WeightCategory.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/WeightCategory.ts
diff --git a/frontend/libs/components/pagescene/src/lib/enums/WeightType.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/WeightType.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/enums/WeightType.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/enums/WeightType.ts
diff --git a/frontend/apps/artcraft/app/src/types/artcraft-web/enums/index.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/index.ts
new file mode 100644
index 00000000..8476cca4
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/types/artcraft-web/enums/index.ts
@@ -0,0 +1,14 @@
+export { AssetFilterOption } from "./AssetFilterOption";
+export { AssetType } from "./AssetType";
+export { ClipGroup } from "./ClipGroup";
+export {
+ FilterEngineCategories,
+} from "./FilterEngineCategories";
+export { FilterMediaClasses } from "./FilterMediaClasses";
+export { FilterMediaType } from "./FilterMediaType";
+export { GenerateTtsAudioErrorType } from "./GenerateTtsAudioErrorType";
+export { MediaFileClass } from "./MediaFileClass";
+export { MediaFileType } from "./MediaFileType";
+export { ToastTypes } from "./ToastTypes";
+export { WeightCategory } from "./WeightCategory";
+export { WeightType } from "./WeightType";
diff --git a/frontend/apps/artcraft/app/src/types/artcraft-web/index.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/index.ts
new file mode 100644
index 00000000..e3237196
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/types/artcraft-web/index.ts
@@ -0,0 +1,10 @@
+// API-shape types and enums for the Artcraft/Storyteller web API, extracted
+// from the retired 3D scene editor lib (@storyteller/ui-pagescene) when it
+// was removed. These describe server payloads, not scene-editor state.
+export * from "./enums";
+export * from "./assets";
+export * from "./mediaInfo";
+export * from "./pagination";
+export * from "./prompts";
+export * from "./types";
+export * from "./userBookmark";
diff --git a/frontend/libs/components/pagescene/src/lib/models/mediaInfo.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/mediaInfo.ts
similarity index 99%
rename from frontend/libs/components/pagescene/src/lib/models/mediaInfo.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/mediaInfo.ts
index b43cddfe..97253a0e 100644
--- a/frontend/libs/components/pagescene/src/lib/models/mediaInfo.ts
+++ b/frontend/apps/artcraft/app/src/types/artcraft-web/mediaInfo.ts
@@ -5,7 +5,7 @@ import {
MediaFileType,
WeightCategory,
WeightType,
-} from "../enums";
+} from "./enums";
export interface MediaInfo {
token: string;
diff --git a/frontend/libs/components/pagescene/src/lib/models/pagination.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/pagination.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/models/pagination.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/pagination.ts
diff --git a/frontend/libs/components/pagescene/src/lib/models/prompts.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/prompts.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/models/prompts.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/prompts.ts
diff --git a/frontend/libs/components/pagescene/src/lib/models/types.tsx b/frontend/apps/artcraft/app/src/types/artcraft-web/types.ts
similarity index 99%
rename from frontend/libs/components/pagescene/src/lib/models/types.tsx
rename to frontend/apps/artcraft/app/src/types/artcraft-web/types.ts
index e97ee488..1012a7b2 100644
--- a/frontend/libs/components/pagescene/src/lib/models/types.tsx
+++ b/frontend/apps/artcraft/app/src/types/artcraft-web/types.ts
@@ -5,7 +5,7 @@ import {
MediaFileType,
WeightCategory,
WeightType,
-} from "../enums";
+} from "./enums";
import { MediaInfo } from "./mediaInfo";
import { Pagination } from "./pagination";
diff --git a/frontend/libs/components/pagescene/src/lib/models/userBookmark.ts b/frontend/apps/artcraft/app/src/types/artcraft-web/userBookmark.ts
similarity index 100%
rename from frontend/libs/components/pagescene/src/lib/models/userBookmark.ts
rename to frontend/apps/artcraft/app/src/types/artcraft-web/userBookmark.ts
diff --git a/frontend/apps/artcraft/package.json b/frontend/apps/artcraft/package.json
index 7cbef135..baacbd97 100644
--- a/frontend/apps/artcraft/package.json
+++ b/frontend/apps/artcraft/package.json
@@ -21,71 +21,44 @@
"staging-build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint playground/* .",
"typecheck": "tsc",
- "update:icons": "npm update @awesome.me/kit-fde2be5eb0",
"chromatic": "npx chromatic --project-token=chpt_0945f90d5d3626c",
"prepare": "husky",
"test": "jest"
},
"dependencies": {
- "@awesome.me/kit-fde2be5eb0": "^1.0.7",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-regular-svg-icons": "^6.5.1",
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
- "@mediapipe/tasks-vision": "^0.10.20",
"@preact/signals-react": "^2.0.1",
- "@radix-ui/react-tooltip": "^1.2.7",
"@remix-run/deno": "^2.8.1",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.17.4",
- "@remix-run/router": "^1.23.2",
- "@remix-run/serve": "^2.8.1",
"@tauri-apps/plugin-http": "2.4.3",
- "@tweenjs/tween.js": "^18.6.4",
- "@types/lodash": "^4.17.6",
- "@types/react-virtualized": "^9.22.2",
- "@uiw/color-convert": "^2.5.5",
- "@zip.js/zip.js": "^2.7.51",
- "ammo.js": "^0.0.10",
- "ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
"csstype": "^3.1.3",
"dayjs": "^1.11.10",
"deep-equal": "^2.2.3",
"detect-gpu": "^5.0.38",
- "http-proxy-middleware": "^3.0.0",
- "isbot": "^4.1.0",
- "konva": "^9.3.20",
- "lodash.debounce": "^4.0.8",
"nx": "^21.1.1",
"posthog-js": "^1.141.0",
"react": "^18.2.0",
- "react-colorful": "^5.6.1",
- "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-resizable-panels": "*",
- "react-helmet": "^6.1.0",
"react-hot-toast": "^2.5.2",
- "react-image-crop": "^11.0.5",
- "react-konva": "^18.2.10",
"react-router": "^6.26.1",
"react-router-dom": "^6.26.1",
- "react-virtualized": "^9.22.6",
"tailwind-merge": "^2.2.2",
"terser": "^5.30.3",
- "uuid": "^9.0.1",
- "webm-writer": "^1.0.0"
+ "uuid": "^9.0.1"
},
"devDependencies": {
- "@originjs/vite-plugin-commonjs": "^1.0.3",
"@remix-run/dev": "^2.8.1",
"@types/deep-equal": "^1.0.4",
"@types/jest": "^29.5.12",
"@types/react": "^19.0.0",
- "@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^19.0.0",
- "@types/react-helmet": "^6.1.11",
"@types/three": "^0.162.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
diff --git a/frontend/apps/artcraft/tsconfig.json b/frontend/apps/artcraft/tsconfig.json
index 51fa05ee..5dcab003 100644
--- a/frontend/apps/artcraft/tsconfig.json
+++ b/frontend/apps/artcraft/tsconfig.json
@@ -28,30 +28,9 @@
"noEmit": true
},
"references": [
- {
- "path": "../../libs/components/video-editor"
- },
- {
- "path": "../../libs/components/moodboard"
- },
- {
- "path": "../../libs/components/vfx"
- },
{
"path": "../../libs/omni-gen"
},
- {
- "path": "../../libs/components/pagedraw"
- },
- {
- "path": "../../libs/components/help-menu"
- },
- {
- "path": "../../libs/components/slider-v2"
- },
- {
- "path": "../../libs/components/loading-spinner"
- },
{
"path": "../../libs/soundboard"
},
@@ -142,9 +121,6 @@
{
"path": "../../libs/tauri-api"
},
- {
- "path": "../../libs/components/pagescene"
- },
{
"path": "../../libs/tauri-utils"
},
diff --git a/frontend/libs/components/badge/.babelrc b/frontend/libs/components/badge/.babelrc
deleted file mode 100644
index 1ea870ea..00000000
--- a/frontend/libs/components/badge/.babelrc
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "presets": [
- [
- "@nx/react/babel",
- {
- "runtime": "automatic",
- "useBuiltIns": "usage"
- }
- ]
- ],
- "plugins": []
-}
diff --git a/frontend/libs/components/badge/README.md b/frontend/libs/components/badge/README.md
deleted file mode 100644
index 13df069b..00000000
--- a/frontend/libs/components/badge/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# @storyteller/ui-badge
-
-This library was generated with [Nx](https://nx.dev).
-
-## Running unit tests
-
-Run `nx test @storyteller/ui-badge` to execute the unit tests via [Vitest](https://vitest.dev/).
diff --git a/frontend/libs/components/badge/package.json b/frontend/libs/components/badge/package.json
deleted file mode 100644
index 6eec275e..00000000
--- a/frontend/libs/components/badge/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-badge",
- "version": "0.0.1",
- "type": "module",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- }
-}
diff --git a/frontend/libs/components/badge/src/index.ts b/frontend/libs/components/badge/src/index.ts
deleted file mode 100644
index 9aa165b5..00000000
--- a/frontend/libs/components/badge/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-
-export * from './lib/badge';
diff --git a/frontend/libs/components/badge/src/lib/badge.module.css b/frontend/libs/components/badge/src/lib/badge.module.css
deleted file mode 100644
index d0246c74..00000000
--- a/frontend/libs/components/badge/src/lib/badge.module.css
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- * Replace this with your own classes
- *
- * e.g.
- * .container {
- * }
-*/
\ No newline at end of file
diff --git a/frontend/libs/components/badge/src/lib/badge.spec.tsx b/frontend/libs/components/badge/src/lib/badge.spec.tsx
deleted file mode 100644
index 0281c33f..00000000
--- a/frontend/libs/components/badge/src/lib/badge.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render } from '@testing-library/react';
-
-import Badge from './badge';
-
-describe('Badge', () => {
-
- it('should render successfully', () => {
- const { baseElement } = render( );
- expect(baseElement).toBeTruthy();
- });
-
-});
diff --git a/frontend/libs/components/badge/src/lib/badge.tsx b/frontend/libs/components/badge/src/lib/badge.tsx
deleted file mode 100644
index e885acc7..00000000
--- a/frontend/libs/components/badge/src/lib/badge.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ReactNode } from "react";
-import { twMerge } from "tailwind-merge";
-
-interface BadgeProps {
- label: string;
- color?: string;
- className?: string;
- icon?: ReactNode;
-}
-
-export const Badge = ({ label, className, icon }: BadgeProps) => {
- return (
-
- {icon}
- {label}
-
- );
-};
diff --git a/frontend/libs/components/badge/tsconfig.json b/frontend/libs/components/badge/tsconfig.json
deleted file mode 100644
index b3d19d8f..00000000
--- a/frontend/libs/components/badge/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "files": [],
- "include": [],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- },
- {
- "path": "./tsconfig.spec.json"
- }
- ],
- "extends": "../../../tsconfig.base.json"
-}
diff --git a/frontend/libs/components/badge/tsconfig.lib.json b/frontend/libs/components/badge/tsconfig.lib.json
deleted file mode 100644
index f0782e3d..00000000
--- a/frontend/libs/components/badge/tsconfig.lib.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "types": [
- "node",
- "@nx/react/typings/cssmodule.d.ts",
- "@nx/react/typings/image.d.ts",
- "vite/client"
- ],
- "rootDir": "src",
- "jsx": "react-jsx",
- "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
- },
- "exclude": [
- "out-tsc",
- "dist",
- "**/*.spec.ts",
- "**/*.test.ts",
- "**/*.spec.tsx",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.test.js",
- "**/*.spec.jsx",
- "**/*.test.jsx",
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx"
- ],
- "include": [
- "src/**/*.js",
- "src/**/*.jsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ]
-}
diff --git a/frontend/libs/components/badge/tsconfig.spec.json b/frontend/libs/components/badge/tsconfig.spec.json
deleted file mode 100644
index 18b7de51..00000000
--- a/frontend/libs/components/badge/tsconfig.spec.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "./out-tsc/vitest",
- "types": [
- "vitest/globals",
- "vitest/importMeta",
- "vite/client",
- "node",
- "vitest"
- ],
- "jsx": "react-jsx"
- },
- "include": [
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx",
- "src/**/*.d.ts"
- ],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/badge/vite.config.ts b/frontend/libs/components/badge/vite.config.ts
deleted file mode 100644
index a6c49894..00000000
--- a/frontend/libs/components/badge/vite.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import dts from 'vite-plugin-dts';
-import * as path from 'path';
-import { isExternal } from '../../shared-vite-config';
-
-export default defineConfig(() => ({
- root: __dirname,
- cacheDir: '../../../node_modules/.vite/libs/components/badge',
- plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
- // Uncomment this if you are using workers.
- // worker: {
- // plugins: [ nxViteTsPaths() ],
- // },
- // Configuration for building your library.
- // See: https://vitejs.dev/guide/build.html#library-mode
- build: {
- outDir: './dist',
- emptyOutDir: true,
- reportCompressedSize: true,
- commonjsOptions: {
- transformMixedEsModules: true,
- },
- lib: {
- // Could also be a dictionary or array of multiple entry points.
- entry: 'src/index.ts',
- name: '@storyteller/ui-badge',
- fileName: 'index',
- // Change this to the formats you want to support.
- // Don't forget to update your package.json as well.
- formats: ['es' as const]
- },
- rollupOptions: {
- // External packages that should not be bundled into your library.
- external: isExternal
- },
- },
- test: {
- watch: false,
- globals: true,
- environment: 'jsdom',
- include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
- reporters: ['default'],
- coverage: {
- reportsDirectory: './test-output/vitest/coverage',
- provider: 'v8' as const,
- }
- },
-}));
diff --git a/frontend/libs/components/button-dropdown/.babelrc b/frontend/libs/components/button-dropdown/.babelrc
deleted file mode 100644
index 1ea870ea..00000000
--- a/frontend/libs/components/button-dropdown/.babelrc
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "presets": [
- [
- "@nx/react/babel",
- {
- "runtime": "automatic",
- "useBuiltIns": "usage"
- }
- ]
- ],
- "plugins": []
-}
diff --git a/frontend/libs/components/button-dropdown/README.md b/frontend/libs/components/button-dropdown/README.md
deleted file mode 100644
index c08c28f1..00000000
--- a/frontend/libs/components/button-dropdown/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# @storyteller/ui-button-dropdown
-
-This library was generated with [Nx](https://nx.dev).
-
-## Running unit tests
-
-Run `nx test @storyteller/ui-button-dropdown` to execute the unit tests via [Vitest](https://vitest.dev/).
diff --git a/frontend/libs/components/button-dropdown/package.json b/frontend/libs/components/button-dropdown/package.json
deleted file mode 100644
index d438e43d..00000000
--- a/frontend/libs/components/button-dropdown/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-button-dropdown",
- "version": "0.0.1",
- "type": "module",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- }
-}
diff --git a/frontend/libs/components/button-dropdown/src/index.ts b/frontend/libs/components/button-dropdown/src/index.ts
deleted file mode 100644
index 579694e7..00000000
--- a/frontend/libs/components/button-dropdown/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-
-export * from './lib/button-dropdown';
diff --git a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.module.css b/frontend/libs/components/button-dropdown/src/lib/button-dropdown.module.css
deleted file mode 100644
index d0246c74..00000000
--- a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.module.css
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- * Replace this with your own classes
- *
- * e.g.
- * .container {
- * }
-*/
\ No newline at end of file
diff --git a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.spec.tsx b/frontend/libs/components/button-dropdown/src/lib/button-dropdown.spec.tsx
deleted file mode 100644
index 54c22034..00000000
--- a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render } from '@testing-library/react';
-
-import ButtonDropdown from './button-dropdown';
-
-describe('ButtonDropdown', () => {
-
- it('should render successfully', () => {
- const { baseElement } = render( );
- expect(baseElement).toBeTruthy();
- });
-
-});
diff --git a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx b/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx
deleted file mode 100644
index acb3a723..00000000
--- a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { ButtonHTMLAttributes, Fragment, useState } from "react";
-import { Menu, Transition } from "@headlessui/react";
-import {
- IconDefinition,
- faChevronDown,
-} from "@fortawesome/pro-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Modal } from "@storyteller/ui-modal";
-import { Button, ButtonProps } from "@storyteller/ui-button";
-import { twMerge } from "tailwind-merge";
-
-type UnionedButtonProps = { label?: string } & ButtonProps;
-
-interface ButtonDropdownProps extends ButtonHTMLAttributes {
- label: string;
- icon?: IconDefinition;
- align?: "left" | "right";
- showSelected?: boolean;
- options: Array<{
- label: string;
- className?: string;
- icon?: IconDefinition;
- selected?: boolean;
- description?: string;
- onClick?: () => void;
- disabled?: boolean;
- divider?: boolean;
- onDialogOpen?: () => void;
- dialogProps?: {
- title: string;
- content: React.ReactNode;
- className?: string;
- confirmButtonProps?: UnionedButtonProps;
- closeButtonProps?: UnionedButtonProps;
- showClose?: boolean;
- onClose?: () => void;
- };
- }>;
-}
-
-export const ButtonDropdown = ({
- className,
- label,
- options,
- icon,
- align = "left",
- showSelected,
-}: ButtonDropdownProps) => {
- const [isOpen, setIsOpen] = useState(false);
- const [selectedOptionIndex, setSelectedOptionIndex] = useState(
- null,
- );
-
- const closeModal = () => {
- setIsOpen(false);
- if (selectedOptionIndex !== null) {
- options[selectedOptionIndex]?.dialogProps?.onClose?.();
- }
- };
-
- const handleOptionClick = (index: number) => {
- const option = options[index];
- if (option.onClick) {
- option.onClick();
- }
- if (option.onDialogOpen) {
- option.onDialogOpen();
- }
- if (option.dialogProps) {
- setSelectedOptionIndex(index);
- setIsOpen(true);
- }
- };
-
- const currentDialogProps =
- selectedOptionIndex !== null
- ? options[selectedOptionIndex]?.dialogProps
- : null;
-
- return (
-
-
-
-
- {icon ? : null}
- {label}
-
-
-
-
-
- {options.map((option, index) => (
-
- {option.divider && (
-
- )}
-
- {({ active }) => (
- handleOptionClick(index)}
- >
-
- {option.icon && (
-
- )}
-
{option.label}
-
- {option.description && option.description}
-
- {showSelected && (
- <>
- {option.selected ? (
-
-
-
-
- ) : (
-
- )}
- >
- )}
-
-
- )}
-
-
- ))}
-
-
-
-
-
- {currentDialogProps && (
-
- {currentDialogProps.content}
-
-
- {currentDialogProps.showClose !== false &&
- currentDialogProps.closeButtonProps && (
-
- {currentDialogProps.closeButtonProps.label}
-
- )}
-
- {currentDialogProps.confirmButtonProps && (
- {
- if (currentDialogProps.confirmButtonProps?.onClick) {
- currentDialogProps.confirmButtonProps?.onClick(e);
- }
- closeModal();
- }}
- >
- {currentDialogProps.confirmButtonProps.label || "Confirm"}
-
- )}
-
-
- )}
-
- );
-};
diff --git a/frontend/libs/components/button-dropdown/tsconfig.json b/frontend/libs/components/button-dropdown/tsconfig.json
deleted file mode 100644
index 09a38a58..00000000
--- a/frontend/libs/components/button-dropdown/tsconfig.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "files": [],
- "include": [],
- "references": [
- {
- "path": "../button"
- },
- {
- "path": "../modal"
- },
- {
- "path": "./tsconfig.lib.json"
- },
- {
- "path": "./tsconfig.spec.json"
- }
- ],
- "extends": "../../../tsconfig.base.json"
-}
diff --git a/frontend/libs/components/button-dropdown/tsconfig.lib.json b/frontend/libs/components/button-dropdown/tsconfig.lib.json
deleted file mode 100644
index 5e02a080..00000000
--- a/frontend/libs/components/button-dropdown/tsconfig.lib.json
+++ /dev/null
@@ -1,53 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "types": [
- "node",
- "@nx/react/typings/cssmodule.d.ts",
- "@nx/react/typings/image.d.ts",
- "vite/client"
- ],
- "rootDir": "src",
- "jsx": "react-jsx",
- "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
- },
- "exclude": [
- "out-tsc",
- "dist",
- "**/*.spec.ts",
- "**/*.test.ts",
- "**/*.spec.tsx",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.test.js",
- "**/*.spec.jsx",
- "**/*.test.jsx",
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx"
- ],
- "include": [
- "src/**/*.js",
- "src/**/*.jsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ],
- "references": [
- {
- "path": "../button/tsconfig.lib.json"
- },
- {
- "path": "../modal/tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/button-dropdown/tsconfig.spec.json b/frontend/libs/components/button-dropdown/tsconfig.spec.json
deleted file mode 100644
index 18b7de51..00000000
--- a/frontend/libs/components/button-dropdown/tsconfig.spec.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "./out-tsc/vitest",
- "types": [
- "vitest/globals",
- "vitest/importMeta",
- "vite/client",
- "node",
- "vitest"
- ],
- "jsx": "react-jsx"
- },
- "include": [
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx",
- "src/**/*.d.ts"
- ],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/button-dropdown/vite.config.ts b/frontend/libs/components/button-dropdown/vite.config.ts
deleted file mode 100644
index 282e89c9..00000000
--- a/frontend/libs/components/button-dropdown/vite.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import dts from 'vite-plugin-dts';
-import * as path from 'path';
-import { isExternal } from '../../shared-vite-config';
-
-export default defineConfig(() => ({
- root: __dirname,
- cacheDir: '../../../node_modules/.vite/libs/components/button-dropdown',
- plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
- // Uncomment this if you are using workers.
- // worker: {
- // plugins: [ nxViteTsPaths() ],
- // },
- // Configuration for building your library.
- // See: https://vitejs.dev/guide/build.html#library-mode
- build: {
- outDir: './dist',
- emptyOutDir: true,
- reportCompressedSize: true,
- commonjsOptions: {
- transformMixedEsModules: true,
- },
- lib: {
- // Could also be a dictionary or array of multiple entry points.
- entry: 'src/index.ts',
- name: '@storyteller/ui-button-dropdown',
- fileName: 'index',
- // Change this to the formats you want to support.
- // Don't forget to update your package.json as well.
- formats: ['es' as const]
- },
- rollupOptions: {
- // External packages that should not be bundled into your library.
- external: isExternal
- },
- },
- test: {
- watch: false,
- globals: true,
- environment: 'jsdom',
- include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
- reporters: ['default'],
- coverage: {
- reportsDirectory: './test-output/vitest/coverage',
- provider: 'v8' as const,
- }
- },
-}));
diff --git a/frontend/libs/components/button-icon/.babelrc b/frontend/libs/components/button-icon/.babelrc
deleted file mode 100644
index 1ea870ea..00000000
--- a/frontend/libs/components/button-icon/.babelrc
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "presets": [
- [
- "@nx/react/babel",
- {
- "runtime": "automatic",
- "useBuiltIns": "usage"
- }
- ]
- ],
- "plugins": []
-}
diff --git a/frontend/libs/components/button-icon/README.md b/frontend/libs/components/button-icon/README.md
deleted file mode 100644
index 03eb7869..00000000
--- a/frontend/libs/components/button-icon/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# @storyteller/ui-button-icon
-
-This library was generated with [Nx](https://nx.dev).
-
-## Running unit tests
-
-Run `nx test @storyteller/ui-button-icon` to execute the unit tests via [Vitest](https://vitest.dev/).
diff --git a/frontend/libs/components/button-icon/package.json b/frontend/libs/components/button-icon/package.json
deleted file mode 100644
index 6fd3ac52..00000000
--- a/frontend/libs/components/button-icon/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-button-icon",
- "version": "0.0.1",
- "type": "module",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- }
-}
diff --git a/frontend/libs/components/button-icon/src/index.ts b/frontend/libs/components/button-icon/src/index.ts
deleted file mode 100644
index 09c79b75..00000000
--- a/frontend/libs/components/button-icon/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-
-export * from './lib/button-icon';
diff --git a/frontend/libs/components/button-icon/src/lib/button-icon.module.css b/frontend/libs/components/button-icon/src/lib/button-icon.module.css
deleted file mode 100644
index d0246c74..00000000
--- a/frontend/libs/components/button-icon/src/lib/button-icon.module.css
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- * Replace this with your own classes
- *
- * e.g.
- * .container {
- * }
-*/
\ No newline at end of file
diff --git a/frontend/libs/components/button-icon/src/lib/button-icon.spec.tsx b/frontend/libs/components/button-icon/src/lib/button-icon.spec.tsx
deleted file mode 100644
index bdd98c14..00000000
--- a/frontend/libs/components/button-icon/src/lib/button-icon.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render } from '@testing-library/react';
-
-import ButtonIcon from './button-icon';
-
-describe('ButtonIcon', () => {
-
- it('should render successfully', () => {
- const { baseElement } = render( );
- expect(baseElement).toBeTruthy();
- });
-
-});
diff --git a/frontend/libs/components/button-icon/src/lib/button-icon.tsx b/frontend/libs/components/button-icon/src/lib/button-icon.tsx
deleted file mode 100644
index 506662b4..00000000
--- a/frontend/libs/components/button-icon/src/lib/button-icon.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- FontAwesomeIcon,
- FontAwesomeIconProps,
-} from "@fortawesome/react-fontawesome";
-import { twMerge } from "tailwind-merge";
-import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
-
-interface ButtonIconProps extends FontAwesomeIconProps {
- icon: IconDefinition;
- onClick: () => void;
- className?: string;
- bgFill?: boolean;
- disabled?: boolean;
-}
-
-export const ButtonIcon = ({
- icon,
- size,
- onClick,
- className: propsClassName,
- bgFill = false,
- disabled,
- ...rest
-}: ButtonIconProps) => {
- const className = twMerge(
- "box-content flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
- bgFill
- ? "bg-ui-controls-button hover:bg-ui-controls-button/[0.75]"
- : "bg-transparent hover:bg-ui-panel/[0.4]",
- disabled && "opacity-50 hover:bg-transparent",
- propsClassName
- );
-
- return (
-
-
-
- );
-};
diff --git a/frontend/libs/components/button-icon/tsconfig.json b/frontend/libs/components/button-icon/tsconfig.json
deleted file mode 100644
index b3d19d8f..00000000
--- a/frontend/libs/components/button-icon/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "files": [],
- "include": [],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- },
- {
- "path": "./tsconfig.spec.json"
- }
- ],
- "extends": "../../../tsconfig.base.json"
-}
diff --git a/frontend/libs/components/button-icon/tsconfig.lib.json b/frontend/libs/components/button-icon/tsconfig.lib.json
deleted file mode 100644
index f0782e3d..00000000
--- a/frontend/libs/components/button-icon/tsconfig.lib.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "types": [
- "node",
- "@nx/react/typings/cssmodule.d.ts",
- "@nx/react/typings/image.d.ts",
- "vite/client"
- ],
- "rootDir": "src",
- "jsx": "react-jsx",
- "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
- },
- "exclude": [
- "out-tsc",
- "dist",
- "**/*.spec.ts",
- "**/*.test.ts",
- "**/*.spec.tsx",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.test.js",
- "**/*.spec.jsx",
- "**/*.test.jsx",
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx"
- ],
- "include": [
- "src/**/*.js",
- "src/**/*.jsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ]
-}
diff --git a/frontend/libs/components/button-icon/tsconfig.spec.json b/frontend/libs/components/button-icon/tsconfig.spec.json
deleted file mode 100644
index 18b7de51..00000000
--- a/frontend/libs/components/button-icon/tsconfig.spec.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "./out-tsc/vitest",
- "types": [
- "vitest/globals",
- "vitest/importMeta",
- "vite/client",
- "node",
- "vitest"
- ],
- "jsx": "react-jsx"
- },
- "include": [
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx",
- "src/**/*.d.ts"
- ],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/button-icon/vite.config.ts b/frontend/libs/components/button-icon/vite.config.ts
deleted file mode 100644
index ac125c1d..00000000
--- a/frontend/libs/components/button-icon/vite.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import dts from 'vite-plugin-dts';
-import * as path from 'path';
-import { isExternal } from '../../shared-vite-config';
-
-export default defineConfig(() => ({
- root: __dirname,
- cacheDir: '../../../node_modules/.vite/libs/components/button-icon',
- plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
- // Uncomment this if you are using workers.
- // worker: {
- // plugins: [ nxViteTsPaths() ],
- // },
- // Configuration for building your library.
- // See: https://vitejs.dev/guide/build.html#library-mode
- build: {
- outDir: './dist',
- emptyOutDir: true,
- reportCompressedSize: true,
- commonjsOptions: {
- transformMixedEsModules: true,
- },
- lib: {
- // Could also be a dictionary or array of multiple entry points.
- entry: 'src/index.ts',
- name: '@storyteller/ui-button-icon',
- fileName: 'index',
- // Change this to the formats you want to support.
- // Don't forget to update your package.json as well.
- formats: ['es' as const]
- },
- rollupOptions: {
- // External packages that should not be bundled into your library.
- external: isExternal
- },
- },
- test: {
- watch: false,
- globals: true,
- environment: 'jsdom',
- include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
- reporters: ['default'],
- coverage: {
- reportsDirectory: './test-output/vitest/coverage',
- provider: 'v8' as const,
- }
- },
-}));
diff --git a/frontend/libs/components/help-menu/.babelrc b/frontend/libs/components/help-menu/.babelrc
deleted file mode 100644
index 1ea870ea..00000000
--- a/frontend/libs/components/help-menu/.babelrc
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "presets": [
- [
- "@nx/react/babel",
- {
- "runtime": "automatic",
- "useBuiltIns": "usage"
- }
- ]
- ],
- "plugins": []
-}
diff --git a/frontend/libs/components/help-menu/README.md b/frontend/libs/components/help-menu/README.md
deleted file mode 100644
index b022a0e4..00000000
--- a/frontend/libs/components/help-menu/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# @storyteller/ui-help-menu
-
-This library was generated with [Nx](https://nx.dev).
-
-## Running unit tests
-
-Run `nx test @storyteller/ui-help-menu` to execute the unit tests via [Vitest](https://vitest.dev/).
diff --git a/frontend/libs/components/help-menu/package.json b/frontend/libs/components/help-menu/package.json
deleted file mode 100644
index 524d215c..00000000
--- a/frontend/libs/components/help-menu/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-help-menu",
- "version": "0.0.1",
- "type": "module",
- "main": "./src/index.ts",
- "module": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./src/index.ts",
- "import": "./src/index.ts",
- "default": "./src/index.ts"
- }
- }
-}
diff --git a/frontend/libs/components/help-menu/src/index.ts b/frontend/libs/components/help-menu/src/index.ts
deleted file mode 100644
index d76fb4c4..00000000
--- a/frontend/libs/components/help-menu/src/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from "./lib/help-menu";
-export { HelpMenuButton as TutorialModalButton } from "./lib/help-menu";
-export * from "./lib/tutorials";
-
diff --git a/frontend/libs/components/help-menu/src/lib/help-menu-store.ts b/frontend/libs/components/help-menu/src/lib/help-menu-store.ts
deleted file mode 100644
index 3a924f8e..00000000
--- a/frontend/libs/components/help-menu/src/lib/help-menu-store.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { create } from "zustand";
-import { TutorialItem } from "./tutorials";
-
-export type ViewType = "grid" | "video" | "news";
-
-interface TutorialModalState {
- isOpen: boolean;
- view: ViewType;
- selected: TutorialItem | null;
- progress: Record; // itemId -> seconds
- setOpen: (isOpen: boolean) => void;
- setGrid: () => void;
- setNews: () => void;
- viewTutorial: (item: TutorialItem) => void;
- setProgress: (id: string, seconds: number) => void;
- getProgress: (id: string) => number;
-}
-
-export const useTutorialModalStore = create()(
- (set, get) => ({
- isOpen: false,
- view: "grid",
- selected: null,
- progress: {},
-
- setOpen: (isOpen) => set({ isOpen }),
- setGrid: () => set({ view: "grid", selected: null }),
- setNews: () => set({ view: "news", selected: null }),
- viewTutorial: (item) => set({ view: "video", selected: item }),
-
- setProgress: (id, seconds) =>
- set((state) => ({
- progress: { ...state.progress, [id]: seconds },
- })),
-
- getProgress: (id) => get().progress[id] || 0,
- })
-);
diff --git a/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx b/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx
deleted file mode 100644
index 37779667..00000000
--- a/frontend/libs/components/help-menu/src/lib/help-menu.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render } from '@testing-library/react';
-
-import HelpMenuButton from './help-menu';
-
-describe('HelpMenuButton', () => {
-
- it('should render successfully', () => {
- const { baseElement } = render( );
- expect(baseElement).toBeTruthy();
- });
-
-});
diff --git a/frontend/libs/components/help-menu/src/lib/help-menu.tsx b/frontend/libs/components/help-menu/src/lib/help-menu.tsx
deleted file mode 100644
index 1d7640d0..00000000
--- a/frontend/libs/components/help-menu/src/lib/help-menu.tsx
+++ /dev/null
@@ -1,433 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import {
- faBook,
- faChevronLeft,
- faCircleQuestion,
- faNewspaper,
- faArrowRight,
-} from "@fortawesome/pro-solid-svg-icons";
-import {
- faDiscord,
- faGithub,
- faYoutube,
-} from "@fortawesome/free-brands-svg-icons";
-import { OpenUrl } from "@storyteller/tauri-api";
-import { Modal } from "@storyteller/ui-modal";
-import { defaultTutorials, TutorialItem } from "./tutorials.js";
-import { useTutorialModalStore } from "./help-menu-store";
-import { Button } from "@storyteller/ui-button";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
-import { getNewsPosts, markdownToHtml } from "@storyteller/markdown-content";
-
-export type HelpMenuButtonProps = {
- items?: TutorialItem[];
- panelTitle?: string;
- className?: string;
- onOpenChange?: (open: boolean) => void;
-};
-
-// Internal News Component
-const NewsView = ({ onBack }: { onBack: () => void }) => {
- const [selectedSlug, setSelectedSlug] = useState(null);
-
- const bundledItems = useMemo(() => getNewsPosts(), []);
- const [items, setItems] = useState(bundledItems);
-
- useEffect(() => {
- // Attempt to fetch fresh news
- const fetchNews = async () => {
- try {
- const baseUrl = import.meta.env.DEV
- ? "http://localhost:4200"
- : "https://getartcraft.com";
- const res = await fetch(`${baseUrl}/news.json`);
- if (res.ok) {
- const data = await res.json();
- if (Array.isArray(data)) {
- setItems(data);
- }
- }
- } catch (e) {
- // Fallback or ignore
- console.warn("Failed to fetch news feed, using bundled content.", e);
- }
- };
- fetchNews();
- }, []);
-
- const selectedPost = useMemo(
- () => (selectedSlug ? items.find((i) => i.slug === selectedSlug) : null),
- [selectedSlug, items],
- );
-
- const html = useMemo(
- () => (selectedPost ? markdownToHtml(selectedPost.body) : ""),
- [selectedPost],
- );
-
- if (selectedPost) {
- return (
-
-
setSelectedSlug(null)}
- className="self-start mb-4 text-sm flex items-center gap-2 text-white/60 hover:text-white transition-colors"
- >
- Back to News
-
-
-
-
{selectedPost.title}
- {selectedPost.date && (
-
{selectedPost.date}
- )}
-
- {selectedPost.thumbnail && (
-
- )}
-
-
-
-
-
- );
- }
-
- return (
-
- {items.map((item) => (
-
setSelectedSlug(item.slug)}
- className="bg-white/5 hover:bg-white/10 border border-white/10 p-4 rounded-lg cursor-pointer transition-all group"
- >
-
-
- {item.title}
-
-
- {item.date && (
-
- {item.date}
-
- )}
-
-
- {item.description}
-
-
- ))}
- {items.length === 0 && (
-
No news yet.
- )}
-
- );
-};
-
-export function HelpMenuButton({
- items,
- panelTitle = "Tutorials",
- className,
- onOpenChange,
-}: HelpMenuButtonProps) {
- const [open, setOpen] = useState(false);
- const tutorials = useMemo(() => items ?? defaultTutorials, [items]);
- const view = useTutorialModalStore((s) => s.view);
- const selected = useTutorialModalStore((s) => s.selected);
- const setGrid = useTutorialModalStore((s) => s.setGrid);
- const setNews = useTutorialModalStore((s) => s.setNews);
- const viewTutorial = useTutorialModalStore((s) => s.viewTutorial);
- const getProgress = useTutorialModalStore((s) => s.getProgress);
- const setProgress = useTutorialModalStore((s) => s.setProgress);
-
- // Sync open state if managed externally? No, local state + optional callback.
-
- const handleOpenTutorials = () => {
- setGrid();
- setOpen(true);
- onOpenChange?.(true);
- };
-
- const handleOpenNews = () => {
- setNews();
- setOpen(true);
- onOpenChange?.(true);
- };
-
- const handleClose = () => {
- setOpen(false);
- onOpenChange?.(false);
- };
-
- // Menu items for the popover
- const menuItems: PopoverItem[] = [
- {
- label: "Tutorials",
- selected: false,
- icon: ,
- action: "tutorials",
- },
- {
- label: "News & Updates",
- selected: false,
- icon: ,
- action: "news",
- },
- {
- label: "Discord",
- selected: false,
- icon: ,
- action: "discord",
- },
- {
- label: "GitHub",
- selected: false,
- icon: ,
- action: "github",
- },
- {
- label: "ArtCraft Studios",
- selected: false,
- icon: ,
- action: "artcraft",
- },
- ];
-
- const handleMenuSelect = (item: PopoverItem) => {
- switch (item.action) {
- case "tutorials":
- handleOpenTutorials();
- break;
- case "news":
- handleOpenNews();
- break;
- case "discord":
- OpenUrl("https://discord.com/invite/75svZP2Vje");
- break;
- case "github":
- OpenUrl("https://github.com/storytold/artcraft");
- break;
- case "artcraft":
- OpenUrl("https://www.youtube.com/@OfficialArtCraftStudios");
- break;
- }
- };
-
- // ------------------------------------------------------
- // YouTube Player setup for restoring and saving progress
- // ------------------------------------------------------
- const playerContainerRef = useRef(null);
- const playerRef = useRef(null);
- const progressTimerRef = useRef(null);
-
- const extractYouTubeId = (url: string): string | null => {
- try {
- const u = new URL(url);
- const host = u.hostname.replace(/^www\./, "");
- if (host === "youtube.com" && u.pathname.startsWith("/embed/")) {
- return u.pathname.split("/")[2] ?? null;
- }
- if (host.includes("youtube.com") && u.pathname.startsWith("/shorts/")) {
- return u.pathname.split("/")[2] ?? null;
- }
- if (host.includes("youtube.com") && u.pathname === "/watch") {
- return u.searchParams.get("v");
- }
- if (host === "youtu.be") {
- return u.pathname.slice(1) || null;
- }
- } catch {}
- return null;
- };
-
- const loadYouTubeIframeAPI = async (): Promise => {
- if (typeof window === "undefined") return;
- const w = window as any;
- if (w.YT && w.YT.Player) return;
- await new Promise((resolve) => {
- const existing = document.getElementById("youtube-iframe-api");
- if (existing) {
- const check = () =>
- w.YT && w.YT.Player ? resolve() : setTimeout(check, 50);
- check();
- return;
- }
- const tag = document.createElement("script");
- tag.id = "youtube-iframe-api";
- tag.src = "https://www.youtube.com/iframe_api";
- document.head.appendChild(tag);
- w.onYouTubeIframeAPIReady = () => resolve();
- });
- };
-
- useEffect(() => {
- if (!open || view !== "video" || !selected) return;
- const run = async () => {
- await loadYouTubeIframeAPI();
- const w = window as any;
- const videoId = extractYouTubeId(selected.videoUrl);
- if (!videoId) return;
-
- if (!playerContainerRef.current) return;
- // Clear any previous iframe content
- playerContainerRef.current.innerHTML = "";
-
- const startSeconds = getProgress(selected.id) || 0;
- playerRef.current = new w.YT.Player(playerContainerRef.current, {
- videoId,
- playerVars: {
- rel: 0,
- modestbranding: 1,
- start: Math.floor(startSeconds),
- playsinline: 1,
- },
- events: {
- onReady: () => {
- // Start polling current time
- progressTimerRef.current = window.setInterval(() => {
- try {
- const t = Math.floor(
- playerRef.current?.getCurrentTime?.() ?? 0,
- );
- if (Number.isFinite(t)) setProgress(selected.id, t);
- } catch {}
- }, 1000);
- },
- },
- });
- };
-
- run();
-
- return () => {
- if (progressTimerRef.current) {
- window.clearInterval(progressTimerRef.current);
- progressTimerRef.current = null;
- }
- try {
- playerRef.current?.destroy?.();
- } catch {}
- playerRef.current = null;
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, view, selected?.id]);
-
- const modalTitle =
- view === "video"
- ? undefined
- : view === "news"
- ? "News & Updates"
- : panelTitle;
-
- return (
-
-
- }
- triggerLabel="Help"
- buttonClassName="h-9"
- />
-
-
- {view === "news" && {}} />}
-
- {view === "grid" && (
- <>
- {tutorials.length === 0 ? (
-
- No tutorials yet.
-
- ) : (
-
- {tutorials.map((item: TutorialItem) => (
-
viewTutorial(item)}
- className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left"
- >
-
-
-
-
- {item.title}
-
-
- ))}
-
- )}
- >
- )}
-
- {view === "video" && selected && (
-
-
-
- Back
-
-
- Tutorial: {selected.title}
-
-
-
-
- )}
-
-
- );
-}
-
-export default HelpMenuButton;
diff --git a/frontend/libs/components/help-menu/src/lib/tutorials.ts b/frontend/libs/components/help-menu/src/lib/tutorials.ts
deleted file mode 100644
index 47fa38f4..00000000
--- a/frontend/libs/components/help-menu/src/lib/tutorials.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-export type TutorialItem = {
- id: string;
- title: string;
- thumbnailUrl: string;
- videoUrl: string;
- category?: string;
-};
-
-export enum TutorialCategory {
- Editor3D = "3D Stage",
- Canvas2D = "Image Editor",
-}
-
-export const defaultTutorials: TutorialItem[] = [
- {
- id: "turn-images-into-objects",
- title: "Turn Images into 3D Objects",
- thumbnailUrl:
- "/resources/images/tutorial-thumbnails/Turn_Images_Into_Objects.jpg",
- videoUrl: "https://youtu.be/t8F-sy_zyK4?si=P8lSzuiwSgdRttKa",
- category: TutorialCategory.Editor3D,
- },
- {
- id: "2d-editor-basics",
- title: "Image Editor Basics",
- thumbnailUrl: "/resources/images/tutorial-thumbnails/2D_Editor_Basics.jpg",
- videoUrl: "https://youtu.be/ZASPqlqUBQc?si=U9yRn6KETNJKaUAo",
- category: TutorialCategory.Canvas2D,
- },
- {
- id: "3d-editor-posing",
- title: "3D Stage & Posing",
- thumbnailUrl: "/resources/images/tutorial-thumbnails/3D_Editor_Basics.jpg",
- videoUrl: "https://youtu.be/pM4CxN-isrY?si=XDyG5C1aMIL4KK5O",
- category: TutorialCategory.Editor3D,
- },
-];
diff --git a/frontend/libs/components/help-menu/tsconfig.json b/frontend/libs/components/help-menu/tsconfig.json
deleted file mode 100644
index 08c8179d..00000000
--- a/frontend/libs/components/help-menu/tsconfig.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "files": [],
- "include": [],
- "references": [
- {
- "path": "../popover"
- },
- {
- "path": "../button"
- },
- {
- "path": "../modal"
- },
- {
- "path": "../../tauri-api"
- },
- {
- "path": "../../markdown-content"
- },
- {
- "path": "./tsconfig.lib.json"
- },
- {
- "path": "./tsconfig.spec.json"
- }
- ],
- "extends": "../../../tsconfig.base.json"
-}
diff --git a/frontend/libs/components/help-menu/tsconfig.lib.json b/frontend/libs/components/help-menu/tsconfig.lib.json
deleted file mode 100644
index 405c64d1..00000000
--- a/frontend/libs/components/help-menu/tsconfig.lib.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "types": [
- "node",
- "@nx/react/typings/cssmodule.d.ts",
- "@nx/react/typings/image.d.ts",
- "vite/client"
- ],
- "rootDir": "src",
- "jsx": "react-jsx",
- "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
- },
- "exclude": [
- "out-tsc",
- "dist",
- "**/*.spec.ts",
- "**/*.test.ts",
- "**/*.spec.tsx",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.test.js",
- "**/*.spec.jsx",
- "**/*.test.jsx",
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx"
- ],
- "include": [
- "src/**/*.js",
- "src/**/*.jsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ],
- "references": [
- {
- "path": "../popover/tsconfig.lib.json"
- },
- {
- "path": "../button/tsconfig.lib.json"
- },
- {
- "path": "../modal/tsconfig.lib.json"
- },
- {
- "path": "../../tauri-api/tsconfig.lib.json"
- },
- {
- "path": "../../markdown-content/tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/help-menu/tsconfig.spec.json b/frontend/libs/components/help-menu/tsconfig.spec.json
deleted file mode 100644
index 18b7de51..00000000
--- a/frontend/libs/components/help-menu/tsconfig.spec.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "./out-tsc/vitest",
- "types": [
- "vitest/globals",
- "vitest/importMeta",
- "vite/client",
- "node",
- "vitest"
- ],
- "jsx": "react-jsx"
- },
- "include": [
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx",
- "src/**/*.d.ts"
- ],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/help-menu/vite.config.ts b/frontend/libs/components/help-menu/vite.config.ts
deleted file mode 100644
index d6b6da6f..00000000
--- a/frontend/libs/components/help-menu/vite.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import dts from 'vite-plugin-dts';
-import * as path from 'path';
-import { isExternal } from '../../shared-vite-config';
-
-export default defineConfig(() => ({
- root: __dirname,
- cacheDir: '../../../node_modules/.vite/libs/components/help-menu',
- plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
- // Uncomment this if you are using workers.
- // worker: {
- // plugins: [ nxViteTsPaths() ],
- // },
- // Configuration for building your library.
- // See: https://vitejs.dev/guide/build.html#library-mode
- build: {
- outDir: './dist',
- emptyOutDir: true,
- reportCompressedSize: true,
- commonjsOptions: {
- transformMixedEsModules: true,
- },
- lib: {
- // Could also be a dictionary or array of multiple entry points.
- entry: 'src/index.ts',
- name: '@storyteller/ui-help-menu',
- fileName: 'index',
- // Change this to the formats you want to support.
- // Don't forget to update your package.json as well.
- formats: ['es' as const]
- },
- rollupOptions: {
- // External packages that should not be bundled into your library.
- external: isExternal
- },
- },
- test: {
- watch: false,
- globals: true,
- environment: 'jsdom',
- include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
- reporters: ['default'],
- coverage: {
- reportsDirectory: './test-output/vitest/coverage',
- provider: 'v8' as const,
- }
- },
-}));
diff --git a/frontend/libs/components/menu-icon-selector/.babelrc b/frontend/libs/components/menu-icon-selector/.babelrc
deleted file mode 100644
index 1ea870ea..00000000
--- a/frontend/libs/components/menu-icon-selector/.babelrc
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "presets": [
- [
- "@nx/react/babel",
- {
- "runtime": "automatic",
- "useBuiltIns": "usage"
- }
- ]
- ],
- "plugins": []
-}
diff --git a/frontend/libs/components/menu-icon-selector/README.md b/frontend/libs/components/menu-icon-selector/README.md
deleted file mode 100644
index 2e30ec49..00000000
--- a/frontend/libs/components/menu-icon-selector/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# @storyteller/menu-icon-selector
-
-This library was generated with [Nx](https://nx.dev).
-
-## Running unit tests
-
-Run `nx test @storyteller/menu-icon-selector` to execute the unit tests via [Vitest](https://vitest.dev/).
diff --git a/frontend/libs/components/menu-icon-selector/package.json b/frontend/libs/components/menu-icon-selector/package.json
deleted file mode 100644
index 58fadb00..00000000
--- a/frontend/libs/components/menu-icon-selector/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-menu-icon-selector",
- "version": "0.0.1",
- "type": "module",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- }
-}
diff --git a/frontend/libs/components/menu-icon-selector/src/index.ts b/frontend/libs/components/menu-icon-selector/src/index.ts
deleted file mode 100644
index 40a0cef4..00000000
--- a/frontend/libs/components/menu-icon-selector/src/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-
-export * from './lib/menu-icon-selector';
diff --git a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.module.css b/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.module.css
deleted file mode 100644
index d0246c74..00000000
--- a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.module.css
+++ /dev/null
@@ -1,7 +0,0 @@
-/*
- * Replace this with your own classes
- *
- * e.g.
- * .container {
- * }
-*/
\ No newline at end of file
diff --git a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.spec.tsx b/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.spec.tsx
deleted file mode 100644
index e2f21763..00000000
--- a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.spec.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { render } from '@testing-library/react';
-
-import MenuIconSelector from './menu-icon-selector';
-
-describe('MenuIconSelector', () => {
-
- it('should render successfully', () => {
- const { baseElement } = render( );
- expect(baseElement).toBeTruthy();
- });
-
-});
diff --git a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.tsx b/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.tsx
deleted file mode 100644
index 7520823f..00000000
--- a/frontend/libs/components/menu-icon-selector/src/lib/menu-icon-selector.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import React, { useRef, useLayoutEffect, useState } from "react";
-import { twMerge } from "tailwind-merge";
-import { Tooltip } from "@storyteller/ui-tooltip";
-
-export interface MenuIconItem {
- id: string;
- label: string;
- icon: React.ReactNode;
- imageSrc?: string;
- description?: string;
- large?: boolean;
- tooltipContent?: React.ReactNode;
- tooltipInteractive?: boolean;
- tooltipPosition?: "top" | "bottom" | "left" | "right";
-}
-
-interface MenuIconSelectorProps {
- menuItems: MenuIconItem[];
- activeMenu: string;
- onMenuChange: (menuId: string) => void;
- className?: string;
- disabled?: boolean;
- disabledMessage?: string;
-}
-
-export const MenuIconSelector: React.FC = ({
- menuItems,
- activeMenu,
- onMenuChange,
- className,
- disabled,
- disabledMessage,
-}) => {
- const selectedIndex = menuItems.findIndex((item) => item.id === activeMenu);
- const [hoveredIndex, setHoveredIndex] = useState(-1);
- const itemsRef = useRef<(HTMLButtonElement | null)[]>([]);
- const containerRef = useRef(null);
- const [activeStyle, setActiveStyle] = useState({ left: 0, width: 0 });
- const [hoverStyle, setHoverStyle] = useState({ left: 0, width: 0 });
-
- // Animate active background
- useLayoutEffect(() => {
- if (
- selectedIndex >= 0 &&
- itemsRef.current[selectedIndex] &&
- containerRef.current
- ) {
- const current = itemsRef.current[selectedIndex];
- const containerRect = containerRef.current.getBoundingClientRect();
- const currentRect = current!.getBoundingClientRect();
- setActiveStyle({
- left: currentRect.left - containerRect.left,
- width: currentRect.width,
- });
- }
- }, [selectedIndex, menuItems]);
-
- // Animate hover background
- useLayoutEffect(() => {
- if (
- hoveredIndex >= 0 &&
- itemsRef.current[hoveredIndex] &&
- containerRef.current
- ) {
- const current = itemsRef.current[hoveredIndex];
- const containerRect = containerRef.current.getBoundingClientRect();
- const currentRect = current!.getBoundingClientRect();
- setHoverStyle({
- left: currentRect.left - containerRect.left,
- width: currentRect.width,
- });
- }
- }, [hoveredIndex, menuItems]);
-
- // On activeMenu change, reset hoveredIndex if it matches
- React.useEffect(() => {
- setHoveredIndex(-1);
- }, [selectedIndex]);
-
- const MenuGroupElement = (
-
-
setHoveredIndex(-1)}
- >
- {/* Active tab background */}
- {selectedIndex >= 0 && (
-
- )}
- {/* Hover background (only if hovering a different tab) */}
- {hoveredIndex !== -1 && hoveredIndex !== selectedIndex && (
-
- )}
- {menuItems.map((item, idx) => {
- const content = item.tooltipContent ?? item.label;
- const interactive = item.tooltipInteractive ?? false;
- const position = item.tooltipPosition ?? "bottom";
- const imageSrc = item.tooltipContent ? undefined : item.imageSrc;
- const description = item.tooltipContent
- ? undefined
- : item.description;
- return (
-
- {
- itemsRef.current[idx] = el;
- }}
- disabled={disabled}
- onClick={() => !disabled && onMenuChange(item.id)}
- onMouseEnter={() => setHoveredIndex(idx)}
- className={twMerge(
- "relative z-30 flex flex-col items-center justify-center px-3 py-2 rounded-full transition-all duration-150 text-base-fg",
- disabled ? "cursor-not-allowed opacity-60" : "",
- )}
- tabIndex={0}
- type="button"
- >
- {item.icon}
-
-
- );
- })}
-
-
- );
-
- return (
- <>
- {disabled && disabledMessage ? (
-
- {MenuGroupElement}
-
- ) : (
- MenuGroupElement
- )}
- >
- );
-};
-
-export default MenuIconSelector;
diff --git a/frontend/libs/components/menu-icon-selector/tsconfig.json b/frontend/libs/components/menu-icon-selector/tsconfig.json
deleted file mode 100644
index 0937c637..00000000
--- a/frontend/libs/components/menu-icon-selector/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "files": [],
- "include": [],
- "references": [
- {
- "path": "../tooltip"
- },
- {
- "path": "./tsconfig.lib.json"
- },
- {
- "path": "./tsconfig.spec.json"
- }
- ],
- "extends": "../../../tsconfig.base.json"
-}
diff --git a/frontend/libs/components/menu-icon-selector/tsconfig.lib.json b/frontend/libs/components/menu-icon-selector/tsconfig.lib.json
deleted file mode 100644
index 445c3e79..00000000
--- a/frontend/libs/components/menu-icon-selector/tsconfig.lib.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "types": [
- "node",
- "@nx/react/typings/cssmodule.d.ts",
- "@nx/react/typings/image.d.ts",
- "vite/client"
- ],
- "rootDir": "src",
- "jsx": "react-jsx",
- "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
- },
- "exclude": [
- "out-tsc",
- "dist",
- "**/*.spec.ts",
- "**/*.test.ts",
- "**/*.spec.tsx",
- "**/*.test.tsx",
- "**/*.spec.js",
- "**/*.test.js",
- "**/*.spec.jsx",
- "**/*.test.jsx",
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx"
- ],
- "include": [
- "src/**/*.js",
- "src/**/*.jsx",
- "src/**/*.ts",
- "src/**/*.tsx"
- ],
- "references": [
- {
- "path": "../tooltip/tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/menu-icon-selector/tsconfig.spec.json b/frontend/libs/components/menu-icon-selector/tsconfig.spec.json
deleted file mode 100644
index 18b7de51..00000000
--- a/frontend/libs/components/menu-icon-selector/tsconfig.spec.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "extends": "../../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "./out-tsc/vitest",
- "types": [
- "vitest/globals",
- "vitest/importMeta",
- "vite/client",
- "node",
- "vitest"
- ],
- "jsx": "react-jsx"
- },
- "include": [
- "vite.config.ts",
- "vite.config.mts",
- "vitest.config.ts",
- "vitest.config.mts",
- "src/**/*.test.ts",
- "src/**/*.spec.ts",
- "src/**/*.test.tsx",
- "src/**/*.spec.tsx",
- "src/**/*.test.js",
- "src/**/*.spec.js",
- "src/**/*.test.jsx",
- "src/**/*.spec.jsx",
- "src/**/*.d.ts"
- ],
- "references": [
- {
- "path": "./tsconfig.lib.json"
- }
- ]
-}
diff --git a/frontend/libs/components/menu-icon-selector/vite.config.ts b/frontend/libs/components/menu-icon-selector/vite.config.ts
deleted file mode 100644
index 54371be4..00000000
--- a/frontend/libs/components/menu-icon-selector/vite.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-///
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import dts from 'vite-plugin-dts';
-import * as path from 'path';
-import { isExternal } from '../../shared-vite-config';
-
-export default defineConfig(() => ({
- root: __dirname,
- cacheDir: '../../../node_modules/.vite/libs/components/menu-icon-selector',
- plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
- // Uncomment this if you are using workers.
- // worker: {
- // plugins: [ nxViteTsPaths() ],
- // },
- // Configuration for building your library.
- // See: https://vitejs.dev/guide/build.html#library-mode
- build: {
- outDir: './dist',
- emptyOutDir: true,
- reportCompressedSize: true,
- commonjsOptions: {
- transformMixedEsModules: true,
- },
- lib: {
- // Could also be a dictionary or array of multiple entry points.
- entry: 'src/index.ts',
- name: '@storyteller/menu-icon-selector',
- fileName: 'index',
- // Change this to the formats you want to support.
- // Don't forget to update your package.json as well.
- formats: ['es' as const]
- },
- rollupOptions: {
- // External packages that should not be bundled into your library.
- external: isExternal
- },
- },
- test: {
- watch: false,
- globals: true,
- environment: 'jsdom',
- include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
- reporters: ['default'],
- coverage: {
- reportsDirectory: './test-output/vitest/coverage',
- provider: 'v8' as const,
- }
- },
-}));
diff --git a/frontend/libs/components/moodboard/package.json b/frontend/libs/components/moodboard/package.json
deleted file mode 100644
index 45f13bcc..00000000
--- a/frontend/libs/components/moodboard/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@storyteller/ui-moodboard",
- "version": "0.0.1",
- "type": "module",
- "main": "./dist/index.js",
- "module": "./dist/index.js",
- "types": "./dist/index.d.ts",
- "exports": {
- "./package.json": "./package.json",
- ".": {
- "development": "./src/index.ts",
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "default": "./dist/index.js"
- }
- }
-}
diff --git a/frontend/libs/components/moodboard/src/index.ts b/frontend/libs/components/moodboard/src/index.ts
deleted file mode 100644
index 807191ec..00000000
--- a/frontend/libs/components/moodboard/src/index.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-// Public API for @storyteller/ui-moodboard
-
-// Components — each platform supplies a MoodboardAdapter.
-// MoodboardWorkspace is the full surface (Grid + freeform Konva Canvas + Present);
-// the individual views are also exported for apps that want to compose their own.
-export { MoodboardWorkspace } from "./lib/MoodboardWorkspace";
-export { BoardGridView as MoodboardGrid } from "./lib/grid/BoardGridView";
-export { PresentationView as MoodboardPresentation } from "./lib/grid/PresentationView";
-
-// Stores + selectors. useMoodboardStore is the Konva canvas runtime state
-// (separate from the durable board model in useBoardLibraryStore).
-export { useBoardLibraryStore } from "./lib/boards/BoardLibraryStore";
-export { useMoodboardStore } from "./lib/canvas/MoodboardStore";
-export {
- useActiveBoard,
- filterItems,
- collectTags,
-} from "./lib/boards/boardSelectors";
-
-// Adapter (platform seams)
-export type {
- MoodboardAdapter,
- MoodboardReference,
- MoodboardPickedItem,
- MoodboardLibraryPickerProps,
- MoodboardPersistenceAdapter,
- RemoteBoardMeta,
-} from "./lib/adapter";
-
-// Server persistence — document shape + sync status, for hosts that
-// implement MoodboardPersistenceAdapter or build tooling around the
-// persisted mood_board documents.
-export {
- serializeMoodboardDocument,
- deserializeMoodboardDocument,
-} from "./lib/persistence/documents";
-export type {
- MoodboardDocument,
- MoodboardCanvasDocument,
-} from "./lib/persistence/documents";
-export { useMoodboardSyncStore } from "./lib/persistence/moodboardSync";
-export type { MoodboardSaveStatus } from "./lib/persistence/moodboardSync";
-
-// Gallery → board drop bridge. Host apps wire their gallery's drop callback to
-// this; the grid + canvas views listen for the event it dispatches.
-export {
- dispatchGalleryMoodboardDrop,
- GALLERY_MOODBOARD_DROP_EVENT,
-} from "./lib/galleryDrop";
-export type { DroppedGalleryItem } from "./lib/galleryDrop";
-
-// Board model types
-export type {
- Board,
- BoardItem,
- BoardImageItem,
- BoardVideoItem,
- BoardTextItem,
- BoardLinkItem,
- BoardColorItem,
- BoardSection,
- BoardItemKind,
- ViewMode,
- GridDensity,
-} from "./lib/boards/boardTypes";
diff --git a/frontend/libs/components/moodboard/src/lib/BoardPicker.tsx b/frontend/libs/components/moodboard/src/lib/BoardPicker.tsx
deleted file mode 100644
index 54fefbac..00000000
--- a/frontend/libs/components/moodboard/src/lib/BoardPicker.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faChevronDown,
- faPlus,
- faTrashCan,
-} from "@fortawesome/pro-regular-svg-icons";
-import { useShallow } from "zustand/react/shallow";
-import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
-import {
- applyCanvasDocument,
- createBoardAndSwitch,
- getCanvasForBoard,
- switchActiveBoard,
-} from "./persistence/canvasBridge";
-import { deleteBoardEverywhere } from "./persistence/moodboardSync";
-
-// Board switcher for the workspace's top-left cluster. Lists every board in
-// the library (local + hydrated remote), switches the active board (swapping
-// the canvas via the bridge), and hosts New/Delete. Rename stays on the grid
-// toolbar's inline title.
-export const BoardPicker = () => {
- const { boards, boardOrder, activeBoardId } = useBoardLibraryStore(
- useShallow((s) => ({
- boards: s.boards,
- boardOrder: s.boardOrder,
- activeBoardId: s.activeBoardId,
- })),
- );
- const [open, setOpen] = useState(false);
- const rootRef = useRef(null);
-
- const activeBoard = activeBoardId ? boards[activeBoardId] : null;
- const orderedBoards = boardOrder
- .map((id) => boards[id])
- .filter((b) => Boolean(b));
-
- useEffect(() => {
- if (!open) return undefined;
- const onPointerDown = (e: PointerEvent) => {
- if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
- };
- window.addEventListener("pointerdown", onPointerDown);
- return () => window.removeEventListener("pointerdown", onPointerDown);
- }, [open]);
-
- const handleSwitch = (boardId: string) => {
- switchActiveBoard(boardId);
- setOpen(false);
- };
-
- const handleNewBoard = () => {
- createBoardAndSwitch();
- setOpen(false);
- };
-
- const handleDelete = async (boardId: string) => {
- const board = boards[boardId];
- if (!board) return;
- if (!window.confirm(`Delete "${board.name}"? This can't be undone.`)) {
- return;
- }
- const wasActive = activeBoardId === boardId;
- await deleteBoardEverywhere(boardId);
- if (wasActive) {
- const nextActiveId = useBoardLibraryStore.getState().activeBoardId;
- applyCanvasDocument(
- nextActiveId ? getCanvasForBoard(nextActiveId) : null,
- );
- }
- };
-
- return (
-
-
setOpen((v) => !v)}
- title="Switch board"
- aria-label="Switch board"
- className="flex max-w-[180px] items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/70 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
- >
- {activeBoard?.name ?? "Boards"}
-
-
-
- {open && (
-
-
- {orderedBoards.map((board) => (
-
- handleSwitch(board.id)}
- className="min-w-0 flex-1 truncate px-3 py-2 text-left text-sm text-base-fg focus:outline-none"
- >
- {board.name}
-
- void handleDelete(board.id)}
- className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-base-fg/40 opacity-0 transition-opacity hover:bg-red-500/20 hover:text-red-400 focus:opacity-100 group-hover:opacity-100"
- >
-
-
-
- ))}
-
-
-
- New board
-
-
- )}
-
- );
-};
diff --git a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx b/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx
deleted file mode 100644
index 722db12a..00000000
--- a/frontend/libs/components/moodboard/src/lib/MoodboardWorkspace.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-import { ReactNode, useState } from "react";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
-import {
- faTableCells,
- faVectorSquare,
- faPlay,
- faCloudArrowUp,
- faCheck,
- faTriangleExclamation,
- faSpinnerThird,
-} from "@fortawesome/pro-regular-svg-icons";
-import { BoardGridView } from "./grid/BoardGridView";
-import { PresentationView } from "./grid/PresentationView";
-import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
-import { useActiveBoard } from "./boards/boardSelectors";
-import type { ViewMode } from "./boards/boardTypes";
-import type { MoodboardAdapter } from "./adapter";
-import { Moodboard } from "./canvas/Moodboard";
-import { BoardPicker } from "./BoardPicker";
-import {
- useMoodboardSync,
- type MoodboardSaveStatus,
-} from "./persistence/moodboardSync";
-
-interface Props {
- adapter: MoodboardAdapter;
- // Host chrome rendered in the top-right cluster, left of nothing — beside the
- // Present button. Webapp hosts that hide the global topbar inject their nav
- // actions (pricing/credits/task queue/profile) here, mirroring the 3D and
- // video editors. Tauri leaves it unset.
- topBarEndSlot?: ReactNode;
-}
-
-// Root moodboard surface. Switches how the board is rendered — Grid (virtualized
-// masonry) vs Canvas (freeform Konva planning) — plus a transient Presentation
-// overlay. Fills its parent; the host app sizes the container. Platform seams
-// (upload, library picker, send-to-generation) arrive via the adapter.
-export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
- const viewMode = useBoardLibraryStore((s) => s.viewMode);
- const setViewMode = useBoardLibraryStore((s) => s.setViewMode);
- const board = useActiveBoard();
- // Remote board sync (no-op unless the adapter provides persistence and
- // the user is signed in).
- const { enabled: syncEnabled, status, saveNow } = useMoodboardSync(adapter);
- // Presentation is a transient overlay, not a persisted view mode — so a
- // reload never reopens straight into a slideshow.
- const [presenting, setPresenting] = useState(false);
-
- const presentItems = board
- ? board.itemOrder.map((id) => board.items[id]).filter((it) => Boolean(it))
- : [];
-
- return (
-
- {viewMode === "grid" ? (
-
- ) : (
-
- )}
-
-
setPresenting(true)}
- saveStatus={syncEnabled ? status : null}
- onSave={syncEnabled ? saveNow : undefined}
- endSlot={topBarEndSlot}
- />
- {presenting && (
- setPresenting(false)}
- />
- )}
-
- );
-};
-
-const OPTIONS: Array<{ mode: ViewMode; label: string; icon: IconDefinition }> =
- [
- { mode: "grid", label: "Grid", icon: faTableCells },
- { mode: "canvas", label: "Canvas", icon: faVectorSquare },
- ];
-
-// Top-left chrome: the board picker plus the Grid/Canvas view switch, sharing
-// one glass island.
-const TopLeftCluster = ({
- mode,
- onChange,
-}: {
- mode: ViewMode;
- onChange: (m: ViewMode) => void;
-}) => (
-
-
-
- {OPTIONS.map((opt) => {
- const active = opt.mode === mode;
- return (
-
onChange(opt.mode)}
- className={[
- "flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium",
- "transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
- active
- ? "bg-base-fg/15 text-base-fg"
- : "text-base-fg/55 hover:text-base-fg",
- ].join(" ")}
- >
-
- {opt.label}
-
- );
- })}
-
-);
-
-// Manual save trigger + live sync state. Only rendered when remote
-// persistence is active (adapter.persistence present + signed in).
-const SaveButton = ({
- status,
- onSave,
-}: {
- status: MoodboardSaveStatus;
- onSave: () => void;
-}) => {
- const icon =
- status === "saving"
- ? faSpinnerThird
- : status === "saved"
- ? faCheck
- : status === "error"
- ? faTriangleExclamation
- : faCloudArrowUp;
- const label =
- status === "saving"
- ? "Saving..."
- : status === "saved"
- ? "Saved"
- : status === "error"
- ? "Retry save"
- : "Save";
-
- return (
-
-
- {label}
-
- );
-};
-
-// Top-right chrome: Save (when remote sync is active), the Present action
-// (always present, including Tauri), and the host's relocated nav actions
-// (webapp only, via endSlot). The wrapper is click-through so the gap between
-// the clusters never blocks the board beneath.
-const TopRightCluster = ({
- onPresent,
- saveStatus,
- onSave,
- endSlot,
-}: {
- onPresent: () => void;
- saveStatus: MoodboardSaveStatus | null;
- onSave?: () => void;
- endSlot?: ReactNode;
-}) => (
-
-
- {saveStatus !== null && onSave && (
-
- )}
-
-
- Present
-
-
- {endSlot &&
{endSlot}
}
-
-);
diff --git a/frontend/libs/components/moodboard/src/lib/adapter.ts b/frontend/libs/components/moodboard/src/lib/adapter.ts
deleted file mode 100644
index db8f4955..00000000
--- a/frontend/libs/components/moodboard/src/lib/adapter.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { ReactNode } from "react";
-
-// Platform-specific seams the moodboard needs but can't own itself. Each app
-// (desktop Tauri / web) supplies a concrete adapter; the lib stays portable.
-
-export interface MoodboardReference {
- id: string;
- url: string;
- mediaToken: string;
-}
-
-export interface MoodboardPickedItem {
- url: string;
- mediaToken: string | null;
- kind: "image" | "video";
-}
-
-export interface MoodboardLibraryPickerProps {
- open: boolean;
- onClose: () => void;
- onPick: (items: MoodboardPickedItem[]) => void;
-}
-
-/** Summary row for a remotely persisted board (one mood_board project). */
-export interface RemoteBoardMeta {
- token: string;
- name: string;
- /** ISO timestamp of the server row's last update. */
- updatedAt: string;
-}
-
-// Server persistence seam for boards. Each board maps to one mood_board
-// project document on the backend; `saveBoard` creates the row when `token`
-// is null and overwrites it otherwise. All members are promise-based and
-// MUST NOT throw (failures come back as { success: false } / empty results)
-// — the sync layer treats a rejection as an implementation bug.
-export interface MoodboardPersistenceAdapter {
- /** Stable id of the signed-in account, or null when logged out. Sync only
- * runs for signed-in users, hydration is keyed per account, and boards
- * are stamped with this id to block cross-account pushes. */
- getUserId: () => string | null;
- /** Notify when the auth state may have changed (session fetch resolving,
- * logout, account switch). Returns an unsubscribe. Without this, a
- * session that resolves after mount would never enable sync, and an
- * account switch would keep syncing as the previous user. */
- subscribeAuthState?: (onChange: () => void) => () => void;
- saveBoard: (args: {
- token: string | null;
- name: string;
- documentJson: string;
- }) => Promise<{ success: boolean; token?: string; errorMessage?: string }>;
- listBoards: () => Promise<{ success: boolean; boards?: RemoteBoardMeta[] }>;
- loadBoard: (
- token: string,
- ) => Promise<{ success: boolean; documentJson?: string }>;
- deleteBoard?: (token: string) => Promise;
- /** Batch-resolve media tokens to display URLs, for items whose blob src
- * couldn't be persisted. Missing tokens are simply absent from the map. */
- resolveMediaUrls?: (tokens: string[]) => Promise>;
-}
-
-export interface MoodboardAdapter {
- /** Upload a file, resolving to a durable media token (or null if the platform
- * can't provide one). When present, uploaded images become reference-capable
- * and persist beyond the blob URL. */
- uploadImage?: (file: File) => Promise;
-
- /** Push selected board images to the platform's generation surface
- * (desktop: prompt store + tab switch; web: sessionStorage + route nav). */
- sendToGeneration: (refs: MoodboardReference[]) => void;
-
- /** Optional library/gallery picker as a render-prop. Omit to disable the
- * "From library" action (e.g. when a platform's picker isn't wired yet). */
- renderLibraryPicker?: (props: MoodboardLibraryPickerProps) => ReactNode;
-
- /** Optional server persistence. When present (and the user is signed in),
- * the workspace autosaves boards as mood_board project documents and
- * hydrates remote boards on mount. Omit for localStorage-only platforms. */
- persistence?: MoodboardPersistenceAdapter;
-}
diff --git a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.spec.ts b/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.spec.ts
deleted file mode 100644
index 537dbfe6..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.spec.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { beforeEach, describe, expect, it } from "vitest";
-import { useBoardLibraryStore } from "./BoardLibraryStore";
-
-// Fresh store per test — clear persisted boards and any selection.
-const reset = () => {
- useBoardLibraryStore.setState({
- boards: {},
- boardOrder: [],
- activeBoardId: null,
- selectedItemIds: new Set(),
- });
-};
-
-const seedItem = (boardId: string): string =>
- useBoardLibraryStore.getState().addTextItem(boardId, "note");
-
-describe("BoardLibraryStore sections", () => {
- beforeEach(reset);
-
- it("creates, renames, and collapses a section", () => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.createBoard("B");
- const sectionId = store.createSection(boardId, "Refs");
-
- let board = useBoardLibraryStore.getState().boards[boardId];
- expect(board.sections).toEqual([{ id: sectionId, name: "Refs" }]);
-
- store.renameSection(boardId, sectionId, "Hero shots");
- store.toggleSectionCollapsed(boardId, sectionId);
- board = useBoardLibraryStore.getState().boards[boardId];
- expect(board.sections[0].name).toBe("Hero shots");
- expect(board.sections[0].collapsed).toBe(true);
- });
-
- it("falls back to a default name for blank section names", () => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.createBoard("B");
- const sectionId = store.createSection(boardId, " ");
- const board = useBoardLibraryStore.getState().boards[boardId];
- expect(board.sections.find((s) => s.id === sectionId)?.name).toBe(
- "New section",
- );
- });
-
- it("assigns items into a section and back to ungrouped", () => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.createBoard("B");
- const itemId = seedItem(boardId);
- const sectionId = store.createSection(boardId);
-
- store.assignItemsToSection(boardId, [itemId], sectionId);
- expect(
- useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
- ).toBe(sectionId);
-
- store.assignItemsToSection(boardId, [itemId], null);
- expect(
- useBoardLibraryStore.getState().boards[boardId].items[itemId].sectionId,
- ).toBeNull();
- });
-
- it("returns items to the ungrouped flow when their section is deleted", () => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.createBoard("B");
- const itemId = seedItem(boardId);
- const sectionId = store.createSection(boardId);
- store.assignItemsToSection(boardId, [itemId], sectionId);
-
- store.deleteSection(boardId, sectionId);
- const board = useBoardLibraryStore.getState().boards[boardId];
- expect(board.sections).toHaveLength(0);
- expect(board.items[itemId].sectionId).toBeNull();
- });
-});
diff --git a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts b/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts
deleted file mode 100644
index 3603fe14..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/BoardLibraryStore.ts
+++ /dev/null
@@ -1,651 +0,0 @@
-import { create } from "zustand";
-import { persist, createJSONStorage } from "zustand/middleware";
-import { v4 as uuidv4 } from "uuid";
-import {
- Board,
- BoardImageItem,
- BoardItem,
- BoardLinkItem,
- BoardVideoItem,
- COLOR_ITEM_ASPECT,
- GridDensity,
- LINK_ITEM_ASPECT,
- TEXT_ITEM_ASPECT,
- ViewMode,
-} from "./boardTypes";
-
-const STORAGE_KEY = "artcraft_moodboard_library";
-const STORAGE_VERSION = 1;
-
-// Persisted slice of the library (excludes transient UI: selection, search,
-// tag filters). All mutations go through the actions below — that indirection
-// is the seam a future backend sync layer slots into without touching views.
-interface PersistedLibrary {
- boards: Record;
- boardOrder: string[];
- activeBoardId: string | null;
- viewMode: ViewMode;
- density: GridDensity;
-}
-
-interface BoardLibraryState extends PersistedLibrary {
- // Transient (not persisted)
- searchQuery: string;
- activeTagFilters: string[];
- ratingFilter: number;
- selectedItemIds: Set;
-
- // Board lifecycle
- ensureActiveBoard: () => string;
- createBoard: (name?: string) => string;
- renameBoard: (id: string, name: string) => void;
- deleteBoard: (id: string) => void;
- setActiveBoard: (id: string) => void;
-
- // Remote sync bookkeeping (used by the persistence layer, not views).
- setBoardRemoteToken: (id: string, token: string) => void;
- // Flag local edits pending a server push (no-op if already flagged, so
- // repeat edits don't churn board identity).
- markBoardNeedsSync: (id: string) => void;
- // Record a completed push/pull: clears needsSync and stamps ownership.
- markBoardSynced: (id: string, args: { ownerId: string }) => void;
- // Insert a remotely loaded board, or replace the content of the local
- // board already linked to the token. Does not change the active board.
- // Returns the (existing or new) local board id.
- upsertRemoteBoard: (args: {
- token: string;
- name: string;
- ownerId: string;
- itemOrder: string[];
- items: Record;
- sections: Board["sections"];
- }) => string;
-
- // Item entry — typed creators that fill base fields + aspect.
- addImageItem: (
- boardId: string,
- data: Pick &
- Partial>,
- ) => string;
- addVideoItem: (
- boardId: string,
- data: Pick,
- ) => string;
- addTextItem: (boardId: string, text: string) => string;
- addLinkItem: (
- boardId: string,
- data: Pick,
- ) => string;
- addColorItem: (boardId: string, color: string) => string;
-
- updateItem: (boardId: string, id: string, patch: Partial) => void;
- removeItems: (boardId: string, ids: string[]) => void;
- // Triage: set the same rating (0-5) on many items in one history step.
- rateItems: (boardId: string, ids: string[], rating: number) => void;
-
- // Sections — organize items into named lanes within a board.
- createSection: (boardId: string, name?: string) => string;
- renameSection: (boardId: string, sectionId: string, name: string) => void;
- deleteSection: (boardId: string, sectionId: string) => void;
- toggleSectionCollapsed: (boardId: string, sectionId: string) => void;
- // Move items into a section (or back to the ungrouped flow with null).
- assignItemsToSection: (
- boardId: string,
- ids: string[],
- sectionId: string | null,
- ) => void;
-
- // View + filters
- setViewMode: (mode: ViewMode) => void;
- setDensity: (density: GridDensity) => void;
- setSearchQuery: (q: string) => void;
- toggleTagFilter: (tag: string) => void;
- setRatingFilter: (min: number) => void;
- clearFilters: () => void;
-
- // Selection (operates on the active board)
- setSelection: (ids: Iterable) => void;
- // Clicks accumulate; `additive` is accepted for caller compatibility but no
- // longer changes behavior (a plain click already toggles within the set).
- toggleSelection: (id: string, additive?: boolean) => void;
- clearSelection: () => void;
- deleteSelected: () => void;
-}
-
-const DEFAULT_BOARD_NAME = "Untitled board";
-const DEFAULT_SECTION_NAME = "New section";
-
-// Monotonic counter for stable createdAt-like ordering. Date.now() is avoided
-// so behaviour stays deterministic and testable; ordering is all we need.
-let seq = 0;
-const nextSeq = () => ++seq;
-
-const mediaAspect = (naturalW: number, naturalH: number): number => {
- if (naturalW > 0 && naturalH > 0) return naturalH / naturalW;
- return 1;
-};
-
-export const useBoardLibraryStore = create()(
- persist(
- (set, get) => ({
- boards: {},
- boardOrder: [],
- activeBoardId: null,
- viewMode: "grid",
- density: "comfortable",
-
- searchQuery: "",
- activeTagFilters: [],
- ratingFilter: 0,
- selectedItemIds: new Set(),
-
- ensureActiveBoard: () => {
- const { activeBoardId, boards } = get();
- if (activeBoardId && boards[activeBoardId]) return activeBoardId;
- const firstId = get().boardOrder[0];
- if (firstId && boards[firstId]) {
- set({ activeBoardId: firstId });
- return firstId;
- }
- return get().createBoard();
- },
-
- createBoard: (name) => {
- const id = uuidv4();
- const order = nextSeq();
- const board: Board = {
- id,
- name: name?.trim() || DEFAULT_BOARD_NAME,
- createdAt: order,
- updatedAt: order,
- itemOrder: [],
- items: {},
- sections: [],
- };
- set((s) => ({
- boards: { ...s.boards, [id]: board },
- boardOrder: [...s.boardOrder, id],
- activeBoardId: id,
- }));
- return id;
- },
-
- renameBoard: (id, name) => {
- set((s) => {
- const board = s.boards[id];
- if (!board) return s;
- return {
- boards: {
- ...s.boards,
- [id]: { ...board, name: name.trim() || DEFAULT_BOARD_NAME },
- },
- };
- });
- },
-
- deleteBoard: (id) => {
- set((s) => {
- if (!s.boards[id]) return s;
- Object.values(s.boards[id].items).forEach(revokeBlobSrc);
- const boards = { ...s.boards };
- delete boards[id];
- const boardOrder = s.boardOrder.filter((b) => b !== id);
- const activeBoardId =
- s.activeBoardId === id ? boardOrder[0] ?? null : s.activeBoardId;
- return { boards, boardOrder, activeBoardId };
- });
- },
-
- setActiveBoard: (id) => {
- if (!get().boards[id]) return;
- set({ activeBoardId: id, selectedItemIds: new Set() });
- },
-
- setBoardRemoteToken: (id, token) => {
- set((s) => {
- const board = s.boards[id];
- if (!board || board.remoteToken === token) return s;
- return {
- boards: { ...s.boards, [id]: { ...board, remoteToken: token } },
- };
- });
- },
-
- markBoardNeedsSync: (id) => {
- set((s) => {
- const board = s.boards[id];
- if (!board || board.needsSync === true) return s;
- return {
- boards: { ...s.boards, [id]: { ...board, needsSync: true } },
- };
- });
- },
-
- markBoardSynced: (id, { ownerId }) => {
- set((s) => {
- const board = s.boards[id];
- if (!board) return s;
- if (board.needsSync !== true && board.ownerId === ownerId) return s;
- return {
- boards: {
- ...s.boards,
- [id]: { ...board, needsSync: false, ownerId },
- },
- };
- });
- },
-
- upsertRemoteBoard: ({
- token,
- name,
- ownerId,
- itemOrder,
- items,
- sections,
- }) => {
- const existing = Object.values(get().boards).find(
- (b) => b.remoteToken === token,
- );
- if (existing) {
- set((s) => ({
- boards: {
- ...s.boards,
- [existing.id]: {
- ...existing,
- name,
- itemOrder,
- items,
- sections,
- ownerId,
- needsSync: false,
- updatedAt: nextSeq(),
- },
- },
- }));
- return existing.id;
- }
-
- const id = uuidv4();
- const order = nextSeq();
- const board: Board = {
- id,
- name,
- createdAt: order,
- updatedAt: order,
- itemOrder,
- items,
- sections,
- remoteToken: token,
- ownerId,
- needsSync: false,
- };
- set((s) => ({
- boards: { ...s.boards, [id]: board },
- boardOrder: [...s.boardOrder, id],
- }));
- return id;
- },
-
- addImageItem: (boardId, data) =>
- addItemToBoard(set, get, boardId, (base) => ({
- ...base,
- kind: "image",
- src: data.src,
- mediaId: data.mediaId,
- naturalW: data.naturalW,
- naturalH: data.naturalH,
- caption: data.caption ?? "",
- aspect: mediaAspect(data.naturalW, data.naturalH),
- })),
-
- addVideoItem: (boardId, data) =>
- addItemToBoard(set, get, boardId, (base) => ({
- ...base,
- kind: "video",
- src: data.src,
- mediaId: data.mediaId,
- naturalW: data.naturalW,
- naturalH: data.naturalH,
- aspect: mediaAspect(data.naturalW, data.naturalH),
- })),
-
- addTextItem: (boardId, text) =>
- addItemToBoard(set, get, boardId, (base) => ({
- ...base,
- kind: "text",
- text,
- aspect: TEXT_ITEM_ASPECT,
- })),
-
- addLinkItem: (boardId, data) =>
- addItemToBoard(set, get, boardId, (base) => ({
- ...base,
- kind: "link",
- url: data.url,
- title: data.title,
- description: data.description,
- image: data.image,
- aspect: data.image ? LINK_ITEM_ASPECT : TEXT_ITEM_ASPECT,
- })),
-
- addColorItem: (boardId, color) =>
- addItemToBoard(set, get, boardId, (base) => ({
- ...base,
- kind: "color",
- color,
- aspect: COLOR_ITEM_ASPECT,
- })),
-
- updateItem: (boardId, id, patch) => {
- set((s) => {
- const board = s.boards[boardId];
- if (!board || !board.items[id]) return s;
- const merged = { ...board.items[id], ...patch } as BoardItem;
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- items: { ...board.items, [id]: merged },
- updatedAt: nextSeq(),
- },
- },
- };
- });
- },
-
- removeItems: (boardId, ids) => {
- if (ids.length === 0) return;
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- const remove = new Set(ids);
- const items = { ...board.items };
- remove.forEach((id) => {
- revokeBlobSrc(items[id]);
- delete items[id];
- });
- const selectedItemIds = new Set(s.selectedItemIds);
- remove.forEach((id) => selectedItemIds.delete(id));
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- items,
- itemOrder: board.itemOrder.filter((x) => !remove.has(x)),
- updatedAt: nextSeq(),
- },
- },
- selectedItemIds,
- };
- });
- },
-
- rateItems: (boardId, ids, rating) => {
- if (ids.length === 0) return;
- const clamped = Math.max(0, Math.min(5, Math.round(rating)));
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- const items = { ...board.items };
- ids.forEach((id) => {
- const it = items[id];
- if (it) items[id] = { ...it, rating: clamped };
- });
- return {
- boards: {
- ...s.boards,
- [boardId]: { ...board, items, updatedAt: nextSeq() },
- },
- };
- });
- },
-
- createSection: (boardId, name) => {
- const id = uuidv4();
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- const section = { id, name: name?.trim() || DEFAULT_SECTION_NAME };
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- sections: [...board.sections, section],
- updatedAt: nextSeq(),
- },
- },
- };
- });
- return id;
- },
-
- renameSection: (boardId, sectionId, name) => {
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- sections: board.sections.map((sec) =>
- sec.id === sectionId
- ? { ...sec, name: name.trim() || DEFAULT_SECTION_NAME }
- : sec,
- ),
- },
- },
- };
- });
- },
-
- deleteSection: (boardId, sectionId) => {
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- // Items in the section fall back to the ungrouped flow.
- const items = { ...board.items };
- for (const [id, it] of Object.entries(items)) {
- if (it.sectionId === sectionId) items[id] = { ...it, sectionId: null };
- }
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- items,
- sections: board.sections.filter((sec) => sec.id !== sectionId),
- updatedAt: nextSeq(),
- },
- },
- };
- });
- },
-
- toggleSectionCollapsed: (boardId, sectionId) => {
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- sections: board.sections.map((sec) =>
- sec.id === sectionId
- ? { ...sec, collapsed: !sec.collapsed }
- : sec,
- ),
- },
- },
- };
- });
- },
-
- assignItemsToSection: (boardId, ids, sectionId) => {
- if (ids.length === 0) return;
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- const target = new Set(ids);
- const items = { ...board.items };
- target.forEach((id) => {
- const it = items[id];
- if (it) items[id] = { ...it, sectionId };
- });
- return {
- boards: {
- ...s.boards,
- [boardId]: { ...board, items, updatedAt: nextSeq() },
- },
- };
- });
- },
-
- setViewMode: (mode) => set({ viewMode: mode }),
- setDensity: (density) => set({ density }),
- setSearchQuery: (q) => set({ searchQuery: q }),
- toggleTagFilter: (tag) =>
- set((s) => ({
- activeTagFilters: s.activeTagFilters.includes(tag)
- ? s.activeTagFilters.filter((t) => t !== tag)
- : [...s.activeTagFilters, tag],
- })),
- setRatingFilter: (min) => set({ ratingFilter: Math.max(0, Math.min(5, min)) }),
- clearFilters: () =>
- set({ searchQuery: "", activeTagFilters: [], ratingFilter: 0 }),
-
- setSelection: (ids) => set({ selectedItemIds: new Set(ids) }),
- // Every click accumulates: a plain click adds the item to the current
- // selection (or removes it if already selected), so clicking item after
- // item builds up a multi-selection without needing a modifier. The
- // `additive` flag (ctrl/cmd/shift-click) behaves identically here; the
- // distinction is only meaningful for marquee/range selection.
- toggleSelection: (id) =>
- set((s) => {
- const next = new Set(s.selectedItemIds);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return { selectedItemIds: next };
- }),
- clearSelection: () => set({ selectedItemIds: new Set() }),
- deleteSelected: () => {
- const { activeBoardId, selectedItemIds } = get();
- if (!activeBoardId || selectedItemIds.size === 0) return;
- get().removeItems(activeBoardId, Array.from(selectedItemIds));
- },
- }),
- {
- name: STORAGE_KEY,
- version: STORAGE_VERSION,
- storage: createJSONStorage(() => localStorage),
- // Only the library + view prefs persist; selection/search are per-session.
- // Blob-backed items are dropped — their object URLs die with the document,
- // so persisting them would only resurrect broken tiles after a reload.
- partialize: (s): PersistedLibrary => ({
- boards: stripEphemeralItems(s.boards),
- boardOrder: s.boardOrder,
- activeBoardId: s.activeBoardId,
- viewMode: s.viewMode,
- density: s.density,
- }),
- // Keep `seq` ahead of any restored ordering values so new items always
- // sort after persisted ones.
- onRehydrateStorage: () => (state) => {
- if (!state) return;
- let max = 0;
- Object.values(state.boards).forEach((b) => {
- max = Math.max(max, b.createdAt, b.updatedAt);
- Object.values(b.items).forEach((it) => {
- max = Math.max(max, it.createdAt);
- });
- });
- seq = Math.max(seq, max);
- },
- },
- ),
-);
-
-// ---------- helpers ----------
-
-// Returns an item's blob: object URL, or null if it isn't blob-backed. Narrows
-// to image/video internally so callers get a plain string back.
-const blobSrc = (item: BoardItem): string | null => {
- if (item.kind !== "image" && item.kind !== "video") return null;
- return item.src.startsWith("blob:") ? item.src : null;
-};
-
-// Release an item's object URL when it leaves the store, so dropped/uploaded
-// blobs don't pin their backing File for the life of the document.
-const revokeBlobSrc = (item: BoardItem | undefined): void => {
- const src = item ? blobSrc(item) : null;
- if (!src) return;
- try {
- URL.revokeObjectURL(src);
- } catch {
- // ignore
- }
-};
-
-// Persistence view of the boards with blob-backed (ephemeral) items removed and
-// their order entries pruned. Non-blob image/video (library picks) are kept.
-const stripEphemeralItems = (
- boards: Record,
-): Record => {
- const out: Record = {};
- for (const [boardId, board] of Object.entries(boards)) {
- const items: Record = {};
- for (const [itemId, item] of Object.entries(board.items)) {
- if (!blobSrc(item)) items[itemId] = item;
- }
- out[boardId] = {
- ...board,
- items,
- itemOrder: board.itemOrder.filter((id) => items[id]),
- };
- }
- return out;
-};
-
-// Shared item-insertion path used by every typed creator. The `build` callback
-// receives the filled base fields and returns the full discriminated item.
-const addItemToBoard = (
- set: (fn: (s: BoardLibraryState) => Partial) => void,
- get: () => BoardLibraryState,
- boardId: string,
- build: (base: {
- id: string;
- sectionId: null;
- createdAt: number;
- tags: string[];
- rating: number;
- }) => BoardItem,
-): string => {
- const id = uuidv4();
- const item = build({
- id,
- sectionId: null,
- createdAt: nextSeq(),
- tags: [],
- rating: 0,
- });
- set((s) => {
- const board = s.boards[boardId];
- if (!board) return s;
- return {
- boards: {
- ...s.boards,
- [boardId]: {
- ...board,
- items: { ...board.items, [id]: item },
- itemOrder: [...board.itemOrder, id],
- updatedAt: nextSeq(),
- },
- },
- };
- });
- return id;
-};
diff --git a/frontend/libs/components/moodboard/src/lib/boards/boardSelectors.ts b/frontend/libs/components/moodboard/src/lib/boards/boardSelectors.ts
deleted file mode 100644
index bce59464..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/boardSelectors.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useBoardLibraryStore } from "./BoardLibraryStore";
-import { Board, BoardItem } from "./boardTypes";
-
-export const useActiveBoard = (): Board | null =>
- useBoardLibraryStore((s) =>
- s.activeBoardId ? s.boards[s.activeBoardId] ?? null : null,
- );
-
-// Items in display order, narrowed by search query, tag filters, and a minimum
-// rating. Pure so the grid can memoize on (board, query, tagFilters, minRating).
-export const filterItems = (
- board: Board,
- query: string,
- tagFilters: string[],
- minRating = 0,
-): BoardItem[] => {
- const q = query.trim().toLowerCase();
- return board.itemOrder
- .map((id) => board.items[id])
- .filter((it): it is BoardItem => Boolean(it))
- .filter((it) => {
- if (minRating > 0 && it.rating < minRating) return false;
- if (tagFilters.length && !tagFilters.every((t) => it.tags.includes(t))) {
- return false;
- }
- return q ? itemMatchesText(it, q) : true;
- });
-};
-
-// All distinct tags across a board, for the filter chips.
-export const collectTags = (board: Board): string[] => {
- const seen = new Set();
- board.itemOrder.forEach((id) => {
- board.items[id]?.tags.forEach((t) => seen.add(t));
- });
- return Array.from(seen).sort();
-};
-
-const itemMatchesText = (it: BoardItem, q: string): boolean => {
- if (it.tags.some((t) => t.toLowerCase().includes(q))) return true;
- switch (it.kind) {
- case "text":
- return it.text.toLowerCase().includes(q);
- case "link":
- return `${it.title} ${it.description} ${it.url}`.toLowerCase().includes(q);
- case "image":
- return it.caption.toLowerCase().includes(q);
- case "color":
- return it.color.toLowerCase().includes(q);
- default:
- return false;
- }
-};
diff --git a/frontend/libs/components/moodboard/src/lib/boards/boardTypes.ts b/frontend/libs/components/moodboard/src/lib/boards/boardTypes.ts
deleted file mode 100644
index 84bb7f89..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/boardTypes.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-// The board model is the source of truth for the moodboard feature. Both the
-// Grid view (virtualized masonry) and the Canvas view render from it. Items
-// carry a deterministic `aspect` so the masonry positioner can lay out and
-// virtualize without measuring the DOM.
-
-export type BoardItemKind = "image" | "video" | "text" | "link" | "color";
-
-export type ViewMode = "grid" | "canvas";
-
-// Grid density maps to a target column width + gap (see gridLayout.ts). Named
-// rather than numeric so it reads well in the UI and persists stably.
-export type GridDensity = "comfortable" | "cozy" | "compact";
-
-interface BoardItemBase {
- id: string;
- kind: BoardItemKind;
- // null = ungrouped (lives in the board's default flow).
- sectionId: string | null;
- createdAt: number;
- tags: string[];
- // height / width ratio used by the masonry positioner.
- aspect: number;
- // 0 = unrated; 1-5 reserved for Lightroom-style triage (Phase 3).
- rating: number;
-}
-
-export interface BoardImageItem extends BoardItemBase {
- kind: "image";
- src: string;
- mediaId: string | null;
- naturalW: number;
- naturalH: number;
- caption: string;
-}
-
-export interface BoardVideoItem extends BoardItemBase {
- kind: "video";
- src: string;
- mediaId: string | null;
- naturalW: number;
- naturalH: number;
-}
-
-export interface BoardTextItem extends BoardItemBase {
- kind: "text";
- text: string;
-}
-
-export interface BoardLinkItem extends BoardItemBase {
- kind: "link";
- url: string;
- title: string;
- description: string;
- image: string | null;
-}
-
-export interface BoardColorItem extends BoardItemBase {
- kind: "color";
- color: string;
-}
-
-export type BoardItem =
- | BoardImageItem
- | BoardVideoItem
- | BoardTextItem
- | BoardLinkItem
- | BoardColorItem;
-
-export interface BoardSection {
- id: string;
- name: string;
- // Header-collapsed in the grid. Persists so a tidied board stays tidy.
- collapsed?: boolean;
-}
-
-export interface Board {
- id: string;
- name: string;
- createdAt: number;
- updatedAt: number;
- // Display / z order for items in the grid.
- itemOrder: string[];
- items: Record;
- sections: BoardSection[];
- // Server-side project token (media_file_token of the mood_board document),
- // set by the first successful remote save. Absent/null for local-only
- // boards and for library payloads persisted before remote sync existed.
- remoteToken?: string | null;
- // User id of the account this board syncs to, stamped at hydration/first
- // push. localStorage is machine-shared, so the sync layer refuses to push
- // a board owned by a different account (cross-account write protection).
- // Absent/null = unowned local board, adoptable by whoever pushes it first.
- ownerId?: string | null;
- // Durable dirty flag: true when local edits haven't reached the server
- // yet. Persisted so a closed tab resumes its pending push next session,
- // and so hydration knows local content is newer than the server copy.
- needsSync?: boolean;
-}
-
-// Nominal aspect ratios for non-media items, so masonry stays deterministic.
-export const TEXT_ITEM_ASPECT = 0.62;
-export const LINK_ITEM_ASPECT = 0.92;
-export const COLOR_ITEM_ASPECT = 1;
diff --git a/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts b/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts
deleted file mode 100644
index 68624528..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/linkMeta.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// Client-side link metadata — no proxy, no network beyond the favicon .
-
-// Bare display host: strips the scheme and a leading "www.".
-export const hostnameOf = (url: string): string => {
- try {
- return new URL(url).hostname.replace(/^www\./, "");
- } catch {
- return url;
- }
-};
-
-// The site's own favicon, served from its origin (no third-party proxy). Falls
-// back to a link glyph at the render site when the errors.
-export const faviconOf = (url: string): string | null => {
- try {
- const u = new URL(url);
- return `${u.protocol}//${u.hostname}/favicon.ico`;
- } catch {
- return null;
- }
-};
diff --git a/frontend/libs/components/moodboard/src/lib/boards/measureMedia.ts b/frontend/libs/components/moodboard/src/lib/boards/measureMedia.ts
deleted file mode 100644
index 417c0f15..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/measureMedia.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// Probe intrinsic dimensions so masonry can lay an item out before it paints.
-// Both resolve (never reject) with a square fallback so a failed probe still
-// yields a usable tile.
-
-export const measureImage = (
- url: string,
-): Promise<{ w: number; h: number }> =>
- new Promise((resolve) => {
- const img = new window.Image();
- // No crossOrigin: we only read naturalWidth/Height (which never taint a
- // canvas), and forcing anonymous CORS makes non-CORS images fail to load —
- // collapsing them to the 320x320 fallback. palette.ts sets crossOrigin
- // itself, where getImageData actually needs it.
- img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
- img.onerror = () => resolve({ w: 320, h: 320 });
- img.src = url;
- });
-
-export const measureVideo = (
- url: string,
-): Promise<{ w: number; h: number }> =>
- new Promise((resolve) => {
- const v = document.createElement("video");
- v.preload = "metadata";
- v.muted = true;
- v.onloadedmetadata = () =>
- resolve({ w: v.videoWidth || 320, h: v.videoHeight || 320 });
- v.onerror = () => resolve({ w: 320, h: 320 });
- v.src = url;
- });
diff --git a/frontend/libs/components/moodboard/src/lib/boards/palette.ts b/frontend/libs/components/moodboard/src/lib/boards/palette.ts
deleted file mode 100644
index ab3ae646..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/palette.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-// Client-side color extraction. Downscales the image to a small sample and
-// buckets colors (4 bits/channel) to find a representative palette + dominant
-// color, with near-duplicate suppression. Returns [] on a cross-origin tainted
-// canvas (getImageData throws) — callers degrade gracefully.
-
-const SAMPLE = 40;
-const MIN_DISTANCE = 48;
-
-interface Rgb {
- r: number;
- g: number;
- b: number;
-}
-
-export const extractPalette = (url: string, count = 5): Promise =>
- new Promise((resolve) => {
- const img = new window.Image();
- img.crossOrigin = "anonymous";
- img.onload = () => {
- try {
- resolve(quantize(img, count));
- } catch {
- resolve([]);
- }
- };
- img.onerror = () => resolve([]);
- img.src = url;
- });
-
-const quantize = (img: HTMLImageElement, count: number): string[] => {
- const canvas = document.createElement("canvas");
- canvas.width = SAMPLE;
- canvas.height = SAMPLE;
- const ctx = canvas.getContext("2d");
- if (!ctx) return [];
- ctx.drawImage(img, 0, 0, SAMPLE, SAMPLE);
- const { data } = ctx.getImageData(0, 0, SAMPLE, SAMPLE);
-
- const buckets = new Map();
- for (let i = 0; i < data.length; i += 4) {
- if (data[i + 3] < 128) continue;
- const r = data[i];
- const g = data[i + 1];
- const b = data[i + 2];
- const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
- const e = buckets.get(key);
- if (e) {
- e.r += r;
- e.g += g;
- e.b += b;
- e.n += 1;
- } else {
- buckets.set(key, { r, g, b, n: 1 });
- }
- }
-
- const sorted = Array.from(buckets.values()).sort((a, b) => b.n - a.n);
- const chosen: Rgb[] = [];
- for (const e of sorted) {
- const c: Rgb = {
- r: Math.round(e.r / e.n),
- g: Math.round(e.g / e.n),
- b: Math.round(e.b / e.n),
- };
- if (chosen.every((o) => distance(o, c) > MIN_DISTANCE)) chosen.push(c);
- if (chosen.length >= count) break;
- }
- return chosen.map((c) => toHex(c));
-};
-
-const distance = (a: Rgb, b: Rgb): number =>
- Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
-
-const toHex = ({ r, g, b }: Rgb): string => {
- const h = (r << 16) | (g << 8) | b;
- return `#${h.toString(16).padStart(6, "0").toUpperCase()}`;
-};
diff --git a/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx b/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx
deleted file mode 100644
index fa37ee5c..00000000
--- a/frontend/libs/components/moodboard/src/lib/boards/useBoardItemEntry.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
-import toast from "react-hot-toast";
-import { useBoardLibraryStore } from "./BoardLibraryStore";
-import { BoardImageItem } from "./boardTypes";
-import { measureImage, measureVideo } from "./measureMedia";
-import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
-
-interface UseReturn {
- triggerUpload: () => void;
- triggerGallery: () => void;
- /** Adds an empty note and returns its id so the grid can open it for editing. */
- addNote: () => string;
- /** Add a local image file: places it from a blob URL, then uploads to
- * capture a durable mediaToken. Also used by the grid's OS file drop. */
- addImageFile: (file: File) => Promise;
- modals: ReactNode;
-}
-
-// Generic add-flows: upload, library pick (via adapter render-prop), clipboard
-// paste (image + URL), note. The platform-specific bits — how a file is
-// uploaded and what the library picker is — come from the adapter. (Color entry
-// lives in the toolbar's ColorPickerPopover, which commits via addColorItem.)
-export const useBoardItemEntry = (
- adapter: MoodboardAdapter,
- enabled: boolean,
-): UseReturn => {
- const fileInputRef = useRef(null);
- const [pickerOpen, setPickerOpen] = useState(false);
-
- const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
- const triggerGallery = useCallback(() => {
- if (adapter.renderLibraryPicker) setPickerOpen(true);
- else
- toast("Library picker isn't available here — upload or paste for now", {
- icon: "🖼️",
- });
- }, [adapter]);
-
- const addNote = useCallback(() => {
- const store = useBoardLibraryStore.getState();
- // Empty text → the card shows its "Note" placeholder and the grid drops
- // straight into edit mode so the user types instead of clearing "New note".
- const id = store.addTextItem(store.ensureActiveBoard(), "");
- store.setSelection([id]);
- return id;
- }, []);
-
- // Place the image immediately from a blob; the upload that captures a
- // durable mediaToken runs in the background with a write-back, so a
- // multi-file add/drop places every tile at once instead of gating each
- // placement behind the previous file's upload roundtrip.
- const addImageFile = useCallback(
- async (file: File) => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.ensureActiveBoard();
- const blobUrl = URL.createObjectURL(file);
- const dims = await measureImage(blobUrl);
- const itemId = store.addImageItem(boardId, {
- src: blobUrl,
- mediaId: null,
- naturalW: dims.w,
- naturalH: dims.h,
- });
- if (!adapter.uploadImage) return;
- adapter.uploadImage(file).then(
- (token) => {
- if (!token) return;
- const current = useBoardLibraryStore.getState();
- // The item may have been deleted while the upload was in flight.
- if (!current.boards[boardId]?.items[itemId]) return;
- const patch: Partial = { mediaId: token };
- current.updateItem(boardId, itemId, patch);
- },
- (err) => {
- console.error("[Moodboard] upload failed", err);
- },
- );
- },
- [adapter],
- );
-
- const handleFiles = useCallback(
- async (files: FileList) => {
- for (const file of Array.from(files)) {
- if (file.type.startsWith("image/")) await addImageFile(file);
- }
- },
- [addImageFile],
- );
-
- const handleFileChange = (e: React.ChangeEvent) => {
- const files = e.target.files;
- if (files && files.length) void handleFiles(files);
- if (fileInputRef.current) fileInputRef.current.value = "";
- };
-
- const handlePicked = useCallback(async (items: MoodboardPickedItem[]) => {
- const store = useBoardLibraryStore.getState();
- const boardId = store.ensureActiveBoard();
- for (const it of items) {
- if (!it.url) continue;
- if (it.kind === "video") {
- const dims = await measureVideo(it.url);
- store.addVideoItem(boardId, {
- src: it.url,
- mediaId: it.mediaToken,
- naturalW: dims.w,
- naturalH: dims.h,
- });
- } else {
- const dims = await measureImage(it.url);
- store.addImageItem(boardId, {
- src: it.url,
- mediaId: it.mediaToken,
- naturalW: dims.w,
- naturalH: dims.h,
- });
- }
- }
- setPickerOpen(false);
- }, []);
-
- // ----- clipboard paste (images + URLs) -----
- useEffect(() => {
- if (!enabled) return undefined;
- const onPaste = (e: ClipboardEvent) => {
- const target = e.target as HTMLElement | null;
- if (target && /input|textarea/i.test(target.tagName)) return;
- const data = e.clipboardData;
- if (!data) return;
-
- const imageFile = Array.from(data.items)
- .find((it) => it.type.startsWith("image/"))
- ?.getAsFile();
- if (imageFile) {
- e.preventDefault();
- void addImageFile(imageFile);
- return;
- }
-
- const text = data.getData("text/plain").trim();
- if (isUrl(text)) {
- e.preventDefault();
- const store = useBoardLibraryStore.getState();
- const boardId = store.ensureActiveBoard();
- store.addLinkItem(boardId, {
- url: text,
- title: hostnameOf(text),
- description: "",
- image: null,
- });
- }
- };
- window.addEventListener("paste", onPaste);
- return () => window.removeEventListener("paste", onPaste);
- }, [enabled, addImageFile]);
-
- const modals = (
- <>
-
- {adapter.renderLibraryPicker?.({
- open: pickerOpen,
- onClose: () => setPickerOpen(false),
- onPick: (items) => void handlePicked(items),
- })}
- >
- );
-
- return { triggerUpload, triggerGallery, addNote, addImageFile, modals };
-};
-
-// ---------- helpers ----------
-
-const isUrl = (text: string): boolean => {
- if (!/^https?:\/\//i.test(text)) return false;
- try {
- new URL(text);
- return true;
- } catch {
- return false;
- }
-};
-
-const hostnameOf = (url: string): string => {
- try {
- return new URL(url).hostname.replace(/^www\./, "");
- } catch {
- return url;
- }
-};
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/FloatingToolbar.tsx b/frontend/libs/components/moodboard/src/lib/canvas/FloatingToolbar.tsx
deleted file mode 100644
index 0b2158c8..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/FloatingToolbar.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { ReactNode } from "react";
-import {
- ToolbarShell,
- ToolbarIconButton,
- ToolbarDivider,
-} from "../ui/Toolbar";
-
-interface FloatingToolbarProps {
- children: ReactNode;
- className?: string;
-}
-
-// Canvas top bar — the same glass island as the Grid toolbar (ToolbarShell),
-// centered and detached from the top edge.
-export const FloatingToolbar = ({
- children,
- className,
-}: FloatingToolbarProps) => (
-
- {children}
-
-);
-
-// Re-exported under the canvas names so existing callers stay unchanged while
-// sharing one button/divider look with the Grid toolbar.
-export const FloatingToolbarDivider = ToolbarDivider;
-export const FloatingToolbarButton = ToolbarIconButton;
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/Moodboard.tsx b/frontend/libs/components/moodboard/src/lib/canvas/Moodboard.tsx
deleted file mode 100644
index d3a67b78..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/Moodboard.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useEffect, useRef } from "react";
-import Konva from "konva";
-import { MoodboardToolbar } from "./MoodboardToolbar";
-import { MoodboardStage } from "./MoodboardStage";
-import { TextEditOverlay } from "./TextEditOverlay";
-import { useMoodboardStore } from "./MoodboardStore";
-import { useUndoRedo } from "./interactions/useUndoRedo";
-import { usePasteHandler } from "./interactions/usePasteHandler";
-import { useGalleryDropEvent } from "./interactions/useGalleryDropEvent";
-import { useKeyboardShortcuts } from "./interactions/useKeyboardShortcuts";
-import { useShortcutCheatsheet } from "./interactions/useShortcutCheatsheet";
-import { useMoodboardImageEntry } from "./useMoodboardImageEntry";
-import { RecenterIndicator } from "./overlays/RecenterIndicator";
-import { ShortcutCheatsheet } from "./overlays/ShortcutCheatsheet";
-import type { MoodboardAdapter } from "../adapter";
-// import { EmptyMoodboardCTA } from "./EmptyMoodboardCTA";
-
-interface Props {
- adapter: MoodboardAdapter;
-}
-
-export const Moodboard = ({ adapter }: Props) => {
- const containerRef = useRef(null);
- const stageRef = useRef(null);
- const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
- const setSelection = useMoodboardStore((s) => s.setSelection);
- const editingTextId = useMoodboardStore((s) => s.transient.editingTextId);
- // const isEmpty = useMoodboardStore((s) => s.rootOrder.length === 0);
-
- useUndoRedo(true);
- usePasteHandler(adapter, true, stageRef);
- useGalleryDropEvent(true, stageRef);
- useKeyboardShortcuts(true);
- const cheatsheetVisible = useShortcutCheatsheet();
- const { triggerUpload, triggerGallery, modals } = useMoodboardImageEntry(
- adapter,
- stageRef,
- );
-
- // Delete / Backspace removes the current selection. Skip when typing in
- // an input or while a text node is in edit mode.
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- const target = e.target as HTMLElement | null;
- if (target && /input|textarea/i.test(target.tagName)) return;
- if (target && target.isContentEditable) return;
- if (editingTextId) return;
- if (e.key === "Delete" || e.key === "Backspace") {
- e.preventDefault();
- deleteSelected();
- } else if (e.key === "Escape") {
- setSelection([]);
- }
- };
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
- }, [deleteSelected, setSelection, editingTextId]);
-
- return (
-
-
-
-
-
- {/* {isEmpty && (
-
- )} */}
-
-
-
- {modals}
-
- );
-};
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardBackground.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardBackground.tsx
deleted file mode 100644
index d1dbd47f..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardBackground.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { memo } from "react";
-import { Shape } from "react-konva";
-import Konva from "konva";
-import { Rect } from "./types";
-
-interface Props {
- // Visible region in stage (document) coordinates. Driven by pan/zoom.
- viewport: Rect;
- spacing: number;
- zoom: number;
- dotColor?: string;
- // On-screen radius in CSS pixels. Converted to stage units so dots stay
- // the same visual size regardless of zoom.
- dotRadiusPx?: number;
-}
-
-// Minimum on-screen gap between dots before we drop to a coarser grid.
-// At zoom 1× with spacing 32, dots are 32px apart; as you zoom out, the
-// LOD step doubles `spacing` so density never drops below this bound.
-const MIN_SCREEN_SPACING = 16;
-
-// Vector dotted grid that follows the visible viewport. Dots stay anchored
-// at fixed stage coords (multiples of the effective spacing); zooming out
-// coarsens the grid in powers of 2 so we never draw tens of thousands of
-// arcs per frame.
-const MoodboardBackgroundInner = ({
- viewport,
- spacing,
- zoom,
- dotColor = "rgba(255,255,255,0.18)",
- dotRadiusPx = 1.2,
-}: Props) => {
- const baseOnScreen = spacing * zoom;
- const lodStep =
- baseOnScreen >= MIN_SCREEN_SPACING
- ? 0
- : Math.ceil(Math.log2(MIN_SCREEN_SPACING / Math.max(baseOnScreen, 1e-6)));
- const effectiveSpacing = spacing * Math.pow(2, lodStep);
- const radius = dotRadiusPx / Math.max(zoom, 1e-6);
-
- return (
- {
- const startX = Math.floor(viewport.x / effectiveSpacing) * effectiveSpacing;
- const startY = Math.floor(viewport.y / effectiveSpacing) * effectiveSpacing;
- const endX = viewport.x + viewport.width;
- const endY = viewport.y + viewport.height;
- ctx.fillStyle = dotColor;
- ctx.beginPath();
- for (let x = startX; x <= endX; x += effectiveSpacing) {
- for (let y = startY; y <= endY; y += effectiveSpacing) {
- ctx.moveTo(x + radius, y);
- ctx.arc(x, y, radius, 0, Math.PI * 2);
- }
- }
- ctx.fill();
- ctx.fillStrokeShape(shape);
- }}
- />
- );
-};
-
-export const MoodboardBackground = memo(MoodboardBackgroundInner);
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardHistoryStore.ts b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardHistoryStore.ts
deleted file mode 100644
index 043f2d20..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardHistoryStore.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { create } from "zustand";
-import { MoodboardSnapshot } from "./types";
-
-const MAX_HISTORY = 50;
-
-interface MoodboardHistoryState {
- past: MoodboardSnapshot[];
- future: MoodboardSnapshot[];
- push: (snapshot: MoodboardSnapshot) => void;
- undo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
- redo: (current: MoodboardSnapshot) => MoodboardSnapshot | null;
- clear: () => void;
-}
-
-export const useMoodboardHistoryStore = create(
- (set, get) => ({
- past: [],
- future: [],
- push: (snapshot) => {
- const past = get().past;
- const next = [...past, snapshot];
- if (next.length > MAX_HISTORY) next.shift();
- set({ past: next, future: [] });
- },
- undo: (current) => {
- const { past, future } = get();
- if (past.length === 0) return null;
- const prior = past[past.length - 1];
- set({
- past: past.slice(0, -1),
- future: [...future, current],
- });
- return prior;
- },
- redo: (current) => {
- const { past, future } = get();
- if (future.length === 0) return null;
- const next = future[future.length - 1];
- set({
- past: [...past, current],
- future: future.slice(0, -1),
- });
- return next;
- },
- clear: () => set({ past: [], future: [] }),
- }),
-);
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx
deleted file mode 100644
index 1c9fa380..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStage.tsx
+++ /dev/null
@@ -1,301 +0,0 @@
-import { useCallback, useEffect, useMemo } from "react";
-import Konva from "konva";
-import { Stage, Layer, Rect, Line, Group, Transformer } from "react-konva";
-import { useThemeFgRgb } from "./useThemeFgRgb";
-import { useShallow } from "zustand/react/shallow";
-import { useMoodboardStore } from "./MoodboardStore";
-import { MoodboardBackground } from "./MoodboardBackground";
-import { ImageNode } from "./nodes/ImageNode";
-import { VideoNode } from "./nodes/VideoNode";
-import { TextNode } from "./nodes/TextNode";
-import { GroupNode } from "./nodes/GroupNode";
-import { useSelection } from "./interactions/useSelection";
-import { useTransformer } from "./interactions/useTransformer";
-import { useViewportControls } from "./interactions/useViewportControls";
-import { stagePointerPos } from "./interactions/useStagePointer";
-
-interface Props {
- containerRef: React.RefObject;
- stageRef: React.MutableRefObject;
-}
-
-export const MoodboardStage = ({ containerRef, stageRef }: Props) => {
- const transformerRef = useTransformer(stageRef);
-
- const nodes = useMoodboardStore((s) => s.nodes);
- const rootOrder = useMoodboardStore((s) => s.rootOrder);
- const selectedIds = useMoodboardStore((s) => s.selectedIds);
- const tool = useMoodboardStore((s) => s.tool);
- const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
- // Only subscribe to the transient fields this component actually renders
- // (marquee + lasso). Skips re-renders on `editingTextId` and `isPanning`
- // toggles, which don't affect Stage JSX.
- const { marquee, lassoPath, guides } = useMoodboardStore(
- useShallow((s) => ({
- marquee: s.transient.marquee,
- lassoPath: s.transient.lassoPath,
- guides: s.transient.guides,
- })),
- );
- const viewport = useMoodboardStore((s) => s.viewport);
- const canvasSize = useMoodboardStore((s) => s.canvasSize);
- const setLastDropPoint = useMoodboardStore((s) => s.setLastDropPoint);
- const setCanvasSize = useMoodboardStore((s) => s.setCanvasSize);
- const toggleInSelection = useMoodboardStore((s) => s.toggleInSelection);
- const setEditingText = useMoodboardStore((s) => s.setEditingText);
- const addText = useMoodboardStore((s) => s.addText);
-
- const zoom = viewport.zoom;
- const pan = viewport.pan;
- const fgRgb = useThemeFgRgb();
-
- // Track wrapper size with ResizeObserver so the Stage fills its container.
- // The size lives in the store so overlays (recenter indicator, future
- // minimap) can read it without drilling refs through the tree.
- useEffect(() => {
- const el = containerRef.current;
- if (!el) return undefined;
- const updateSize = () => {
- setCanvasSize({ width: el.clientWidth, height: el.clientHeight });
- };
- updateSize();
- const ro = new ResizeObserver(updateSize);
- ro.observe(el);
- return () => ro.disconnect();
- }, [containerRef, setCanvasSize]);
-
- useSelection(stageRef);
- useViewportControls(stageRef);
-
- // Update lastDropPoint as the cursor moves across the stage so external
- // drops (uploads, paste fallbacks) land near the cursor.
- useEffect(() => {
- const stage = stageRef.current;
- if (!stage) return undefined;
- const handle = () => {
- const pos = stagePointerPos(stage);
- if (pos) setLastDropPoint(pos);
- };
- stage.on("mousemove.moodboardCursor", handle);
- return () => {
- stage.off("mousemove.moodboardCursor");
- };
- }, [setLastDropPoint, stageRef]);
-
- // Stable across renders so memoized node children see an unchanged
- // onSelect prop and skip re-render when only camera/selection state
- // changes. Depends only on a Zustand action, which itself is stable.
- const handleNodeSelect = useCallback(
- (id: string, e: Konva.KonvaEventObject) => {
- const evt = e.evt as MouseEvent | TouchEvent;
- const additive = "shiftKey" in evt && evt.shiftKey;
- toggleInSelection(id, additive);
- },
- [toggleInSelection],
- );
-
- const handleStageClick = (e: Konva.KonvaEventObject) => {
- if (e.target !== stageRef.current) return;
- if (tool === "text") {
- const pos = stagePointerPos(stageRef.current);
- if (pos) addText(pos);
- return;
- }
- // Don't setSelection([]) here: Konva fires `click` after any marquee
- // drag that ends on bare canvas, which would stomp on the selection
- // `useSelection` just committed. The zero-area branch in `useSelection`
- // handles the "bare click with no drag" case.
- setEditingText(null);
- };
-
- // Visible region in stage coords; drives the dotted background grid so
- // it follows pan/zoom rather than rendering a fixed (0,0,w,h) block.
- // Memoized so transient store updates (marquee, lasso, isPanning) don't
- // churn its identity and force `MoodboardBackground` to re-run sceneFunc
- // every frame of a marquee drag.
- const visibleViewport = useMemo(
- () => ({
- x: -pan.x / zoom,
- y: -pan.y / zoom,
- width: canvasSize.width / zoom,
- height: canvasSize.height / zoom,
- }),
- [pan.x, pan.y, zoom, canvasSize.width, canvasSize.height],
- );
-
- return (
- {
- stageRef.current = s;
- }}
- width={canvasSize.width}
- height={canvasSize.height}
- x={pan.x}
- y={pan.y}
- scaleX={zoom}
- scaleY={zoom}
- onClick={handleStageClick}
- onTap={handleStageClick}
- style={{ cursor: tool === "text" ? "text" : "default" }}
- >
-
-
-
-
- {rootOrder.map((id) => {
- const n = nodes[id];
- if (!n) return null;
- const selected = selectedIds.has(id);
- const draggable = tool === "select";
- if (n.kind === "image") {
- return (
-
- );
- }
- if (n.kind === "video") {
- return (
-
- );
- }
- if (n.kind === "text") {
- return (
-
- );
- }
- if (n.kind === "group") {
- return (
-
- );
- }
- return null;
- })}
- {
- transformerRef.current = t;
- }}
- rotateEnabled
- keepRatio={false}
- />
-
-
- {Array.from(selectedIds).map((id) => {
- const n = nodes[id];
- if (!n || n.parentId !== null) return null;
- if (n.kind === "group") {
- // Union AABB of children in group-local coords, wrapped in a
- // Konva Group so the outline rotates around the group's origin.
- let minX = Infinity;
- let minY = Infinity;
- let maxX = -Infinity;
- let maxY = -Infinity;
- for (const cid of n.childIds) {
- const c = nodes[cid];
- if (!c) continue;
- minX = Math.min(minX, c.x);
- minY = Math.min(minY, c.y);
- maxX = Math.max(maxX, c.x + c.width);
- maxY = Math.max(maxY, c.y + c.height);
- }
- if (!Number.isFinite(minX)) return null;
- return (
-
-
-
- );
- }
- return (
-
- );
- })}
- {marquee && (
-
- )}
- {lassoPath && lassoPath.length >= 2 && (
- [p.x, p.y])}
- stroke="#3b82f6"
- strokeWidth={1 / zoom}
- dash={[4 / zoom, 4 / zoom]}
- closed={false}
- />
- )}
- {guides.map((g, i) => (
-
- ))}
-
-
- );
-};
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts
deleted file mode 100644
index 6b3e010e..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardStore.ts
+++ /dev/null
@@ -1,706 +0,0 @@
-import { create } from "zustand";
-import { v4 as uuidv4 } from "uuid";
-import {
- CardNode,
- GroupNode,
- ImageNode,
- MoodboardNode,
- MoodboardSnapshot,
- Rect,
- TextNode,
- Tool,
- Vec2,
- VideoNode,
-} from "./types";
-import { useMoodboardHistoryStore } from "./MoodboardHistoryStore";
-import {
- DEFAULT_CARD_COLOR,
- DEFAULT_CARD_PADDING,
- DEFAULT_CARD_WIDTH,
- computeCardHeight,
-} from "./cardLayout";
-import {
- CanvasSize,
- Viewport,
- computeAABBById,
- computeContentBounds,
- fitViewportToBounds,
-} from "./layout/geometry";
-import { SnapGuide } from "./layout/snapping";
-
-// Hover state published during a node drag so overlay UI can highlight the card
-// (or to-be card) the dragged node would land in. `targetId` is null when the
-// pointer is over bare canvas.
-export interface DragPreview {
- draggedId: string;
- targetId: string | null;
-}
-
-interface Transient {
- marquee: Rect | null;
- lassoPath: Vec2[] | null;
- editingTextId: string | null;
- isPanning: boolean;
- dragPreview: DragPreview | null;
- // Alignment guides shown live during a snapped drag.
- guides: SnapGuide[];
-}
-
-interface MoodboardState {
- nodes: Record;
- rootOrder: string[];
- selectedIds: Set;
- tool: Tool;
- gridSpacing: number;
- // Snap a dragged node to nearby node edges/centers + the grid.
- snapEnabled: boolean;
- lastDropPoint: Vec2;
- viewport: Viewport;
- canvasSize: CanvasSize;
- transient: Transient;
-
- setTool: (t: Tool) => void;
- toggleSnap: () => void;
- setGuides: (guides: SnapGuide[]) => void;
- setLastDropPoint: (p: Vec2) => void;
- setZoom: (z: number) => void;
- setPan: (p: Vec2) => void;
- setCanvasSize: (s: CanvasSize) => void;
- resetViewport: () => void;
- fitToContent: (padding?: number) => void;
- selectAll: () => void;
-
- addImage: (
- src: string,
- pos: Vec2,
- naturalW: number,
- naturalH: number,
- mediaId?: string | null,
- ) => string;
- addVideo: (
- src: string,
- pos: Vec2,
- naturalW: number,
- naturalH: number,
- mediaId?: string | null,
- ) => string;
- addText: (pos: Vec2) => string;
- updateNode: (id: string, patch: Partial) => void;
- setCardColor: (id: string, color: string) => void;
- deleteSelected: () => void;
- applyLayoutPatches: (
- patches: Array<{ id: string; patch: Partial }>,
- ) => void;
-
- setSelection: (ids: Iterable) => void;
- toggleInSelection: (id: string, additive: boolean) => void;
- clearSelection: () => void;
-
- group: () => string | null;
- ungroup: () => void;
- // Atomic multi-cluster grouping for "auto-group by proximity": creates one
- // group per provided cluster of ids, with a single history entry covering
- // all of them. Returns the new group ids.
- groupClusters: (clusters: string[][]) => string[];
-
- // Card stacking (drag-onto-node gesture). `createCardFromDrop` forms a new
- // card from a bare target node plus the dragged node; `appendToCard` adds the
- // dragged node into an existing card. Both remove the dragged node from the
- // root and reflow the card's height.
- createCardFromDrop: (draggedId: string, targetId: string) => string | null;
- appendToCard: (cardId: string, draggedId: string) => void;
-
- setMarquee: (r: Rect | null) => void;
- setLassoPath: (p: Vec2[] | null) => void;
- setEditingText: (id: string | null) => void;
- setIsPanning: (v: boolean) => void;
- setDragPreview: (p: DragPreview | null) => void;
-
- // Snapshot helpers used by history wiring (do NOT push to history themselves).
- snapshot: () => MoodboardSnapshot;
- applySnapshot: (snap: MoodboardSnapshot) => void;
- pushHistory: () => void;
- undo: () => void;
- redo: () => void;
-
- reset: () => void;
-}
-
-const DEFAULT_TEXT_W = 200;
-const DEFAULT_TEXT_H = 60;
-const DEFAULT_FONT_SIZE = 24;
-const DEFAULT_TEXT_COLOR = "#FFFFFF";
-const MAX_IMAGE_DIMENSION = 480;
-
-const fitWithinBox = (w: number, h: number, max: number): { w: number; h: number } => {
- if (w <= max && h <= max) return { w, h };
- const ratio = Math.min(max / w, max / h);
- return { w: w * ratio, h: h * ratio };
-};
-
-export const useMoodboardStore = create((set, get) => ({
- nodes: {},
- rootOrder: [],
- selectedIds: new Set(),
- tool: "select",
- gridSpacing: 32,
- snapEnabled: true,
- lastDropPoint: { x: 200, y: 200 },
- viewport: { zoom: 1, pan: { x: 0, y: 0 } },
- canvasSize: { width: 800, height: 600 },
- transient: {
- marquee: null,
- lassoPath: null,
- editingTextId: null,
- isPanning: false,
- dragPreview: null,
- guides: [],
- },
-
- setTool: (t) => set({ tool: t }),
- toggleSnap: () => set((s) => ({ snapEnabled: !s.snapEnabled })),
- setGuides: (guides) =>
- set((s) => ({ transient: { ...s.transient, guides } })),
- setLastDropPoint: (p) => set({ lastDropPoint: p }),
- setZoom: (z) => set((s) => ({ viewport: { ...s.viewport, zoom: z } })),
- setPan: (p) => set((s) => ({ viewport: { ...s.viewport, pan: p } })),
- setCanvasSize: (sz) => set({ canvasSize: sz }),
- resetViewport: () =>
- set({ viewport: { zoom: 1, pan: { x: 0, y: 0 } } }),
- fitToContent: (padding = 48) => {
- const { nodes, rootOrder, canvasSize } = get();
- const bounds = computeContentBounds(nodes, rootOrder);
- if (!bounds) return;
- set({ viewport: fitViewportToBounds(bounds, canvasSize, padding) });
- },
- selectAll: () => {
- const { rootOrder } = get();
- if (rootOrder.length === 0) return;
- set({ selectedIds: new Set(rootOrder) });
- },
-
- addImage: (src, pos, naturalW, naturalH, mediaId = null) => {
- get().pushHistory();
- const id = uuidv4();
- const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
- const node: ImageNode = {
- id,
- kind: "image",
- parentId: null,
- x: pos.x - fit.w / 2,
- y: pos.y - fit.h / 2,
- width: fit.w,
- height: fit.h,
- rotation: 0,
- zIndex: get().rootOrder.length,
- src,
- mediaId,
- naturalW,
- naturalH,
- };
- set((s) => ({
- nodes: { ...s.nodes, [id]: node },
- rootOrder: [...s.rootOrder, id],
- selectedIds: new Set([id]),
- }));
- return id;
- },
-
- addVideo: (src, pos, naturalW, naturalH, mediaId = null) => {
- get().pushHistory();
- const id = uuidv4();
- const fit = fitWithinBox(naturalW || 320, naturalH || 320, MAX_IMAGE_DIMENSION);
- const node: VideoNode = {
- id,
- kind: "video",
- parentId: null,
- x: pos.x - fit.w / 2,
- y: pos.y - fit.h / 2,
- width: fit.w,
- height: fit.h,
- rotation: 0,
- zIndex: get().rootOrder.length,
- src,
- mediaId,
- naturalW,
- naturalH,
- // Silent looping autoplay is the Milanote-style default — browsers
- // only permit autoplay when muted, so these three travel together.
- muted: true,
- loop: true,
- autoplay: true,
- };
- set((s) => ({
- nodes: { ...s.nodes, [id]: node },
- rootOrder: [...s.rootOrder, id],
- selectedIds: new Set([id]),
- }));
- return id;
- },
-
- addText: (pos) => {
- get().pushHistory();
- const id = uuidv4();
- const node: TextNode = {
- id,
- kind: "text",
- parentId: null,
- x: pos.x - DEFAULT_TEXT_W / 2,
- y: pos.y - DEFAULT_TEXT_H / 2,
- width: DEFAULT_TEXT_W,
- height: DEFAULT_TEXT_H,
- rotation: 0,
- zIndex: get().rootOrder.length,
- text: "Note",
- fontSize: DEFAULT_FONT_SIZE,
- color: DEFAULT_TEXT_COLOR,
- };
- set((s) => ({
- nodes: { ...s.nodes, [id]: node },
- rootOrder: [...s.rootOrder, id],
- selectedIds: new Set([id]),
- transient: { ...s.transient, editingTextId: id },
- }));
- return id;
- },
-
- updateNode: (id, patch) => {
- set((s) => {
- const existing = s.nodes[id];
- if (!existing) return s;
- // Keep discriminant intact by spreading then overlaying with patch fields.
- const merged = { ...existing, ...patch } as MoodboardNode;
- return { nodes: { ...s.nodes, [id]: merged } };
- });
- },
-
- setCardColor: (id, color) => {
- // Live edit (called continuously while dragging the color picker), so it
- // deliberately skips history — the undo stack would otherwise flood with a
- // frame per pointermove.
- set((s) => {
- const n = s.nodes[id];
- if (!n || n.kind !== "card") return s;
- return { nodes: { ...s.nodes, [id]: { ...n, backgroundColor: color } } };
- });
- },
-
- deleteSelected: () => {
- const { selectedIds } = get();
- if (selectedIds.size === 0) return;
- get().pushHistory();
- set((s) => {
- const removed = new Set();
- const collect = (id: string) => {
- const n = s.nodes[id];
- if (!n) return;
- removed.add(id);
- if (n.kind === "group" || n.kind === "card") n.childIds.forEach(collect);
- };
- selectedIds.forEach(collect);
-
- const nodes: Record = {};
- Object.entries(s.nodes).forEach(([id, n]) => {
- if (removed.has(id)) return;
- // Filter dangling child references in container nodes.
- if (n.kind === "group" || n.kind === "card") {
- nodes[id] = {
- ...n,
- childIds: n.childIds.filter((c) => !removed.has(c)),
- };
- } else {
- nodes[id] = n;
- }
- });
-
- return {
- nodes,
- rootOrder: s.rootOrder.filter((id) => !removed.has(id)),
- selectedIds: new Set(),
- };
- });
- },
-
- applyLayoutPatches: (patches) => {
- if (patches.length === 0) return;
- get().pushHistory();
- set((s) => {
- const nodes = { ...s.nodes };
- patches.forEach(({ id, patch }) => {
- const existing = nodes[id];
- if (!existing) return;
- nodes[id] = { ...existing, ...patch } as MoodboardNode;
- });
- return { nodes };
- });
- },
-
- setSelection: (ids) => {
- set({ selectedIds: stripAncestorsAndDescendants(get().nodes, new Set(ids)) });
- },
-
- toggleInSelection: (id, additive) => {
- set((s) => {
- const next = additive ? new Set(s.selectedIds) : new Set();
- if (additive && s.selectedIds.has(id)) {
- next.delete(id);
- } else {
- next.add(id);
- }
- return { selectedIds: stripAncestorsAndDescendants(s.nodes, next) };
- });
- },
-
- clearSelection: () => set({ selectedIds: new Set() }),
-
- group: () => {
- const { selectedIds, nodes } = get();
- const topLevelIds = Array.from(selectedIds).filter((id) => {
- const n = nodes[id];
- return n && n.parentId === null;
- });
- if (topLevelIds.length < 2) return null;
-
- get().pushHistory();
- const groupId = uuidv4();
-
- const aabb = computeAABBById(nodes, topLevelIds);
- if (!aabb) return null;
-
- const groupNode: GroupNode = {
- id: groupId,
- kind: "group",
- parentId: null,
- x: aabb.x,
- y: aabb.y,
- width: aabb.width,
- height: aabb.height,
- rotation: 0,
- zIndex: get().rootOrder.length,
- childIds: topLevelIds,
- };
-
- set((s) => {
- const newNodes: Record = { ...s.nodes };
- // Rebase each child position to be group-local.
- topLevelIds.forEach((id) => {
- const n = newNodes[id];
- if (!n) return;
- newNodes[id] = {
- ...n,
- parentId: groupId,
- x: n.x - aabb.x,
- y: n.y - aabb.y,
- } as MoodboardNode;
- });
- newNodes[groupId] = groupNode;
- return {
- nodes: newNodes,
- rootOrder: [
- ...s.rootOrder.filter((id) => !topLevelIds.includes(id)),
- groupId,
- ],
- selectedIds: new Set([groupId]),
- };
- });
- return groupId;
- },
-
- groupClusters: (clusters) => {
- // Single-shot atomic version of group() that creates one group per
- // cluster of ids, with one history entry covering all of them. Used by
- // "Auto-group by proximity" so undo collapses to a single step.
- const validClusters = clusters.filter((cluster) => cluster.length >= 2);
- if (validClusters.length === 0) return [];
- get().pushHistory();
- const newGroupIds: string[] = [];
- set((s) => {
- const newNodes: Record = { ...s.nodes };
- let newRootOrder = [...s.rootOrder];
- for (const cluster of validClusters) {
- const topLevelIds = cluster.filter((id) => {
- const n = newNodes[id];
- return n && n.parentId === null;
- });
- if (topLevelIds.length < 2) continue;
- const aabb = computeAABBById(newNodes, topLevelIds);
- if (!aabb) continue;
- const groupId = uuidv4();
- const groupNode: GroupNode = {
- id: groupId,
- kind: "group",
- parentId: null,
- x: aabb.x,
- y: aabb.y,
- width: aabb.width,
- height: aabb.height,
- rotation: 0,
- zIndex: newRootOrder.length,
- childIds: topLevelIds,
- };
- topLevelIds.forEach((id) => {
- const n = newNodes[id];
- if (!n) return;
- newNodes[id] = {
- ...n,
- parentId: groupId,
- x: n.x - aabb.x,
- y: n.y - aabb.y,
- } as MoodboardNode;
- });
- newNodes[groupId] = groupNode;
- newRootOrder = [
- ...newRootOrder.filter((id) => !topLevelIds.includes(id)),
- groupId,
- ];
- newGroupIds.push(groupId);
- }
- return {
- nodes: newNodes,
- rootOrder: newRootOrder,
- selectedIds: new Set(newGroupIds),
- };
- });
- return newGroupIds;
- },
-
- createCardFromDrop: (draggedId, targetId) => {
- const { nodes } = get();
- const dragged = nodes[draggedId];
- const target = nodes[targetId];
- if (!dragged || !target || draggedId === targetId) return null;
- // If the target is already a card, this is really an append.
- if (target.kind === "card") {
- get().appendToCard(targetId, draggedId);
- return targetId;
- }
- // Only top-level bare nodes can seed a new card.
- if (target.parentId !== null || dragged.parentId !== null) return null;
-
- get().pushHistory();
- const cardId = uuidv4();
- // Target was there first, so it sits on top; the dragged node stacks below.
- const childIds = [targetId, draggedId];
-
- set((s) => {
- const newNodes: Record = { ...s.nodes };
- childIds.forEach((cid) => {
- const c = newNodes[cid];
- if (!c) return;
- // Children are reparented; their x/y are ignored by the card's flex
- // layout, so we leave them as-is to make ungrouping cheap later.
- newNodes[cid] = { ...c, parentId: cardId } as MoodboardNode;
- });
- const base: CardNode = {
- id: cardId,
- kind: "card",
- parentId: null,
- x: target.x,
- y: target.y,
- width: DEFAULT_CARD_WIDTH,
- height: 0,
- rotation: 0,
- zIndex: s.rootOrder.length,
- childIds,
- padding: DEFAULT_CARD_PADDING,
- backgroundColor: DEFAULT_CARD_COLOR,
- };
- newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
- return {
- nodes: newNodes,
- rootOrder: [
- ...s.rootOrder.filter((id) => id !== targetId && id !== draggedId),
- cardId,
- ],
- selectedIds: new Set([cardId]),
- };
- });
- return cardId;
- },
-
- appendToCard: (cardId, draggedId) => {
- const { nodes } = get();
- const card = nodes[cardId];
- const dragged = nodes[draggedId];
- if (!card || card.kind !== "card" || !dragged) return;
- if (draggedId === cardId || card.childIds.includes(draggedId)) return;
-
- get().pushHistory();
- set((s) => {
- const current = s.nodes[cardId];
- const child = s.nodes[draggedId];
- if (!current || current.kind !== "card" || !child) return s;
- const newNodes: Record = { ...s.nodes };
- newNodes[draggedId] = { ...child, parentId: cardId } as MoodboardNode;
- const base: CardNode = {
- ...current,
- childIds: [...current.childIds, draggedId],
- };
- newNodes[cardId] = { ...base, height: computeCardHeight(base, newNodes) };
- return {
- nodes: newNodes,
- rootOrder: s.rootOrder.filter((id) => id !== draggedId),
- selectedIds: new Set([cardId]),
- };
- });
- },
-
- ungroup: () => {
- const { selectedIds, nodes } = get();
- const groupIds = Array.from(selectedIds).filter(
- (id) => nodes[id]?.kind === "group",
- );
- if (groupIds.length === 0) return;
-
- get().pushHistory();
-
- set((s) => {
- const newNodes: Record = { ...s.nodes };
- const newRootOrder = [...s.rootOrder];
- const releasedChildIds: string[] = [];
-
- groupIds.forEach((gid) => {
- const g = newNodes[gid];
- if (!g || g.kind !== "group") return;
- // Rebase children back to stage-local using group's transform.
- g.childIds.forEach((cid) => {
- const child = newNodes[cid];
- if (!child) return;
- const local = { x: child.x, y: child.y };
- const stagePoint = applyGroupTransform(local, g);
- newNodes[cid] = {
- ...child,
- parentId: null,
- x: stagePoint.x,
- y: stagePoint.y,
- // Carry the group's rotation onto the child so the visual stays put.
- rotation: child.rotation + g.rotation,
- } as MoodboardNode;
- releasedChildIds.push(cid);
- });
- delete newNodes[gid];
- const idx = newRootOrder.indexOf(gid);
- if (idx >= 0) newRootOrder.splice(idx, 1);
- });
-
- releasedChildIds.forEach((cid) => newRootOrder.push(cid));
-
- return {
- nodes: newNodes,
- rootOrder: newRootOrder,
- selectedIds: new Set(releasedChildIds),
- };
- });
- },
-
- setMarquee: (r) =>
- set((s) => ({ transient: { ...s.transient, marquee: r } })),
- setLassoPath: (p) =>
- set((s) => ({ transient: { ...s.transient, lassoPath: p } })),
- setEditingText: (id) =>
- set((s) => ({ transient: { ...s.transient, editingTextId: id } })),
- setIsPanning: (v) =>
- set((s) => ({ transient: { ...s.transient, isPanning: v } })),
- setDragPreview: (p) =>
- set((s) => ({ transient: { ...s.transient, dragPreview: p } })),
-
- snapshot: () => {
- const { nodes, rootOrder, selectedIds } = get();
- return {
- nodes: structuredClone(nodes),
- rootOrder: [...rootOrder],
- selectedIds: Array.from(selectedIds),
- };
- },
-
- applySnapshot: (snap) => {
- set({
- nodes: structuredClone(snap.nodes),
- rootOrder: [...snap.rootOrder],
- selectedIds: new Set(snap.selectedIds),
- });
- },
-
- pushHistory: () => {
- useMoodboardHistoryStore.getState().push(get().snapshot());
- },
-
- undo: () => {
- const current = get().snapshot();
- const prior = useMoodboardHistoryStore.getState().undo(current);
- if (prior) get().applySnapshot(prior);
- },
-
- redo: () => {
- const current = get().snapshot();
- const next = useMoodboardHistoryStore.getState().redo(current);
- if (next) get().applySnapshot(next);
- },
-
- reset: () => {
- useMoodboardHistoryStore.getState().clear();
- set({
- nodes: {},
- rootOrder: [],
- selectedIds: new Set(),
- tool: "select",
- lastDropPoint: { x: 200, y: 200 },
- viewport: { zoom: 1, pan: { x: 0, y: 0 } },
- transient: {
- marquee: null,
- lassoPath: null,
- editingTextId: null,
- isPanning: false,
- dragPreview: null,
- guides: [],
- },
- });
- },
-}));
-
-// ---------- helpers ----------
-
-const stripAncestorsAndDescendants = (
- nodes: Record,
- ids: Set,
-): Set => {
- const result = new Set(ids);
- // Drop any node whose ancestor (parent chain up to root) is in the set.
- ids.forEach((id) => {
- let cur = nodes[id];
- while (cur && cur.parentId) {
- if (result.has(cur.parentId)) {
- result.delete(id);
- break;
- }
- cur = nodes[cur.parentId];
- }
- });
- // Drop any descendant whose ancestor container (group or card) is in the set.
- ids.forEach((id) => {
- const n = nodes[id];
- if (n && (n.kind === "group" || n.kind === "card")) {
- const stack = [...n.childIds];
- while (stack.length) {
- const c = stack.pop()!;
- if (result.has(c)) result.delete(c);
- const cn = nodes[c];
- if (cn && (cn.kind === "group" || cn.kind === "card")) {
- stack.push(...cn.childIds);
- }
- }
- }
- });
- return result;
-};
-
-// Maps a group-local point to stage-local coords using the group's transform.
-const applyGroupTransform = (local: Vec2, g: GroupNode): Vec2 => {
- const rad = (g.rotation * Math.PI) / 180;
- const cos = Math.cos(rad);
- const sin = Math.sin(rad);
- return {
- x: g.x + local.x * cos - local.y * sin,
- y: g.y + local.x * sin + local.y * cos,
- };
-};
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx b/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx
deleted file mode 100644
index 97b0f3c6..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/MoodboardToolbar.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import {
- faMousePointer,
- faDrawPolygon,
- faObjectGroup,
- faObjectUngroup,
- faGripVertical,
- faTableCells,
- faDiagramProject,
- faRotateLeft,
- faRotateRight,
- faTrash,
- faPlus,
- faImages,
- faArrowUpFromBracket,
- faText,
- faMagnet,
-} from "@fortawesome/pro-solid-svg-icons";
-import { ButtonIconSelect } from "@storyteller/ui-button-icon-select";
-import { PopoverMenu } from "@storyteller/ui-popover";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { Tooltip } from "@storyteller/ui-tooltip";
-import {
- FloatingToolbar,
- FloatingToolbarButton,
- FloatingToolbarDivider,
-} from "./FloatingToolbar";
-import { useMoodboardStore } from "./MoodboardStore";
-import { Tool } from "./types";
-import { MOD, fmtShortcut } from "./overlays/shortcuts";
-import { computeFitToGridPatches } from "./layout/fitToGrid";
-import { computeAABB } from "./layout/geometry";
-import { computePackPatches } from "./layout/packCollage";
-import { clusterByProximity } from "./layout/clusterProximity";
-
-const MODES: Array<{
- value: Tool;
- icon: typeof faMousePointer;
- tooltip: string;
-}> = [
- { value: "select", icon: faMousePointer, tooltip: "Select (V)" },
- { value: "lasso", icon: faDrawPolygon, tooltip: "Lasso (L)" },
- { value: "text", icon: faText, tooltip: "Text (T)" },
-];
-
-interface Props {
- onUploadClick: () => void;
- onGalleryClick: () => void;
-}
-
-export const MoodboardToolbar = ({ onUploadClick, onGalleryClick }: Props) => {
- const tool = useMoodboardStore((s) => s.tool);
- const setTool = useMoodboardStore((s) => s.setTool);
- const group = useMoodboardStore((s) => s.group);
- const groupClusters = useMoodboardStore((s) => s.groupClusters);
- const ungroup = useMoodboardStore((s) => s.ungroup);
- const deleteSelected = useMoodboardStore((s) => s.deleteSelected);
- const undo = useMoodboardStore((s) => s.undo);
- const redo = useMoodboardStore((s) => s.redo);
- const applyLayoutPatches = useMoodboardStore((s) => s.applyLayoutPatches);
- const selectedIds = useMoodboardStore((s) => s.selectedIds);
- const nodes = useMoodboardStore((s) => s.nodes);
- const rootOrder = useMoodboardStore((s) => s.rootOrder);
- const gridSpacing = useMoodboardStore((s) => s.gridSpacing);
- const snapEnabled = useMoodboardStore((s) => s.snapEnabled);
- const toggleSnap = useMoodboardStore((s) => s.toggleSnap);
-
- const handleFitToGrid = () => {
- if (selectedIds.size === 0) return;
- const targetNodes = Array.from(selectedIds)
- .map((id) => nodes[id])
- .filter(Boolean);
- applyLayoutPatches(computeFitToGridPatches(targetNodes, gridSpacing));
- };
-
- const handlePackCollage = () => {
- const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
- const targetNodes = ids
- .map((id) => nodes[id])
- .filter((n) => n && n.parentId === null);
- if (targetNodes.length < 2) return;
- const aabb = computeAABB(targetNodes);
- if (!aabb) return;
- applyLayoutPatches(computePackPatches(targetNodes, aabb));
- };
-
- const handleAutoGroup = () => {
- const ids = selectedIds.size > 0 ? Array.from(selectedIds) : rootOrder;
- const targetNodes = ids
- .map((id) => nodes[id])
- .filter((n) => n && n.parentId === null);
- if (targetNodes.length < 2) return;
- const clusters = clusterByProximity(targetNodes);
- const clusterIds = clusters.map((c) => c.map((n) => n.id));
- groupClusters(clusterIds);
- };
-
- const handleAddAction = (action: string) => {
- if (action === "upload") onUploadClick();
- else if (action === "library") onGalleryClick();
- };
-
- return (
-
-
-
- ),
- action: "upload",
- },
- {
- label: "Pick from Library",
- selected: false,
- icon: ,
- action: "library",
- },
- ]}
- onPanelAction={handleAddAction}
- showIconsInList
- buttonClassName="h-9 w-9 rounded-[10px] border-transparent bg-primary/90 text-lg text-white hover:bg-primary/70"
- triggerIcon={ }
- />
-
-
-
-
- setTool(value as Tool)}
- selectedOption={tool}
- />
-
-
-
- group()}
- disabled={selectedIds.size < 2}
- tooltipDelay={100}
- />
- ungroup()}
- disabled={selectedIds.size === 0}
- tooltipDelay={100}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/frontend/libs/components/moodboard/src/lib/canvas/TextEditOverlay.tsx b/frontend/libs/components/moodboard/src/lib/canvas/TextEditOverlay.tsx
deleted file mode 100644
index 2aabbd16..00000000
--- a/frontend/libs/components/moodboard/src/lib/canvas/TextEditOverlay.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useEffect, useRef } from "react";
-import { useMoodboardStore } from "./MoodboardStore";
-
-interface Props {
- containerRef: React.RefObject;
-}
-
-// Floats a positioned