Compare commits

...
Author SHA1 Message Date
Michael Chung b08c288754 UI addressed. 2025-04-07 18:37:36 -04:00
Michael Chung e938871614 Reshaping the app ui 2025-04-07 14:51:25 -04:00
120 changed files with 6296 additions and 264 deletions
+56 -7
View File
@@ -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",
+3 -1
View File
@@ -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",
+2 -2
View File
@@ -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);
+43 -38
View File
@@ -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} />
@@ -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" />
&nbsp;&nbsp;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