mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
fix: split bulk requests over 500, cancel on confirm dismissal, passive badge in select mode
This commit is contained in:
1 parent
fe5dbecdc5
commit
43463d0268
7 files changed
+82
-37
No files matched your search
+36
-16
@@ -149,6 +149,15 @@ export type DownloadZipSummary = {
|
||||
size_bytes: number;
|
||||
};
|
||||
|
||||
/** The most ids one bulk request (delete, favourite, an AI run) accepts. */
|
||||
export const BULK_LIMIT = 500;
|
||||
|
||||
function bulkChunks(ids: string[]): string[][] {
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += BULK_LIMIT) chunks.push(ids.slice(i, i + BULK_LIMIT));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export const printsApi = {
|
||||
fileUrl: (rel: string) => {
|
||||
if (!rel) return rel;
|
||||
@@ -452,25 +461,36 @@ export const printsApi = {
|
||||
|
||||
/** Each model on its own: the ones that fail are counted, the rest are still deleted. */
|
||||
bulkDelete: async (ids: string[]): Promise<{ deleted: string[]; failed: number }> => {
|
||||
const res = await fetch(`${apiBase()}/prints/delete`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ print_ids: ids }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Delete failed"));
|
||||
return res.json();
|
||||
const result = { deleted: [] as string[], failed: 0 };
|
||||
for (const chunk of bulkChunks(ids)) {
|
||||
const res = await fetch(`${apiBase()}/prints/delete`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ print_ids: chunk }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Delete failed"));
|
||||
const part = (await res.json()) as { deleted: string[]; failed: number };
|
||||
result.deleted.push(...part.deleted);
|
||||
result.failed += part.failed;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
bulkFavorite: async (ids: string[], favorite: boolean): Promise<Print[]> => {
|
||||
const res = await fetch(`${apiBase()}/prints/favorite`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ print_ids: ids, favorite }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, favorite ? "Failed to favorite" : "Failed to unfavorite"));
|
||||
return ((await res.json()) as { prints: Print[] }).prints;
|
||||
const prints: Print[] = [];
|
||||
for (const chunk of bulkChunks(ids)) {
|
||||
const res = await fetch(`${apiBase()}/prints/favorite`, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ print_ids: chunk, favorite }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok)
|
||||
throw new Error(await readErrorMessage(res, favorite ? "Failed to favorite" : "Failed to unfavorite"));
|
||||
prints.push(...((await res.json()) as { prints: Print[] }).prints);
|
||||
}
|
||||
return prints;
|
||||
},
|
||||
|
||||
updateCategory: async (id: string, category_id: string | null) => {
|
||||
|
||||
@@ -15,9 +15,12 @@ export type ConfirmOptions = {
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
type PendingConfirm = ConfirmOptions & { resolve: (result: boolean) => void };
|
||||
/** true: confirmed; false: the cancel button; null: dismissed (Escape or a click outside). */
|
||||
export type ConfirmResult = boolean | null;
|
||||
|
||||
const ConfirmContext = React.createContext<((options: ConfirmOptions) => Promise<boolean>) | null>(null);
|
||||
type PendingConfirm = ConfirmOptions & { resolve: (result: ConfirmResult) => void };
|
||||
|
||||
const ConfirmContext = React.createContext<((options: ConfirmOptions) => Promise<ConfirmResult>) | null>(null);
|
||||
|
||||
/** Replaces `window.confirm` with one shared dialog. Mounted once in AppLayout. */
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
@@ -25,12 +28,12 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pending, setPending] = React.useState<PendingConfirm | null>(null);
|
||||
|
||||
const confirm = React.useCallback((options: ConfirmOptions) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
return new Promise<ConfirmResult>((resolve) => {
|
||||
setPending({ ...options, resolve });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const settle = (result: boolean) => {
|
||||
const settle = (result: ConfirmResult) => {
|
||||
pending?.resolve(result);
|
||||
setPending(null);
|
||||
};
|
||||
@@ -38,7 +41,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<Dialog open={Boolean(pending)} onClose={() => settle(false)} maxWidth="xs" fullWidth>
|
||||
<Dialog open={Boolean(pending)} onClose={() => settle(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{pending?.title || t("confirm")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{pending?.message}</DialogContentText>
|
||||
@@ -54,7 +57,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves true/false with the user's choice. */
|
||||
/** Resolves with the user's choice; a dismissal is falsy too, so `if (!confirmed)` treats it as cancel. */
|
||||
export function useConfirm() {
|
||||
const confirm = React.useContext(ConfirmContext);
|
||||
if (!confirm) throw new Error("useConfirm must be used within a ConfirmProvider");
|
||||
|
||||
@@ -24,15 +24,18 @@ type Props = {
|
||||
provider: string | null | undefined;
|
||||
/** The badge's width and height, in px. */
|
||||
size?: number;
|
||||
/** Off while the card it sits on is a selectable checkbox: no tab stop, and clicks reach the card. */
|
||||
interactive?: boolean;
|
||||
sx?: SxProps<Theme>;
|
||||
};
|
||||
|
||||
/** Where a model came from, as the provider's logo on a round badge; its chip shows on hover or focus. A click opens
|
||||
* the provider's About dialog; a direct upload's Thingport badge has none. */
|
||||
export default function ProviderBadge({ provider, size = 28, sx }: Props) {
|
||||
export default function ProviderBadge({ provider, size = 28, interactive = true, sx }: Props) {
|
||||
const { t } = useTranslation(["models", "app"]);
|
||||
const theme = useTheme();
|
||||
const openAbout = useProviderAbout(provider);
|
||||
const about = useProviderAbout(provider);
|
||||
const openAbout = interactive ? about : undefined;
|
||||
const info = printProviderInfo(provider);
|
||||
const label = provider ? t("card.importedFrom", { provider: info.label }) : t("card.uploadedDirectly");
|
||||
const logoSize = Math.round(size * 0.6);
|
||||
@@ -57,7 +60,7 @@ export default function ProviderBadge({ provider, size = 28, sx }: Props) {
|
||||
slotProps={{ tooltip: { sx: { bgcolor: "transparent", p: 0, boxShadow: 3, borderRadius: 1 } } }}
|
||||
>
|
||||
<Box
|
||||
tabIndex={0}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
{...(openAbout && {
|
||||
role: "button",
|
||||
"aria-label": `${label}. ${t("app:providerAbout.aboutName", { name: info.label })}`,
|
||||
@@ -86,6 +89,7 @@ export default function ProviderBadge({ provider, size = 28, sx }: Props) {
|
||||
borderColor: "divider",
|
||||
boxShadow: 2,
|
||||
cursor: openAbout ? "pointer" : "default",
|
||||
...(!interactive && { pointerEvents: "none" }),
|
||||
transition: "transform 150ms ease",
|
||||
"&:hover, &:focus-visible": { transform: "scale(1.08)" },
|
||||
"&:focus-visible": { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 },
|
||||
|
||||
@@ -25,7 +25,7 @@ import AutoFixNormalIcon from "@mui/icons-material/AutoFixNormal";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeselectIcon from "@mui/icons-material/Deselect";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
import { BULK_LIMIT, type Print, printsApi } from "../../api/prints";
|
||||
import { AiCategorizationError, aiCategorizationApi } from "../../api/aiCategorization";
|
||||
import { aiDescriptionsApi } from "../../api/aiDescriptions";
|
||||
import { collectionsApi } from "../../api/collections";
|
||||
@@ -85,6 +85,8 @@ export default function ModelSelectionToolbar({
|
||||
const allSelected = count > 0 && count === total;
|
||||
const allFavorite = count > 0 && selected.every((item) => item.is_favorite);
|
||||
const disabled = count === 0 || busy !== null;
|
||||
// An AI run is one request, so unlike delete and favourite it can't be split.
|
||||
const tooManyForAi = count > BULK_LIMIT;
|
||||
|
||||
// Both statuses are cached for the session, so this costs nothing after the first grid.
|
||||
useEffect(() => {
|
||||
@@ -185,12 +187,15 @@ export default function ModelSelectionToolbar({
|
||||
item.category_source !== "rule",
|
||||
).length;
|
||||
const includeManual =
|
||||
handPicked > 0 &&
|
||||
(await confirm({
|
||||
message: t("models:selection.confirmManual", { count: handPicked }),
|
||||
confirmLabel: t("models:selection.includeManual"),
|
||||
cancelLabel: t("models:selection.skipManual"),
|
||||
}));
|
||||
handPicked > 0
|
||||
? await confirm({
|
||||
message: t("models:selection.confirmManual", { count: handPicked }),
|
||||
confirmLabel: t("models:selection.includeManual"),
|
||||
cancelLabel: t("models:selection.skipManual"),
|
||||
})
|
||||
: false;
|
||||
// Dismissed: neither "include" nor "skip", so nothing runs.
|
||||
if (includeManual === null) return;
|
||||
setBusy("ai");
|
||||
try {
|
||||
await categorization.startRun({
|
||||
@@ -233,6 +238,13 @@ export default function ModelSelectionToolbar({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const aiHint = (running: boolean) =>
|
||||
running
|
||||
? t("models:selection.aiRunActive")
|
||||
: tooManyForAi
|
||||
? t("models:selection.aiTooMany", { max: BULK_LIMIT })
|
||||
: undefined;
|
||||
|
||||
const favoriteLabel = allFavorite ? t("models:detail.removeFromFavorites") : t("models:detail.addToFavorites");
|
||||
const selectAllLabel = allSelected
|
||||
? t("common:clearSelection")
|
||||
@@ -330,21 +342,21 @@ export default function ModelSelectionToolbar({
|
||||
|
||||
<Menu anchorEl={aiMenuAnchor} open={Boolean(aiMenuAnchor)} onClose={() => setAiMenuAnchor(null)}>
|
||||
{aiEnabled && (
|
||||
<MenuItem onClick={() => void categorize()} disabled={categorization.running}>
|
||||
<MenuItem onClick={() => void categorize()} disabled={categorization.running || tooManyForAi}>
|
||||
<ListItemIcon>
|
||||
<AutoAwesomeIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText secondary={categorization.running ? t("models:selection.aiRunActive") : undefined}>
|
||||
<ListItemText secondary={aiHint(categorization.running)}>
|
||||
{t("models:aiCategorization.categorize")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
{descriptionsEnabled && (
|
||||
<MenuItem onClick={() => void improveDescriptions()} disabled={descriptions.running}>
|
||||
<MenuItem onClick={() => void improveDescriptions()} disabled={descriptions.running || tooManyForAi}>
|
||||
<ListItemIcon>
|
||||
<AutoFixNormalIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText secondary={descriptions.running ? t("models:selection.aiRunActive") : undefined}>
|
||||
<ListItemText secondary={aiHint(descriptions.running)}>
|
||||
{t("models:selection.improveDescriptions")}
|
||||
</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
@@ -3012,6 +3012,7 @@
|
||||
"aiActions": "AI actions",
|
||||
"improveDescriptions": "Improve descriptions with AI",
|
||||
"aiRunActive": "An AI run is already in progress",
|
||||
"aiTooMany": "Select up to {{max}} models for an AI run",
|
||||
"addToCollectionTitle_one": "Add {{count}} model to a collection",
|
||||
"addToCollectionTitle_other": "Add {{count}} models to a collection",
|
||||
"downloadTitle_one": "Download {{count}} model",
|
||||
|
||||
@@ -3088,6 +3088,7 @@
|
||||
"aiActions": "DI veiksmai",
|
||||
"improveDescriptions": "Patobulinti aprašymus naudojant DI",
|
||||
"aiRunActive": "DI jau vykdo užduotį",
|
||||
"aiTooMany": "DI užduočiai pažymėkite ne daugiau kaip {{max}} modelių",
|
||||
"addToCollectionTitle_one": "Pridėti {{count}} modelį prie kolekcijos",
|
||||
"addToCollectionTitle_few": "Pridėti {{count}} modelius prie kolekcijos",
|
||||
"addToCollectionTitle_other": "Pridėti {{count}} modelių prie kolekcijos",
|
||||
|
||||
@@ -170,7 +170,11 @@ export default function ModelCard({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ProviderBadge provider={item.source_provider} sx={{ position: "absolute", top: 8, left: 8 }} />
|
||||
<ProviderBadge
|
||||
provider={item.source_provider}
|
||||
interactive={!selectMode}
|
||||
sx={{ position: "absolute", top: 8, left: 8 }}
|
||||
/>
|
||||
|
||||
{selectMode && (
|
||||
<Box
|
||||
|
||||
Reference in new issue
Block a user