mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-12 04:42:16 +00:00
libs/components: mark fire-and-forget promises with void (Sonar S9383) (#2807)
## Summary
Resolves all 66 SonarCloud **typescript:S9383** findings ("Promises must
be awaited, end with a call to .catch, end with a call to .then with a
rejection handler or be explicitly marked as ignored with the `void`
operator") in `frontend/libs/components`, across 41 files:
| Library | Findings |
|------------------------|----------|
| `upload-modal` | 14 |
| `promptbox` | 11 |
| `settings-modal` | 11 |
| `pagescene` | 10 |
| `video-editor` | 7 |
| `lightbox-modal` | 3 |
| `pagedraw` | 3 |
| `generation-list` | 2 |
| `help-menu` | 2 |
| `gallery-modal` | 1 |
| `pricing-modal` | 1 |
| `provider-setup-modal` | 1 |
The flagged expressions include settings and session loaders
(`fetchData()`, `fetchSession()`) called from effects, upload starts and
retries in the upload modals, enqueue handlers in the prompt boxes,
`audio.play()` and `el.play()` previews, scene render and load calls in
`pagescene`, bookmark actions in `video-editor`, and async IIFEs.
This is part 3 of 3 for artcraft's 157 S9383 findings. Part 1 (Tauri
libraries) is #2805, and part 2 (`apps/artcraft/app`) is #2806.
Some of these files are also changed by open PRs for unrelated reasons,
for example #2615 (settings panes), #2519, #2520 and #2083. Each change
here inserts one word at the start of a statement, so a rebase either
way is trivial.
## Approach
This ports the approach of storytold/artcraft-services#179, which fixes
the same rule in artcraft-services. **#179 is still open and unmerged.**
This PR follows its approach and does not depend on it.
- Each flagged expression is a fire-and-forget promise: a listener
`setup()`, an `unlisten.then(f => f())` cleanup, a loader called from an
effect, an upload kicked off from a handler, or an async IIFE. Prefixing
it with `void` marks the ignored promise explicitly, which is the fix
the rule documents.
- It deliberately does not add `.catch()` everywhere. These call sites
already handle failures inside the async function or surface them
through their own state, and empty handlers would hide errors.
- Async IIFEs become `void (async () => …)()`. `if (cond) call()`
becomes `if (cond) void call()`.
- There is no behaviour change. `void` only discards the promise value,
which was already being discarded.
Each insertion is placed at the exact `textRange` start offset of the
SonarCloud finding, with no other edits.
## Testing
- Each changed file parses with `esbuild <file>`, using the same check
as #179.
- I parsed each changed file with the TypeScript 5.9 compiler API
(`createSourceFile`). That found no parse errors, and every inserted
`void` is either a whole expression statement or an arrow-function body
applied to a call, never part of a larger expression.
- `frontend/.eslintrc.json` has no `no-void` rule.
- I did not run the full Nx type-check, lint or test targets, because
`frontend/node_modules` is not installed in my environment. Please rely
on CI.
- No tests are added. Discarding an already-discarded promise value is
not observable, so no test can fail before this change and pass after
it.
**Added by @dylanmtaylor, to clear the new-code duplication gate.** The
`void` lines sat inside copy-pasted code:
- `ProviderAccountBlock` replaces the identical `GrokAccountBlock` and
`MidjourneyAccountBlock`.
- `UploadFilesPanels` (file list, resize handle, previous/next; each
modal passes its preview) replaces the layout the 3D, Image and Splat
upload modals repeated.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **New Features**
* Multi-file uploads across image, 3D, and splat workflows now share a
two-panel layout with file statuses, preview selection, removal, retry,
and previous/next navigation.
* Grok and Midjourney account controls now use a shared interface that
displays login status and provides available connect or disconnect
actions. Failed credential checks clear the displayed session, and
account controls show a loading state while checking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
This commit is contained in:
1 parent
d6772fdd94
commit
130bfd71d6
44 files changed
+382
-580
No files matched your search
@@ -948,7 +948,7 @@ export const GalleryModal = React.memo(
|
||||
// couldn't see it — making the library button appear broken.
|
||||
galleryModalDraggingUnder.value = false;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
setUsernameError(false);
|
||||
const name = await fetchCurrentUsername();
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -90,7 +90,7 @@ export function useMediaPromptTokens(
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, string>();
|
||||
|
||||
@@ -63,7 +63,7 @@ export function usePrompts(
|
||||
const key = [...new Set(cleaned)].sort((a, b) => a.localeCompare(b)).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (key.length > 0) fetchMissing(key.split(","));
|
||||
if (key.length > 0) void fetchMissing(key.split(","));
|
||||
}, [key]);
|
||||
|
||||
const result = new Map<string, Prompts>();
|
||||
|
||||
@@ -43,7 +43,7 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
|
||||
console.warn("Failed to fetch news feed, using bundled content.", e);
|
||||
}
|
||||
};
|
||||
fetchNews();
|
||||
void fetchNews();
|
||||
}, []);
|
||||
|
||||
const selectedPost = useMemo(
|
||||
@@ -316,7 +316,7 @@ export function HelpMenuButton({
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
if (progressTimerRef.current) {
|
||||
|
||||
@@ -180,7 +180,7 @@ export function LightboxModal({
|
||||
audioPlayerRef.current = null;
|
||||
};
|
||||
audioPlayerRef.current = audio;
|
||||
audio.play();
|
||||
void audio.play();
|
||||
setPlayingAudioToken(token);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -83,7 +83,7 @@ export function TagsSection({
|
||||
useEffect(() => {
|
||||
if (currentUsername !== undefined) return;
|
||||
let cancelled = false;
|
||||
resolveSessionUsername().then((username) => {
|
||||
void resolveSessionUsername().then((username) => {
|
||||
if (!cancelled) setResolvedUsername(username);
|
||||
});
|
||||
return () => {
|
||||
@@ -133,7 +133,7 @@ export function TagsSection({
|
||||
}
|
||||
ownTagsLoadedRef.current = true;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
void (async () => {
|
||||
const all: TagDetails[] = [];
|
||||
let cursor: string | undefined = undefined;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
|
||||
@@ -1041,7 +1041,7 @@ const PageDraw = ({
|
||||
await tryFit();
|
||||
};
|
||||
|
||||
autoFitCanvas();
|
||||
void autoFitCanvas();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseImageBitmap, baseImageInfo?.isBlankCanvas]);
|
||||
|
||||
|
||||
@@ -1501,7 +1501,7 @@ export const PaintSurface = ({
|
||||
|
||||
// Load the checkboard image for transparency background
|
||||
useEffect(() => {
|
||||
loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
void loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
}, []);
|
||||
|
||||
// Separate function to render transformers on their own layer
|
||||
|
||||
@@ -1165,7 +1165,7 @@ export const useSceneStore = create<SceneState>((set, get, store) => ({
|
||||
console.error("Failed to restore image:", error);
|
||||
}
|
||||
};
|
||||
loadImage();
|
||||
void loadImage();
|
||||
}
|
||||
|
||||
return node;
|
||||
|
||||
@@ -387,7 +387,7 @@ export const Stage3DBody = ({
|
||||
useEffect(() => {
|
||||
const handler = onImageDrop(
|
||||
(item: GalleryItem, position: { x: number; y: number }) => {
|
||||
(async () => {
|
||||
void (async () => {
|
||||
if (!editor) {
|
||||
console.warn("Cannot drop asset: editor engine not ready");
|
||||
return;
|
||||
|
||||
@@ -14,7 +14,7 @@ export function toggleObjectVisibility(editor: Editor, uuid: string): void {
|
||||
if (!obj) return;
|
||||
const before = obj.visible;
|
||||
editor.bus.emit(new OutlinerItemVisibilityToggledEvent(uuid));
|
||||
editor.sceneManager?.hideObject(uuid);
|
||||
void editor.sceneManager?.hideObject(uuid);
|
||||
editor.history.record(
|
||||
new VisibilityAction(editor, uuid, before, !before),
|
||||
);
|
||||
|
||||
@@ -190,7 +190,7 @@ class Editor {
|
||||
// Ammo.js WASM is a global side-effect script the physics path
|
||||
// depends on. The singleton ensures it's appended to document.body
|
||||
// exactly once, even across Editor re-construction.
|
||||
ensureAmmoLoaded();
|
||||
void ensureAmmoLoaded();
|
||||
|
||||
// Version and name.
|
||||
this.version = 2.0;
|
||||
@@ -667,9 +667,9 @@ class Editor {
|
||||
};
|
||||
|
||||
if (!this.utils.isEmpty(cacheJson)) {
|
||||
this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
|
||||
void this.save_manager.loadCache(cacheJson).then(applyIfLoaded);
|
||||
} else if (!this.utils.isEmpty(sceneToken)) {
|
||||
this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
|
||||
void this.save_manager.loadScene(sceneToken).then(applyIfLoaded);
|
||||
} else {
|
||||
this.adapter.onSceneTitleChange?.({
|
||||
title: "Untitled New Scene",
|
||||
@@ -1065,7 +1065,7 @@ class Editor {
|
||||
}
|
||||
|
||||
this.lastFrameTime = frameTime;
|
||||
this.renderSingleFrame();
|
||||
void this.renderSingleFrame();
|
||||
}
|
||||
|
||||
startRenderLoop() {
|
||||
|
||||
+2
-2
@@ -151,7 +151,7 @@ export class ModalTransformController {
|
||||
}
|
||||
}
|
||||
this.target.position.copy(this.startPos).add(delta);
|
||||
this.editor.renderScene();
|
||||
void this.editor.renderScene();
|
||||
}
|
||||
|
||||
private axisVector(axis: "x" | "y" | "z"): THREE.Vector3 {
|
||||
@@ -190,7 +190,7 @@ export class ModalTransformController {
|
||||
|
||||
private cancel() {
|
||||
if (this.target) this.target.position.copy(this.startPos);
|
||||
this.editor.renderScene();
|
||||
void this.editor.renderScene();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export class LoadingPlaceHolderManager {
|
||||
if (result === undefined) {
|
||||
return;
|
||||
}
|
||||
result.destroyWithScene(this.scene);
|
||||
void result.destroyWithScene(this.scene);
|
||||
this.messages.delete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ class Scene {
|
||||
initialize() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.placeholder_manager = new LoadingPlaceHolderManager(this.scene);
|
||||
this.placeholder_manager.initialize();
|
||||
void this.placeholder_manager.initialize();
|
||||
|
||||
this._createGrid();
|
||||
this._create_base_lighting();
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useCurrency(): UseCurrencyResult {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchRates().then((r) => {
|
||||
void fetchRates().then((r) => {
|
||||
if (!cancelled) {
|
||||
setRates(r);
|
||||
setRatesLoaded(true);
|
||||
|
||||
@@ -103,7 +103,7 @@ const AudioRefTile = ({
|
||||
el.volume = 0.2;
|
||||
audioRef.current = el;
|
||||
el.onended = () => setIsPlaying(false);
|
||||
el.play();
|
||||
void el.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -347,7 +347,7 @@ export const PromptBox2D = ({
|
||||
const isSubmitCombo = enterToGenerate && !e.shiftKey;
|
||||
if (isSubmitCombo) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
void handleGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -537,7 +537,7 @@ export const PromptBox3D = ({
|
||||
const isSubmitCombo = enterToGenerate && !e.shiftKey;
|
||||
if (isSubmitCombo) {
|
||||
e.preventDefault();
|
||||
handleEnqueue();
|
||||
void handleEnqueue();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -353,7 +353,7 @@ export const PromptBoxAudio = ({
|
||||
if (e.key !== "Enter") return;
|
||||
if (enterToGenerate && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEnqueue();
|
||||
void handleEnqueue();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@ export const PromptBoxImage = ({
|
||||
const isSubmitCombo = enterToGenerate && !e.shiftKey;
|
||||
if (isSubmitCombo) {
|
||||
e.preventDefault();
|
||||
handleEnqueue();
|
||||
void handleEnqueue();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1230,7 +1230,7 @@ export const PromptBoxVideo = ({
|
||||
console.error("PromptBoxVideo - enqueue failed", failure.reason);
|
||||
toast.error(commandErrorMessage(failure.reason, "Failed to start video generation. Please try again."));
|
||||
}
|
||||
if (acceptedIds.length) onEnqueuePressed?.(prompt, acceptedIds);
|
||||
if (acceptedIds.length) void onEnqueuePressed?.(prompt, acceptedIds);
|
||||
|
||||
setIsEnqueueing(false);
|
||||
};
|
||||
@@ -1281,7 +1281,7 @@ export const PromptBoxVideo = ({
|
||||
return;
|
||||
}
|
||||
|
||||
handleEnqueue();
|
||||
void handleEnqueue();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1391,7 +1391,7 @@ export const PromptBoxVideo = ({
|
||||
)
|
||||
return;
|
||||
if (!prompt.trim()) return;
|
||||
handleEnqueue();
|
||||
void handleEnqueue();
|
||||
}
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DeckCard = ({
|
||||
el.volume = 0.2;
|
||||
audioRef.current = el;
|
||||
el.onended = () => setIsPlaying(false);
|
||||
el.play();
|
||||
void el.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [isPlaying, item.url, stopAudio]);
|
||||
|
||||
@@ -516,8 +516,8 @@ export function useDeckMedia<
|
||||
);
|
||||
|
||||
if (images.length > 0) processImageFiles(images, "start");
|
||||
if (videos.length > 0 && setReferenceVideos) processVideoFiles(videos);
|
||||
if (audios.length > 0 && setReferenceAudios) processAudioFiles(audios);
|
||||
if (videos.length > 0 && setReferenceVideos) void processVideoFiles(videos);
|
||||
if (audios.length > 0 && setReferenceAudios) void processAudioFiles(audios);
|
||||
};
|
||||
|
||||
// maxImages of 0 means the page's model takes no image refs at all.
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ProviderSetupModal({
|
||||
icon={ArrowRightIcon}
|
||||
iconFlip={true}
|
||||
onClick={() => {
|
||||
buttonOnClick();
|
||||
void buttonOnClick();
|
||||
}}
|
||||
>
|
||||
{modalButtonText}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AboutSettingsPane = (args: AboutSettingsPaneProps) => {
|
||||
const prefs = await GetAppInfo();
|
||||
setAppInfo(prefs.payload);
|
||||
};
|
||||
fetchData();
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const handleVersionClick = () => {
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
import { ArtcraftAccountBlock } from "./ArtcraftAccountBlock";
|
||||
import { MidjourneyAccountBlock } from "./MidjourneyAccountBlock";
|
||||
import { GrokAccountBlock } from "./GrokAccountBlock";
|
||||
import { ProviderAccountBlock } from "./ProviderAccountBlock";
|
||||
import { GrokGetCredentialInfo, MidjourneyGetCredentialInfo } from "@storyteller/tauri-api";
|
||||
|
||||
interface AccountSettingsPaneProps {
|
||||
globalAccountLogoutCallback: () => void;
|
||||
@@ -15,9 +15,17 @@ export const AccountSettingsPane = ({
|
||||
globalAccountLogoutCallback={globalAccountLogoutCallback}
|
||||
/>
|
||||
<hr className="border-ui-panel-border" />
|
||||
<GrokAccountBlock />
|
||||
<ProviderAccountBlock
|
||||
name="Grok"
|
||||
commandPrefix="grok"
|
||||
getCredentialInfo={GrokGetCredentialInfo}
|
||||
/>
|
||||
<hr className="border-ui-panel-border" />
|
||||
<MidjourneyAccountBlock />
|
||||
<ProviderAccountBlock
|
||||
name="Midjourney"
|
||||
commandPrefix="midjourney"
|
||||
getCredentialInfo={MidjourneyGetCredentialInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -40,7 +40,7 @@ export const ArtcraftAccountBlock = ({
|
||||
setIsCheckingArtcraftSession(false);
|
||||
}
|
||||
};
|
||||
fetchSession();
|
||||
void fetchSession();
|
||||
}, []);
|
||||
|
||||
const handleArtcraftButton = async () => {
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { useRefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
import { RefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
import { GrokGetCredentialInfo, GrokGetCredentialInfoSuccess } from "@storyteller/tauri-api";
|
||||
|
||||
export const GrokAccountBlock = () => {
|
||||
const [grokSession, setGrokSession] = useState<GrokGetCredentialInfoSuccess| undefined>(undefined);
|
||||
const [isCheckingGrokSession, setIsCheckingGrokSession] = useState(false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingGrokSession(true);
|
||||
try {
|
||||
const result = await GrokGetCredentialInfo();
|
||||
setGrokSession(result);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Grok session", e);
|
||||
setGrokSession(undefined);
|
||||
} finally {
|
||||
setIsCheckingGrokSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async (event: RefreshAccountStateEvent) => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await invoke("grok_clear_credentials_command");
|
||||
} catch (e) {
|
||||
console.error("Error clearing Grok credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
const openLogin = async() => {
|
||||
try {
|
||||
await invoke("grok_open_login_command");
|
||||
} catch (e) {
|
||||
console.error("Error opening Grok login", e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrokButton = async () => {
|
||||
if (grokSession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setGrokSession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return(
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Grok account
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-base-fg/80">
|
||||
{grokSession?.payload?.maybe_email || "Not logged in"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={
|
||||
grokSession?.payload?.can_clear_state && !isCheckingGrokSession
|
||||
? "destructive"
|
||||
: grokSession?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-9 shrink-0 px-3"
|
||||
onClick={handleGrokButton}
|
||||
disabled={isCheckingGrokSession}
|
||||
>
|
||||
{isCheckingGrokSession ? (
|
||||
<LoaderCircleIcon className="animate-spin text-sm" />
|
||||
) : grokSession?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MidjourneyGetCredentialInfo, MidjourneyGetCredentialInfoSuccess } from "@storyteller/tauri-api";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { useRefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
import { RefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
|
||||
export const MidjourneyAccountBlock = () => {
|
||||
const [midjourneySession, setMidjourneySession] = useState<MidjourneyGetCredentialInfoSuccess| undefined>(undefined);
|
||||
const [isCheckingMidjourneySession, setIsCheckingMidjourneySession] = useState(false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
setIsCheckingMidjourneySession(true);
|
||||
try {
|
||||
const result = await MidjourneyGetCredentialInfo();
|
||||
setMidjourneySession(result);
|
||||
} catch (e) {
|
||||
console.error("Error fetching Midjourney session", e);
|
||||
setMidjourneySession(undefined);
|
||||
} finally {
|
||||
setIsCheckingMidjourneySession(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async (event: RefreshAccountStateEvent) => {
|
||||
fetchSession();
|
||||
});
|
||||
|
||||
const clearState = async() => {
|
||||
try {
|
||||
await invoke("midjourney_clear_credentials_command");
|
||||
} catch (e) {
|
||||
console.error("Error clearing Midjourney credentials", e);
|
||||
}
|
||||
}
|
||||
|
||||
const openLogin = async() => {
|
||||
try {
|
||||
await invoke("midjourney_open_login_command");
|
||||
} catch (e) {
|
||||
console.error("Error opening Midjourney login", e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleMidjourneyButton = async () => {
|
||||
if (midjourneySession?.payload?.can_clear_state) {
|
||||
await clearState();
|
||||
setMidjourneySession(undefined);
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return(
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
Midjourney account
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-base-fg/80">
|
||||
{midjourneySession?.payload?.maybe_email || "Not logged in"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={
|
||||
midjourneySession?.payload?.can_clear_state && !isCheckingMidjourneySession
|
||||
? "destructive"
|
||||
: midjourneySession?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-9 shrink-0 px-3"
|
||||
onClick={handleMidjourneyButton}
|
||||
disabled={isCheckingMidjourneySession}
|
||||
>
|
||||
{isCheckingMidjourneySession ? (
|
||||
<LoaderCircleIcon className="animate-spin text-sm" />
|
||||
) : midjourneySession?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@ export const OpenAIApiKeyBlock = () => {
|
||||
setIsCheckingOpenAIApiKey(false);
|
||||
}
|
||||
};
|
||||
fetchKey();
|
||||
void fetchKey();
|
||||
}, []);
|
||||
|
||||
const syncOpenAIApiKey = async (key: string) => {
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { useRefreshAccountStateEvent } from "@storyteller/tauri-events";
|
||||
|
||||
interface CredentialInfo {
|
||||
payload: { maybe_email?: string; can_clear_state: boolean };
|
||||
}
|
||||
|
||||
interface ProviderAccountBlockProps {
|
||||
/** Shown in the heading and in logged errors, e.g. "Grok". */
|
||||
name: string;
|
||||
/** Prefix of the provider's Tauri commands, e.g. "grok" for `grok_open_login_command`. */
|
||||
commandPrefix: string;
|
||||
getCredentialInfo: () => Promise<CredentialInfo>;
|
||||
}
|
||||
|
||||
/** A provider account signed in through the app's own login window: who is signed in, and Connect / Disconnect. */
|
||||
export const ProviderAccountBlock = ({
|
||||
name,
|
||||
commandPrefix,
|
||||
getCredentialInfo,
|
||||
}: ProviderAccountBlockProps) => {
|
||||
const [session, setSession] = useState<CredentialInfo | undefined>(undefined);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
const fetchSession = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
setSession(await getCredentialInfo());
|
||||
} catch (e) {
|
||||
console.error(`Error fetching ${name} session`, e);
|
||||
setSession(undefined);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSession();
|
||||
}, []);
|
||||
|
||||
useRefreshAccountStateEvent(async () => {
|
||||
void fetchSession();
|
||||
});
|
||||
|
||||
/** Whether the stored credentials were cleared. */
|
||||
const clearState = async () => {
|
||||
try {
|
||||
await invoke(`${commandPrefix}_clear_credentials_command`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Error clearing ${name} credentials`, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const openLogin = async () => {
|
||||
try {
|
||||
await invoke(`${commandPrefix}_open_login_command`);
|
||||
} catch (e) {
|
||||
console.error(`Error opening ${name} login`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleButton = async () => {
|
||||
if (session?.payload?.can_clear_state) {
|
||||
// A failed clear may have cleared part of the state; show what is left (Disconnect again retries).
|
||||
if (await clearState()) setSession(undefined);
|
||||
else await fetchSession();
|
||||
} else {
|
||||
await openLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
|
||||
{name} account
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-base-fg/80">
|
||||
{session?.payload?.maybe_email || "Not logged in"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={
|
||||
session?.payload?.can_clear_state && !isChecking
|
||||
? "destructive"
|
||||
: session?.payload?.can_clear_state
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
className="h-9 shrink-0 px-3"
|
||||
onClick={handleButton}
|
||||
disabled={isChecking}
|
||||
>
|
||||
{isChecking ? (
|
||||
<LoaderCircleIcon className="animate-spin text-sm" />
|
||||
) : session?.payload?.can_clear_state ? (
|
||||
"Disconnect"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const AudioSettingsPane = (args: AudioSettingsPaneProps) => {
|
||||
const prefs = await GetAppPreferences();
|
||||
setPreferences(prefs.preferences);
|
||||
};
|
||||
fetchData();
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const playSounds = preferences?.play_sounds || false;
|
||||
|
||||
@@ -221,7 +221,7 @@ export const DownloadsSettingsPane = () => {
|
||||
if (value === "custom") {
|
||||
setFilenameMode("custom");
|
||||
} else {
|
||||
saveFilenamePreference("artcraft_convention");
|
||||
void saveFilenamePreference("artcraft_convention");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -139,7 +139,7 @@ export const ProviderPrioritySettingsPane = () => {
|
||||
|
||||
setItems(items);
|
||||
};
|
||||
fetchData();
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -178,7 +178,7 @@ export const ProviderPrioritySettingsPane = () => {
|
||||
const newOrder = arrayMove(prevItems, oldIndex, newIndex);
|
||||
|
||||
// Send update to backend
|
||||
updateProviderPriorityOnBackend(newOrder);
|
||||
void updateProviderPriorityOnBackend(newOrder);
|
||||
|
||||
return newOrder;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, LoaderIcon, RotateCwIcon, XIcon } from "lucide-react";
|
||||
|
||||
interface UploadFileRowProps {
|
||||
entry: { file: File; status: string };
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onRemove: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/** One file in an upload modal's list: its name, Remove while idle, then a spinner, a check, or an error with Retry. */
|
||||
const UploadFileRow = ({ entry, selected, onSelect, onRemove, onRetry }: UploadFileRowProps) => (
|
||||
<li
|
||||
className={`group flex cursor-pointer items-center justify-between gap-1.5 rounded-[3px] px-2 py-1 text-sm transition-colors ${
|
||||
selected ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="flex-1 truncate" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
{(entry.status === "uploading" || entry.status === "converting") && (
|
||||
<LoaderIcon className="animate-spin opacity-60" />
|
||||
)}
|
||||
{entry.status === "success" && <CheckIcon className="text-green-400" />}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CircleAlertIcon className="text-red-400" />
|
||||
<button
|
||||
className="inline-flex items-center text-xs text-white/60 opacity-0 transition hover:text-white group-hover:opacity-100 focus-visible:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
interface UploadFilesPanelsProps {
|
||||
entries: { file: File; status: string }[];
|
||||
previewIndex: number;
|
||||
setPreviewIndex: Dispatch<SetStateAction<number>>;
|
||||
onRemove: (index: number) => void;
|
||||
onRetry: (index: number) => void;
|
||||
/** The preview of the selected file, above the previous / next buttons. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** An upload modal with several files: the file list, a resize handle, and the selected file's preview with previous / next. */
|
||||
export const UploadFilesPanels = ({
|
||||
entries,
|
||||
previewIndex,
|
||||
setPreviewIndex,
|
||||
onRemove,
|
||||
onRetry,
|
||||
children,
|
||||
}: UploadFilesPanelsProps) => (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={33} minSize={20}>
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-none border border-white/15 bg-black/30 p-2">
|
||||
{entries.map((entry, i) => (
|
||||
<UploadFileRow
|
||||
key={i}
|
||||
entry={entry}
|
||||
selected={i === previewIndex}
|
||||
onSelect={() => setPreviewIndex(i)}
|
||||
onRemove={() => onRemove(i)}
|
||||
onRetry={() => onRetry(i)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-1 rounded-none bg-white/20 transition-colors hover:bg-white/40"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</PanelResizeHandle>
|
||||
|
||||
<Panel defaultSize={67} minSize={25}>
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
{children}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {entries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.min(entries.length - 1, p + 1))}
|
||||
disabled={previewIndex === entries.length - 1}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ListDropdown } from "@storyteller/ui-list-dropdown";
|
||||
import { Select } from "@storyteller/ui-select";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { BoneIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, LoaderIcon, RotateCwIcon, XIcon } from "lucide-react";
|
||||
import { BoneIcon, BoxIcon, LoaderIcon } from "lucide-react";
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
convertFbxToGlb,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
UploaderState,
|
||||
UploaderStates,
|
||||
} from "../Types";
|
||||
import { UploadFilesPanels } from "../UploadFilesPanels";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
@@ -270,7 +270,7 @@ export const UploadFiles3D = ({
|
||||
// B's key.
|
||||
useEffect(() => {
|
||||
if (previewStatus.type === "OK" && canvasRef.current) {
|
||||
snapshotCanvasAsThumbnail({
|
||||
void snapshotCanvasAsThumbnail({
|
||||
targetNode: canvasRef.current,
|
||||
resultCallback: (blob) => {
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
@@ -503,7 +503,7 @@ export const UploadFiles3D = ({
|
||||
// can only ever be a clean single-file submission; the excludedCount
|
||||
// check makes that invariant explicit rather than incidental.
|
||||
if (excludedCount === 0 && files.length === 1 && fileEntries.length === 1) {
|
||||
upload3DObjects({
|
||||
void upload3DObjects({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
engineCategory,
|
||||
@@ -521,7 +521,7 @@ export const UploadFiles3D = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
void upload3DObjectsBatch({
|
||||
files,
|
||||
thumbnails: pendingThumbnails,
|
||||
engineCategory,
|
||||
@@ -581,7 +581,7 @@ export const UploadFiles3D = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
void upload3DObjectsBatch({
|
||||
files: failedFiles,
|
||||
thumbnails: failedThumbnails,
|
||||
engineCategory,
|
||||
@@ -695,123 +695,35 @@ export const UploadFiles3D = ({
|
||||
{selectionError && <h6 className="z-10 text-red">{selectionError}</h6>}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={33} minSize={20}>
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-none border border-white/15 bg-black/30 p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex cursor-pointer items-center justify-between gap-1.5 rounded-[3px] px-2 py-1 text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="flex-1 truncate" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
{(entry.status === "uploading" ||
|
||||
entry.status === "converting") && (
|
||||
<LoaderIcon
|
||||
|
||||
className="animate-spin opacity-60" />
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<CheckIcon
|
||||
|
||||
className="text-green-400" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CircleAlertIcon
|
||||
|
||||
className="text-red-400" />
|
||||
<button
|
||||
className="hidden items-center text-xs text-white/60 transition-colors hover:text-white group-hover:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-1 rounded-none bg-white/20 transition-colors hover:bg-white/40"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
<UploadFilesPanels
|
||||
entries={fileEntries}
|
||||
previewIndex={previewIndex}
|
||||
setPreviewIndex={setPreviewIndex}
|
||||
onRemove={removeFile}
|
||||
onRetry={(i) => void retrySingleFile(i)}
|
||||
>
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
</PanelResizeHandle>
|
||||
|
||||
<Panel defaultSize={67} minSize={25}>
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{animationPicker}
|
||||
{convertingOverlay}
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<BoxIcon />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1),
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{animationPicker}
|
||||
{convertingOverlay}
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<BoxIcon />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</UploadFilesPanels>
|
||||
) : (
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
|
||||
+21
-109
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, LoaderIcon, RotateCwIcon, XIcon } from "lucide-react";
|
||||
import { uploadImage } from "./utilities/uploadImage";
|
||||
import { uploadImagesBatch } from "./utilities/uploadImagesBatch";
|
||||
import {
|
||||
@@ -10,6 +8,7 @@ import {
|
||||
UploaderState,
|
||||
UploaderStates,
|
||||
} from "../Types";
|
||||
import { UploadFilesPanels } from "../UploadFilesPanels";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
@@ -117,7 +116,7 @@ export const UploadFilesImage = ({
|
||||
.filter((i) => i !== -1);
|
||||
|
||||
if (files.length === 1 && fileEntries.length === 1) {
|
||||
uploadImage({
|
||||
void uploadImage({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
progressCallback: onUploadProgress,
|
||||
@@ -128,7 +127,7 @@ export const UploadFilesImage = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
uploadImagesBatch({
|
||||
void uploadImagesBatch({
|
||||
files,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(originalIndices[batchIndex], status, errorMessage),
|
||||
@@ -159,7 +158,7 @@ export const UploadFilesImage = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
uploadImagesBatch({
|
||||
void uploadImagesBatch({
|
||||
files: failedFiles,
|
||||
onFileStatusChange: (batchIndex, status, errorMessage) =>
|
||||
updateFileStatus(failedIndices[batchIndex], status, errorMessage),
|
||||
@@ -202,111 +201,24 @@ export const UploadFilesImage = ({
|
||||
|
||||
{fileEntries.length > 0 && (
|
||||
isMulti ? (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={33} minSize={20}>
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-none border border-white/15 bg-black/30 p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded-[3px] px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<LoaderIcon
|
||||
|
||||
className="animate-spin opacity-60" />
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<CheckIcon
|
||||
|
||||
className="text-green-400" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CircleAlertIcon
|
||||
|
||||
className="text-red-400" />
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-1 rounded-none bg-white/20 transition-colors hover:bg-white/40"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</PanelResizeHandle>
|
||||
|
||||
<Panel defaultSize={67} minSize={25}>
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-none border border-white/15 bg-black/30 flex items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
key={previewIndex}
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<UploadFilesPanels
|
||||
entries={fileEntries}
|
||||
previewIndex={previewIndex}
|
||||
setPreviewIndex={setPreviewIndex}
|
||||
onRemove={removeFile}
|
||||
onRetry={(i) => void retrySingleFile(i)}
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-none border border-white/15 bg-black/30 flex items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
key={previewIndex}
|
||||
alt="Preview"
|
||||
className="m-auto max-h-full max-w-full object-contain"
|
||||
src={previewUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</UploadFilesPanels>
|
||||
) : (
|
||||
previewUrl && (
|
||||
<div className="relative m-auto flex aspect-square w-full items-center justify-center overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
|
||||
+31
-116
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { ListDropdown } from "@storyteller/ui-list-dropdown";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { FileUploader } from "@storyteller/ui-file-uploader";
|
||||
import { BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, LoaderIcon, RotateCwIcon, XIcon } from "lucide-react";
|
||||
import { BoxIcon } from "lucide-react";
|
||||
import { WebGLRenderer, PerspectiveCamera } from "three";
|
||||
import { loadPreviewOnCanvas, snapshotCanvasAsThumbnail } from "../UploadModal3D/utilities";
|
||||
import { upload3DObjects } from "../UploadModal3D/utilities/upload3DObjects";
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
UploaderState,
|
||||
UploaderStates,
|
||||
} from "../Types";
|
||||
import { UploadFilesPanels } from "../UploadFilesPanels";
|
||||
|
||||
interface FileEntry {
|
||||
file: File;
|
||||
@@ -127,7 +127,7 @@ export const UploadFilesSplat = ({
|
||||
const canvas = canvasRef.current;
|
||||
const currentFile = fileEntries[previewIndex]?.file;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
snapshotCanvasAsThumbnail({
|
||||
void snapshotCanvasAsThumbnail({
|
||||
targetNode: canvas,
|
||||
resultCallback: (blob) => {
|
||||
if (blob && currentFile) {
|
||||
@@ -222,7 +222,7 @@ export const UploadFilesSplat = ({
|
||||
);
|
||||
|
||||
if (files.length === 1 && fileEntries.length === 1) {
|
||||
upload3DObjects({
|
||||
void upload3DObjects({
|
||||
title: files[0].name.slice(0, files[0].name.lastIndexOf(".")),
|
||||
assetFile: files[0],
|
||||
engineCategory,
|
||||
@@ -236,7 +236,7 @@ export const UploadFilesSplat = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: files.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
void upload3DObjectsBatch({
|
||||
files,
|
||||
thumbnails: pendingThumbnails,
|
||||
engineCategory,
|
||||
@@ -277,7 +277,7 @@ export const UploadFilesSplat = ({
|
||||
setIsUploading(true);
|
||||
setOverallProgress({ current: 0, total: failedFiles.length });
|
||||
|
||||
upload3DObjectsBatch({
|
||||
void upload3DObjectsBatch({
|
||||
files: failedFiles,
|
||||
thumbnails: failedThumbnails,
|
||||
engineCategory,
|
||||
@@ -331,118 +331,33 @@ export const UploadFilesSplat = ({
|
||||
)}
|
||||
|
||||
{isMulti ? (
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={33} minSize={20}>
|
||||
<ul className="flex h-full flex-col gap-1 overflow-y-auto rounded-none border border-white/15 bg-black/30 p-2">
|
||||
{fileEntries.map((entry, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className={`group flex items-center justify-between gap-1.5 rounded-[3px] px-2 py-1 cursor-pointer text-sm transition-colors ${
|
||||
i === previewIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
<span className="truncate flex-1" title={entry.file.name}>
|
||||
{entry.file.name.slice(0, entry.file.name.lastIndexOf("."))}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entry.status === "idle" && (
|
||||
<button
|
||||
className="opacity-40 hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
title="Remove"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "uploading" && (
|
||||
<LoaderIcon
|
||||
|
||||
className="animate-spin opacity-60" />
|
||||
)}
|
||||
{entry.status === "success" && (
|
||||
<CheckIcon className="text-green-400" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CircleAlertIcon
|
||||
|
||||
className="text-red-400" />
|
||||
<button
|
||||
className="hidden group-hover:inline-flex items-center text-xs text-white/60 hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retrySingleFile(i);
|
||||
}}
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="flex w-4 items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-1 rounded-none bg-white/20 transition-colors hover:bg-white/40"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
<UploadFilesPanels
|
||||
entries={fileEntries}
|
||||
previewIndex={previewIndex}
|
||||
setPreviewIndex={setPreviewIndex}
|
||||
onRemove={removeFile}
|
||||
onRetry={(i) => void retrySingleFile(i)}
|
||||
>
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
</PanelResizeHandle>
|
||||
|
||||
<Panel defaultSize={67} minSize={25}>
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="relative w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
className="pointer-events-none h-full min-h-48 !w-full"
|
||||
ref={canvasCallbackRef}
|
||||
/>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<BoxIcon />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPreviewIndex((p) => Math.max(0, p - 1))}
|
||||
disabled={previewIndex === 0}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-sm opacity-60">
|
||||
{previewIndex + 1} / {fileEntries.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setPreviewIndex((p) =>
|
||||
Math.min(fileEntries.length - 1, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={previewIndex === fileEntries.length - 1}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{!currentFile && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 flex w-full items-center justify-center gap-2.5 text-center opacity-50">
|
||||
<BoxIcon />
|
||||
Your model preview will appear here
|
||||
</h6>
|
||||
)}
|
||||
{previewStatus.type.includes("Error") && (
|
||||
<h6 className="pointer-events-auto absolute left-0 top-1/2 -mt-5 w-full text-center">
|
||||
{previewStatus.type}
|
||||
{previewStatus.message && <br />}
|
||||
{previewStatus.message}
|
||||
</h6>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</UploadFilesPanels>
|
||||
) : (
|
||||
<div className="relative m-auto w-full min-h-48 overflow-hidden rounded-none border border-white/15 bg-black/30">
|
||||
<canvas
|
||||
|
||||
@@ -441,7 +441,7 @@ export function useEditorActions() {
|
||||
useActionHandler(
|
||||
"toggle-bookmark",
|
||||
() => {
|
||||
editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
|
||||
void editor.scenes.toggleBookmark({ time: editor.playback.getCurrentTime() });
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useFontAtlas({ open }: { open: boolean }) {
|
||||
if (!open || atlas) return;
|
||||
|
||||
setStatus("loading");
|
||||
loadFontAtlas().then((data) => {
|
||||
void loadFontAtlas().then((data) => {
|
||||
if (data) {
|
||||
setAtlas(data);
|
||||
setStatus("idle");
|
||||
@@ -34,7 +34,7 @@ export function useFontAtlas({ open }: { open: boolean }) {
|
||||
const retry = useCallback(() => {
|
||||
clearFontAtlasCache();
|
||||
setStatus("loading");
|
||||
loadFontAtlas().then((data) => {
|
||||
void loadFontAtlas().then((data) => {
|
||||
if (data) {
|
||||
setAtlas(data);
|
||||
setStatus("idle");
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useFullscreen({
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
containerRef.current.requestFullscreen();
|
||||
}
|
||||
|
||||
+2
-2
@@ -313,7 +313,7 @@ function BookmarkPopoverContent({
|
||||
}, [bookmark.color]);
|
||||
|
||||
const handleRemove = () => {
|
||||
editor.scenes.removeBookmark({ time });
|
||||
void editor.scenes.removeBookmark({ time });
|
||||
onPopoverClose();
|
||||
};
|
||||
|
||||
@@ -334,7 +334,7 @@ function BookmarkPopoverContent({
|
||||
updates.duration = duration;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
editor.scenes.updateBookmark({ time, updates });
|
||||
void editor.scenes.updateBookmark({ time, updates });
|
||||
};
|
||||
const maxDuration = mediaTimeToSeconds({
|
||||
time:
|
||||
|
||||
+1
-1
@@ -240,7 +240,7 @@ export function useBookmarkDrag({
|
||||
const clampedTime =
|
||||
dragState.currentTime > duration ? duration : dragState.currentTime;
|
||||
|
||||
editor.scenes.moveBookmark({
|
||||
void editor.scenes.moveBookmark({
|
||||
fromTime: dragState.bookmarkTime,
|
||||
toTime: clampedTime,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user