mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 01:12:40 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08c288754 | ||
|
|
e938871614 |
Generated
+56
-7
@@ -10,11 +10,13 @@
|
||||
"dependencies": {
|
||||
"@devnomic/marquee": "^1.0.3",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
|
||||
"@headlessui/react": "^2.1.3",
|
||||
"@preact/signals-react": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"fortawesome": "^0.0.1-security",
|
||||
"konva": "^9.3.14",
|
||||
"onnxruntime-web": "^1.19.2",
|
||||
"posthog-js": "^1.174.3",
|
||||
@@ -32,7 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@preact/signals-react-transform": "^0.4.0",
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
@@ -620,18 +622,50 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/pro-solid-svg-icons": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.6.0/pro-solid-svg-icons-6.6.0.tgz",
|
||||
"integrity": "sha512-IwYuyO+i681arIxOjPUoqRwoHeTqeg6i2s5rY2WbZ1R7PnwavOd1+aPHCJlfU2k8l5LZ+u3hAKlVqu+emQFlZg==",
|
||||
"dev": true,
|
||||
"node_modules/@fortawesome/pro-regular-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-regular-svg-icons/-/6.7.2/pro-regular-svg-icons-6.7.2.tgz",
|
||||
"integrity": "sha512-cJolK4K/s56mHAqOyno24SmjviHqSqG9ZfckBKCpWjJlgDexi61EfET5wWbmmEot6/BqasFjw9S6tudFQUL/PQ==",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.6.0"
|
||||
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/pro-regular-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
|
||||
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/pro-solid-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/pro-solid-svg-icons/-/6.7.2/pro-solid-svg-icons-6.7.2.tgz",
|
||||
"integrity": "sha512-SY2aP8d7uBlg1rNYznA9w9SVnnQhu3x/C7CCKQYcOGOC1YtPPHgIJhxKGbSzm6RkeJs1HVEfAkORX3J0XB5K9Q==",
|
||||
"dev": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/pro-solid-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/fontawesome-common-types/-/6.7.2/fontawesome-common-types-6.7.2.tgz",
|
||||
"integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://npm.fontawesome.com/@fortawesome/react-fontawesome/-/0.2.2/react-fontawesome-0.2.2.tgz",
|
||||
@@ -3227,6 +3261,11 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/fortawesome": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fortawesome/-/fortawesome-0.0.1-security.tgz",
|
||||
"integrity": "sha512-o/pwn9ZSnveFLYP0d5IejSwz0rpGMDQsBaYVvR8WXSvnxILIwh7IUJK0P90QMF4cnIyouVF2BGrz0LEVSyzimA=="
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -4544,6 +4583,8 @@
|
||||
},
|
||||
"node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
@@ -5284,6 +5325,8 @@
|
||||
},
|
||||
"node_modules/npm/node_modules/cross-spawn/node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
@@ -5332,6 +5375,8 @@
|
||||
},
|
||||
"node_modules/npm/node_modules/debug/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT"
|
||||
@@ -7158,6 +7203,8 @@
|
||||
},
|
||||
"node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
@@ -7195,6 +7242,8 @@
|
||||
},
|
||||
"node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"dependencies": {
|
||||
"@devnomic/marquee": "^1.0.3",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.6.0",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.7.2",
|
||||
"@headlessui/react": "^2.1.3",
|
||||
"@preact/signals-react": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@techstark/opencv-js": "^4.10.0-release.1",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"fortawesome": "^0.0.1-security",
|
||||
"konva": "^9.3.14",
|
||||
"onnxruntime-web": "^1.19.2",
|
||||
"posthog-js": "^1.174.3",
|
||||
@@ -35,7 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@preact/signals-react-transform": "^0.4.0",
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
|
||||
@@ -67,14 +67,14 @@ export class EditEngine {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
this.bgLayer = new Konva.Layer();
|
||||
//this.bgLayer = new Konva.Layer();
|
||||
|
||||
this.mediaLayer = new Konva.Layer();
|
||||
|
||||
this.nodeIsolationLayer = new Konva.Layer();
|
||||
this.uiLayer = new Konva.Layer();
|
||||
|
||||
this.stage.add(this.bgLayer);
|
||||
//this.stage.add(this.bgLayer);
|
||||
this.stage.add(this.mediaLayer);
|
||||
this.stage.add(this.nodeIsolationLayer);
|
||||
this.stage.add(this.uiLayer);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Konva from "konva";
|
||||
|
||||
import {uiAccess, uiEvents} from "~/signals";
|
||||
import {ImageNode, ShapeNode, ShapeType, TextNode, VideoNode} from "./Nodes";
|
||||
import {PaintNode} from "./Nodes/PaintNode";
|
||||
import {PreviewCopyNode} from "./Nodes/PreviewCopy";
|
||||
import {UndoStackManager} from "./UndoRedo";
|
||||
import {CommandManager, MatteBox, SceneManager} from "./EngineUtitlities";
|
||||
import { uiAccess, uiEvents } from "~/signals";
|
||||
import { ImageNode, ShapeNode, ShapeType, TextNode, VideoNode } from "./Nodes";
|
||||
import { PaintNode } from "./Nodes/PaintNode";
|
||||
import { PreviewCopyNode } from "./Nodes/PreviewCopy";
|
||||
import { UndoStackManager } from "./UndoRedo";
|
||||
import { CommandManager, MatteBox, SceneManager } from "./EngineUtitlities";
|
||||
import {
|
||||
NodeIsolator,
|
||||
NodesManager,
|
||||
@@ -16,22 +16,21 @@ import {
|
||||
SelectionManagerEvents,
|
||||
SelectorSquare,
|
||||
} from "./NodesManagers";
|
||||
import {EngineOptions, TextNodeData, VideoNodeData} from "./types";
|
||||
import { EngineOptions, TextNodeData, VideoNodeData } from "./types";
|
||||
|
||||
import {AppModes, VideoResolutions} from "./constants";
|
||||
import {ToolbarMainButtonNames} from "~/components/features/ToolbarMain/enum";
|
||||
import { AppModes, VideoResolutions } from "./constants";
|
||||
import { ToolbarMainButtonNames } from "~/components/features/ToolbarMain/enum";
|
||||
|
||||
import {ToolbarNodeButtonNames} from "~/components/features/ToolbarNode/enums";
|
||||
import {NavigateFunction} from "react-router-dom";
|
||||
import {LoadingVideosProvider} from "./EngineUtitlities/LoadingVideosProvider";
|
||||
|
||||
import {VideoExtractionHandler} from "./EngineUtitlities/VideoExtractionHandler/VideoExtractionHandler";
|
||||
import {RealTimeDrawEngine} from "./RenderingPrimitives/RealTimeDrawEngine";
|
||||
import {NodeColor} from "~/signals/uiEvents/toolbarNode";
|
||||
import {invoke} from "@tauri-apps/api/core";
|
||||
import {DecodeBase64ToImage} from "~/utilities/DecodeBase64ToImage.ts";
|
||||
import {EncodeImageBitmapToBase64} from "~/utilities/EncodeImageBitmapToBase64.ts";
|
||||
import { ToolbarNodeButtonNames } from "~/components/features/ToolbarNode/enums";
|
||||
import { NavigateFunction } from "react-router-dom";
|
||||
import { LoadingVideosProvider } from "./EngineUtitlities/LoadingVideosProvider";
|
||||
|
||||
import { VideoExtractionHandler } from "./EngineUtitlities/VideoExtractionHandler/VideoExtractionHandler";
|
||||
import { RealTimeDrawEngine } from "./RenderingPrimitives/RealTimeDrawEngine";
|
||||
import { NodeColor } from "~/signals/uiEvents/toolbarNode";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { DecodeBase64ToImage } from "~/utilities/DecodeBase64ToImage.ts";
|
||||
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64.ts";
|
||||
|
||||
export interface RenderingOptions {
|
||||
artstyle: string;
|
||||
@@ -315,7 +314,7 @@ export class Engine {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
uiEvents.toolbarNode.DELETE.onClick(() =>
|
||||
this.commandManager.deleteNodes(),
|
||||
);
|
||||
@@ -346,7 +345,7 @@ export class Engine {
|
||||
});
|
||||
|
||||
uiEvents.toolbarMain.GENERATE.onClick(async (/*event*/) => {
|
||||
console.log('generate clicked');
|
||||
console.log("generate clicked");
|
||||
await this.realTimeDrawEngine.generateImage();
|
||||
});
|
||||
|
||||
@@ -396,7 +395,6 @@ export class Engine {
|
||||
this.addText(textdata);
|
||||
});
|
||||
|
||||
|
||||
let renderTimeout: NodeJS.Timeout;
|
||||
|
||||
uiEvents.promptEvents.onPromptStrengthChanged(async (strength) => {
|
||||
@@ -429,8 +427,7 @@ export class Engine {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
uiEvents.toolbarMain.onBgColorChanged((data)=>{
|
||||
uiEvents.toolbarMain.onBgColorChanged((data) => {
|
||||
this.realTimeDrawEngine.updateBackground(data);
|
||||
});
|
||||
|
||||
@@ -462,29 +459,38 @@ export class Engine {
|
||||
return; // Can only remove background from a single image at a time.
|
||||
}
|
||||
|
||||
const [ selectedNode ] = selectedNodeSet;
|
||||
const [selectedNode] = selectedNodeSet;
|
||||
|
||||
const removeBgInvocation
|
||||
= async (selectedNode: ImageNode, callback: (existingImageNode: ImageNode, createdImage: ImageBitmap) => void) => {
|
||||
|
||||
const image : HTMLImageElement = selectedNode.kNode.image();
|
||||
const removeBgInvocation = async (
|
||||
selectedNode: ImageNode,
|
||||
callback: (
|
||||
existingImageNode: ImageNode,
|
||||
createdImage: ImageBitmap,
|
||||
) => void,
|
||||
) => {
|
||||
const image: HTMLImageElement = selectedNode.kNode.image();
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
const base64BitmapResponse = await invoke("remove_background", {
|
||||
image: base64Bitmap,
|
||||
});
|
||||
|
||||
const createdImage = await DecodeBase64ToImage(base64BitmapResponse as string);
|
||||
const createdImage = await DecodeBase64ToImage(
|
||||
base64BitmapResponse as string,
|
||||
);
|
||||
|
||||
callback(selectedNode, createdImage);
|
||||
};
|
||||
|
||||
const _promise = removeBgInvocation(selectedNode, (selectedNode: ImageNode, createdImage: ImageBitmap) => {
|
||||
if (!this.nodesManager.hasNode(selectedNode)) {
|
||||
return;
|
||||
}
|
||||
this.commandManager.deleteSpecificNodes([selectedNode]);
|
||||
this.addImageFromImageBitmap(createdImage);
|
||||
})
|
||||
const _promise = removeBgInvocation(
|
||||
selectedNode,
|
||||
(selectedNode: ImageNode, createdImage: ImageBitmap) => {
|
||||
if (!this.nodesManager.hasNode(selectedNode)) {
|
||||
return;
|
||||
}
|
||||
this.commandManager.deleteSpecificNodes([selectedNode]);
|
||||
this.addImageFromImageBitmap(createdImage);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
disableAllButtons() {
|
||||
@@ -618,7 +624,6 @@ export class Engine {
|
||||
}
|
||||
|
||||
public addShape(type: ShapeType, size: number, color?: string) {
|
||||
|
||||
const shapeNode = new ShapeNode({
|
||||
canvasPosition: this.realTimeDrawEngine.captureCanvas.position(),
|
||||
canvasSize: this.realTimeDrawEngine.captureCanvas.size(),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { MediaNode } from "../types";
|
||||
import { RenderTask } from "./RenderTask";
|
||||
import { OffScreenSceneCanvas } from "./OffScreenSceneCanvas";
|
||||
|
||||
|
||||
import { PaintNode } from "../Nodes/PaintNode";
|
||||
|
||||
import { WebSocketClient } from "../../PyServer/DrawServer";
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
} from "~/signals/uiEvents/loadingIndicator";
|
||||
import { ensureBase64Prefix } from "../EngineUtitlities/Base64Helpers";
|
||||
import { DecodeBase64ToImage } from "~/utilities/DecodeBase64ToImage.ts";
|
||||
import {EncodeImageBitmapToBase64} from "~/utilities/EncodeImageBitmapToBase64.ts";
|
||||
import { EncodeImageBitmapToBase64 } from "~/utilities/EncodeImageBitmapToBase64.ts";
|
||||
|
||||
interface ServerSettings {
|
||||
model_path: string;
|
||||
@@ -50,7 +49,6 @@ export class RealTimeDrawEngine {
|
||||
private outputBitmap: ImageBitmap | undefined;
|
||||
|
||||
// private frames: ImageBitmap[];
|
||||
|
||||
// capturing composite within window
|
||||
|
||||
private mediaLayerRef: Konva.Layer;
|
||||
@@ -67,8 +65,6 @@ export class RealTimeDrawEngine {
|
||||
public captureCanvas: Konva.Rect;
|
||||
public backgroundRasterRect: Konva.Image;
|
||||
|
||||
public previewCanvas: Konva.Image;
|
||||
|
||||
public videoLoadingCanvas: VideoNode | undefined;
|
||||
|
||||
public fps: number = 24;
|
||||
@@ -100,9 +96,7 @@ export class RealTimeDrawEngine {
|
||||
private client: WebSocketClient | null = null;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
|
||||
|
||||
public backgroundColor: string = "#d2d2d2";
|
||||
public backgroundColor: string = "#d2d2d2"; // off white
|
||||
constructor({
|
||||
width,
|
||||
height,
|
||||
@@ -131,17 +125,15 @@ export class RealTimeDrawEngine {
|
||||
this.imageNodes = [];
|
||||
this.onDrawCallback = onDraw;
|
||||
this.onPreviewCopyCallback = onPreviewCopy; // Assign Callback
|
||||
|
||||
// TODO: Make this dynamic and update this on change of canvas.
|
||||
this.width = width * 1.5;
|
||||
this.height = height * 1;
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.positionX = window.innerWidth - this.width;
|
||||
this.positionY = window.innerHeight - this.height;
|
||||
|
||||
this.positionX = window.innerWidth / 2 - this.width / 2 - this.width;
|
||||
this.positionY = window.innerHeight / 2 - this.height / 2;
|
||||
|
||||
this.positionPreviewX = window.innerWidth / 2 - this.width / 2 + this.width;
|
||||
this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
|
||||
// this.positionPreviewX = window.innerWidth / 2 - this.width / 2 + this.width;
|
||||
// this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
|
||||
|
||||
this.offScreenCanvas = offScreenCanvas;
|
||||
this.offScreenCanvas.width = this.width;
|
||||
@@ -165,6 +157,7 @@ export class RealTimeDrawEngine {
|
||||
|
||||
this.currentPrompt = "";
|
||||
this.currentStrength = 1;
|
||||
|
||||
// This is captures a subset of the medialayer ref
|
||||
this.captureCanvas = new Konva.Rect({
|
||||
name: "CaptureCanvas",
|
||||
@@ -172,49 +165,36 @@ export class RealTimeDrawEngine {
|
||||
y: this.positionY,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
fill: this.backgroundColor,
|
||||
stroke: "black",
|
||||
stroke: "blue",
|
||||
strokeWidth: 1,
|
||||
cornerRadius: 10,
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
this.previewCanvas = new Konva.Image({
|
||||
name: "PreviewCanvas",
|
||||
x: this.positionX,
|
||||
y: this.positionY,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
image: undefined,
|
||||
stroke: "black",
|
||||
strokeWidth: 1,
|
||||
draggable: false,
|
||||
fill: "white",
|
||||
});
|
||||
|
||||
this.offscreenRenderDiv = document.createElement("div");
|
||||
|
||||
this.backgroundRasterRect = new Konva.Image({
|
||||
name: "backgroundRasterRect",
|
||||
x: this.positionX,
|
||||
y: this.positionY,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
fill: this.backgroundColor,
|
||||
image: undefined,
|
||||
stroke: "black",
|
||||
strokeWidth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
// this.backgroundRasterRect = new Konva.Image({
|
||||
// name: "backgroundRasterRect",
|
||||
// x: this.positionX,
|
||||
// y: this.positionY,
|
||||
// width: this.width,
|
||||
// height: this.height,
|
||||
// fill: "red",
|
||||
// image: undefined,
|
||||
// stroke: "black",
|
||||
// strokeWidth: 1,
|
||||
// draggable: false,
|
||||
// });
|
||||
|
||||
this.mediaLayerRef.add(this.captureCanvas);
|
||||
this.mediaLayerRef.add(this.backgroundRasterRect);
|
||||
this.mediaLayerRef.add(this.previewCanvas);
|
||||
//this.mediaLayerRef.add(this.backgroundRasterRect);
|
||||
//this.mediaLayerRef.add(this.previewCanvas);
|
||||
// send back
|
||||
this.captureCanvas.setZIndex(0);
|
||||
this.backgroundRasterRect.setZIndex(0);
|
||||
this.previewCanvas.setZIndex(1);
|
||||
//this.backgroundRasterRect.setZIndex(0);
|
||||
//this.previewCanvas.setZIndex(1);
|
||||
// Add mouse events for preview canvas copying
|
||||
this.previewCopyListener();
|
||||
//this.previewCopyListener();
|
||||
|
||||
this.listenToServerEvents();
|
||||
}
|
||||
@@ -283,20 +263,6 @@ export class RealTimeDrawEngine {
|
||||
}
|
||||
});
|
||||
|
||||
this.client.onResult(async (response) => {
|
||||
try {
|
||||
// Convert the base64 image to ImageBitmap and update the preview
|
||||
const bitmap = await DecodeBase64ToImage(response.image);
|
||||
this.outputBitmap = bitmap;
|
||||
this.previewCanvas.image(bitmap);
|
||||
this.mediaLayerRef.batchDraw();
|
||||
} catch (error) {
|
||||
console.error("Error processing result image:", error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for connection to be established
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.client) return reject("Client not initialized");
|
||||
@@ -535,86 +501,6 @@ export class RealTimeDrawEngine {
|
||||
}
|
||||
}
|
||||
|
||||
public previewCopyListener() {
|
||||
// Start of Selection
|
||||
this.previewCanvas.on("mousedown touchstart", () => {
|
||||
if (!this.outputBitmap) {
|
||||
console.log("No preview image to copy");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create draggable preview copy
|
||||
const previewCopy = new Konva.Image({
|
||||
x: this.previewCanvas.x(),
|
||||
y: this.previewCanvas.y(),
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
image: this.outputBitmap,
|
||||
draggable: true,
|
||||
listening: true,
|
||||
});
|
||||
|
||||
if (this.onPreviewCopyCallback) {
|
||||
this.onPreviewCopyCallback(previewCopy);
|
||||
}
|
||||
previewCopy.startDrag();
|
||||
previewCopy.moveToTop();
|
||||
previewCopy.on("dragend", (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const previewBox = previewCopy.getClientRect();
|
||||
const captureBox = this.captureCanvas.getClientRect();
|
||||
|
||||
if (Konva.Util.haveIntersection(previewBox, captureBox)) {
|
||||
// Snap to capture canvas position
|
||||
previewCopy.position({
|
||||
x: this.captureCanvas.x(),
|
||||
y: this.captureCanvas.y(),
|
||||
});
|
||||
|
||||
e.target.off("dragend");
|
||||
} else {
|
||||
// Start Generation Here
|
||||
previewCopy.destroy();
|
||||
this.mediaLayerRef.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
// Start Generation Here
|
||||
const stage = this.mediaLayerRef.getStage();
|
||||
const layer = this.mediaLayerRef; // Use the existing media layer
|
||||
|
||||
// Create centered text
|
||||
// Start of Selection
|
||||
const centeredText = new Konva.Text({
|
||||
text: "Hold and Drag Over, To Copy",
|
||||
fontSize: 24,
|
||||
fontFamily: "Arial",
|
||||
fill: "black",
|
||||
x: this.captureCanvas.x() + this.captureCanvas.width() / 2,
|
||||
y: this.captureCanvas.y() + this.captureCanvas.height() / 2,
|
||||
id: "copyText",
|
||||
listening: false,
|
||||
});
|
||||
// Calculate and set offsets based on text size to center it
|
||||
centeredText.offsetX(centeredText.width() / 2);
|
||||
centeredText.offsetY(centeredText.height() / 2);
|
||||
|
||||
layer.add(centeredText);
|
||||
centeredText.moveToTop();
|
||||
layer.batchDraw();
|
||||
|
||||
// Remove text on mouseup/touchend
|
||||
const removeText = () => {
|
||||
const text = layer.findOne("#copyText");
|
||||
if (text) {
|
||||
text.destroy();
|
||||
layer.batchDraw();
|
||||
}
|
||||
stage.off("mouseup touchend", removeText);
|
||||
};
|
||||
stage.on("mouseup touchend", removeText);
|
||||
});
|
||||
}
|
||||
|
||||
public findImageNodeById(
|
||||
id: string,
|
||||
): ImageNode | TextNode | ShapeNode | PaintNode | undefined {
|
||||
@@ -629,7 +515,7 @@ export class RealTimeDrawEngine {
|
||||
width: number | undefined,
|
||||
height: number | undefined,
|
||||
) {
|
||||
if (!this.captureCanvas || !this.previewCanvas) {
|
||||
if (!this.captureCanvas) {
|
||||
return;
|
||||
}
|
||||
if (width) {
|
||||
@@ -648,33 +534,13 @@ export class RealTimeDrawEngine {
|
||||
const oldPositionY = this.positionY;
|
||||
|
||||
// recompute the position
|
||||
const padBetweenCaptureAndPreview = 2;
|
||||
this.positionX =
|
||||
window.innerWidth / 2 -
|
||||
this.width / 2 -
|
||||
this.width / 2 -
|
||||
padBetweenCaptureAndPreview;
|
||||
this.positionY = window.innerHeight / 2 - this.height / 2;
|
||||
|
||||
this.positionPreviewX =
|
||||
window.innerWidth / 2 -
|
||||
this.width / 2 +
|
||||
this.width / 2 +
|
||||
padBetweenCaptureAndPreview;
|
||||
this.positionPreviewY = window.innerHeight / 2 - this.height / 2;
|
||||
|
||||
this.captureCanvas.setPosition({
|
||||
x: this.positionX,
|
||||
y: this.positionY,
|
||||
x: this.positionX / 2,
|
||||
y: this.positionY / 2,
|
||||
});
|
||||
this.captureCanvas.size({ width: this.width, height: this.height });
|
||||
|
||||
this.previewCanvas.setPosition({
|
||||
x: this.positionPreviewX,
|
||||
y: this.positionPreviewY,
|
||||
});
|
||||
this.previewCanvas.size({ width: this.width, height: this.height });
|
||||
|
||||
// this is the change in positions
|
||||
const deltaX = this.positionX - oldPositionX;
|
||||
const deltaY = this.positionY - oldPositionY;
|
||||
@@ -684,7 +550,7 @@ export class RealTimeDrawEngine {
|
||||
let node = children[i];
|
||||
|
||||
// skip the capture canvas and preview canvas update.
|
||||
if (node.name() === "CaptureCanvas" || node.name() === "PreviewCanvas") {
|
||||
if (node.name() === "CaptureCanvas") {
|
||||
continue;
|
||||
}
|
||||
const pos = node.getPosition();
|
||||
@@ -781,8 +647,7 @@ export class RealTimeDrawEngine {
|
||||
if (this.isEnabled) {
|
||||
this.disableDragging();
|
||||
}
|
||||
// ensure this goes to the top.
|
||||
this.previewCanvas.moveToTop();
|
||||
|
||||
await this.render();
|
||||
}
|
||||
|
||||
@@ -808,7 +673,10 @@ export class RealTimeDrawEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private cloneStageForRender(stage: Konva.Stage, layerOfInterest: Konva.Layer): Konva.Stage {
|
||||
private cloneStageForRender(
|
||||
stage: Konva.Stage,
|
||||
layerOfInterest: Konva.Layer,
|
||||
): Konva.Stage {
|
||||
const stageClone = new Konva.Stage({
|
||||
width: stage.width(),
|
||||
height: stage.height(),
|
||||
@@ -852,14 +720,17 @@ export class RealTimeDrawEngine {
|
||||
// Clone the required layer from the stage
|
||||
// Set the right details (like removing highlight stroke)
|
||||
// Then render the cloned stage to a bitmap
|
||||
const stageClone = this.cloneStageForRender(stage, config.layerOfInterest);
|
||||
const stageBlob = await stageClone.toBlob({
|
||||
const stageClone = this.cloneStageForRender(
|
||||
stage,
|
||||
config.layerOfInterest,
|
||||
);
|
||||
const stageBlob = (await stageClone.toBlob({
|
||||
x: x,
|
||||
y: y,
|
||||
width: config.width || Math.ceil(box.width),
|
||||
height: config.height || Math.ceil(box.height),
|
||||
pixelRatio: pixelRatio,
|
||||
}) as Blob;
|
||||
})) as Blob;
|
||||
|
||||
// if config.test is true, the result is downloaded to the local files
|
||||
// config.test = true;
|
||||
@@ -914,15 +785,10 @@ export class RealTimeDrawEngine {
|
||||
console.error("No rendered bitmap available to generate from");
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Bitmap = await this.imageBitmapToBase64(this.lastRenderedBitmap);
|
||||
|
||||
//const base64Bitmap = await EncodeImageBitmapToBase64(this.outputBitmap);
|
||||
|
||||
const generateResponse = await invoke("image_generation_command", {
|
||||
image: base64Bitmap,
|
||||
prompt: this.currentPrompt,
|
||||
});
|
||||
const base64Bitmap = await this.imageBitmapToBase64(
|
||||
this.lastRenderedBitmap,
|
||||
);
|
||||
}
|
||||
|
||||
public async render() {
|
||||
@@ -958,17 +824,14 @@ export class RealTimeDrawEngine {
|
||||
|
||||
try {
|
||||
//const base64Bitmap = await this.imageBitmapToBase64(bitmap);
|
||||
|
||||
//const base64BitmapResponse = await invoke("image_generation_command", {
|
||||
// image: base64Bitmap,
|
||||
// prompt: this.currentPrompt,
|
||||
//});
|
||||
|
||||
////console.log(base64BitmapResponse);
|
||||
//const decoded = await DecodeBase64ToImage(
|
||||
// base64BitmapResponse as string,
|
||||
//);
|
||||
|
||||
//this.outputBitmap = decoded;
|
||||
//this.previewCanvas.image(decoded);
|
||||
} catch (error) {
|
||||
@@ -1014,12 +877,10 @@ export class RealTimeDrawEngine {
|
||||
|
||||
imageSource.onload = () => {
|
||||
this.backgroundRasterRect.fill(color);
|
||||
this.backgroundRasterRect.image(imageSource)
|
||||
this.backgroundRasterRect.image(imageSource);
|
||||
|
||||
this.mediaLayerRef.batchDraw();
|
||||
this.render();
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ export class RenderEngine {
|
||||
this.port = undefined;
|
||||
|
||||
this.fps = 24;
|
||||
|
||||
this.captureCanvas = new Konva.Rect({
|
||||
name: "CaptureCanvas",
|
||||
x: this.positionX,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SignaledCanvasDragDropFiles } from "./SignaledCanvasDragDropFiles";
|
||||
import { SignaledDialogs } from "./SignaledDialogs";
|
||||
import { SignaledToolbarMain } from "./SignaledToolbarMain";
|
||||
import { SignaledToolbarVideoExtraction } from "./SignaledToolbarVideoExtraction";
|
||||
|
||||
import { PromptBox } from "~/components/PromptBox";
|
||||
// The KonvaApp is the root of the Konva stage
|
||||
// and only entry point for anything in Konva JS
|
||||
import { EngineType } from "~/KonvaApp";
|
||||
@@ -56,14 +56,14 @@ export const KonvaRootComponent = ({
|
||||
<KonvaCanvasContainer
|
||||
ref={konvaContainerCallbackRef}
|
||||
className={className}
|
||||
// retreive the classNames from the parent for sizing/styling
|
||||
// retreive the classNames from the parent for sizing/styling
|
||||
/>
|
||||
{/* <SignaledMagicBox /> */}
|
||||
<SignaledCanvasDragDropFiles
|
||||
openAddImage={appUiContext.openAddImage}
|
||||
openAddVideo={appUiContext.openAddVideo}
|
||||
/>
|
||||
<SignaledPromptText />
|
||||
<PromptBox />
|
||||
<SignaledToolbarMain
|
||||
layoutSignal={layoutContext.signal}
|
||||
appUiContext={appUiContext}
|
||||
|
||||
@@ -25,13 +25,13 @@ export const SignaledModelButtons = () => {
|
||||
|
||||
return (
|
||||
<div className={twMerge("z-20 flex h-fit w-fit items-center gap-2")}>
|
||||
<ModelButton
|
||||
{/* <ModelButton
|
||||
iconSrc={modelIconSrc}
|
||||
iconAlt={selectedModelData?.name || "Model"}
|
||||
label={selectedModelData?.name || "Realistic"}
|
||||
subtitle="Model"
|
||||
type="model"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
{/* <ModelButton
|
||||
iconSrc={loraIconSrc}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSignals } from "@preact/signals-react/runtime";
|
||||
import {
|
||||
UploaderStates,
|
||||
UploaderState,
|
||||
uploadImage,
|
||||
} from "~/components/UploadImage";
|
||||
|
||||
import { PopoverMenu } from "~/components/reusable/Popover";
|
||||
import { Tooltip } from "~/components/reusable/Tooltip";
|
||||
import { Button } from "~/components/reusable/Button";
|
||||
import { ToggleButton } from "~/components/reusable/ToggleButton";
|
||||
|
||||
import {
|
||||
faCamera,
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
faDownload,
|
||||
faSave,
|
||||
faSparkles,
|
||||
faSpinnerThird,
|
||||
faTimes,
|
||||
faPlus,
|
||||
faUpload,
|
||||
faImages,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
faRectangleWide,
|
||||
faRectangleVertical,
|
||||
faSquare,
|
||||
faRectangle,
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { PopoverItem } from "~/components/reusable/Popover";
|
||||
|
||||
interface ReferenceImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
mediaToken: string;
|
||||
}
|
||||
|
||||
interface ExtendedPopoverItem extends PopoverItem {
|
||||
id: string;
|
||||
focalLength: number;
|
||||
}
|
||||
|
||||
export const PromptBox = () => {
|
||||
useSignals();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [isEnqueueing, setisEnqueueing] = useState(false);
|
||||
const [useSystemPrompt, setUseSystemPrompt] = useState(true);
|
||||
const [referenceImages, setReferenceImages] = useState<ReferenceImage[]>([]);
|
||||
const [uploadingImages, setUploadingImages] = useState<
|
||||
{ id: string; file: File }[]
|
||||
>([]);
|
||||
const [isCameraSettingsOpen, setIsCameraSettingsOpen] = useState(false);
|
||||
const [aspectRatioList, setAspectRatioList] = useState<PopoverItem[]>([
|
||||
{
|
||||
label: "3:2",
|
||||
selected: true,
|
||||
icon: <FontAwesomeIcon icon={faRectangle} className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "2:3",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faRectangleVertical} className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "1:1",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faSquare} className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "16:9",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faRectangleWide} className="h-4 w-4" />,
|
||||
},
|
||||
]);
|
||||
const [cameraList, setCameraList] = useState<ExtendedPopoverItem[]>([
|
||||
{
|
||||
id: "main",
|
||||
label: "Main View",
|
||||
selected: true,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
{
|
||||
id: "cam2",
|
||||
label: "Camera 2",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [prompt]);
|
||||
|
||||
// Update aspect ratio list based on the current cameraAspectRatio signal
|
||||
useEffect(() => {
|
||||
setAspectRatioList((prev) => prev);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleCameraSelect = (selectedItem: PopoverItem) => {};
|
||||
|
||||
const handleAddCamera = () => {
|
||||
const newIndex = cameraList.length + 1;
|
||||
const newId = `cam${newIndex}`;
|
||||
setCameraList((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
label: `Camera ${newIndex}`,
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faCamera} className="h-4 w-4" />,
|
||||
focalLength: 35,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleCameraNameChange = (id: string, newName: string) => {
|
||||
setCameraList((prev) =>
|
||||
prev.map((cam) => (cam.id === id ? { ...cam, label: newName } : cam)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCameraFocalLengthChange = (id: string, value: number) => {
|
||||
setCameraList((prev) =>
|
||||
prev.map((cam) => (cam.id === id ? { ...cam, focalLength: value } : cam)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAspectRatioSelect = (selectedItem: PopoverItem) => {
|
||||
setAspectRatioList((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
selected: item.label === selectedItem.label,
|
||||
})),
|
||||
);
|
||||
|
||||
// Map the selected label to the corresponding CameraAspectRatio enum value
|
||||
|
||||
// Publish the change to the engine
|
||||
// Queue.publish({
|
||||
// queueName: QueueNames.TO_ENGINE,
|
||||
// action: toEngineActions.CHANGE_CAMERA_ASPECT_RATIO,
|
||||
// data: newRatio,
|
||||
// });
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files) {
|
||||
Array.from(files).forEach((file) => {
|
||||
const uploadId = Math.random().toString(36).substring(7);
|
||||
setUploadingImages((prev) => [...prev, { id: uploadId, file }]);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
uploadImage({
|
||||
title: `reference-image-${Math.random().toString(36).substring(2, 15)}`,
|
||||
assetFile: file,
|
||||
progressCallback: (newState) => {
|
||||
console.debug("Upload progress:", newState.data);
|
||||
if (newState.status === UploaderStates.success && newState.data) {
|
||||
const referenceImage: ReferenceImage = {
|
||||
id: Math.random().toString(36).substring(7),
|
||||
url: reader.result as string,
|
||||
file,
|
||||
mediaToken: newState.data || "",
|
||||
};
|
||||
setReferenceImages((prev) => [...prev, referenceImage]);
|
||||
setUploadingImages((prev) =>
|
||||
prev.filter((img) => img.id !== uploadId),
|
||||
);
|
||||
console.log("Reference image added:", referenceImage);
|
||||
} else if (
|
||||
newState.status === UploaderStates.assetError ||
|
||||
newState.status === UploaderStates.imageCreateError
|
||||
) {
|
||||
setUploadingImages((prev) =>
|
||||
prev.filter((img) => img.id !== uploadId),
|
||||
);
|
||||
console.error("Upload failed");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveReference = (id: string) => {
|
||||
setReferenceImages((prev) => prev.filter((img) => img.id !== id));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleLibrarySelect = () => {
|
||||
console.log("Opening library selector");
|
||||
};
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
switch (action) {
|
||||
case "upload":
|
||||
handleUploadClick();
|
||||
break;
|
||||
case "library":
|
||||
handleLibrarySelect();
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown action:", action);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData("text").trim();
|
||||
setPrompt(pastedText);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPrompt(e.target.value);
|
||||
};
|
||||
|
||||
const handleEnqueue = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setisEnqueueing(true);
|
||||
|
||||
try {
|
||||
// Here we would pass both the prompt and reference images to the generation
|
||||
console.log(
|
||||
"Enqueuing with prompt:",
|
||||
prompt,
|
||||
"and reference images:",
|
||||
referenceImages,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
} finally {
|
||||
setisEnqueueing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current aspect ratio icon based on the cameraAspectRatio signal
|
||||
const getCurrentAspectRatioIcon = () => {};
|
||||
|
||||
const handleSaveFrame = async () => {};
|
||||
|
||||
const handleDownloadFrame = () => {};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEnqueue();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col gap-3">
|
||||
{(referenceImages.length > 0 || uploadingImages.length > 0) && (
|
||||
<div className="flex w-full gap-2">
|
||||
{referenceImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="glass relative aspect-square w-20 rounded-lg"
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt="Reference"
|
||||
className="h-full w-full rounded-lg object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRemoveReference(image.id)}
|
||||
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md transition-colors hover:bg-black"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{uploadingImages.map(({ id, file }) => {
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="glass relative aspect-square w-20 overflow-hidden rounded-lg"
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploading preview"
|
||||
className="h-full w-full object-cover blur-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="h-6 w-6 animate-spin text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="glass w-[730px] rounded-xl p-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
multiple
|
||||
/>
|
||||
<div className="flex justify-center gap-2">
|
||||
<PopoverMenu
|
||||
mode="button"
|
||||
panelTitle="Add Image"
|
||||
items={[
|
||||
{
|
||||
label: "Upload from device",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faUpload} className="h-4 w-4" />,
|
||||
action: "upload",
|
||||
},
|
||||
{
|
||||
label: "Choose from library",
|
||||
selected: false,
|
||||
icon: <FontAwesomeIcon icon={faImages} className="h-4 w-4" />,
|
||||
action: "library",
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
onPanelAction={handleAction}
|
||||
showIconsInList
|
||||
buttonClassName="backdrop-blur-none bg-transparent hover:bg-transparent py-1.5 px-0 pr-1 m-0 hover:opacity-50 transition-opacity duration-100 ring-0 border-none focus:ring-0 outline-none"
|
||||
triggerIcon={<FontAwesomeIcon icon={faPlus} className="text-xl" />}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Describe your image..."
|
||||
className="text-md mb-2 max-h-[5.5em] flex-1 resize-none overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-white placeholder-white placeholder:text-white/60 focus:outline-none"
|
||||
value={prompt}
|
||||
onChange={handleChange}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
content="Aspect ratio"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
>
|
||||
<PopoverMenu
|
||||
items={aspectRatioList}
|
||||
onSelect={handleAspectRatioSelect}
|
||||
mode="toggle"
|
||||
panelTitle="Aspect Ratio"
|
||||
showIconsInList
|
||||
triggerIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={getCurrentAspectRatioIcon()}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content="Camera"
|
||||
position="top"
|
||||
className="z-50"
|
||||
delay={300}
|
||||
closeOnClick={true}
|
||||
></Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
useSystemPrompt
|
||||
? "Use system prompt: ON"
|
||||
: "Use system prompt: OFF"
|
||||
}
|
||||
position="top"
|
||||
className="z-50"
|
||||
delay={200}
|
||||
>
|
||||
<ToggleButton
|
||||
isActive={useSystemPrompt}
|
||||
icon={faMessageXmark}
|
||||
activeIcon={faMessageCheck}
|
||||
onClick={() => setUseSystemPrompt(!useSystemPrompt)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
content="Download frame"
|
||||
position="top"
|
||||
className="z-50"
|
||||
closeOnClick={true}
|
||||
delay={200}
|
||||
>
|
||||
<Button
|
||||
className="flex h-9 items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
|
||||
variant="secondary"
|
||||
icon={faDownload}
|
||||
onClick={handleDownloadFrame}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
className="flex items-center border-none bg-[#5F5F68]/60 px-3 text-sm text-white backdrop-blur-lg hover:bg-[#5F5F68]/90"
|
||||
variant="secondary"
|
||||
icon={faSave}
|
||||
onClick={handleSaveFrame}
|
||||
>
|
||||
Save frame
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-brand-primary flex items-center border-none px-3 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
icon={!isEnqueueing ? faSparkles : undefined}
|
||||
onClick={handleEnqueue}
|
||||
disabled={isEnqueueing || !prompt.trim()}
|
||||
>
|
||||
{isEnqueueing ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faSpinnerThird}
|
||||
className="animate-spin text-lg"
|
||||
/>
|
||||
) : (
|
||||
"Generate"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { MediaUploadApi } from "~/Classes/ApiManager";
|
||||
|
||||
import { getFileName } from "~/utilities";
|
||||
|
||||
export enum UploaderStates {
|
||||
ready,
|
||||
uploadingAsset,
|
||||
uploadingImage,
|
||||
uploadingCover,
|
||||
settingCover,
|
||||
success,
|
||||
assetError,
|
||||
coverCreateError,
|
||||
coverSetError,
|
||||
imageCreateError,
|
||||
}
|
||||
|
||||
export interface UploaderState {
|
||||
status: UploaderStates;
|
||||
errorMessage?: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export const initialUploaderState = {
|
||||
status: UploaderStates.ready,
|
||||
};
|
||||
|
||||
export const uploadImage = async ({
|
||||
title,
|
||||
assetFile,
|
||||
progressCallback,
|
||||
}: {
|
||||
title: string;
|
||||
assetFile: File;
|
||||
progressCallback: (newState: UploaderState) => void;
|
||||
}) => {
|
||||
const mediaUploadApi = new MediaUploadApi();
|
||||
|
||||
console.log("Uploading image:", assetFile);
|
||||
|
||||
progressCallback({ status: UploaderStates.uploadingImage });
|
||||
|
||||
const imageResponse = await mediaUploadApi.UploadImage({
|
||||
uuid: uuidv4(),
|
||||
blob: assetFile,
|
||||
fileName: getFileName(assetFile),
|
||||
maybe_title: "char_frame_" + title,
|
||||
});
|
||||
|
||||
console.log("Image response:", imageResponse);
|
||||
|
||||
if (imageResponse == undefined) {
|
||||
console.log("Error: Could not upload image!");
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: "Could not upload image!",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageResponse.success || !imageResponse.data) {
|
||||
console.log("Error:", imageResponse.errorMessage);
|
||||
progressCallback({
|
||||
status: UploaderStates.imageCreateError,
|
||||
errorMessage: imageResponse.errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Upload successful:", imageResponse.data);
|
||||
progressCallback({
|
||||
status: UploaderStates.success,
|
||||
data: imageResponse.data,
|
||||
});
|
||||
};
|
||||
@@ -63,11 +63,11 @@ export const ToolbarMain = ({
|
||||
buttonProps={buttonProps.SELECT}
|
||||
tooltip="Select"
|
||||
/>
|
||||
<ToolbarButton
|
||||
{/* <ToolbarButton
|
||||
icon={faText}
|
||||
buttonProps={buttonProps.ADD_TEXT}
|
||||
tooltip="Add Text"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<Popover className="relative">
|
||||
{({ close }) => (
|
||||
@@ -239,7 +239,7 @@ export const ToolbarMain = ({
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative">
|
||||
<Tooltip tip="Background Color" position="right">
|
||||
{/* <Tooltip tip="Background Color" position="right">
|
||||
<div>
|
||||
<BackgroundMenu
|
||||
color={bgColor.value}
|
||||
@@ -259,7 +259,7 @@ export const ToolbarMain = ({
|
||||
style={{ backgroundColor: bgColor.value }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {twMerge} from "tailwind-merge";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {faPalette,} from "@fortawesome/pro-solid-svg-icons";
|
||||
import {ToolbarButton, ToolbarButtonProps,} from "~/components/features/ToolbarButton";
|
||||
import {paperWrapperStyles} from "~/components/styles";
|
||||
import {ToolbarNodeButtonNames} from "./enums";
|
||||
import {ToolbarNodeButtonData} from "./data";
|
||||
import {ColorPicker} from "~/components/ui/TextEditor/ColorPicker";
|
||||
import {DEFAULT_PAINT_COLOR} from "~/signals/uiEvents/toolbarMain/paintMode";
|
||||
import { faPalette } from "@fortawesome/pro-solid-svg-icons";
|
||||
import {
|
||||
ToolbarButton,
|
||||
ToolbarButtonProps,
|
||||
} from "~/components/features/ToolbarButton";
|
||||
import { paperWrapperStyles } from "~/components/styles";
|
||||
import { ToolbarNodeButtonNames } from "./enums";
|
||||
import { ToolbarNodeButtonData } from "./data";
|
||||
import { ColorPicker } from "~/components/ui/TextEditor/ColorPicker";
|
||||
import { DEFAULT_PAINT_COLOR } from "~/signals/uiEvents/toolbarMain/paintMode";
|
||||
|
||||
export interface ToolbarNodeProps {
|
||||
disabled?: boolean;
|
||||
@@ -29,7 +32,7 @@ export const ToolbarNode = ({
|
||||
onLockClicked,
|
||||
buttonsProps,
|
||||
color = DEFAULT_PAINT_COLOR,
|
||||
onColorChange = () => { },
|
||||
onColorChange = () => {},
|
||||
}: ToolbarNodeProps) => {
|
||||
return (
|
||||
<div
|
||||
@@ -41,7 +44,7 @@ export const ToolbarNode = ({
|
||||
>
|
||||
{ToolbarNodeButtonData.map((buttonDatum, idx) => {
|
||||
const buttonProps = buttonsProps?.[buttonDatum.name];
|
||||
|
||||
console.log(buttonDatum, idx);
|
||||
if (!buttonProps || buttonProps.hidden) {
|
||||
return;
|
||||
}
|
||||
@@ -55,14 +58,20 @@ export const ToolbarNode = ({
|
||||
>
|
||||
<span className="text-[16px]">{buttonDatum.tooltip}</span>
|
||||
</ToolbarButton>
|
||||
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ColorPicker color={color} onChange={onColorChange} faIcon={faPalette} borderStyle="w-full justify-start h-auto gap-2.5 text-white/80" showBar={false} key={idx}>
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={onColorChange}
|
||||
faIcon={faPalette}
|
||||
borderStyle="w-full justify-start h-auto gap-2.5 text-white/80"
|
||||
showBar={false}
|
||||
key={idx}
|
||||
>
|
||||
<span className="text-[16px]">{buttonDatum.tooltip}</span>
|
||||
</ColorPicker>
|
||||
)
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,16 @@ export const ToolbarTopLeft = () => {
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ id: "realtime", label: "Sketch" },
|
||||
//{ id: "edit", label: "Edit" },
|
||||
{ id: "gallery", label: "Gallery" },
|
||||
//{ id: "generate", label: "Generate" },
|
||||
// { id: "video", label: "Video" },
|
||||
];
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
// Update the global signal
|
||||
dispatchUiEvents.changeAppMode(tabId as "realtime" | "edit" | "generate");
|
||||
dispatchUiEvents.changeAppMode(
|
||||
tabId as "realtime" | "edit" | "generate" | "gallery",
|
||||
);
|
||||
};
|
||||
|
||||
const generationState = generationSignal.value;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Badge = ({ label, className }: BadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Badge } from "./Badge";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as ButtonStories from './Button.stories';
|
||||
|
||||
<Meta of={ButtonStories} />
|
||||
|
||||
# Button
|
||||
|
||||
A button denotes a possible action to be taken on behalf of the user.
|
||||
|
||||
There are three types of Button: primary, secondary, action.
|
||||
|
||||
<Canvas of={ButtonStories.Primary} />
|
||||
<Canvas of={ButtonStories.Secondary} />
|
||||
<Canvas of={ButtonStories.Action} />
|
||||
|
||||
It can be shown with an optional icon. If "iconFlip" is true, the icon shows after the "children", if false it shows before.
|
||||
|
||||
<Canvas of={ButtonStories.WithIconFlip} />
|
||||
<Canvas of={ButtonStories.WithIconNoFlip} />
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./Button";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
component: Button,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {
|
||||
render: () => <Button variant="primary">Primary</Button>,
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: () => <Button variant="secondary">Secondary</Button>,
|
||||
};
|
||||
|
||||
export const Action: Story = {
|
||||
render: () => <Button variant="action">Action</Button>,
|
||||
};
|
||||
|
||||
export const WithIconFlip: Story = {
|
||||
render: () => (
|
||||
<Button variant="secondary" iconFlip icon={faAngleLeft}>
|
||||
Icon Flip
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithIconNoFlip: Story = {
|
||||
render: () => (
|
||||
<Button variant="secondary" icon={faAngleLeft}>
|
||||
Icon
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon?: IconDefinition;
|
||||
iconClassName?: string;
|
||||
iconFlip?: boolean;
|
||||
htmlFor?: string;
|
||||
variant?: "primary" | "secondary" | "action";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
icon,
|
||||
iconClassName,
|
||||
children,
|
||||
className: propsClassName,
|
||||
htmlFor,
|
||||
variant: propsVariant = "primary",
|
||||
disabled,
|
||||
iconFlip = false,
|
||||
loading,
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
function getVariantClassNames(variant: string) {
|
||||
switch (variant) {
|
||||
case "secondary": {
|
||||
return "bg-brand-secondary hover:bg-brand-secondary-700 text-white focus-visible:outline-brand-secondary";
|
||||
}
|
||||
case "action": {
|
||||
return "bg-action hover:bg-action-900 text-white focus-visible:outline-action";
|
||||
}
|
||||
case "primary":
|
||||
default: {
|
||||
return "bg-brand-primary hover:bg-brand-primary-400 text-white focus-visible:outline-brand-primary-600";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disabledClass = twMerge(
|
||||
disabled || loading ? "opacity-50 pointer-events-none" : "",
|
||||
);
|
||||
|
||||
const className = twMerge(
|
||||
"text-sm font-medium rounded-lg px-3 py-2 border border-[#363636] shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150 flex gap-2 items-center justify-center",
|
||||
getVariantClassNames(propsVariant),
|
||||
propsClassName,
|
||||
disabledClass,
|
||||
);
|
||||
|
||||
// const ButtonType = htmlFor ? "label" : "button";
|
||||
if (htmlFor) {
|
||||
return (
|
||||
<label className={className} htmlFor={htmlFor} style={rest.style}>
|
||||
{loading && !iconFlip ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{icon && !iconFlip ? (
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
{loading && iconFlip ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{icon && iconFlip ? (
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
disabled={disabled || loading}
|
||||
{...{ ...rest, htmlFor }}
|
||||
>
|
||||
{loading && !iconFlip ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{icon && !iconFlip ? (
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
{loading && iconFlip ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{icon && iconFlip ? (
|
||||
<FontAwesomeIcon icon={icon} className={iconClassName} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Button";
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { TransitionDialogue, Button, ButtonProps } from "~/components";
|
||||
|
||||
type UnionedButtonProps = { label?: string } & ButtonProps;
|
||||
|
||||
interface ButtonDialoguePropsI {
|
||||
buttonProps?: UnionedButtonProps;
|
||||
confirmButtonProps?: UnionedButtonProps;
|
||||
closeButtonProps?: UnionedButtonProps;
|
||||
dialogProps?: {
|
||||
className?: string;
|
||||
};
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
export const ButtonDialogue = ({
|
||||
dialogProps = {},
|
||||
buttonProps: unionedButtonProps,
|
||||
confirmButtonProps,
|
||||
closeButtonProps: unionedCloseButtonProps,
|
||||
title,
|
||||
children,
|
||||
showClose = true,
|
||||
}: ButtonDialoguePropsI) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const closeModal = () => setIsOpen(false);
|
||||
const openModal = () => setIsOpen(true);
|
||||
|
||||
const { label: buttonLabel, ...buttonProps } = unionedButtonProps || {
|
||||
label: "Open",
|
||||
};
|
||||
const { label: closeButtonLabel, ...closeButtonProps } =
|
||||
unionedCloseButtonProps || { label: "Close" };
|
||||
return (
|
||||
<>
|
||||
<Button type="button" onClick={openModal} {...buttonProps}>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
|
||||
<TransitionDialogue
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className={dialogProps.className}
|
||||
>
|
||||
<div className="mt-2 h-full">{children}</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{showClose && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
{...closeButtonProps}
|
||||
variant="secondary"
|
||||
>
|
||||
{closeButtonLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{confirmButtonProps && (
|
||||
<Button
|
||||
type="button"
|
||||
{...confirmButtonProps}
|
||||
onClick={(e) => {
|
||||
if (confirmButtonProps.onClick) {
|
||||
confirmButtonProps.onClick(e);
|
||||
}
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
{confirmButtonProps.label ? confirmButtonProps.label : "Confirm"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TransitionDialogue>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ButtonDialogue } from './ButtonDialogue';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Canvas, Meta } from "@storybook/blocks";
|
||||
|
||||
import * as ButtonDropdownStories from "./ButtonDropdown.stories";
|
||||
|
||||
<Meta of={ButtonDropdownStories} />
|
||||
|
||||
# Button for dropdown actions
|
||||
|
||||
Dropdown options that open up a dialog
|
||||
|
||||
<Canvas of={ButtonDropdownStories.OpenDialog} />
|
||||
|
||||
Dropdown options with onClick event
|
||||
|
||||
<Canvas of={ButtonDropdownStories.ClickEvent} />
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ButtonDropdown } from "./ButtonDropdown";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faAngleLeft, faFile } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof ButtonDropdown> = {
|
||||
component: ButtonDropdown,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof ButtonDropdown>;
|
||||
|
||||
export const OpenDialog: Story = {
|
||||
render: () => (
|
||||
<ButtonDropdown
|
||||
label="Dialog Options"
|
||||
icon={faFile}
|
||||
options={[
|
||||
{
|
||||
label: "Open Dialog",
|
||||
description: "Desc 1",
|
||||
onDialogOpen: () => {
|
||||
//console.log("Opened Dialog");
|
||||
},
|
||||
dialogProps: {
|
||||
title: "Dialog Title",
|
||||
content: <div className="text-white">Dialog content here...</div>,
|
||||
confirmButtonProps: {
|
||||
label: "Create",
|
||||
disabled: false,
|
||||
onClick: () => {
|
||||
//console.log("Action on Dialog");
|
||||
},
|
||||
},
|
||||
closeButtonProps: {
|
||||
label: "Cancel",
|
||||
},
|
||||
showClose: true,
|
||||
onClose: () => {
|
||||
//console.log("Closed Dialog");
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Disabled",
|
||||
description: "Desc 2",
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const ClickEvent: Story = {
|
||||
render: () => (
|
||||
<ButtonDropdown
|
||||
label="Event Options"
|
||||
icon={faFile}
|
||||
options={[
|
||||
{
|
||||
label: "Click Event",
|
||||
description: "Desc",
|
||||
onClick: () => {
|
||||
//console.log("Clicked");
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,212 @@
|
||||
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 { Button, ButtonProps, TransitionDialogue } from "~/components";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type UnionedButtonProps = { label?: string } & ButtonProps;
|
||||
|
||||
interface ButtonDropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
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<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
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 (
|
||||
<div className="relative">
|
||||
<Menu as="div" className="inline-block text-left">
|
||||
<Menu.Button as="div">
|
||||
<Button
|
||||
className={className}
|
||||
icon={faChevronDown}
|
||||
iconFlip={true}
|
||||
variant="secondary"
|
||||
>
|
||||
{icon ? <FontAwesomeIcon icon={icon} /> : null}
|
||||
{label}
|
||||
</Button>
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
static
|
||||
className={twMerge(
|
||||
"absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none",
|
||||
align === "left" ? "left-0" : "right-0",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{options.map((option, index) => (
|
||||
<Fragment key={index}>
|
||||
{option.divider && (
|
||||
<div className="my-1.5 border-t border-ui-divider" />
|
||||
)}
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
disabled={option.disabled}
|
||||
className={twMerge(
|
||||
"duration-50 bg-brand-secondary font-medium text-white transition-all",
|
||||
active ? "bg-brand-secondary-800" : "",
|
||||
option.disabled
|
||||
? "pointer-events-none opacity-40"
|
||||
: "",
|
||||
"group flex w-full items-center py-1.5 pl-7 pr-4 text-sm",
|
||||
option.className,
|
||||
)}
|
||||
onClick={() => handleOptionClick(index)}
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{option.icon && (
|
||||
<FontAwesomeIcon
|
||||
icon={option.icon}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
<div className="grow text-start">{option.label}</div>
|
||||
<div className="ml-10 font-normal text-white/75">
|
||||
{option.description && option.description}
|
||||
</div>
|
||||
{showSelected && (
|
||||
<>
|
||||
{option.selected ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
className="ml-3 flex h-5"
|
||||
>
|
||||
<path
|
||||
opacity="1"
|
||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
|
||||
fill="#FC6B68"
|
||||
/>
|
||||
<path
|
||||
d="M369 175c-9.4 9.4-9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-8" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
||||
{currentDialogProps && (
|
||||
<TransitionDialogue
|
||||
title={currentDialogProps.title}
|
||||
isOpen={isOpen}
|
||||
onClose={closeModal}
|
||||
className={currentDialogProps.className}
|
||||
>
|
||||
{currentDialogProps.content}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{currentDialogProps.showClose !== false &&
|
||||
currentDialogProps.closeButtonProps && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
{...currentDialogProps.closeButtonProps}
|
||||
onClick={closeModal}
|
||||
>
|
||||
{currentDialogProps.closeButtonProps.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentDialogProps.confirmButtonProps && (
|
||||
<Button
|
||||
{...currentDialogProps.confirmButtonProps}
|
||||
onClick={(e) => {
|
||||
if (currentDialogProps.confirmButtonProps?.onClick) {
|
||||
currentDialogProps.confirmButtonProps?.onClick(e);
|
||||
}
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
{currentDialogProps.confirmButtonProps.label || "Confirm"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TransitionDialogue>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ButtonDropdown";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as ButtonIconStories from './ButtonIcon.stories';
|
||||
|
||||
<Meta of={ButtonIconStories} />
|
||||
|
||||
# ButtonIcon
|
||||
|
||||
A button denotes a possible action to be taken on behalf of the user.
|
||||
|
||||
This button shows only a single icon.
|
||||
|
||||
<Canvas of={ButtonIconStories.Default} />
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ButtonIcon } from "./ButtonIcon";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof ButtonIcon> = {
|
||||
component: ButtonIcon,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof ButtonIcon>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ButtonIcon icon={faAngleLeft} onClick={() => {}} />,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,39 @@
|
||||
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 (
|
||||
<button className={className} onClick={onClick} disabled={disabled}>
|
||||
<FontAwesomeIcon icon={icon} size={size} {...rest} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ButtonIcon";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as ButtonIconSelectStories from './ButtonIconSelect.stories';
|
||||
|
||||
<Meta of={ButtonIconSelectStories} />
|
||||
|
||||
# ButtonIconSelectStories
|
||||
|
||||
A button denotes a possible action to be taken on behalf of the user.
|
||||
|
||||
This button shows only a single icon.
|
||||
|
||||
<Canvas of={ButtonIconSelectStories.Default} />
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ButtonIconSelect } from "./ButtonIconSelect";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faAngleLeft, faAngleRight } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof ButtonIconSelect> = {
|
||||
component: ButtonIconSelect,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof ButtonIconSelect>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ButtonIconSelect
|
||||
onOptionChange={() => {}}
|
||||
options={[
|
||||
{
|
||||
value: "V1",
|
||||
icon: faAngleLeft,
|
||||
text: "Select 1",
|
||||
},
|
||||
{
|
||||
value: "V2",
|
||||
icon: faAngleRight,
|
||||
text: "Select 2",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
icon: IconDefinition;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ButtonIconSelectProps {
|
||||
options: Option[];
|
||||
onOptionChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ButtonIconSelect({
|
||||
options,
|
||||
onOptionChange,
|
||||
}: ButtonIconSelectProps) {
|
||||
const [selectedOption, setSelectedOption] = useState<string>(
|
||||
options[0].value,
|
||||
);
|
||||
|
||||
const handleOptionChange = (value: string) => {
|
||||
setSelectedOption(value);
|
||||
if (onOptionChange) {
|
||||
onOptionChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
{options.map(({ value, icon, text }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={twMerge(
|
||||
`flex h-8 items-center justify-center rounded-lg border text-sm transition-all duration-150`,
|
||||
text ? "h-auto w-auto gap-2 px-2.5 py-1" : "w-8",
|
||||
selectedOption === value
|
||||
? "border-brand-primary bg-ui-panel/[0.3]"
|
||||
: "border-transparent hover:bg-ui-panel/[0.4]",
|
||||
)}
|
||||
onClick={() => handleOptionChange(value)}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
{text && (
|
||||
<span className="text-nowrap text-sm font-medium">{text}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ButtonIconSelect";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as ButtonIconStackStories from './ButtonIconStack.stories';
|
||||
|
||||
<Meta of={ButtonIconStackStories} />
|
||||
|
||||
# ButtonIconStackStories
|
||||
|
||||
A button denotes a possible action to be taken on behalf of the user.
|
||||
|
||||
This button shows only a single icon.
|
||||
|
||||
<Canvas of={ButtonIconStackStories.Default} />
|
||||
@@ -0,0 +1,28 @@
|
||||
import { faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ButtonIconStack } from "./ButtonIconStack";
|
||||
|
||||
const meta: Meta<typeof ButtonIconStack> = {
|
||||
component: ButtonIconStack,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof ButtonIconStack>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<ButtonIconStack
|
||||
onClick={() => { }}
|
||||
icon={faImage}
|
||||
text={"Start Frame"}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ButtonIconStackProps {
|
||||
icon: IconDefinition;
|
||||
text: string;
|
||||
onClick?: () => void;
|
||||
style?: string;
|
||||
additionalStyle?: string;
|
||||
}
|
||||
|
||||
export function ButtonIconStack({
|
||||
icon,
|
||||
text = "",
|
||||
onClick,
|
||||
style,
|
||||
additionalStyle,
|
||||
}: ButtonIconStackProps) {
|
||||
|
||||
const defaultStyle = twMerge(
|
||||
"flex flex-col items-center justify-center text-xs transition-all duration-150",
|
||||
"h-full w-full gap-1 p-2",
|
||||
additionalStyle
|
||||
)
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
className={style ?? defaultStyle}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} size="xl" />
|
||||
{text && (
|
||||
<span className="text-nowrap text-xs font-medium">{text}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ButtonIconStack";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as ButtonLinkStories from './ButtonLink.stories';
|
||||
|
||||
<Meta of={ButtonLinkStories} />
|
||||
|
||||
# ButtonLink
|
||||
|
||||
A button denotes a possible action to be taken on behalf of the user.
|
||||
|
||||
The button label comes via "children".
|
||||
|
||||
There are three types of Button: primary, secondary, action.
|
||||
|
||||
<Canvas of={ButtonLinkStories.Primary} />
|
||||
<Canvas of={ButtonLinkStories.Secondary} />
|
||||
|
||||
It can be shown with an optional icon.
|
||||
|
||||
<Canvas of={ButtonLinkStories.WithIcon} />
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { ButtonLink } from "./ButtonLink";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faAngleLeft } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof ButtonLink> = {
|
||||
component: ButtonLink,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof ButtonLink>;
|
||||
|
||||
export const Primary: Story = {
|
||||
render: () => (
|
||||
<ButtonLink to="google.com" variant="primary">
|
||||
Primary
|
||||
</ButtonLink>
|
||||
),
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
render: () => (
|
||||
<ButtonLink to="google.com" variant="secondary">
|
||||
Secondary
|
||||
</ButtonLink>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<ButtonLink to="google.com" variant="secondary" icon={faAngleLeft}>
|
||||
Icon
|
||||
</ButtonLink>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link, LinkProps } from "@remix-run/react";
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface LinkButtonProps extends LinkProps {
|
||||
icon?: IconDefinition;
|
||||
variant?: "primary" | "secondary";
|
||||
}
|
||||
|
||||
export const ButtonLink = ({
|
||||
icon,
|
||||
children,
|
||||
className: propsClassName,
|
||||
variant = "primary",
|
||||
...rest
|
||||
}: LinkButtonProps) => {
|
||||
//TODO: Duplicated from Button.tsx
|
||||
function getVariantClassNames(variant: string) {
|
||||
switch (variant) {
|
||||
case "secondary": {
|
||||
return "bg-brand-secondary hover:bg-brand-secondary-900 text-white focus-visible:outline-brand-secondary";
|
||||
}
|
||||
case "primary":
|
||||
default: {
|
||||
return "bg-brand-primary hover:bg-brand-primary-400 text-white focus-visible:outline-brand-primary-600";
|
||||
}
|
||||
}
|
||||
}
|
||||
const baseClassName =
|
||||
"text-sm font-medium whitespace-nowrap rounded-lg px-3 py-2 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 transition-all duration-150";
|
||||
const variantClassNames = getVariantClassNames(variant);
|
||||
const className = twMerge(baseClassName, variantClassNames, propsClassName);
|
||||
// END TODO
|
||||
|
||||
return (
|
||||
<Link {...rest}>
|
||||
<button className={className}>
|
||||
{icon && <FontAwesomeIcon className="mr-2" icon={icon} size="sm" />}
|
||||
{children}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ButtonLink";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { DoNotShow } from "~/constants";
|
||||
import { Button } from "~/components";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onOk?: () => void;
|
||||
okText?: string;
|
||||
okColor?: string;
|
||||
onCancel?: () => void;
|
||||
cancelText?: string;
|
||||
canHide?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmationModal = ({
|
||||
text,
|
||||
title,
|
||||
open,
|
||||
onClose,
|
||||
onOk,
|
||||
okText = "OK",
|
||||
okColor = "bg-brand-success",
|
||||
onCancel,
|
||||
cancelText = "Cancel",
|
||||
canHide,
|
||||
}: Props) => {
|
||||
const handleOk = useCallback(() => {
|
||||
const element = document.getElementById("hide-dialog");
|
||||
if (element) {
|
||||
if ((element as HTMLInputElement).checked) {
|
||||
localStorage.setItem(title.replace(" ", "-"), DoNotShow);
|
||||
}
|
||||
}
|
||||
|
||||
onOk!();
|
||||
}, [onOk, title]);
|
||||
|
||||
return (
|
||||
<Transition appear show={open} as={Fragment}>
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-5 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h4"
|
||||
className="mb-4 text-xl font-bold text-white"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
||||
<div className="mt-2">{text}</div>
|
||||
{canHide && (
|
||||
<div className="mt-2">
|
||||
<label>
|
||||
<input id="hide-dialog" type="checkbox" />
|
||||
Do not show this again
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{!!onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-3 py-2"
|
||||
variant="secondary"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
)}
|
||||
{!!onOk && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOk}
|
||||
className={[okColor, "rounded-lg px-3 py-2"].join(" ")}
|
||||
>
|
||||
{okText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { P } from "~/components";
|
||||
|
||||
export const DragAndDropZone = ({
|
||||
file,
|
||||
fileTypes,
|
||||
}: {
|
||||
file?: File;
|
||||
fileTypes: string[];
|
||||
}) => {
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
<u>Upload a file</u> or drop it here
|
||||
</P>
|
||||
|
||||
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
|
||||
{fileTypes.join(", ").toString()} supported
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = file.name.split(".")[0].toUpperCase();
|
||||
const fileSize =
|
||||
file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: `${Math.floor(file.size / 1024)} KB`;
|
||||
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</P>
|
||||
<P className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FileUploader as DragDropFileUploader } from "react-drag-drop-files";
|
||||
// Usage refer to https://github.com/KarimMokhtar/react-drag-drop-files
|
||||
|
||||
import { DragAndDropZone } from "./DragAndDropZone";
|
||||
|
||||
export const FileUploader = ({
|
||||
file,
|
||||
fileTypes,
|
||||
handleChange,
|
||||
}: {
|
||||
file: File | undefined;
|
||||
handleChange: (file: File) => void;
|
||||
fileTypes: string[];
|
||||
}) => (
|
||||
<DragDropFileUploader
|
||||
handleChange={handleChange}
|
||||
name="file"
|
||||
maxSize={50}
|
||||
types={fileTypes}
|
||||
>
|
||||
<DragAndDropZone file={file} fileTypes={fileTypes} />
|
||||
</DragDropFileUploader>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FileUploader";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as FilterButtonsStories from './FilterButtons.stories';
|
||||
|
||||
<Meta of={FilterButtonsStories} />
|
||||
|
||||
# Three buttons which appear at the top of some of the media tabs.
|
||||
|
||||
The value can be set to Featured
|
||||
<Canvas of={FilterButtonsStories.Featured} />
|
||||
Mine
|
||||
<Canvas of={FilterButtonsStories.Mine} />
|
||||
or Bookmarked
|
||||
<Canvas of={FilterButtonsStories.Bookmarked} />
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FilterButtons } from "./FilterButtons";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { AssetFilterOption } from "~/enums";
|
||||
|
||||
const meta: Meta<typeof FilterButtons> = {
|
||||
component: FilterButtons,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["click"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof FilterButtons>;
|
||||
|
||||
export const Featured: Story = {
|
||||
render: () => (
|
||||
<FilterButtons onClick={() => null} value={AssetFilterOption.FEATURED} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Mine: Story = {
|
||||
render: () => (
|
||||
<FilterButtons onClick={() => null} value={AssetFilterOption.MINE} />
|
||||
),
|
||||
};
|
||||
|
||||
export const Bookmarked: Story = {
|
||||
render: () => (
|
||||
<FilterButtons onClick={() => null} value={AssetFilterOption.BOOKMARKED} />
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AssetFilterOption } from "~/enums";
|
||||
|
||||
interface Props {
|
||||
onClick: (button: AssetFilterOption) => void;
|
||||
value: AssetFilterOption;
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{
|
||||
text: "Featured",
|
||||
option: AssetFilterOption.FEATURED,
|
||||
},
|
||||
{
|
||||
text: "Mine",
|
||||
option: AssetFilterOption.MINE,
|
||||
},
|
||||
{
|
||||
text: "Bookmarked",
|
||||
option: AssetFilterOption.BOOKMARKED,
|
||||
},
|
||||
];
|
||||
|
||||
export function FilterButtons({ onClick, value }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 overflow-x-auto overflow-y-hidden px-4">
|
||||
{FILTERS.map((filter) => {
|
||||
const isBookmarks = filter.option === AssetFilterOption.BOOKMARKED;
|
||||
return (
|
||||
<button
|
||||
key={filter.option}
|
||||
className={`filter-tab${value === filter.option ? " active" : ""}`}
|
||||
disabled={isBookmarks}
|
||||
onClick={() => onClick(filter.option)}
|
||||
>
|
||||
{filter.text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FilterButtons";
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import EnvironmentVariables from "~/Classes/EnvironmentVariables";
|
||||
|
||||
interface Props {
|
||||
size: number;
|
||||
email_hash?: string;
|
||||
username?: string;
|
||||
avatarIndex?: number;
|
||||
backgroundIndex?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Gravatar(props: Props) {
|
||||
//url to the local fallback image
|
||||
const localDefaultUrl =
|
||||
props.avatarIndex === undefined
|
||||
? "/resources/avatars/0.webp"
|
||||
: `/resources/avatars/${props.avatarIndex}.webp`;
|
||||
|
||||
// submitted fallback need to be a url served from our server for gravatar to grab it
|
||||
const defaultImageUrl =
|
||||
props.avatarIndex === undefined
|
||||
? "https://storyteller.ai/images/avatars/2000x2000/0.webp"
|
||||
: `https://storyteller.ai/images/avatars/2000x2000/${props.avatarIndex}.webp`;
|
||||
|
||||
// NB: Gravatar suggests URI encoding these:
|
||||
// https://en.gravatar.com/site/implement/images/
|
||||
const encondedDefaultImage = encodeURIComponent(defaultImageUrl);
|
||||
|
||||
const gravatarUrl = `${EnvironmentVariables.values.GRAVATAR_API}/avatar/${props.email_hash}?s=${props.size}&d=${encondedDefaultImage}`;
|
||||
|
||||
const [{ imgUrl, showLoader }, setState] = useState<{
|
||||
imgUrl: string;
|
||||
showLoader: boolean;
|
||||
}>({
|
||||
imgUrl: gravatarUrl,
|
||||
showLoader: true,
|
||||
});
|
||||
|
||||
const altText = props.username ? `${props.username}'s gravatar` : "gravatar";
|
||||
|
||||
function getBackgroundColor(backgroundIndex?: number): string {
|
||||
switch (backgroundIndex) {
|
||||
case 0:
|
||||
return "#E66462";
|
||||
case 1:
|
||||
return "#FD881B";
|
||||
case 2:
|
||||
return "#E7C13C";
|
||||
case 3:
|
||||
return "#4BA905";
|
||||
case 4:
|
||||
return "#25B8A0";
|
||||
case 5:
|
||||
return "#0078D1";
|
||||
case 6:
|
||||
return "#7F52C1";
|
||||
case 7:
|
||||
return "#FF66AC";
|
||||
case 8:
|
||||
return "#259FEC";
|
||||
default:
|
||||
return `#1a1a27`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative aspect-square overflow-hidden rounded-full border border-white",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{showLoader && (
|
||||
<div className="absolute flex h-full w-full items-center justify-center bg-brand-secondary">
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin size={"lg"} />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
crossOrigin="anonymous"
|
||||
alt={altText}
|
||||
src={imgUrl}
|
||||
height={props.size}
|
||||
width={props.size}
|
||||
style={{ backgroundColor: getBackgroundColor(props.backgroundIndex) }}
|
||||
onLoad={() => {
|
||||
setState((curr) => ({
|
||||
imgUrl: curr.imgUrl,
|
||||
showLoader: false,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
setState((curr) => ({
|
||||
imgUrl:
|
||||
curr.imgUrl !== localDefaultUrl ? localDefaultUrl : curr.imgUrl,
|
||||
showLoader: false,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Gravatar };
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Gravatar";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as InputStories from './Input.stories';
|
||||
|
||||
<Meta of={InputStories} />
|
||||
|
||||
# Input control
|
||||
|
||||
Here is the input with placeholder
|
||||
<Canvas of={InputStories.Default} />
|
||||
|
||||
Here is the input with icon
|
||||
<Canvas of={InputStories.WithIcon} />
|
||||
|
||||
Here is the input with label
|
||||
<Canvas of={InputStories.WithLabel} />
|
||||
|
||||
Here is the input with error message
|
||||
<Canvas of={InputStories.WithError} />
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input } from "./Input";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
import { faUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
component: Input,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["change", "blur", "focus"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Input placeholder="field placeholder" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Input icon={faUser} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Input label="Input label" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Input errorMessage="Error message" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconDefinition } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Label, H6 } from "~/components";
|
||||
import { kebabCase } from "~/utilities";
|
||||
import {
|
||||
disableHotkeyInput,
|
||||
enableHotkeyInput,
|
||||
DomLevels,
|
||||
} from "~/pages/PageEnigma/signals";
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
inputClassName?: string;
|
||||
iconClassName?: string;
|
||||
label?: string;
|
||||
icon?: IconDefinition;
|
||||
isError?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef(
|
||||
(
|
||||
{
|
||||
label,
|
||||
icon,
|
||||
inputClassName,
|
||||
iconClassName,
|
||||
className,
|
||||
id,
|
||||
isError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
errorMessage,
|
||||
...rest
|
||||
}: InputProps,
|
||||
ref: React.ForwardedRef<HTMLInputElement>,
|
||||
) => {
|
||||
return (
|
||||
<div className={twMerge("flex flex-col", className)}>
|
||||
{label && <Label htmlFor={id ? id : kebabCase(label)}>{label}</Label>}
|
||||
|
||||
<div className="relative w-full">
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
className={twMerge("text-md absolute pl-3 pt-3", iconClassName)}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={id ? id : label ? kebabCase(label) : undefined}
|
||||
className={twMerge(
|
||||
"h-10 w-full rounded-lg bg-[#242424] px-3 py-2.5 text-white placeholder-white/50 outline-none",
|
||||
"border border-[#363636] transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:!outline-none",
|
||||
icon && "pl-10",
|
||||
isError && "outline-red focus:outline-red",
|
||||
inputClassName,
|
||||
)}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<H6 className="absolute z-10 text-red">{errorMessage}</H6>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Input";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as InputVectorStories from './InputVector.stories';
|
||||
|
||||
<Meta of={InputVectorStories} />
|
||||
|
||||
# 3 Input controls: one for x, y, and z
|
||||
It allows entry os plus or minus numbers with decimals. It can also be adjusted via dragging the mouse left or right.
|
||||
|
||||
<Canvas of={InputVectorStories.Default} />
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { InputVector } from "./InputVector";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
|
||||
const meta: Meta<typeof InputVector> = {
|
||||
component: InputVector,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["change", "blur", "focus"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof InputVector>;
|
||||
|
||||
const InputVectorRender = () => {
|
||||
// Sets the hooks for both the label and primary props
|
||||
const [xyz, setXyz] = useState<Record<string, string>>({
|
||||
x: "0",
|
||||
y: "0",
|
||||
z: "0",
|
||||
});
|
||||
|
||||
return (
|
||||
<InputVector
|
||||
x={xyz.x}
|
||||
y={xyz.y}
|
||||
z={xyz.z}
|
||||
onChange={(value) => setXyz(value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<InputVectorRender />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { ChangeEvent, RefObject, useRef } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
disableHotkeyInput,
|
||||
DomLevels,
|
||||
enableHotkeyInput,
|
||||
} from "~/pages/PageEnigma/signals";
|
||||
|
||||
interface InputVectorProps {
|
||||
x: string;
|
||||
y: string;
|
||||
z: string;
|
||||
onChange: (v: Record<string, string>) => void;
|
||||
increment?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const InputVector = ({
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
onChange,
|
||||
increment = 0.1,
|
||||
disabled,
|
||||
}: InputVectorProps) => {
|
||||
const xRef = useRef<HTMLInputElement>(null);
|
||||
const yRef = useRef<HTMLInputElement>(null);
|
||||
const zRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isDragging = useRef(false);
|
||||
const previousMousePosition = useRef(0);
|
||||
|
||||
const oldX = useRef("");
|
||||
const oldY = useRef("");
|
||||
const oldZ = useRef("");
|
||||
|
||||
oldX.current = x;
|
||||
oldY.current = y;
|
||||
oldZ.current = z;
|
||||
|
||||
const inputCommonClasses =
|
||||
"relative h-6 rounded-r-lg bg-brand-secondary-900/35 p-2 text-sm text-white transition-all duration-100 ease-in-out outline-none -outline-offset-2 text-end w-full hover:cursor-e-resize hover:bg-brand-secondary-900";
|
||||
|
||||
const wrapperCommonClasses =
|
||||
"relative flex items-center before:inline-block before:h-6 before:bg-brand-primary before:text-white before:rounded-l-lg before:w-1.5 before:text-center before:justify-center before:items-center before:font-semibold before:flex before:align-middle before:leading-8 text-xs";
|
||||
|
||||
const lockedCommonClasses =
|
||||
"hover:cursor-not-allowed hover:bg-brand-secondary";
|
||||
|
||||
function validateInput(newValue: string, oldValue: string) {
|
||||
if (newValue === "" || newValue === "-" || newValue === ".") {
|
||||
return newValue;
|
||||
}
|
||||
return /^-?\d*(\.\d{0,2})?$/.test(newValue) ? newValue : oldValue;
|
||||
}
|
||||
|
||||
function handleOnChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const newVector: Record<string, string> = {
|
||||
x: oldX.current,
|
||||
y: oldY.current,
|
||||
z: oldZ.current,
|
||||
};
|
||||
|
||||
newVector[event.target.name] = validateInput(
|
||||
event.target.value,
|
||||
newVector[event.target.name],
|
||||
);
|
||||
|
||||
onChange(newVector);
|
||||
}
|
||||
|
||||
const blurAllInputs = () => {
|
||||
xRef.current?.blur();
|
||||
yRef.current?.blur();
|
||||
zRef.current?.blur();
|
||||
};
|
||||
|
||||
// For dragging the input value to increment/decrement
|
||||
const handleMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
ref: RefObject<HTMLInputElement>,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
blurAllInputs();
|
||||
previousMousePosition.current = e.clientX;
|
||||
isDragging.current = true;
|
||||
|
||||
const mouseMoveHandler = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const currentMousePosition = e.clientX;
|
||||
const direction =
|
||||
currentMousePosition === previousMousePosition.current
|
||||
? 0
|
||||
: currentMousePosition > previousMousePosition.current
|
||||
? 1
|
||||
: -1;
|
||||
|
||||
previousMousePosition.current = currentMousePosition;
|
||||
|
||||
const newVector = {
|
||||
x: oldX.current,
|
||||
y: oldY.current,
|
||||
z: oldZ.current,
|
||||
} as Record<string, string>;
|
||||
|
||||
const newValue =
|
||||
parseFloat(newVector[ref.current?.name ?? ""]) + direction * increment;
|
||||
|
||||
newVector[ref.current?.name ?? ""] = (
|
||||
Math.round(newValue * 1000) / 1000
|
||||
).toString();
|
||||
|
||||
onChange(newVector);
|
||||
};
|
||||
|
||||
const mouseUpHandler = () => {
|
||||
document.removeEventListener("mousemove", mouseMoveHandler);
|
||||
document.removeEventListener("mouseup", mouseUpHandler);
|
||||
isDragging.current = false;
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", mouseMoveHandler);
|
||||
document.addEventListener("mouseup", mouseUpHandler);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
(e.currentTarget as HTMLInputElement).blur(); // Unfocus the input field on pressing Enter
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col justify-between gap-1.5">
|
||||
<span
|
||||
className={twMerge(
|
||||
wrapperCommonClasses,
|
||||
"before:bg-axis-x",
|
||||
disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-3.5 z-10 font-semibold">X</div>
|
||||
<input
|
||||
className={twMerge(
|
||||
inputCommonClasses,
|
||||
"focus:outline-axis-x",
|
||||
disabled && lockedCommonClasses,
|
||||
)}
|
||||
type="text"
|
||||
name="x"
|
||||
onChange={handleOnChange}
|
||||
ref={xRef}
|
||||
value={x}
|
||||
disabled={disabled}
|
||||
onFocus={() => {
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onBlur={() => {
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
handleMouseDown(e, xRef);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
wrapperCommonClasses,
|
||||
"before:bg-axis-y",
|
||||
disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-3.5 z-10 font-semibold">Y</div>
|
||||
<input
|
||||
className={twMerge(
|
||||
inputCommonClasses,
|
||||
"focus:outline-axis-y",
|
||||
disabled && lockedCommonClasses,
|
||||
)}
|
||||
type="text"
|
||||
name="y"
|
||||
onChange={handleOnChange}
|
||||
ref={yRef}
|
||||
value={y}
|
||||
disabled={disabled}
|
||||
onFocus={() => {
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onBlur={() => {
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
handleMouseDown(e, yRef);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
wrapperCommonClasses,
|
||||
"before:bg-axis-z",
|
||||
disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="absolute left-3.5 z-10 font-semibold">Z</div>
|
||||
<input
|
||||
className={twMerge(
|
||||
inputCommonClasses,
|
||||
"focus:outline-axis-z",
|
||||
disabled && lockedCommonClasses,
|
||||
)}
|
||||
type="text"
|
||||
name="z"
|
||||
onChange={handleOnChange}
|
||||
ref={zRef}
|
||||
value={z}
|
||||
disabled={disabled}
|
||||
onFocus={() => {
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onBlur={() => {
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
handleMouseDown(e, zRef);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./InputVector";
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
interface ListDropdownProps {
|
||||
list: { [key: string]: string }[];
|
||||
onSelect: (val: string) => void;
|
||||
}
|
||||
export const ListDropdown = ({ list, onSelect }: ListDropdownProps) => {
|
||||
const [selected, setSelected] = useState(list[0]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelect(Object.values(selected)[0]);
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<Listbox value={selected} onChange={setSelected}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
|
||||
<span className="block truncate">{Object.keys(selected)[0]}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
|
||||
{list.map((item, itemIdx) => (
|
||||
<Listbox.Option
|
||||
key={itemIdx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
|
||||
active ? "text-white" : "text-gray-400"
|
||||
}`
|
||||
}
|
||||
value={item}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={`block truncate ${
|
||||
selected ? "font-medium" : "font-normal"
|
||||
}`}
|
||||
>
|
||||
{Object.values(item)[0]}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { LoadingSpinner } from "~/components";
|
||||
|
||||
interface LoadingBarProps {
|
||||
id?: string;
|
||||
wrapperClassName?: string;
|
||||
innerWrapperClassName?: string;
|
||||
progressBackgroundClassName?: string;
|
||||
progressClassName?: string;
|
||||
variant?: string;
|
||||
show?: boolean;
|
||||
hasSpinner?: boolean;
|
||||
progressData: {
|
||||
progress: number;
|
||||
label?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
export const LoadingBar = ({
|
||||
wrapperClassName: propsWrapperClassName,
|
||||
innerWrapperClassName: propsInnerWrapperClassName,
|
||||
progressBackgroundClassName: propsProgressBackgroundClassName,
|
||||
progressClassName: propsProgressClassName,
|
||||
progressData,
|
||||
hasSpinner,
|
||||
variant = "primary",
|
||||
show = true,
|
||||
...rest
|
||||
}: LoadingBarProps) => {
|
||||
function getVariantClassNames(variant: string) {
|
||||
switch (variant) {
|
||||
case "secondary": {
|
||||
return " bg-brand-secondary text-white ";
|
||||
}
|
||||
case "primary":
|
||||
default: {
|
||||
return " bg-brand-primary text-white ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wrapperClassName = twMerge(
|
||||
"w-full h-full relative bg-ui-background",
|
||||
propsWrapperClassName,
|
||||
);
|
||||
const innerWrapperClassName = twMerge(
|
||||
"w-full h-full p-4 gap-6 m-auto flex flex-col justify-center items-center",
|
||||
propsInnerWrapperClassName,
|
||||
);
|
||||
const progressBackgroundClassName = twMerge(
|
||||
"w-full bg-brand-secondary-800 rounded-full h-2.5",
|
||||
propsProgressBackgroundClassName,
|
||||
);
|
||||
const progressClassName = twMerge(
|
||||
"h-2.5 rounded-full transition-all duration-1000",
|
||||
getVariantClassNames(variant),
|
||||
propsProgressClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
className={wrapperClassName}
|
||||
show={show}
|
||||
enter="transition-opacity duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-1000"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
{...rest}
|
||||
>
|
||||
<div className={innerWrapperClassName}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{hasSpinner && <LoadingSpinner className="h-5" />}
|
||||
{progressData.label && (
|
||||
<label className="text-lg font-medium">
|
||||
{progressData.label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={progressBackgroundClassName}>
|
||||
<div
|
||||
className={progressClassName}
|
||||
style={{ width: progressData.progress + "%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{progressData.message && (
|
||||
<p className="text-center text-sm opacity-75">
|
||||
{progressData.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { H4 } from '.';
|
||||
|
||||
interface LoadingDotsProps {
|
||||
className?: string;
|
||||
isShowing?: boolean;
|
||||
};
|
||||
|
||||
interface LoadingDotsInnerProps {
|
||||
className?: string;
|
||||
isShowing?: boolean;
|
||||
type?: 'typing'|'bricks';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export const LoadingDotsTyping = (props: LoadingDotsProps)=>{
|
||||
return (
|
||||
<LoadingDots {...props} />
|
||||
)
|
||||
}
|
||||
export const LoadingDotsBricks = (props: LoadingDotsProps)=>{
|
||||
return (
|
||||
<LoadingDots {...props} type="bricks"/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingDots({
|
||||
className,
|
||||
isShowing=true,
|
||||
type = 'typing',
|
||||
message,
|
||||
}: LoadingDotsInnerProps){
|
||||
const classNames = twMerge("w-full h-full flex flex-col justify-center items-center bg-ui-background gap-6", className);
|
||||
|
||||
return(
|
||||
<Transition
|
||||
className={classNames}
|
||||
show={isShowing}
|
||||
enter="transition-opacity duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-1000"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{ type==='typing' &&
|
||||
<div className="dot-typing"></div>
|
||||
}
|
||||
{ type==='bricks' &&
|
||||
<div className="dot-bricks"></div>
|
||||
}
|
||||
{
|
||||
message &&
|
||||
<H4>{message}</H4>
|
||||
}
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner = ({ className }: LoadingSpinnerProps) => {
|
||||
return (
|
||||
<svg
|
||||
className={twMerge("h-6 w-6 animate-spin", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Input } from "../Input";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronDown, faChevronUp } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const NumberInput: React.FC<NumberInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState<string>(value.toString());
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(Math.round(value * 100) / 100 + "");
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
if (
|
||||
newValue === "" ||
|
||||
(!isNaN(Number(newValue)) && Number(newValue) <= 100)
|
||||
) {
|
||||
setInputValue(newValue);
|
||||
const numericValue = parseFloat(newValue);
|
||||
if (newValue !== "") {
|
||||
onChange(Math.round(numericValue * 100) / 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputValue === "") {
|
||||
setInputValue(Math.round(value * 100) / 100 + "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
const newValue = Math.min(Number(inputValue) + 1, 100);
|
||||
setInputValue(Math.round(newValue * 100) / 100 + "");
|
||||
onChange(Math.round(newValue * 100) / 100);
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
const newValue = Math.max(Number(inputValue) - 1, 0);
|
||||
setInputValue(Math.round(newValue * 100) / 100 + "");
|
||||
onChange(Math.round(newValue * 100) / 100);
|
||||
};
|
||||
|
||||
const incrementButtonStyle =
|
||||
"flex h-[15px] w-6 items-center justify-center text-[10px] text-white/80 bg-ui-controls hover:bg-ui-controls-button outline-none focus:outline-none transition-all active:outline-none";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
inputClassName="w-[70px] h-[30px] rounded-md text-sm"
|
||||
/>
|
||||
|
||||
<div className="absolute right-0 top-0 flex h-[30px] flex-col rounded-r-md border-l border-white/15 bg-gray-800">
|
||||
<button
|
||||
className={twMerge(
|
||||
incrementButtonStyle,
|
||||
"rounded-tr-md border-b border-white/10",
|
||||
)}
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronUp} />
|
||||
</button>
|
||||
<button
|
||||
className={twMerge(incrementButtonStyle, "rounded-br-md")}
|
||||
onClick={handleDecrement}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./NumberInput";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as PaginationStories from './Pagination.stories';
|
||||
|
||||
<Meta of={PaginationStories} />
|
||||
|
||||
# Controls for pagination.
|
||||
|
||||
Here is what it looks like when empty
|
||||
<Canvas of={PaginationStories.Empty} />
|
||||
First Page
|
||||
<Canvas of={PaginationStories.FirstPage} />
|
||||
Middle
|
||||
<Canvas of={PaginationStories.Middle} />
|
||||
Last Page
|
||||
<Canvas of={PaginationStories.LastPage} />
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
|
||||
const meta: Meta<typeof Pagination> = {
|
||||
component: Pagination,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["pageChange"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Pagination>;
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] bg-brand-secondary-200">
|
||||
<Pagination
|
||||
className=""
|
||||
currentPage={0}
|
||||
totalPages={0}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const FirstPage: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] bg-brand-secondary-200">
|
||||
<Pagination
|
||||
className=""
|
||||
currentPage={0}
|
||||
totalPages={5}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Middle: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] bg-brand-secondary-200">
|
||||
<Pagination
|
||||
className=""
|
||||
currentPage={2}
|
||||
totalPages={5}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LastPage: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] bg-brand-secondary-200">
|
||||
<Pagination
|
||||
className=""
|
||||
currentPage={4}
|
||||
totalPages={5}
|
||||
onPageChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
faChevronRight,
|
||||
faChevronLeft,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ButtonIcon } from "~/components";
|
||||
|
||||
export const Pagination = ({
|
||||
className,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onFetchMorePages,
|
||||
}: {
|
||||
className: string;
|
||||
currentPage: number; // index starts from 0
|
||||
totalPages: number;
|
||||
onPageChange: (newPage: number) => void;
|
||||
onFetchMorePages?: () => void;
|
||||
}) => {
|
||||
const pageGroupSize = 5;
|
||||
const pageGroup = Math.floor(currentPage / pageGroupSize);
|
||||
const totalPagesInPageGroup =
|
||||
(pageGroup + 1) * pageGroupSize > totalPages
|
||||
? totalPages % pageGroupSize
|
||||
: pageGroupSize;
|
||||
const pageGroupOffset = pageGroup * pageGroupSize;
|
||||
|
||||
const handleSetPreviousPage = () => {
|
||||
onPageChange(currentPage - 1);
|
||||
};
|
||||
const handleSetNextPage = () => {
|
||||
onPageChange(currentPage + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={twMerge(
|
||||
"flex items-center justify-between border-gray-200 px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ButtonIcon
|
||||
className="-ml-2 pt-2 text-gray-400"
|
||||
icon={faChevronLeft}
|
||||
onClick={handleSetPreviousPage}
|
||||
disabled={currentPage === 0}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="hidden md:flex">
|
||||
{[...Array(totalPagesInPageGroup)].map((e, i) => {
|
||||
const pageIndex = i + pageGroupOffset;
|
||||
if (pageIndex === currentPage) {
|
||||
return (
|
||||
<button
|
||||
key={pageIndex}
|
||||
className="border-indigo-500 text-indigo-600 inline-flex items-center border-t-2 px-4 pt-2 text-sm font-medium"
|
||||
aria-current="page"
|
||||
onClick={() => onPageChange(pageIndex)}
|
||||
>
|
||||
{pageIndex + 1}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<button
|
||||
key={pageIndex}
|
||||
className="inline-flex items-center border-t-2 border-transparent px-4 pt-2 text-sm font-medium text-gray-500 hover:border-brand-primary hover:text-brand-primary"
|
||||
onClick={() => onPageChange(pageIndex)}
|
||||
>
|
||||
{pageIndex + 1}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<span>
|
||||
{currentPage + 1 !== totalPages ? (
|
||||
<ButtonIcon
|
||||
className="-mr-2 pt-2 text-gray-500 hover:text-brand-primary"
|
||||
icon={faChevronRight}
|
||||
onClick={handleSetNextPage}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
onFetchMorePages && (
|
||||
<button
|
||||
className="inline-flex items-center border-t-2 border-transparent pl-2 pr-4 pt-2 text-sm font-medium text-gray-500 hover:text-brand-primary"
|
||||
key={totalPages}
|
||||
onClick={onFetchMorePages}
|
||||
>
|
||||
More...
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Pagination";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as PillStories from './Pill.stories';
|
||||
|
||||
<Meta of={PillStories} />
|
||||
|
||||
# Pill
|
||||
|
||||
A pill is a container around the children.
|
||||
|
||||
<Canvas of={PillStories.Default} />
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
const meta: Meta<typeof Pill> = {
|
||||
component: Pill,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Pill>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <Pill>A Pill</Pill>,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const Pill = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-fit rounded-md bg-brand-primary px-1.5 py-0.5 text-xs font-bold",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Pill";
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../Button";
|
||||
|
||||
export interface PopoverItem {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
icon?: ReactNode;
|
||||
action?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface PopoverMenuProps {
|
||||
items?: PopoverItem[];
|
||||
onSelect?: (item: PopoverItem) => void;
|
||||
onAdd?: () => void;
|
||||
triggerIcon?: ReactNode;
|
||||
showAddButton?: boolean;
|
||||
showIconsInList?: boolean;
|
||||
mode?: "default" | "toggle" | "button";
|
||||
triggerLabel?: string;
|
||||
children?: ReactNode;
|
||||
buttonClassName?: string;
|
||||
panelClassName?: string;
|
||||
onPanelAction?: (action: string) => void;
|
||||
panelTitle?: string;
|
||||
position?: "top" | "bottom";
|
||||
align?: "start" | "center" | "end";
|
||||
panelActionLabel?: string;
|
||||
}
|
||||
|
||||
export const PopoverMenu = ({
|
||||
items = [],
|
||||
onSelect,
|
||||
onAdd,
|
||||
triggerIcon,
|
||||
showAddButton = false,
|
||||
showIconsInList = false,
|
||||
mode = "default",
|
||||
triggerLabel,
|
||||
children,
|
||||
buttonClassName,
|
||||
panelClassName,
|
||||
onPanelAction,
|
||||
panelTitle,
|
||||
position = "top",
|
||||
align = "start",
|
||||
panelActionLabel,
|
||||
}: PopoverMenuProps) => {
|
||||
const selectedItem = items.find((item) => item.selected);
|
||||
|
||||
const handleItemClick = (item: PopoverItem, close: () => void) => {
|
||||
if (mode === "button" && item.action && onPanelAction) {
|
||||
onPanelAction(item.action);
|
||||
close();
|
||||
} else {
|
||||
onSelect?.(item);
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const className = twMerge(
|
||||
"text-sm font-medium rounded-lg px-3 py-2 shadow-sm",
|
||||
"flex gap-2 items-center justify-center outline-none",
|
||||
"transition-all duration-150",
|
||||
"bg-[#5F5F68]/60 px-3 text-white backdrop-blur-lg hover:bg-[#5F5F68]/90",
|
||||
buttonClassName,
|
||||
);
|
||||
|
||||
const positionClasses = {
|
||||
top: "bottom-full",
|
||||
bottom: "top-full",
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
start: "left-0",
|
||||
center: "left-1/2 -translate-x-1/2",
|
||||
end: "right-0",
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
{({ close }) => (
|
||||
<>
|
||||
<Popover.Button className={className}>
|
||||
{triggerIcon}
|
||||
{mode === "toggle" && selectedItem ? selectedItem.label : null}
|
||||
{mode === "default" && triggerLabel ? triggerLabel : null}
|
||||
</Popover.Button>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute transform-gpu",
|
||||
positionClasses[position],
|
||||
alignClasses[align],
|
||||
)}
|
||||
>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom={
|
||||
position === "bottom"
|
||||
? "translate-y-2 opacity-0"
|
||||
: "-translate-y-2 opacity-0"
|
||||
}
|
||||
enterTo="translate-y-0 opacity-100"
|
||||
leave="transition duration-100 ease-in"
|
||||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo={
|
||||
position === "bottom"
|
||||
? "translate-y-2 opacity-0"
|
||||
: "-translate-y-2 opacity-0"
|
||||
}
|
||||
>
|
||||
<Popover.Panel
|
||||
className={twMerge(
|
||||
position === "bottom" ? "origin-top" : "origin-bottom",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"z-10 min-w-48 rounded-lg bg-[#46464B] p-1.5 shadow-lg",
|
||||
position === "top" && "mb-2",
|
||||
position === "bottom" && "mt-2",
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
{panelTitle && (
|
||||
<div className="mb-2 mt-0.5 flex justify-between px-1.5 text-sm font-normal text-white opacity-70">
|
||||
{panelTitle}
|
||||
{panelActionLabel && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onPanelAction?.(panelActionLabel);
|
||||
close();
|
||||
}}
|
||||
className="text-end text-sm text-white/85 hover:underline"
|
||||
>
|
||||
{panelActionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "default" && children ? (
|
||||
<div className="text-sm text-white">{children}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0 text-sm text-white">
|
||||
{items.map((item, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className={twMerge(
|
||||
"flex items-center justify-between border-transparent bg-transparent px-1.5",
|
||||
"hover:bg-[#63636B]/60",
|
||||
mode === "toggle" && item.selected
|
||||
? "hover:bg-[#63636B]"
|
||||
: "bg-transparent",
|
||||
item.disabled
|
||||
? "!cursor-not-allowed opacity-50"
|
||||
: "",
|
||||
)}
|
||||
onClick={() =>
|
||||
!item.disabled && handleItemClick(item, close)
|
||||
}
|
||||
variant="secondary"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showIconsInList && item.icon}
|
||||
{item.label}
|
||||
</div>
|
||||
{mode === "toggle" && (
|
||||
<input
|
||||
type="radio"
|
||||
checked={item.selected}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{showAddButton && onAdd && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mb-0.5 mt-2 border-none bg-[#7B7B84] py-1 hover:bg-[#8c8c96]"
|
||||
onClick={onAdd}
|
||||
>
|
||||
+ Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Popover";
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
faCircleXmark,
|
||||
faFilterList,
|
||||
faSearch,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button, Input, Tooltip } from "~/components";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
interface SearchFilterProps {
|
||||
onSearchChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchTerm?: string;
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
export const SearchFilter = ({
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
searchTerm = "",
|
||||
showFilters = false,
|
||||
}: SearchFilterProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = useState(searchTerm);
|
||||
const [hasText, setHasText] = useState(searchTerm.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(searchTerm);
|
||||
setHasText(searchTerm.length > 0);
|
||||
}, [searchTerm]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedChangeHandler = useCallback(
|
||||
debounce((value: string) => {
|
||||
onSearchChange(value);
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setInputValue(value);
|
||||
setHasText(value.length > 0);
|
||||
debouncedChangeHandler(value);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setInputValue("");
|
||||
setHasText(false);
|
||||
onSearchChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative grow">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
icon={faSearch}
|
||||
iconClassName="opacity-50 pt-[11px] text-sm"
|
||||
inputClassName="h-9 rounded-lg text-sm pr-8 pl-9"
|
||||
placeholder={placeholder}
|
||||
className="grow"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
{hasText && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCircleXmark}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 transform cursor-pointer opacity-50 transition-all duration-100 hover:opacity-100"
|
||||
onClick={clearSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<Tooltip position="top" content="Filters">
|
||||
<Button
|
||||
icon={faFilterList}
|
||||
variant="secondary"
|
||||
className="h-9 w-9"
|
||||
iconClassName="text-[16px]"
|
||||
></Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SearchFilter";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Fragment } from "react";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { faCheck, faChevronDown } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { MediaFileAnimationType } from "~/enums";
|
||||
|
||||
export type SelectValue = string | number;
|
||||
export type SelectOption = { label: string; value: SelectValue };
|
||||
|
||||
interface ListDropdownProps {
|
||||
options: SelectOption[];
|
||||
onChange: (val: MediaFileAnimationType) => void;
|
||||
placeholder?: string;
|
||||
value?: SelectValue;
|
||||
}
|
||||
export const Select = ({
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
value,
|
||||
}: ListDropdownProps) => {
|
||||
const selectedOption = options.find(
|
||||
(option: SelectOption) => option.value === value,
|
||||
) || { label: placeholder || "", value: "" };
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm">
|
||||
<span className={`block truncate${value ? "" : " opacity-50"}`}>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<FontAwesomeIcon icon={faChevronDown} aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm">
|
||||
{options.map((option, itemIdx) => (
|
||||
<Listbox.Option
|
||||
key={itemIdx}
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${
|
||||
active ? "text-white" : "text-gray-400"
|
||||
}`
|
||||
}
|
||||
value={option.value}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span
|
||||
className={`block truncate ${
|
||||
selected ? "font-medium" : "font-normal"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<FontAwesomeIcon icon={faCheck} aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import React, { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface SliderProps {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
step: number;
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
tooltipContent?: ReactNode;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export const Slider: React.FC<SliderProps> = ({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
step,
|
||||
className,
|
||||
showTooltip,
|
||||
tooltipContent,
|
||||
suffix,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const updatePosition = (x: number) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const fraction = (x - rect.left) / rect.width;
|
||||
const newValue = fraction * (max - min) + min;
|
||||
const roundedValue = Math.round(newValue / step) * step;
|
||||
const clampedValue = Math.min(Math.max(roundedValue, min), max);
|
||||
setLocalValue(clampedValue);
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setShowHandleTooltip(true);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
setShowHandleTooltip(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = ((localValue - min) / (max - min)) * 100;
|
||||
const displayValue =
|
||||
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
|
||||
(suffix || "");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={twMerge(
|
||||
"relative h-2.5 w-full cursor-pointer rounded-full bg-brand-secondary-900",
|
||||
className,
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
className="absolute h-2.5 rounded-full bg-brand-primary"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute -top-1 h-[18px] w-[18px] -translate-x-1/2 transform rounded-full bg-gray-200 transition-colors",
|
||||
isDragging ? "bg-white" : "bg-gray-300 hover:bg-white",
|
||||
)}
|
||||
style={{ left: `${percentage}%` }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<Transition
|
||||
show={showHandleTooltip}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
{showTooltip && showHandleTooltip && (
|
||||
<div
|
||||
className="absolute -top-8 z-10 rounded-md bg-ui-controls px-2 py-1 text-xs text-white shadow-lg"
|
||||
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
{tooltipContent ? tooltipContent : displayValue}
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Slider";
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import React, { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "../Button";
|
||||
import { faMinus, faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
interface SliderProps {
|
||||
min: number;
|
||||
max: number;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
step: number;
|
||||
className?: string;
|
||||
showTooltip?: boolean;
|
||||
tooltipContent?: ReactNode;
|
||||
suffix?: string;
|
||||
innerLabel?: string;
|
||||
showDecrement?: boolean;
|
||||
showIncrement?: boolean;
|
||||
}
|
||||
|
||||
export const SliderV2 = ({
|
||||
min,
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
step,
|
||||
className,
|
||||
showTooltip,
|
||||
tooltipContent,
|
||||
suffix,
|
||||
innerLabel,
|
||||
showDecrement,
|
||||
showIncrement,
|
||||
}: SliderProps) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showHandleTooltip, setShowHandleTooltip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const setClampedValue = (value: number) => {
|
||||
const roundedValue = Math.round(value / step) * step;
|
||||
const clampedValue = Math.min(Math.max(roundedValue, min), max);
|
||||
setLocalValue(clampedValue);
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
const updatePosition = (x: number) => {
|
||||
if (!sliderRef.current) return;
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const fraction = (x - rect.left) / rect.width;
|
||||
const newValue = fraction * (max - min) + min;
|
||||
setClampedValue(newValue);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
setShowHandleTooltip(true);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
updatePosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
setShowHandleTooltip(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isDragging) {
|
||||
setShowHandleTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
setClampedValue(localValue - step);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
setClampedValue(localValue + step);
|
||||
};
|
||||
|
||||
const percentage = ((localValue - min) / (max - min)) * 100;
|
||||
const displayValue =
|
||||
localValue.toFixed(step.toString().split(".")[1]?.length || 0) +
|
||||
(suffix || "");
|
||||
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
{showDecrement && (
|
||||
<Button
|
||||
icon={faMinus}
|
||||
className="focus-visible:outline-primary my-auto mr-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
|
||||
onClick={handleDecrement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={twMerge(
|
||||
"glass border-ui-border group relative h-7 w-full cursor-pointer overflow-hidden rounded-lg border",
|
||||
isDragging && "!bg-ui-controls/90",
|
||||
className,
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute h-full bg-brand-primary/30 transition-colors duration-300 group-hover:bg-brand-primary/50",
|
||||
isDragging && "!bg-brand-primary/50",
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
>
|
||||
{innerLabel && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"absolute top-1/2 ml-2.5 -translate-y-1/2 text-nowrap text-sm font-medium text-white/60 transition-colors duration-300 group-hover:text-white",
|
||||
isDragging && "!text-white",
|
||||
)}
|
||||
>
|
||||
{innerLabel}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute right-0 top-1/2 mr-1 h-4 w-0.5 -translate-y-1/2 rounded-full",
|
||||
isDragging ? "bg-white" : "bg-white/50",
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showIncrement && (
|
||||
<Button
|
||||
icon={faPlus}
|
||||
className="focus-visible:outline-primary my-auto ml-1 size-6 rounded-full bg-transparent text-white/80 hover:bg-white/10 active:bg-brand-primary/30"
|
||||
onClick={handleIncrement}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTooltip && (
|
||||
<Transition
|
||||
as={"div"}
|
||||
show={showHandleTooltip}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="absolute -top-8 z-10 rounded-md bg-ui-panel px-2 py-1 text-xs text-black shadow-lg"
|
||||
style={{ left: `${percentage}%`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
{tooltipContent ? tooltipContent : displayValue}
|
||||
</div>
|
||||
</Transition>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SliderV2";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type TabType = {
|
||||
header: string;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export const Tabs = ({ tabs: tabProps }: { tabs: TabType[] }) => {
|
||||
const [tabs] = useState(tabProps);
|
||||
|
||||
const tabHeaderClassName = (selected: boolean) =>
|
||||
twMerge(
|
||||
"px-4 py-4 text-md font-medium leading-5 focus:outline-none transition duration-150 ease-in-out border-b-[3px] border-white/[.1]",
|
||||
selected
|
||||
? "text-white border-brand-primary"
|
||||
: "text-white/[0.5] hover:text-white hover:border-white/[.3]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col px-2 sm:px-0">
|
||||
<Tab.Group defaultIndex={3}>
|
||||
<Tab.List className="flex">
|
||||
{tabs.map((tab, idx) => (
|
||||
<Tab
|
||||
key={idx}
|
||||
className={({ selected }) => tabHeaderClassName(selected)}
|
||||
>
|
||||
{tab.header}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="grow overflow-auto p-4">
|
||||
{tabs.map((tab, idx) => (
|
||||
<Tab.Panel key={idx}>{tab.children}</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Tabs } from './Tabs';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Canvas, Meta } from '@storybook/blocks';
|
||||
|
||||
import * as TextareaStories from './Textarea.stories';
|
||||
|
||||
<Meta of={TextareaStories} />
|
||||
|
||||
# Textarea control
|
||||
|
||||
Here is the Textarea with placeholder
|
||||
<Canvas of={TextareaStories.Default} />
|
||||
|
||||
Here is the Textarea with label
|
||||
<Canvas of={TextareaStories.WithLabel} />
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Textarea } from "./Textarea";
|
||||
import { withActions } from "@storybook/addon-actions/decorator";
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
actions: {
|
||||
handles: ["change", "blur", "focus"],
|
||||
},
|
||||
},
|
||||
decorators: [withActions],
|
||||
};
|
||||
|
||||
type Story = StoryObj<typeof Textarea>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Textarea placeholder="field placeholder" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="bg-action p-8">
|
||||
<Textarea label="Label" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { TextareaHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Label } from "~/components";
|
||||
import { kebabCase } from "~/utilities";
|
||||
import {
|
||||
disableHotkeyInput,
|
||||
enableHotkeyInput,
|
||||
DomLevels,
|
||||
} from "~/pages/PageEnigma/signals";
|
||||
|
||||
type ResizeType =
|
||||
| "none"
|
||||
| "both"
|
||||
| "horizontal"
|
||||
| "vertical"
|
||||
| "block"
|
||||
| "inline"
|
||||
| undefined;
|
||||
|
||||
export const Textarea = ({
|
||||
className,
|
||||
label,
|
||||
resize = "vertical",
|
||||
id,
|
||||
...rest
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
label?: string;
|
||||
resize?: ResizeType;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{label && <Label htmlFor={id ? id : kebabCase(label)}>{label}</Label>}
|
||||
|
||||
<textarea
|
||||
id={id ? id : label ? kebabCase(label) : undefined}
|
||||
className={twMerge(
|
||||
"rounded-lg border border-[#363636] bg-brand-secondary px-3 py-2 placeholder-white/50 outline-none transition-all duration-150 ease-in-out hover:border-brand-primary/60 focus:border-brand-primary focus:outline-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
resize: resize,
|
||||
}}
|
||||
onFocus={() => {
|
||||
disableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onBlur={() => {
|
||||
enableHotkeyInput(DomLevels.INPUT);
|
||||
}}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Textarea";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Button } from "~/components/reusable/Button";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ToggleButtonProps {
|
||||
isActive: boolean;
|
||||
icon?: IconDefinition;
|
||||
activeIcon?: IconDefinition;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ToggleButton = ({
|
||||
isActive,
|
||||
icon,
|
||||
activeIcon,
|
||||
onClick,
|
||||
className,
|
||||
}: ToggleButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
className={twMerge(
|
||||
"flex h-9 w-9 items-center border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all",
|
||||
isActive
|
||||
? "bg-brand-primary/40 hover:bg-brand-primary/40 border-white/20 hover:border-white/30"
|
||||
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90",
|
||||
className,
|
||||
)}
|
||||
variant="secondary"
|
||||
icon={isActive && activeIcon ? activeIcon : icon}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ToggleButton";
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactElement;
|
||||
content: React.ReactNode;
|
||||
position: "top" | "bottom" | "left" | "right";
|
||||
className?: string;
|
||||
delay?: number;
|
||||
closeOnClick?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
position,
|
||||
className,
|
||||
delay = 300,
|
||||
closeOnClick = false,
|
||||
}: TooltipProps) => {
|
||||
const [isShowing, setIsShowing] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkForOpenPopovers = () => {
|
||||
if (!triggerRef.current) return false;
|
||||
return (
|
||||
triggerRef.current.querySelectorAll('[data-headlessui-state="open"]')
|
||||
.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (
|
||||
mutation.type === "attributes" &&
|
||||
mutation.attributeName === "data-headlessui-state"
|
||||
) {
|
||||
const target = mutation.target as HTMLElement;
|
||||
if (target.getAttribute("data-headlessui-state") === "open") {
|
||||
setIsShowing(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (triggerRef.current) {
|
||||
observer.observe(triggerRef.current, {
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["data-headlessui-state"],
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStyleForPosition = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
switch (position) {
|
||||
case "top":
|
||||
return {
|
||||
bottom: rect.height + 10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
case "bottom":
|
||||
return {
|
||||
top: rect.height + 10,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
};
|
||||
case "left":
|
||||
return {
|
||||
right: rect.width + 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
case "right":
|
||||
return {
|
||||
left: rect.width + 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (closeOnClick) {
|
||||
setIsShowing(false);
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={() => {
|
||||
if (!checkForOpenPopovers()) {
|
||||
setIsShowing(true);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsShowing(false);
|
||||
}}
|
||||
onClick={handleClick}
|
||||
className="relative"
|
||||
>
|
||||
{children}
|
||||
<Transition
|
||||
show={isShowing}
|
||||
enter={twMerge(
|
||||
"transition ease-out duration-200",
|
||||
delay ? `delay-[${delay}ms]` : "delay-[300ms]",
|
||||
)}
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
style={{
|
||||
...getStyleForPosition(),
|
||||
transitionDelay: `${delay}ms`,
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: "200ms",
|
||||
transitionTimingFunction: "ease-out",
|
||||
}}
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute z-10 w-max rounded-lg bg-[#5F5F68] px-2.5 py-1.5 text-sm font-medium text-white shadow-xl",
|
||||
className ? className : "",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, Fragment, ReactNode } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
disableHotkeyInput,
|
||||
enableHotkeyInput,
|
||||
DomLevels,
|
||||
} from "~/pages/PageEnigma/signals";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
IconDefinition,
|
||||
faDownLeftAndUpRightToCenter,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Tooltip } from "~/components";
|
||||
|
||||
const DialogBackdrop = () => {
|
||||
useEffect(() => {
|
||||
disableHotkeyInput(DomLevels.DIALOGUE);
|
||||
return () => {
|
||||
enableHotkeyInput(DomLevels.DIALOGUE);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60" />
|
||||
</Transition.Child>
|
||||
);
|
||||
};
|
||||
|
||||
export const TransitionDialogue = ({
|
||||
isOpen,
|
||||
title,
|
||||
titleIcon,
|
||||
onTitleIconClick,
|
||||
onClose,
|
||||
className,
|
||||
width,
|
||||
children,
|
||||
childPadding = true,
|
||||
titleIconClassName,
|
||||
showClose = true,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title?: ReactNode;
|
||||
titleIcon?: IconDefinition;
|
||||
onTitleIconClick?: () => void;
|
||||
titleIconClassName?: string;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
width?: number;
|
||||
children: ReactNode;
|
||||
childPadding?: boolean;
|
||||
showClose?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[70]" onClose={onClose}>
|
||||
<DialogBackdrop />
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={twMerge(
|
||||
"w-full max-w-lg transform rounded-xl",
|
||||
"border border-ui-panel-border bg-[#2C2C2C]",
|
||||
"text-left align-middle shadow-xl transition-all",
|
||||
className,
|
||||
)}
|
||||
style={{ minWidth: width }}
|
||||
>
|
||||
{title && (
|
||||
<Dialog.Title
|
||||
as="div"
|
||||
className="mb-5 flex justify-between p-5 pb-0 text-xl font-bold text-white"
|
||||
>
|
||||
<>
|
||||
{onTitleIconClick ? (
|
||||
<button
|
||||
className="flex items-center gap-3"
|
||||
onClick={onTitleIconClick}
|
||||
>
|
||||
{titleIcon && (
|
||||
<FontAwesomeIcon
|
||||
icon={titleIcon}
|
||||
className={titleIconClassName}
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
{titleIcon && (
|
||||
<FontAwesomeIcon
|
||||
icon={titleIcon}
|
||||
className={titleIconClassName}
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{showClose && (
|
||||
<Tooltip position="top" content="Close">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="opacity-50 transition-opacity duration-150 hover:opacity-80 focus:outline-none"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faDownLeftAndUpRightToCenter}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Dialog.Title>
|
||||
)}
|
||||
<div
|
||||
className={`h-full ${childPadding ? "p-5 pt-0" : ""}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NavLink, NavLinkProps } from "@remix-run/react";
|
||||
import { LabelHTMLAttributes, ReactNode } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface TypoProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
const baseTypo = "text-white";
|
||||
|
||||
export const H1 = ({ className, children }: TypoProps) => (
|
||||
<h1 className={twMerge(baseTypo, "text-2xl font-medium", className)}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
export const H2 = ({ className, children }: TypoProps) => (
|
||||
<h2 className={twMerge(baseTypo, "text-xl font-medium", className)}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
export const H3 = ({ className, children }: TypoProps) => (
|
||||
<h3 className={twMerge(baseTypo, "text-lg font-medium", className)}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const H4 = ({ className, children }: TypoProps) => (
|
||||
<h4 className={twMerge(baseTypo, "text-base font-medium", className)}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
|
||||
export const Label = ({
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: LabelHTMLAttributes<HTMLLabelElement>) => (
|
||||
<label
|
||||
className={twMerge(baseTypo, "mb-2 text-[15px] font-medium", className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
export const H5 = ({ className, children }: TypoProps) => (
|
||||
<h5 className={twMerge(baseTypo, "text-sm font-medium", className)}>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
|
||||
export const H6 = ({ className, children }: TypoProps) => (
|
||||
<h6 className={twMerge(baseTypo, "text-sm font-normal", className)}>
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
|
||||
export const Link = ({ className, ...rest }: NavLinkProps) => (
|
||||
<NavLink
|
||||
className={twMerge(
|
||||
"text-brand-primary transition-all duration-150 hover:text-brand-primary-400",
|
||||
className as string,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
export const P = ({ className, children }: TypoProps) => (
|
||||
<p className={twMerge(baseTypo, className)}> {children} </p>
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFileArrowUp, faFileAudio } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { P } from "~/components";
|
||||
import { getFileName } from "~/utilities";
|
||||
|
||||
interface Props {
|
||||
file: File | null;
|
||||
fileTypes: string[];
|
||||
}
|
||||
|
||||
export const DragAndDropZone = ({ file, fileTypes }: Props) => {
|
||||
const fileSize =
|
||||
file && file.size >= 1024 * 1024
|
||||
? (file.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
: file
|
||||
? `${Math.floor(file.size / 1024)} KB`
|
||||
: null;
|
||||
|
||||
const fileName = file && getFileName(file).toUpperCase();
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileArrowUp} className="text-4xl" />
|
||||
<div className="flex flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
<u>Upload a file</u> or drop it here
|
||||
</P>
|
||||
<P className="flex items-center gap-2 text-sm font-normal opacity-50">
|
||||
{fileTypes.join(", ").toString().toUpperCase()} supported
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3">
|
||||
<FontAwesomeIcon icon={faFileAudio} className="text-4xl" />
|
||||
<div className="flex grow flex-col gap-0">
|
||||
<P className="font-medium">
|
||||
{file.name.slice(0, file.name.lastIndexOf("."))}
|
||||
</P>
|
||||
<P className="flex items-center gap-2 text-sm font-normal">
|
||||
<span className="opacity-50">
|
||||
{`${fileName} file size: ${fileSize} `}
|
||||
</span>
|
||||
<u className="transition-all hover:text-white/80">Change File</u>
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FileUploader as DragDropFilesUploader } from "react-drag-drop-files";
|
||||
import { DragAndDropZone } from "./DragAndDropZone";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
file: File | null;
|
||||
setFile: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const FileUploader = ({ fileTypes, title, file, setFile }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{title}
|
||||
<DragDropFilesUploader
|
||||
handleChange={(file: File | null) => {
|
||||
setFile(file);
|
||||
}}
|
||||
name="file"
|
||||
types={fileTypes}
|
||||
maxSize={50}
|
||||
>
|
||||
<DragAndDropZone file={file} fileTypes={fileTypes} />
|
||||
</DragDropFilesUploader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button, P, H3 } from "~/components";
|
||||
import { FilterEngineCategories } from "~/enums";
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
type: FilterEngineCategories | string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const UploadAssetError = ({
|
||||
onCancel,
|
||||
onRetry,
|
||||
type,
|
||||
errorMessage,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<H3>Error in Uploading {type}</H3>
|
||||
<P>{errorMessage ?? "Unknown Error"}</P>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onRetry} variant="primary">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Button, H6, Input, ListDropdown } from "~/components";
|
||||
import { FileUploader } from "./FileUploader";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
THUMBNAILS_FILE_TYPE,
|
||||
MediaFileAnimationType,
|
||||
FilterEngineCategories,
|
||||
} from "~/enums";
|
||||
import { uploadAssets } from "./uploadAssets";
|
||||
import { UploaderState } from "~/models";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
engineCategory: FilterEngineCategories;
|
||||
fileTypes: string[];
|
||||
onClose: () => void;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
hasThumbnailUpload?: boolean;
|
||||
};
|
||||
onUploadProgress: (newState: UploaderState) => void;
|
||||
}
|
||||
|
||||
export const UploadFiles = ({
|
||||
fileTypes,
|
||||
engineCategory,
|
||||
onClose,
|
||||
title,
|
||||
options,
|
||||
onUploadProgress,
|
||||
}: Props) => {
|
||||
const fileSubtypes = options?.fileSubtypes;
|
||||
const hasLength = options?.hasLength;
|
||||
const hasThumbnailUpload = options?.hasThumbnailUpload;
|
||||
|
||||
const [typeOption, setTypeOption] = useState<
|
||||
MediaFileAnimationType | undefined
|
||||
>(
|
||||
fileSubtypes
|
||||
? (Object.values(fileSubtypes[0])[0] as MediaFileAnimationType)
|
||||
: undefined,
|
||||
);
|
||||
const [uploadTitle, setUploadTitle] = useState<{
|
||||
value: string;
|
||||
error?: string;
|
||||
}>({
|
||||
value: "",
|
||||
});
|
||||
|
||||
const [assetFile, setAssetFile] = useState<{
|
||||
value: File | null;
|
||||
error?: string;
|
||||
}>({ value: null });
|
||||
const [uploadLength, setUploadLength] = useState<number>();
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!uploadTitle.value) {
|
||||
setUploadTitle((curr) => ({
|
||||
...curr,
|
||||
error: "Please enter a title.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetFile.value) {
|
||||
setAssetFile((curr) => ({
|
||||
...curr,
|
||||
error: "Please select a file to upload.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
uploadAssets({
|
||||
title: uploadTitle.value,
|
||||
assetFile: assetFile.value,
|
||||
engineCategory: engineCategory,
|
||||
animationType: typeOption,
|
||||
thumbnailFile: thumbnailFile || undefined,
|
||||
length: uploadLength,
|
||||
progressCallback: onUploadProgress,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="Enter the title here"
|
||||
errorMessage={uploadTitle.error}
|
||||
value={uploadTitle.value}
|
||||
onChange={(event) => setUploadTitle({ value: event.target.value })}
|
||||
className={uploadTitle.error ? "mb-3" : ""}
|
||||
/>
|
||||
{fileSubtypes && fileSubtypes.length > 1 && (
|
||||
<ListDropdown
|
||||
list={fileSubtypes}
|
||||
onSelect={(value) => setTypeOption(value as MediaFileAnimationType)}
|
||||
/>
|
||||
)}
|
||||
{hasLength && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter the length in ms (optional)"
|
||||
value={uploadLength}
|
||||
onChange={(event) => setUploadLength(parseInt(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
<FileUploader
|
||||
title={title}
|
||||
fileTypes={fileTypes}
|
||||
file={assetFile.value}
|
||||
setFile={(file: File | null) => {
|
||||
setAssetFile({
|
||||
value: file,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{assetFile.error && (
|
||||
<H6 className="z-10 text-red">{assetFile.error}</H6>
|
||||
)}
|
||||
{hasThumbnailUpload && (
|
||||
<FileUploader
|
||||
title="Upload Thumbnail (optional)"
|
||||
fileTypes={Object.values(THUMBNAILS_FILE_TYPE)}
|
||||
file={thumbnailFile}
|
||||
setFile={setThumbnailFile}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="primary" onClick={handleSubmit}>
|
||||
Upload
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TransitionDialogue, LoadingDots } from "~/components";
|
||||
|
||||
import { UploadAssetError } from "./UploadAssetError";
|
||||
import { UploadSuccess } from "./UploadSuccess";
|
||||
import { UploadFiles } from "./UploadFiles";
|
||||
|
||||
import { FilterEngineCategories, UploaderStates } from "~/enums";
|
||||
import { UploaderState, initialUploaderState } from "~/models";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
fileTypes: string[];
|
||||
type: FilterEngineCategories;
|
||||
options?: {
|
||||
fileSubtypes?: { [key: string]: string }[];
|
||||
hasLength?: boolean;
|
||||
hasThumbnailUpload?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function UploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
title,
|
||||
fileTypes,
|
||||
type,
|
||||
options,
|
||||
}: Props) {
|
||||
const [uploaderState, setUploaderState] =
|
||||
useState<UploaderState>(initialUploaderState);
|
||||
|
||||
const updateUploaderState = (newState: UploaderState) => {
|
||||
setUploaderState(newState);
|
||||
};
|
||||
|
||||
const resetModalState = () => {
|
||||
setUploaderState(initialUploaderState);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
resetModalState();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const UploaderModalContent = () => {
|
||||
switch (uploaderState.status) {
|
||||
case UploaderStates.ready:
|
||||
return (
|
||||
<UploadFiles
|
||||
title={title}
|
||||
engineCategory={type}
|
||||
fileTypes={fileTypes}
|
||||
options={options}
|
||||
onClose={onClose}
|
||||
onUploadProgress={updateUploaderState}
|
||||
/>
|
||||
);
|
||||
case UploaderStates.uploadingAsset:
|
||||
case UploaderStates.uploadingCover:
|
||||
case UploaderStates.settingCover:
|
||||
return (
|
||||
<>
|
||||
<LoadingDots className="mb-1 bg-transparent" />
|
||||
<div className="w-100 text-center opacity-50">Uploading...</div>
|
||||
</>
|
||||
);
|
||||
case UploaderStates.success:
|
||||
return (
|
||||
<UploadSuccess
|
||||
title={title}
|
||||
onOk={() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case UploaderStates.assetError:
|
||||
return (
|
||||
<UploadAssetError
|
||||
onCancel={onClose}
|
||||
onRetry={() => {
|
||||
resetModalState();
|
||||
}}
|
||||
type={type}
|
||||
errorMessage={uploaderState.errorMessage}
|
||||
/>
|
||||
);
|
||||
case UploaderStates.coverCreateError:
|
||||
case UploaderStates.coverSetError:
|
||||
return (
|
||||
<UploadAssetError
|
||||
onCancel={onClose}
|
||||
onRetry={() => {
|
||||
resetModalState();
|
||||
}}
|
||||
type={"Thumbnail"}
|
||||
errorMessage={uploaderState.errorMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TransitionDialogue isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<UploaderModalContent />
|
||||
</TransitionDialogue>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from "~/components";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
onOk: () => void;
|
||||
}
|
||||
|
||||
export const UploadSuccess = ({ title, onOk }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-100 text-center opacity-50">{`Added ${title} to your library`}</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button onClick={onOk} variant="primary">
|
||||
Ok
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./UploadModal";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user