mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-21 09:22:41 -04:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53bd51c91f | ||
|
|
4f2b63ac31 | ||
|
|
658b15d6aa | ||
|
|
60461e9208 | ||
|
|
a8b4541000 | ||
|
|
7cc1c0209f | ||
|
|
43c82e0a82 |
@@ -11,7 +11,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
import { PasswordResetApi, BillingApi } from "@storyteller/api";
|
||||
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
@@ -31,6 +31,8 @@ const VerifyReset = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [redirectTo, setRedirectTo] = useState("/");
|
||||
const [redirectLabel, setRedirectLabel] = useState("Back to Homepage");
|
||||
|
||||
const handleRedeemReset = async () => {
|
||||
setError(null);
|
||||
@@ -70,6 +72,28 @@ const VerifyReset = () => {
|
||||
|
||||
if (response.success) {
|
||||
setSuccess(true);
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
|
||||
// Check if user has an active subscription to decide redirect
|
||||
try {
|
||||
const billingApi = new BillingApi();
|
||||
const billingResponse = await billingApi.ListActiveSubscriptions();
|
||||
if (
|
||||
billingResponse.success &&
|
||||
billingResponse.data &&
|
||||
billingResponse.data.active_subscriptions.length > 0
|
||||
) {
|
||||
setRedirectTo("/");
|
||||
setRedirectLabel("Back to Homepage");
|
||||
} else {
|
||||
setRedirectTo("/pricing");
|
||||
setRedirectLabel("Continue");
|
||||
}
|
||||
} catch {
|
||||
// Default to homepage if billing check fails
|
||||
setRedirectTo("/");
|
||||
setRedirectLabel("Back to Homepage");
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
response.errorMessage ||
|
||||
@@ -256,15 +280,15 @@ const VerifyReset = () => {
|
||||
Password Reset Successfully
|
||||
</h3>
|
||||
<p className="text-white/60 text-sm mb-8">
|
||||
Your password has been changed. You can now log in with your
|
||||
new password.
|
||||
Your password has been changed and you've been logged in
|
||||
successfully.
|
||||
</p>
|
||||
<Button
|
||||
id="go-to-login-btn"
|
||||
id="back-to-homepage-btn"
|
||||
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-12"
|
||||
onClick={() => navigate("/login")}
|
||||
onClick={() => navigate(redirectTo)}
|
||||
>
|
||||
Go to Login
|
||||
{redirectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,7 +21,10 @@ import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
|
||||
import { ProviderSetupModal } from "@storyteller/provider-setup-modal";
|
||||
import { useSubscriptionState } from "@storyteller/subscription";
|
||||
import { DownloadUrl } from "@storyteller/tauri-api";
|
||||
import { useCreditsBalanceChangedEvent, useSubscriptionPlanChangedEvent } from "@storyteller/tauri-events";
|
||||
import {
|
||||
useCreditsBalanceChangedEvent,
|
||||
useSubscriptionPlanChangedEvent,
|
||||
} from "@storyteller/tauri-events";
|
||||
import {
|
||||
useTauriPlatform,
|
||||
useTauriWindowControls,
|
||||
@@ -251,9 +254,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
) => {
|
||||
try {
|
||||
if (mediaId) {
|
||||
useImageTo3DWorldStore
|
||||
.getState()
|
||||
.setPendingExternalImage(url, mediaId);
|
||||
useImageTo3DWorldStore.getState().setPendingExternalImage(url, mediaId);
|
||||
}
|
||||
useTabStore.getState().setActiveTab("IMAGE_TO_3D_WORLD");
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
@@ -569,7 +570,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
/>
|
||||
|
||||
<ProviderSetupModal />
|
||||
<ProviderBillingModal />
|
||||
<ProviderBillingModal isVideoPage={tabStore.activeTabId === "VIDEO"} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { DRAW_LAYER_ID, INPAINT_LAYER_ID, PaintSurface } from "./PaintSurface";
|
||||
import "./App.css";
|
||||
import PromptEditor from "./PromptEditor/PromptEditor";
|
||||
import SideToolbar from "./components/ui/SideToolbar";
|
||||
import { AspectRatioType, useSceneStore } from "./stores/SceneState";
|
||||
import {
|
||||
AspectRatioType,
|
||||
type SceneState,
|
||||
useSceneStore,
|
||||
} from "./stores/SceneState";
|
||||
import { useUndoRedoHotkeys } from "./hooks/useUndoRedoHotkeys";
|
||||
import { useDeleteHotkeys } from "./hooks/useDeleteHotkeys";
|
||||
import { useCopyPasteHotkeys } from "./hooks/useCopyPasteHotkeys";
|
||||
@@ -18,16 +23,26 @@ import {
|
||||
useSelectedImageModel,
|
||||
useSelectedProviderForModel,
|
||||
} from "@storyteller/ui-model-selector";
|
||||
import { EnqueueEditImage, EnqueueEditImageRequest, EnqueueEditImageResolution, EnqueueEditImageSize, EnqueueImageInpaint, EnqueueImageInpaintRequest, useCanvasBgRemovedEvent } from "@storyteller/tauri-api";
|
||||
import {
|
||||
EnqueueEditImage,
|
||||
EnqueueEditImageRequest,
|
||||
EnqueueEditImageResolution,
|
||||
EnqueueEditImageSize,
|
||||
EnqueueImageInpaint,
|
||||
EnqueueImageInpaintRequest,
|
||||
useCanvasBgRemovedEvent,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { HelpMenuButton } from "@storyteller/ui-help-menu";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
import { HistoryStack, ImageBundle } from "../PageEdit/HistoryStack";
|
||||
import { BaseImageSelector, BaseSelectorImage } from "../PageEdit/BaseImageSelector";
|
||||
import {
|
||||
BaseImageSelector,
|
||||
BaseSelectorImage,
|
||||
} from "../PageEdit/BaseImageSelector";
|
||||
import { normalizeCanvas } from "~/Helpers/CanvasHelpers";
|
||||
import { EncodeImageBitmapToBase64 } from "./utilities/EncodeImageBitmapToBase64";
|
||||
import { RefImage, usePrompt2DStore } from "@storyteller/ui-promptbox";
|
||||
import { PromptsApi } from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const PAGE_ID: ModelPage = ModelPage.Canvas2D;
|
||||
|
||||
@@ -62,26 +77,123 @@ const PageDraw = () => {
|
||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||
const stageRef = useRef<Konva.Stage>({} as Konva.Stage);
|
||||
const transformerRefs = useRef<{ [key: string]: Konva.Transformer }>({});
|
||||
const store = useSceneStore();
|
||||
|
||||
/*
|
||||
* Scene store: use a selector + useShallow to avoid re-rendering on every store change.
|
||||
* Without this, useSceneStore() subscribes to the whole store (e.g. moveNode during
|
||||
* drag updates state constantly) and PageDraw would re-render every frame.
|
||||
*
|
||||
* useShallow compares the selected object by top-level keys: we only re-render when
|
||||
* one of these values actually changes. Do NOT add:
|
||||
* - cursorPosition / cursorVisible: updated every mouse move → would re-render constantly.
|
||||
* - historyImageNodeMap: mutated in place in the store (reference never changes),
|
||||
* so shallow would never see updates; fix the store to replace the Map if you need it.
|
||||
* When adding new store fields here, prefer primitive/array/object refs that the store
|
||||
* replaces (e.g. new array from .map()), not in-place mutations.
|
||||
*
|
||||
* IMPORTANT: The selector function is memoized to prevent infinite loops. If you modify
|
||||
* this selector, ensure the function reference stays stable (use useMemo if needed).
|
||||
*/
|
||||
const selector = useMemo(
|
||||
() => (state: SceneState) => ({
|
||||
baseImageInfo: state.baseImageInfo,
|
||||
baseImageBitmap: state.baseImageBitmap,
|
||||
nodes: state.nodes,
|
||||
selectedNodeIds: state.selectedNodeIds,
|
||||
lineNodes: state.lineNodes,
|
||||
activeTool: state.activeTool,
|
||||
currentShape: state.currentShape,
|
||||
fillColor: state.fillColor,
|
||||
brushColor: state.brushColor,
|
||||
brushSize: state.brushSize,
|
||||
historyImageBundles: state.historyImageBundles,
|
||||
getAspectRatioDimensions: state.getAspectRatioDimensions,
|
||||
finishRemoveBackground: state.finishRemoveBackground,
|
||||
createImageFromUrl: state.createImageFromUrl,
|
||||
createImageFromFile: state.createImageFromFile,
|
||||
setBaseImageInfo: state.setBaseImageInfo,
|
||||
RESET: state.RESET,
|
||||
clearLineNodes: state.clearLineNodes,
|
||||
setNodes: state.setNodes,
|
||||
removeHistoryImage: state.removeHistoryImage,
|
||||
addHistoryImageBundle: state.addHistoryImageBundle,
|
||||
setAspectRatioType: state.setAspectRatioType,
|
||||
setActiveTool: state.setActiveTool,
|
||||
selectNode: state.selectNode,
|
||||
setCurrentShape: state.setCurrentShape,
|
||||
setBrushColor: state.setBrushColor,
|
||||
setBrushSize: state.setBrushSize,
|
||||
setBrushOpacity: state.setBrushOpacity,
|
||||
setFillColor: state.setFillColor,
|
||||
toggleLock: state.toggleLock,
|
||||
beginRemoveBackground: state.beginRemoveBackground,
|
||||
bringToFront: state.bringToFront,
|
||||
bringForward: state.bringForward,
|
||||
sendBackward: state.sendBackward,
|
||||
sendToBack: state.sendToBack,
|
||||
copySelectedItems: state.copySelectedItems,
|
||||
pasteItems: state.pasteItems,
|
||||
deleteSelectedItems: state.deleteSelectedItems,
|
||||
undo: state.undo,
|
||||
redo: state.redo,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
baseImageInfo,
|
||||
baseImageBitmap,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
lineNodes,
|
||||
activeTool,
|
||||
currentShape,
|
||||
fillColor,
|
||||
brushColor,
|
||||
brushSize,
|
||||
historyImageBundles,
|
||||
getAspectRatioDimensions,
|
||||
finishRemoveBackground,
|
||||
createImageFromUrl,
|
||||
createImageFromFile,
|
||||
setBaseImageInfo,
|
||||
RESET,
|
||||
clearLineNodes,
|
||||
setNodes,
|
||||
removeHistoryImage,
|
||||
addHistoryImageBundle,
|
||||
setAspectRatioType,
|
||||
setActiveTool,
|
||||
selectNode,
|
||||
setCurrentShape,
|
||||
setBrushColor,
|
||||
setBrushSize,
|
||||
setBrushOpacity,
|
||||
setFillColor,
|
||||
toggleLock,
|
||||
beginRemoveBackground,
|
||||
bringToFront,
|
||||
bringForward,
|
||||
sendBackward,
|
||||
sendToBack,
|
||||
copySelectedItems,
|
||||
pasteItems,
|
||||
deleteSelectedItems,
|
||||
undo,
|
||||
redo,
|
||||
} = useSceneStore(useShallow(selector));
|
||||
|
||||
const promptStoreProvider = usePrompt2DStore;
|
||||
const generationCount = promptStoreProvider((state) => state.generationCount);
|
||||
const setGenerationCount = promptStoreProvider((state) => state.setGenerationCount);
|
||||
const useSystemPrompt = promptStoreProvider((state) => state.useSystemPrompt);
|
||||
const referenceImages = promptStoreProvider((state) => state.referenceImages);
|
||||
const prompt = promptStoreProvider((state) => state.prompt);
|
||||
const setGenerationCount = promptStoreProvider(
|
||||
(state) => state.setGenerationCount,
|
||||
);
|
||||
|
||||
const baseImageKonvaRef = useRef<Konva.Image>({} as Konva.Image);
|
||||
const baseImageUrl = store.baseImageInfo?.url;
|
||||
const baseImageUrl = baseImageInfo?.url;
|
||||
const [pendingGenerations, setPendingGenerations] = useState<
|
||||
{ id: string; count: number }[]
|
||||
>([]);
|
||||
const addHistoryImageBundle = useSceneStore(
|
||||
(state) => state.addHistoryImageBundle,
|
||||
);
|
||||
const removeHistoryImage = useSceneStore((state) => state.removeHistoryImage);
|
||||
const historyImageBundles = useSceneStore(
|
||||
(state) => state.historyImageBundles,
|
||||
);
|
||||
|
||||
const selectedImageModel: ImageModel | undefined =
|
||||
useSelectedImageModel(PAGE_ID);
|
||||
@@ -92,11 +204,11 @@ const PageDraw = () => {
|
||||
const supportsMaskedInpainting =
|
||||
selectedImageModel?.usesInpaintingMask ?? false;
|
||||
|
||||
useDeleteHotkeys({ onDelete: store.deleteSelectedItems });
|
||||
useUndoRedoHotkeys({ undo: store.undo, redo: store.redo });
|
||||
useDeleteHotkeys({ onDelete: deleteSelectedItems });
|
||||
useUndoRedoHotkeys({ undo, redo });
|
||||
useCopyPasteHotkeys({
|
||||
onCopy: store.copySelectedItems,
|
||||
onPaste: store.pasteItems,
|
||||
onCopy: copySelectedItems,
|
||||
onPaste: pasteItems,
|
||||
});
|
||||
|
||||
useCanvasBgRemovedEvent(async (event) => {
|
||||
@@ -113,24 +225,19 @@ const PageDraw = () => {
|
||||
// const file = new File([blob], "generated_image.png", {
|
||||
// type: blob.type,
|
||||
// });
|
||||
store.finishRemoveBackground(
|
||||
nodeId,
|
||||
event.media_token,
|
||||
event.image_cdn_url,
|
||||
);
|
||||
finishRemoveBackground(nodeId, event.media_token, event.image_cdn_url);
|
||||
});
|
||||
|
||||
// Create a function to use the left layer ref and download the bitmap from it
|
||||
const getMaskArrayBuffer = async (): Promise<Uint8Array> => {
|
||||
if (
|
||||
!stageRef.current ||
|
||||
!baseImageKonvaRef.current
|
||||
) {
|
||||
if (!stageRef.current || !baseImageKonvaRef.current) {
|
||||
console.error("Stage or left panel ref is not available");
|
||||
throw new Error("Stage or left panel or base image ref is not available");
|
||||
}
|
||||
|
||||
const layer = stageRef.current.getLayers().find((l) => l.id() === INPAINT_LAYER_ID)!;
|
||||
const layer = stageRef.current
|
||||
.getLayers()
|
||||
.find((l) => l.id() === INPAINT_LAYER_ID)!;
|
||||
|
||||
// Get the canvas area that's covered by the image/rectangle
|
||||
const rect = baseImageKonvaRef.current;
|
||||
@@ -195,7 +302,7 @@ const PageDraw = () => {
|
||||
|
||||
console.log("Creating image from URL:", imageUrl);
|
||||
|
||||
store.createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
|
||||
createImageFromUrl(stagePoint.x, stagePoint.y, imageUrl);
|
||||
|
||||
console.log(
|
||||
`Created image "${item.label}" at stage position:`,
|
||||
@@ -214,12 +321,12 @@ const PageDraw = () => {
|
||||
handleGallery2DDrop as EventListener,
|
||||
);
|
||||
};
|
||||
}, [store]);
|
||||
// Stable action ref; no need to depend on full store.
|
||||
}, [createImageFromUrl]);
|
||||
|
||||
const handleImageUpload = async (files: File[]): Promise<void> => {
|
||||
// Determine current canvas dimensions from the store (according to aspect-ratio)
|
||||
const { width: canvasW, height: canvasH } =
|
||||
store.getAspectRatioDimensions();
|
||||
const { width: canvasW, height: canvasH } = getAspectRatioDimensions();
|
||||
|
||||
// Target maximum size – 85 % of the canvas in each direction
|
||||
const maxW = canvasW * 0.85;
|
||||
@@ -240,80 +347,20 @@ const PageDraw = () => {
|
||||
const x = (canvasW - finalW) / 2;
|
||||
const y = (canvasH - finalH) / 2;
|
||||
|
||||
store.createImageFromFile(x, y, file, finalW, finalH);
|
||||
createImageFromFile(x, y, file, finalW, finalH);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTauriEnqueue = async (image: ImageBitmap, aspectRatio: EnqueueEditImageSize | undefined, resolution: EnqueueEditImageResolution | undefined, subscriberId: string) => {
|
||||
if (image === undefined) {
|
||||
console.log("image is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
const api = new PromptsApi();
|
||||
const base64Bitmap = await EncodeImageBitmapToBase64(image);
|
||||
|
||||
const byteString = atob(base64Bitmap);
|
||||
const mimeString = "image/png";
|
||||
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const uuid = crypto.randomUUID(); // Generate a new UUID
|
||||
const file = new File([ab], `${uuid}.png`, { type: mimeString });
|
||||
|
||||
const snapshotMediaToken = await api.uploadSceneSnapshot({
|
||||
screenshot: file,
|
||||
});
|
||||
|
||||
if (snapshotMediaToken.data === undefined) {
|
||||
toast.error("Error: Unable to upload scene snapshot Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("useSystemPrompt", useSystemPrompt);
|
||||
console.log("Snapshot media token:", snapshotMediaToken.data);
|
||||
|
||||
const request: EnqueueEditImageRequest = {
|
||||
model: selectedImageModel,
|
||||
scene_image_media_token: snapshotMediaToken.data!,
|
||||
image_media_tokens: referenceImages
|
||||
.map((image) => image.mediaToken)
|
||||
.filter((t) => t.length > 0),
|
||||
disable_system_prompt: !useSystemPrompt,
|
||||
prompt: prompt,
|
||||
image_count: generationCount,
|
||||
aspect_ratio: aspectRatio,
|
||||
image_resolution: resolution,
|
||||
frontend_caller: "image_editor",
|
||||
frontend_subscriber_id: subscriberId,
|
||||
};
|
||||
|
||||
if (selectedProvider) {
|
||||
request.provider = selectedProvider;
|
||||
}
|
||||
|
||||
const generateResponse = await EnqueueEditImage(request);
|
||||
console.log("generateResponse", generateResponse);
|
||||
return generateResponse;
|
||||
}
|
||||
|
||||
const getCompositeCanvasFile = useCallback(async (): Promise<File | null> => {
|
||||
if (
|
||||
!stageRef.current ||
|
||||
!baseImageKonvaRef.current ||
|
||||
!store.baseImageBitmap
|
||||
) {
|
||||
if (!stageRef.current || !baseImageKonvaRef.current || !baseImageBitmap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editsLayer = stageRef.current.getLayers().find((l) => l.id() === DRAW_LAYER_ID);
|
||||
const editsLayer = stageRef.current
|
||||
.getLayers()
|
||||
.find((l) => l.id() === DRAW_LAYER_ID);
|
||||
|
||||
if (!editsLayer) {
|
||||
console.error("Edits layer not found");
|
||||
@@ -328,7 +375,7 @@ const PageDraw = () => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
ctx.drawImage(store.baseImageBitmap, 0, 0, width, height);
|
||||
ctx.drawImage(baseImageBitmap, 0, 0, width, height);
|
||||
|
||||
const markerLayerCanvas = editsLayer.toCanvas({
|
||||
x: stageRef.current.x(),
|
||||
@@ -347,7 +394,7 @@ const PageDraw = () => {
|
||||
const blob = await canvas.convertToBlob({ type: "image/png" });
|
||||
const uuid = crypto.randomUUID();
|
||||
return new File([blob], `${uuid}.png`, { type: "image/png" });
|
||||
}, [store.baseImageBitmap]);
|
||||
}, [baseImageBitmap]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (
|
||||
@@ -359,7 +406,7 @@ const PageDraw = () => {
|
||||
selectedProvider?: GenerationProvider;
|
||||
},
|
||||
) => {
|
||||
const editedImageToken = store.baseImageInfo?.mediaToken;
|
||||
const editedImageToken = baseImageInfo?.mediaToken;
|
||||
|
||||
if (!editedImageToken) {
|
||||
console.error("Base image is not available");
|
||||
@@ -400,7 +447,7 @@ const PageDraw = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { width, height } = store.getAspectRatioDimensions();
|
||||
const { width, height } = getAspectRatioDimensions();
|
||||
const subscriberId: string =
|
||||
crypto?.randomUUID?.() ??
|
||||
`inpaint-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
@@ -518,7 +565,9 @@ const PageDraw = () => {
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}, [generationCount, getCompositeCanvasFile, selectedImageModel, store]);
|
||||
},
|
||||
[generationCount, getCompositeCanvasFile, selectedImageModel],
|
||||
);
|
||||
|
||||
const onFitPressed = async () => {
|
||||
// Get the stage and its container dimensions
|
||||
@@ -530,8 +579,8 @@ const PageDraw = () => {
|
||||
const containerHeight = stage.container().offsetHeight;
|
||||
|
||||
// Get canvas dimensions from store aspect ratio
|
||||
const canvasW = store.getAspectRatioDimensions().width;
|
||||
const canvasH = store.getAspectRatioDimensions().height;
|
||||
const canvasW = getAspectRatioDimensions().width;
|
||||
const canvasH = getAspectRatioDimensions().height;
|
||||
|
||||
// Add padding to ensure canvas doesn't touch the edges
|
||||
const padding = 40;
|
||||
@@ -560,15 +609,17 @@ const PageDraw = () => {
|
||||
};
|
||||
|
||||
// When the model inpainting support changes, we need to auto-change the tool so it's not set to inpainting
|
||||
// Note: setActiveTool is a stable Zustand action, so we don't need it in deps
|
||||
useEffect(() => {
|
||||
if (!supportsMaskedInpainting && store.activeTool === "inpaint") {
|
||||
store.setActiveTool("select");
|
||||
if (!supportsMaskedInpainting && activeTool === "inpaint") {
|
||||
setActiveTool("select");
|
||||
}
|
||||
}, [store, supportsMaskedInpainting]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTool, supportsMaskedInpainting]);
|
||||
|
||||
// Auto-fit canvas to screen on initial load
|
||||
useEffect(() => {
|
||||
if (!store.baseImageBitmap) {
|
||||
if (!baseImageBitmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -597,11 +648,11 @@ const PageDraw = () => {
|
||||
|
||||
autoFitCanvas();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store.baseImageBitmap]);
|
||||
}, [baseImageBitmap]);
|
||||
|
||||
// Display image selector on launch, otherwise hide it
|
||||
// Also show loading state if info is set but image is loading
|
||||
if (!store.baseImageInfo || !store.baseImageBitmap) {
|
||||
if (!baseImageInfo || !baseImageBitmap) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@@ -613,11 +664,9 @@ const PageDraw = () => {
|
||||
<BaseImageSelector
|
||||
onImageSelect={(image: BaseSelectorImage) => {
|
||||
addHistoryImageBundle({ images: [image] });
|
||||
store.setBaseImageInfo(image);
|
||||
setBaseImageInfo(image);
|
||||
}}
|
||||
showLoading={
|
||||
store.baseImageInfo !== null && store.baseImageInfo === null
|
||||
}
|
||||
showLoading={baseImageInfo !== null && baseImageBitmap === null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -629,29 +678,30 @@ const PageDraw = () => {
|
||||
<>
|
||||
<div className="fixed inset-0 -z-10 bg-ui-background" />
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed right-4 top-1/2 z-10 -translate-y-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<HistoryStack
|
||||
onClear={() => {
|
||||
store.RESET();
|
||||
RESET();
|
||||
setPendingGenerations([]);
|
||||
}}
|
||||
imageBundles={historyImageBundles}
|
||||
pendingPlaceholders={pendingGenerations}
|
||||
blurredBackgroundUrl={baseImageUrl}
|
||||
onImageSelect={(baseImage) => {
|
||||
store.clearLineNodes();
|
||||
store.setNodes([]);
|
||||
store.setBaseImageInfo(baseImage);
|
||||
clearLineNodes();
|
||||
setNodes([]);
|
||||
setBaseImageInfo(baseImage);
|
||||
}}
|
||||
onImageRemove={(baseImage) => {
|
||||
if (
|
||||
pendingGenerations.length === 0 &&
|
||||
store.historyImageBundles.length === 1 &&
|
||||
store.historyImageBundles[0].images.length <= 1
|
||||
historyImageBundles.length === 1 &&
|
||||
historyImageBundles[0].images.length <= 1
|
||||
) {
|
||||
store.RESET();
|
||||
RESET();
|
||||
} else {
|
||||
removeHistoryImage(baseImage);
|
||||
}
|
||||
@@ -662,12 +712,13 @@ const PageDraw = () => {
|
||||
onResolvePending={(id: string) =>
|
||||
setPendingGenerations((prev) => prev.filter((p) => p.id !== id))
|
||||
}
|
||||
selectedImageToken={store.baseImageInfo?.mediaToken}
|
||||
selectedImageToken={baseImageInfo?.mediaToken}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
className={`preserve-aspect-ratio fixed bottom-0 left-1/2 z-10 -translate-x-1/2 transform ${
|
||||
isSelecting ? "pointer-events-none" : "pointer-events-auto"
|
||||
}`}
|
||||
>
|
||||
<PromptEditor
|
||||
onAspectRatioChange={async (ratio: string) => {
|
||||
@@ -685,7 +736,7 @@ const PageDraw = () => {
|
||||
};
|
||||
|
||||
const aspectRatioType = ratioToType(ratio);
|
||||
store.setAspectRatioType(aspectRatioType);
|
||||
setAspectRatioType(aspectRatioType);
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
onFitPressed();
|
||||
@@ -705,28 +756,28 @@ const PageDraw = () => {
|
||||
<SideToolbar
|
||||
className="fixed left-0 top-1/2 z-10 -translate-y-1/2 transform"
|
||||
onSelect={(): void => {
|
||||
store.setActiveTool("select");
|
||||
setActiveTool("select");
|
||||
}}
|
||||
onActivateShapeTool={(
|
||||
shape: "rectangle" | "circle" | "triangle",
|
||||
): void => {
|
||||
store.selectNode(null);
|
||||
store.setCurrentShape(shape);
|
||||
store.setActiveTool("shape");
|
||||
store.selectNode(null);
|
||||
selectNode(null);
|
||||
setCurrentShape(shape);
|
||||
setActiveTool("shape");
|
||||
selectNode(null);
|
||||
}}
|
||||
onPaintBrush={(hex: string, size: number, opacity: number): void => {
|
||||
store.setActiveTool("draw");
|
||||
store.setBrushColor(hex);
|
||||
store.setBrushSize(size);
|
||||
store.setBrushOpacity(opacity);
|
||||
setActiveTool("draw");
|
||||
setBrushColor(hex);
|
||||
setBrushSize(size);
|
||||
setBrushOpacity(opacity);
|
||||
}}
|
||||
onCanvasBackground={(hex: string): void => {
|
||||
console.log("Canvas background activated", { color: hex });
|
||||
// Add background change logic here
|
||||
// TODO: minor bug needs to update the preview panel
|
||||
// Debounce also causes issues with real time color change.
|
||||
store.setFillColor(hex);
|
||||
setFillColor(hex);
|
||||
}}
|
||||
onUploadImage={(): void => {
|
||||
// Create input element dynamically like in PromptEditor
|
||||
@@ -765,14 +816,14 @@ const PageDraw = () => {
|
||||
input.click();
|
||||
}}
|
||||
supportsMaskTool={supportsMaskedInpainting}
|
||||
activeToolId={store.activeTool}
|
||||
currentShape={store.currentShape}
|
||||
activeToolId={activeTool}
|
||||
currentShape={currentShape}
|
||||
/>
|
||||
<div className="relative z-0">
|
||||
<ContextMenuContainer
|
||||
onAction={(e, action) => {
|
||||
if (action === "contextMenu") {
|
||||
const hasSelection = store.selectedNodeIds.length > 0;
|
||||
const hasSelection = selectedNodeIds.length > 0;
|
||||
if (hasSelection) {
|
||||
console.log("An item is selected.");
|
||||
return true;
|
||||
@@ -786,51 +837,54 @@ const PageDraw = () => {
|
||||
onMenuAction={async (action) => {
|
||||
switch (action) {
|
||||
case "LOCK":
|
||||
store.toggleLock(store.selectedNodeIds);
|
||||
toggleLock(selectedNodeIds);
|
||||
break;
|
||||
case "REMOVE_BACKGROUND":
|
||||
await store.beginRemoveBackground(store.selectedNodeIds);
|
||||
await beginRemoveBackground(selectedNodeIds);
|
||||
break;
|
||||
case "BRING_TO_FRONT":
|
||||
store.bringToFront(store.selectedNodeIds);
|
||||
bringToFront(selectedNodeIds);
|
||||
break;
|
||||
case "BRING_FORWARD":
|
||||
store.bringForward(store.selectedNodeIds);
|
||||
bringForward(selectedNodeIds);
|
||||
break;
|
||||
case "SEND_BACKWARD":
|
||||
store.sendBackward(store.selectedNodeIds);
|
||||
sendBackward(selectedNodeIds);
|
||||
break;
|
||||
case "SEND_TO_BACK":
|
||||
store.sendToBack(store.selectedNodeIds);
|
||||
sendToBack(selectedNodeIds);
|
||||
break;
|
||||
case "DUPLICATE":
|
||||
store.copySelectedItems();
|
||||
store.pasteItems();
|
||||
copySelectedItems();
|
||||
pasteItems();
|
||||
break;
|
||||
case "DELETE":
|
||||
store.deleteSelectedItems();
|
||||
deleteSelectedItems();
|
||||
break;
|
||||
default:
|
||||
// No action
|
||||
}
|
||||
}}
|
||||
isLocked={store.selectedNodeIds.some((id) => {
|
||||
const node = store.nodes.find((n) => n.id === id);
|
||||
const lineNode = store.lineNodes.find((n) => n.id === id);
|
||||
isLocked={selectedNodeIds.some((id: string) => {
|
||||
const node = nodes.find((n: (typeof nodes)[number]) => n.id === id);
|
||||
const lineNode = lineNodes.find(
|
||||
(n: (typeof lineNodes)[number]) => n.id === id,
|
||||
);
|
||||
return (node?.locked || lineNode?.locked) ?? false;
|
||||
})}
|
||||
>
|
||||
<PaintSurface
|
||||
nodes={store.nodes}
|
||||
selectedNodeIds={store.selectedNodeIds}
|
||||
nodes={nodes}
|
||||
lineNodes={lineNodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
onCanvasSizeChange={(width: number, height: number): void => {
|
||||
canvasWidth.current = width;
|
||||
canvasHeight.current = height;
|
||||
}}
|
||||
fillColor={store.fillColor}
|
||||
activeTool={store.activeTool}
|
||||
brushColor={store.brushColor}
|
||||
brushSize={store.brushSize}
|
||||
fillColor={fillColor}
|
||||
activeTool={activeTool}
|
||||
brushColor={brushColor}
|
||||
brushSize={brushSize}
|
||||
onSelectionChange={setIsSelecting}
|
||||
stageRef={stageRef}
|
||||
transformerRefs={transformerRefs}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { Vector2d } from 'konva/lib/types';
|
||||
import { useEffect } from 'react';
|
||||
import { DragState } from '~/pages/PageEdit/EditPaintSurface';
|
||||
import { Vector2d } from "konva/lib/types";
|
||||
import { useEffect } from "react";
|
||||
import { DragState } from "~/pages/PageEdit/EditPaintSurface";
|
||||
// This piece of code ensures that when you are selecting or dragging and go off the stage,
|
||||
// it releases the controls mouse drag.
|
||||
export const useGlobalMouseUp = (
|
||||
@@ -10,11 +10,12 @@ export const useGlobalMouseUp = (
|
||||
setIsSelecting: (value: boolean) => void,
|
||||
isSelectingRef: React.MutableRefObject<boolean>,
|
||||
setSelectionRect: (value: any) => void,
|
||||
setLastPoint: (value: { x: number, y: number } | null) => void,
|
||||
onSelectionChange?: (isSelecting: boolean) => void,
|
||||
onBeforeReset?: () => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
onBeforeReset?.();
|
||||
// Always reset all states when mouse is released anywhere
|
||||
setIsDragging(undefined);
|
||||
setIsDrawing(false);
|
||||
@@ -23,16 +24,15 @@ export const useGlobalMouseUp = (
|
||||
isSelectingRef.current = false;
|
||||
onSelectionChange?.(false);
|
||||
setSelectionRect(null);
|
||||
setLastPoint(null);
|
||||
};
|
||||
|
||||
// Listen to mouseup on the entire window
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
window.addEventListener('touchend', handleGlobalMouseUp);
|
||||
window.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
window.addEventListener("touchend", handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
window.removeEventListener('touchend', handleGlobalMouseUp);
|
||||
window.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
window.removeEventListener("touchend", handleGlobalMouseUp);
|
||||
};
|
||||
}, [onSelectionChange]);
|
||||
}
|
||||
}, [onSelectionChange, onBeforeReset]);
|
||||
};
|
||||
|
||||
@@ -86,11 +86,17 @@ type SerializedNodeData = {
|
||||
locked: boolean;
|
||||
};
|
||||
|
||||
interface SceneState {
|
||||
interface HistoryNodeData {
|
||||
nodes: Node[];
|
||||
lineNodes: LineNode[];
|
||||
}
|
||||
|
||||
export interface SceneState {
|
||||
// Nodes
|
||||
nodes: Node[];
|
||||
selectedNodeIds: string[];
|
||||
lineNodes: LineNode[]; // Add lineNodes to state
|
||||
historyImageNodeMap: Map<BaseSelectorImage, HistoryNodeData>;
|
||||
|
||||
// Clipboard
|
||||
clipboard: (Node | LineNode)[]; // To store copied items
|
||||
@@ -292,6 +298,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
nodes: [],
|
||||
lineNodes: [],
|
||||
selectedNodeIds: [],
|
||||
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
|
||||
clipboard: [],
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
@@ -1587,15 +1594,40 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
},
|
||||
|
||||
setBaseImageInfo: (image: BaseSelectorImage | null) => {
|
||||
set({ baseImageInfo: image });
|
||||
|
||||
// If the new base image is null, return early because nothing needs to be loaded.
|
||||
if (!image) {
|
||||
// Set the new base image info - setting null is allowed.
|
||||
set({ baseImageInfo: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the image bitmap as well
|
||||
// Save the current node data to the history image node map
|
||||
const currentBaseImage = get().baseImageInfo;
|
||||
if (currentBaseImage) {
|
||||
get().historyImageNodeMap.set(currentBaseImage, {
|
||||
nodes: get().nodes,
|
||||
lineNodes: get().lineNodes,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the new base image info
|
||||
set({ baseImageInfo: image });
|
||||
|
||||
// Load the image bitmap now
|
||||
const imgBitmap = new Image();
|
||||
imgBitmap.onload = () => {
|
||||
// Once the base image is set, check previous node data - or create new empty one
|
||||
const previousNodeData = get().historyImageNodeMap.get(image);
|
||||
if (previousNodeData) {
|
||||
set({
|
||||
nodes: previousNodeData.nodes,
|
||||
lineNodes: previousNodeData.lineNodes,
|
||||
});
|
||||
} else {
|
||||
set({ nodes: [], lineNodes: [] });
|
||||
}
|
||||
|
||||
// Finally, set the base image bitmap
|
||||
set({ baseImageBitmap: imgBitmap });
|
||||
};
|
||||
imgBitmap.onerror = (event) => {
|
||||
@@ -1615,7 +1647,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
},
|
||||
|
||||
clearHistoryImages: () => {
|
||||
set({ historyImageBundles: [] });
|
||||
set({
|
||||
historyImageBundles: [],
|
||||
historyImageNodeMap: new Map<BaseSelectorImage, HistoryNodeData>(),
|
||||
});
|
||||
},
|
||||
|
||||
addHistoryImageBundle(bundle) {
|
||||
@@ -1627,6 +1662,10 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
removeHistoryImage(image) {
|
||||
console.log("Removing history image:", image);
|
||||
set((state) => {
|
||||
// Remove the history image node data
|
||||
state.historyImageNodeMap.delete(image);
|
||||
|
||||
// Remove the image from the history image bundles
|
||||
const updatedBundles = state.historyImageBundles
|
||||
.map((bundle) => {
|
||||
return {
|
||||
|
||||
@@ -90,7 +90,6 @@ export const EditPaintSurface = ({
|
||||
/* 1️⃣ Track SplitPane percent so we can re-measure */
|
||||
const [leftPct, setLeftPct] = useState(singlePaneMode ? 100 : 50);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [, setLastPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const selectionRectRef = React.useRef<Konva.Rect>(null);
|
||||
const [selectionRect, setSelectionRect] = useState<{
|
||||
@@ -144,7 +143,6 @@ export const EditPaintSurface = ({
|
||||
setIsSelecting,
|
||||
isSelectingRef,
|
||||
setSelectionRect,
|
||||
setLastPoint,
|
||||
onSelectionChange,
|
||||
);
|
||||
|
||||
@@ -282,7 +280,6 @@ export const EditPaintSurface = ({
|
||||
store.addLineNode(newLineNode, false);
|
||||
setCurrentLineId(lineId);
|
||||
setIsDrawing(true);
|
||||
setLastPoint(stagePoint);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,8 +448,6 @@ export const EditPaintSurface = ({
|
||||
lineNode.points(currentPoints);
|
||||
lineNode.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
setLastPoint(stagePoint);
|
||||
}
|
||||
|
||||
// Handle selection rectangle - Update directly through Konva
|
||||
@@ -481,11 +476,11 @@ export const EditPaintSurface = ({
|
||||
setSelectionRect((prev) =>
|
||||
prev
|
||||
? {
|
||||
// Ensure prev is not null
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
// Ensure prev is not null
|
||||
...prev,
|
||||
endX: clampedPoint.x,
|
||||
endY: clampedPoint.y,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -562,7 +557,6 @@ export const EditPaintSurface = ({
|
||||
isSelectingRef.current = false;
|
||||
onSelectionChange?.(false);
|
||||
setSelectionRect(null);
|
||||
setLastPoint(null);
|
||||
setIsMiddleMousePressed(false); // Reset middle mouse state
|
||||
};
|
||||
|
||||
@@ -638,8 +632,8 @@ export const EditPaintSurface = ({
|
||||
if (!container) return;
|
||||
const defaultCursor =
|
||||
activeTool === "edit" ||
|
||||
activeTool === "marker" ||
|
||||
activeTool === "eraser"
|
||||
activeTool === "marker" ||
|
||||
activeTool === "eraser"
|
||||
? "none"
|
||||
: activeTool === "select"
|
||||
? "grab"
|
||||
@@ -693,7 +687,7 @@ export const EditPaintSurface = ({
|
||||
const touch2 = touches[1];
|
||||
return Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1473,11 +1467,11 @@ export const EditPaintSurface = ({
|
||||
scaleY={previewScale}
|
||||
x={0}
|
||||
y={0}
|
||||
// style={{
|
||||
// background: '#f5f5f5',
|
||||
// border: '1px solid #ddd',
|
||||
// borderRadius: '8px',
|
||||
// }}
|
||||
// style={{
|
||||
// background: '#f5f5f5',
|
||||
// border: '1px solid #ddd',
|
||||
// borderRadius: '8px',
|
||||
// }}
|
||||
>
|
||||
<Layer>
|
||||
<Image
|
||||
|
||||
@@ -120,7 +120,11 @@ export function LightboxModal({
|
||||
const [prompt, setPrompt] = useState<string | null>(null);
|
||||
const [promptLoading, setPromptLoading] = useState<boolean>(false);
|
||||
const [hasPromptToken, setHasPromptToken] = useState<boolean>(false);
|
||||
const [isPromptHovered, setIsPromptHovered] = useState<boolean>(false);
|
||||
const [isPromptExpanded, setIsPromptExpanded] = useState<boolean>(false);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
const [isPromptClamped, setIsPromptClamped] = useState<boolean>(false);
|
||||
const [promptCopied, setPromptCopied] = useState<boolean>(false);
|
||||
const promptCopiedTimeoutRef = useRef<number | null>(null);
|
||||
const [generationProvider, setGenerationProvider] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -151,6 +155,7 @@ export function LightboxModal({
|
||||
setSelectedIndex(0);
|
||||
setMediaLoaded(false);
|
||||
setShareCopied(false);
|
||||
setIsPromptExpanded(false);
|
||||
if (shareCopiedTimeoutRef.current) {
|
||||
window.clearTimeout(shareCopiedTimeoutRef.current);
|
||||
shareCopiedTimeoutRef.current = null;
|
||||
@@ -234,6 +239,23 @@ export function LightboxModal({
|
||||
fetchPrompt();
|
||||
}, [currentMediaId]);
|
||||
|
||||
// Detect if prompt text is clamped (overflows the 4-line clamp)
|
||||
useEffect(() => {
|
||||
if (!promptRef.current || !prompt || promptLoading) {
|
||||
setIsPromptClamped(false);
|
||||
return;
|
||||
}
|
||||
// Use requestAnimationFrame to ensure DOM has painted
|
||||
const raf = requestAnimationFrame(() => {
|
||||
if (promptRef.current) {
|
||||
setIsPromptClamped(
|
||||
promptRef.current.scrollHeight > promptRef.current.clientHeight,
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [prompt, promptLoading, isPromptExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBatch = async () => {
|
||||
if (!batchImageToken) {
|
||||
@@ -533,7 +555,7 @@ export function LightboxModal({
|
||||
|
||||
{/* info + actions - fixed width */}
|
||||
<div className="flex h-full w-[280px] shrink-0 flex-col">
|
||||
<div className="flex-1 space-y-5 text-base-fg">
|
||||
<div className="flex-1 overflow-y-auto space-y-5 text-base-fg min-h-0 pb-2">
|
||||
{/* <div className="text-xl font-medium">
|
||||
{title || "Image Generation"}
|
||||
</div> */}
|
||||
@@ -552,25 +574,48 @@ export function LightboxModal({
|
||||
{hasPromptToken && (
|
||||
<>
|
||||
{/* Prompt */}
|
||||
<div className="relative space-y-1.5">
|
||||
<div className="text-sm font-medium text-base-fg/90">
|
||||
Prompt
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-base-fg/90">
|
||||
Prompt
|
||||
</div>
|
||||
{!promptLoading && prompt && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-xs text-base-fg/60 hover:text-base-fg transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!prompt) return;
|
||||
navigator.clipboard
|
||||
.writeText(prompt)
|
||||
.catch(() => {});
|
||||
toast.success("Prompt copied");
|
||||
setPromptCopied(true);
|
||||
if (promptCopiedTimeoutRef.current) {
|
||||
window.clearTimeout(
|
||||
promptCopiedTimeoutRef.current,
|
||||
);
|
||||
}
|
||||
promptCopiedTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
setPromptCopied(false);
|
||||
promptCopiedTimeoutRef.current = null;
|
||||
},
|
||||
1500,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={promptCopied ? faCheck : faCopy}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span>{promptCopied ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative text-sm text-base-fg break-words p-3 rounded-lg cursor-pointer transition-colors duration-100 leading-relaxed",
|
||||
)}
|
||||
className="text-sm text-base-fg break-words p-3 rounded-lg leading-relaxed"
|
||||
style={{
|
||||
background: isPromptHovered
|
||||
? "rgb(var(--st-controls-rgb) / 0.30)"
|
||||
: "rgb(var(--st-controls-rgb) / 0.20)",
|
||||
}}
|
||||
onMouseEnter={() => setIsPromptHovered(true)}
|
||||
onMouseLeave={() => setIsPromptHovered(false)}
|
||||
onClick={() => {
|
||||
if (!prompt) return;
|
||||
navigator.clipboard.writeText(prompt).catch(() => {});
|
||||
toast.success("Prompt copied");
|
||||
background: "rgb(var(--st-controls-rgb) / 0.20)",
|
||||
}}
|
||||
>
|
||||
{promptLoading ? (
|
||||
@@ -581,32 +626,51 @@ export function LightboxModal({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
prompt || (
|
||||
<span className="text-sm text-base-fg">
|
||||
No prompt
|
||||
</span>
|
||||
)
|
||||
<div
|
||||
ref={promptRef}
|
||||
className={twMerge(
|
||||
!isPromptExpanded && "line-clamp-4",
|
||||
)}
|
||||
>
|
||||
{prompt || (
|
||||
<span className="text-sm text-base-fg">
|
||||
No prompt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!promptLoading && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"pointer-events-none absolute inset-0 flex items-end justify-end opacity-0 transition-opacity duration-50",
|
||||
isPromptHovered && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-base-fg backdrop-blur-md p-1.5 rounded-tl-lg rounded-br-lg"
|
||||
style={{
|
||||
background: "rgb(var(--st-controls-rgb) / 0.80)",
|
||||
{!promptLoading &&
|
||||
prompt &&
|
||||
(isPromptClamped || isPromptExpanded) && (
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-1 text-xs text-base-fg/70 hover:text-base-fg transition-colors py-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPromptExpanded((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} className="h-3 w-3" />
|
||||
<span>Copy prompt</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span>{isPromptExpanded ? "Hide" : "See all"}</span>
|
||||
<svg
|
||||
className={twMerge(
|
||||
"h-3 w-3 transition-transform duration-200",
|
||||
isPromptExpanded && "rotate-180",
|
||||
)}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.5 4.5L6 8L9.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextImages && contextImages.length > 0 && (
|
||||
|
||||
+17
-10
@@ -1,23 +1,30 @@
|
||||
import { PricingContent } from "@storyteller/ui-pricing-modal";
|
||||
|
||||
interface ArtcraftBillingBlockProps {
|
||||
isVideoPage?: boolean;
|
||||
}
|
||||
|
||||
export function ArtcraftBillingBlock({
|
||||
isVideoPage = false,
|
||||
}: ArtcraftBillingBlockProps) {
|
||||
const title = isVideoPage
|
||||
? "Video Generation is Resource Intensive"
|
||||
: "Subscribe for Credits to Generate";
|
||||
|
||||
const subtitle = isVideoPage
|
||||
? `Creating high-quality videos requires significant computing power.
|
||||
To generate more, you can subscribe to ArtCraft or buy ArtCraft credits.
|
||||
You can also add 3rd party providers and use your existing services.
|
||||
Your support helps us keep building and improving ArtCraft!
|
||||
We're building the image and video tool that you can own forever.`
|
||||
: `To generate more, subscribe to ArtCraft or buy credits.
|
||||
You can also add 3rd party providers and use your existing services.
|
||||
Your support helps fund open source development and keeps ArtCraft improving!
|
||||
We're building the creative tool that you can own forever.`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PricingContent
|
||||
title="Video Generation is Resource Intensive"
|
||||
subtitle="
|
||||
Creating high-quality videos requires significant computing power.
|
||||
To generate more, you can subscribe to ArtCraft or buy ArtCraft credits.
|
||||
You can also add 3rd party providers and use your existing services.
|
||||
Your support helps us keep building and improving ArtCraft!
|
||||
We're building the image and video tool that you can own forever.
|
||||
"
|
||||
/>
|
||||
<PricingContent title={title} subtitle={subtitle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,22 @@ import { ArtcraftBillingBlock } from "./artcraft/ArtcraftBillingBlock";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface ProviderBillingModalProps {
|
||||
isVideoPage?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderBillingModal({
|
||||
isVideoPage = false,
|
||||
}: ProviderBillingModalProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [provider, setProvider] = useState<GenerationProvider>(GenerationProvider.Artcraft);
|
||||
const [provider, setProvider] = useState<GenerationProvider>(
|
||||
GenerationProvider.Artcraft,
|
||||
);
|
||||
|
||||
useShowProviderBillingModalEvent(async (event) => {
|
||||
console.log("Show provider billing modal event received from Tauri:", event);
|
||||
console.log(
|
||||
"Show provider billing modal event received from Tauri:",
|
||||
event,
|
||||
);
|
||||
setProvider(event.provider);
|
||||
setShowModal(true);
|
||||
});
|
||||
@@ -32,7 +39,7 @@ export function ProviderBillingModal({
|
||||
block = <GenericProviderBillingBlock provider={provider} />;
|
||||
break;
|
||||
case GenerationProvider.Artcraft:
|
||||
block = <ArtcraftBillingBlock />;
|
||||
block = <ArtcraftBillingBlock isVideoPage={isVideoPage} />;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -46,14 +53,18 @@ export function ProviderBillingModal({
|
||||
}}
|
||||
className={twMerge(
|
||||
"bg-ui-panel border border-ui-panel-border transition-all duration-300 overflow-y-auto",
|
||||
isArtcraft ? "max-w-screen-2xl max-h-[90vh]" : "max-w-2xl max-h-[500px]"
|
||||
isArtcraft
|
||||
? "max-w-screen-2xl max-h-[90vh]"
|
||||
: "max-w-2xl max-h-[500px]",
|
||||
)}
|
||||
showClose={true}
|
||||
>
|
||||
<div className={twMerge(
|
||||
"flex flex-col items-center justify-center",
|
||||
isArtcraft ? "h-full w-full" : "gap-6 p-6"
|
||||
)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex flex-col items-center justify-center",
|
||||
isArtcraft ? "h-full w-full" : "gap-6 p-6",
|
||||
)}
|
||||
>
|
||||
{block}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -37,8 +37,8 @@ QUERY = """
|
||||
"""
|
||||
|
||||
BATCH_PAUSE_SECONDS = 0.5
|
||||
MAX_CONSECUTIVE_FAILURES = 5
|
||||
FAILURE_BACKOFF_SECONDS = 5
|
||||
MAX_BACKOFF_SECONDS = 300
|
||||
|
||||
|
||||
def load_config():
|
||||
@@ -102,14 +102,10 @@ def run_migration():
|
||||
|
||||
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
|
||||
failures += 1
|
||||
print(f" Error ({failures}/{MAX_CONSECUTIVE_FAILURES}): {e}")
|
||||
|
||||
if failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
print("Too many consecutive failures. Aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" Reconnecting in {FAILURE_BACKOFF_SECONDS}s...")
|
||||
time.sleep(FAILURE_BACKOFF_SECONDS)
|
||||
backoff = min(FAILURE_BACKOFF_SECONDS * failures, MAX_BACKOFF_SECONDS)
|
||||
print(f" Error ({failures}): {e}")
|
||||
print(f" Reconnecting in {backoff}s...")
|
||||
time.sleep(backoff)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database status polling script. Runs a count query in a loop and logs results.
|
||||
Reads MySQL credentials from the same secrets.toml as migrate.py.
|
||||
|
||||
Requirements:
|
||||
pip install pymysql
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("pymysql is required: pip install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
SECRETS_PATH = "secrets.toml"
|
||||
|
||||
# -- Count query to poll --
|
||||
QUERY = """
|
||||
select count(*)
|
||||
from media_files
|
||||
where maybe_creator_user_token is null
|
||||
"""
|
||||
|
||||
POLL_INTERVAL_SECONDS = 60
|
||||
FAILURE_BACKOFF_SECONDS = 5
|
||||
MAX_BACKOFF_SECONDS = 300
|
||||
|
||||
|
||||
def load_config():
|
||||
with open(SECRETS_PATH, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
url = config["database"]["url"]
|
||||
parsed = urlparse(url)
|
||||
return {
|
||||
"host": parsed.hostname,
|
||||
"port": parsed.port or 3306,
|
||||
"user": parsed.username,
|
||||
"password": parsed.password,
|
||||
"database": parsed.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def connect(config):
|
||||
return pymysql.connect(
|
||||
host=config["host"],
|
||||
port=config["port"],
|
||||
user=config["user"],
|
||||
password=config["password"],
|
||||
database=config["database"],
|
||||
connect_timeout=10,
|
||||
read_timeout=30,
|
||||
write_timeout=30,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
def run_status():
|
||||
config = load_config()
|
||||
conn = connect(config)
|
||||
failures = 0
|
||||
|
||||
print(f"Connected to {config['host']}/{config['database']}")
|
||||
print(f"Polling every {POLL_INTERVAL_SECONDS}s with:\n{QUERY.strip()}\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
query_start = time.monotonic()
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(QUERY)
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
failures = 0
|
||||
query_time = int(time.monotonic() - query_start)
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f" Remaining: {count:,} rows | Query: {query_time}s | Clock: {now}")
|
||||
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
except (pymysql.OperationalError, pymysql.InterfaceError) as e:
|
||||
failures += 1
|
||||
backoff = min(FAILURE_BACKOFF_SECONDS * failures, MAX_BACKOFF_SECONDS)
|
||||
print(f" Error ({failures}): {e}")
|
||||
print(f" Reconnecting in {backoff}s...")
|
||||
time.sleep(backoff)
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
conn = connect(config)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_status()
|
||||
Reference in New Issue
Block a user