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:
FredAppsandDylan M. Taylor authored and GitHub committed 2026-11-10 22:39:17 -05:00
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() {
@@ -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 = () => {
@@ -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>
);
};
@@ -40,7 +40,7 @@ export const ArtcraftAccountBlock = ({
setIsCheckingArtcraftSession(false);
}
};
fetchSession();
void fetchSession();
}, []);
const handleArtcraftButton = async () => {
@@ -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>
)
}
@@ -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>
)
}
@@ -28,7 +28,7 @@ export const OpenAIApiKeyBlock = () => {
setIsCheckingOpenAIApiKey(false);
}
};
fetchKey();
void fetchKey();
}, []);
const syncOpenAIApiKey = async (key: string) => {
@@ -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");
}
}}
/>
@@ -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
@@ -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">
@@ -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();
}
@@ -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:
@@ -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,
});