feat: multi-select models in grids with bulk actions

A select toggle above each model grid (models, collections, tags, search,
author) switches clicks from opening a card to selecting it. The selected
models can then be added to or removed from a collection, favourited or
unfavourited, downloaded as one zip, deleted, or sent to AI categorization
or description improvement.

The AI actions start a single library run limited to the selected models,
so they get the run's progress bar and one summary notification instead
of one provider call and notification per model.

Closes #91
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 07:53:55 +02:00
1 parent 72408abbea
commit fe5dbecdc5
22 files changed
+1096 -107

No files matched your search

+2
View File
@@ -160,6 +160,7 @@ const runSchema = z.object({
include_ai: z.boolean(),
include_rule: z.boolean(),
include_manual: z.boolean(),
print_ids: z.array(z.string()).min(1).max(500).optional(),
});
router.post(
"/ai-categorization/run",
@@ -173,6 +174,7 @@ router.post(
ai: body.include_ai,
rule: body.include_rule,
manual: body.include_manual,
printIds: body.print_ids,
});
if (!run) throw new HttpError(409, "AI categorization run is already active", "running");
res.status(202).json({ run: runOut(run) });
+3 -1
View File
@@ -119,13 +119,15 @@ router.get(
}),
);
const runSchema = z.object({ print_ids: z.array(z.string()).min(1).max(500).optional() });
router.post(
"/ai-descriptions/run",
asyncHandler(async (req, res) => {
if (!aiDescriptionsEnabled(await getAiDescriptionSettings())) {
throw new HttpError(503, "Improved descriptions are off", "ai_disabled");
}
const run = startAiDescriptionRun(req.userId!);
const { print_ids } = parseBody(runSchema, req.body ?? {});
const run = startAiDescriptionRun(req.userId!, print_ids);
if (!run) throw new HttpError(409, "Improving descriptions is already running", "running");
res.status(202).json({ run: runOut(run) });
}),
+31
View File
@@ -277,6 +277,37 @@ router.post(
}),
);
// Many at once, from a grid's multi-select. The prints themselves are untouched.
router.delete(
"/collection/:id/items",
asyncHandler(async (req, res) => {
if (isSystemCollectionId(req.params.id)) {
throw new HttpError(400, "This collection can't be edited");
}
const body = parseBody(addItemsSchema, req.body);
const collection = await prisma.collection.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!collection) throw new HttpError(404, "Collection not found");
const prints = await prisma.print.findMany({
where: { id: { in: body.print_ids }, userId: req.userId },
select: { id: true },
});
const printIds = prints.map((p) => p.id);
const { count } = await prisma.collectionItem.deleteMany({
where: { collectionId: collection.id, printId: { in: printIds } },
});
await relocatePrintsForToken("collection", printIds);
res.json({ removed: count });
if (count) {
void createLog({
userId: req.userId!,
action: "collection_item_removed",
targetId: collection.id,
details: { name: collection.name, count },
});
}
}),
);
router.post(
"/collection/:id/bookmark",
asyncHandler(async (req, res) => {
+59 -16
View File
@@ -21,7 +21,7 @@ import {
} from "../services/descriptionImageService";
import { deleteAuthorIfOrphaned, getLinkedAuthorIds } from "../services/authorService";
import { toPrintOut } from "../dto";
import { loadFullPrint, printOutById } from "../services/printLoader";
import { loadFullPrint, printOutById, printOutsByIds } from "../services/printLoader";
import { deleteAllPrintFiles, saveFileFromTemp } from "../services/printFileService";
import { RENDERABLE_MODEL_EXTS } from "../config";
import { estimateDownloadSize, resolvePrintsForDownload, sendPrintsZip } from "../services/downloadZip";
@@ -645,26 +645,69 @@ router.post(
}),
);
async function deletePrint(userId: string, printId: string): Promise<void> {
const full = await loadFullPrint(userId, printId);
await deleteAllPrintFiles(printId);
await deleteAllPreviewImages(printId);
await deleteAllDescriptionImages(printId);
await prisma.print.delete({ where: { id: printId } });
for (const plate of full.plates) {
await deletePlateFiles(plate);
await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
}
void recordActivity(userId, "delete", printId, full.print.name);
void createLog({
userId,
action: "model_deleted",
targetId: printId,
details: { name: full.print.name },
});
}
router.delete(
"/print/:id",
asyncHandler(async (req, res) => {
const full = await loadFullPrint(req.userId!, req.params.id);
await deleteAllPrintFiles(req.params.id);
await deleteAllPreviewImages(req.params.id);
await deleteAllDescriptionImages(req.params.id);
await prisma.print.delete({ where: { id: req.params.id } });
for (const plate of full.plates) {
await deletePlateFiles(plate);
await fs.promises.rm(plateThumbPath(plate.id), { force: true }).catch(() => undefined);
}
await deletePrint(req.userId!, req.params.id);
res.json({ ok: true });
void recordActivity(req.userId!, "delete", req.params.id, full.print.name);
void createLog({
userId: req.userId!,
action: "model_deleted",
targetId: req.params.id,
details: { name: full.print.name },
}),
);
// The grids' multi-select. One at a time, so a model that fails doesn't take the rest with it.
const bulkPrintIdsSchema = z.object({ print_ids: z.array(z.string()).min(1).max(500) });
router.post(
"/prints/delete",
asyncHandler(async (req, res) => {
const body = parseBody(bulkPrintIdsSchema, req.body);
const deleted: string[] = [];
let failed = 0;
for (const printId of new Set(body.print_ids)) {
try {
await deletePrint(req.userId!, printId);
deleted.push(printId);
} catch {
failed++;
}
}
res.json({ deleted, failed });
}),
);
const bulkFavoriteSchema = bulkPrintIdsSchema.extend({ favorite: z.boolean() });
router.post(
"/prints/favorite",
asyncHandler(async (req, res) => {
const body = parseBody(bulkFavoriteSchema, req.body);
// Only the ones changing, so an existing favourite keeps its date and its place in Favourites.
await prisma.print.updateMany({
where: {
id: { in: body.print_ids },
userId: req.userId,
favoritedAt: body.favorite ? null : { not: null },
},
data: { favoritedAt: body.favorite ? new Date() : null },
});
const prints = await printOutsByIds(req.userId!, body.print_ids);
res.json({ prints: body.print_ids.flatMap((id) => prints.get(id) ?? []) });
}),
);
@@ -492,8 +492,16 @@ export async function aiCategorizationCounts(userId: string) {
return counts;
}
/** Which models a run takes. `manual` also takes older categories, which may have been picked by hand. */
export type AiRunScope = { uncategorized: boolean; folders: boolean; ai: boolean; rule: boolean; manual: boolean };
/** Which models a run takes. `manual` also takes older categories, which may have been picked by hand.
* `printIds` narrows it to the models picked in a grid; the flags still apply to those. */
export type AiRunScope = {
uncategorized: boolean;
folders: boolean;
ai: boolean;
rule: boolean;
manual: boolean;
printIds?: string[];
};
export function startAiCategorizationRun(userId: string, scope: AiRunScope): AiCategorizationRun | null {
const running = runs.get(userId);
@@ -513,7 +521,7 @@ export function startAiCategorizationRun(userId: string, scope: AiRunScope): AiC
lastError: null,
};
runs.set(userId, run);
log.info("run started", { user: userId, ...scope });
log.info("run started", { user: userId, ...scope, printIds: scope.printIds?.length });
void runAiCategorization(userId, run, scope)
.catch((error) => {
run.failed++;
@@ -547,7 +555,7 @@ export function startAiCategorizationRun(userId: string, scope: AiRunScope): AiC
async function runAiCategorization(userId: string, run: AiCategorizationRun, scope: AiRunScope): Promise<void> {
const prints = await prisma.print.findMany({
where: { userId },
where: { userId, ...(scope.printIds ? { id: { in: scope.printIds } } : {}) },
include: { category: { select: { kind: true } } },
orderBy: { createdAt: "asc" },
});
+8 -7
View File
@@ -491,8 +491,9 @@ export function currentAiDescriptionRun(userId: string): AiDescriptionRun | null
return runs.get(userId) ?? null;
}
/** Always every model the user owns; the ones with nothing new are skipped without asking the AI. */
export function startAiDescriptionRun(userId: string): AiDescriptionRun | null {
/** Every model the user owns, where the ones with nothing new are skipped without asking the AI; or the models
* picked in a grid, which are asked about even with nothing new, as a request for one model is. */
export function startAiDescriptionRun(userId: string, printIds?: string[]): AiDescriptionRun | null {
if (runs.get(userId)?.running) return null;
const run: AiDescriptionRun = {
running: true,
@@ -509,8 +510,8 @@ export function startAiDescriptionRun(userId: string): AiDescriptionRun | null {
lastError: null,
};
runs.set(userId, run);
log.info("run started", { user: userId });
void runAiDescriptions(userId, run)
log.info("run started", { user: userId, printIds: printIds?.length });
void runAiDescriptions(userId, run, printIds)
.catch((error) => {
run.failed++;
run.lastError = noSecretError(error).message;
@@ -530,9 +531,9 @@ export function startAiDescriptionRun(userId: string): AiDescriptionRun | null {
return run;
}
async function runAiDescriptions(userId: string, run: AiDescriptionRun): Promise<void> {
async function runAiDescriptions(userId: string, run: AiDescriptionRun, printIds?: string[]): Promise<void> {
const prints = await prisma.print.findMany({
where: { userId },
where: { userId, ...(printIds ? { id: { in: printIds } } : {}) },
select: { id: true },
orderBy: { createdAt: "asc" },
});
@@ -542,7 +543,7 @@ async function runAiDescriptions(userId: string, run: AiDescriptionRun): Promise
if (run.cancelled) break;
let stop = false;
try {
const outcome = await improvePrintDescription(userId, print.id, { source: "run" });
const outcome = await improvePrintDescription(userId, print.id, { source: "run", force: Boolean(printIds) });
consecutiveFailures = 0;
run[outcome]++;
} catch (error) {
+2
View File
@@ -35,6 +35,8 @@ export type AiRunScope = {
include_rule: boolean;
/** Also takes older categories, which may have been picked by hand. */
include_manual: boolean;
/** Only these models, still filtered by the flags above. */
print_ids?: string[];
};
export type AiSuggestions = { total: number; items: Print[] };
export type AiBulkResult = { accepted: number; rejected: number; failed: number };
+3 -1
View File
@@ -124,7 +124,9 @@ export const aiDescriptionsApi = {
bulk: (action: "accept" | "reject", printIds: string[]): Promise<{ done: number; failed: number }> =>
request("/ai-descriptions/suggestions/bulk", "POST", { action, print_ids: printIds }),
runState: (): Promise<{ run: AiDescriptionRun | null }> => request("/ai-descriptions/run"),
startRun: (): Promise<{ run: AiDescriptionRun }> => request("/ai-descriptions/run", "POST", {}),
/** Every model, or only `printIds`. */
startRun: (printIds?: string[]): Promise<{ run: AiDescriptionRun }> =>
request("/ai-descriptions/run", "POST", printIds ? { print_ids: printIds } : {}),
cancelRun: (): Promise<{ run: AiDescriptionRun | null }> => request("/ai-descriptions/run/cancel", "POST", {}),
status: cachedStatus,
};
+19
View File
@@ -111,6 +111,25 @@ export const collectionsApi = {
assertOk(res, "Add to collection failed");
},
addItems: async (collectionId: string, printIds: string[]): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${collectionId}/items`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ print_ids: printIds }),
});
assertOk(res, "Add to collection failed");
},
/** The prints themselves are untouched. */
removeItems: async (collectionId: string, printIds: string[]): Promise<void> => {
const res = await fetch(`${apiBase()}/collection/${collectionId}/items`, {
method: "DELETE",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ print_ids: printIds }),
});
assertOk(res, "Remove from collection failed");
},
listForPrint: async (printId: string): Promise<CollectionMembership[]> => {
const res = await fetch(`${apiBase()}/print/${printId}/collections`, { headers: authHeaders() });
assertOk(res, "Failed to list collections");
+23
View File
@@ -450,6 +450,29 @@ export const printsApi = {
return res.json();
},
/** 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();
},
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;
},
updateCategory: async (id: string, category_id: string | null) => {
const res = await fetch(`${apiBase()}/print/${id}/category`, {
method: "POST",
@@ -15,7 +15,8 @@ export type TrackedRun<R, S> = {
type AiRunContextValue = {
categorization: TrackedRun<AiRun, AiRunScope>;
descriptions: TrackedRun<AiDescriptionRun, void>;
/** Every model, or only the ids given. */
descriptions: TrackedRun<AiDescriptionRun, string[] | void>;
};
const AiRunContext = createContext<AiRunContextValue | null>(null);
@@ -84,10 +85,10 @@ const categorizationRuns: RunApi<AiRun, AiRunScope> = {
start: async (scope) => (await aiCategorizationApi.startRun(scope)).run,
cancel: async () => (await aiCategorizationApi.cancelRun()).run,
};
const descriptionRuns: RunApi<AiDescriptionRun, void> = {
const descriptionRuns: RunApi<AiDescriptionRun, string[] | void> = {
enabled: async () => (await aiDescriptionsApi.status()).enabled,
state: async () => (await aiDescriptionsApi.runState()).run,
start: async () => (await aiDescriptionsApi.startRun()).run,
start: async (printIds) => (await aiDescriptionsApi.startRun(printIds || undefined)).run,
cancel: async () => (await aiDescriptionsApi.cancelRun()).run,
};
@@ -125,6 +126,6 @@ export function useAiRun(): TrackedRun<AiRun, AiRunScope> {
return useAiRuns().categorization;
}
export function useAiDescriptionRun(): TrackedRun<AiDescriptionRun, void> {
export function useAiDescriptionRun(): TrackedRun<AiDescriptionRun, string[] | void> {
return useAiRuns().descriptions;
}
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import Chip from "@mui/material/Chip";
import Skeleton from "@mui/material/Skeleton";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import { UnauthorizedError } from "../../api/client";
import { type Collection, collectionsApi } from "../../api/collections";
type Props = {
open: boolean;
onClose: () => void;
printIds: string[];
onUnauthorized?: () => void;
};
/** Each chip adds every selected model to that collection at once. Unlike the one-model dialog it doesn't remove:
* the selection may be only partly in a collection, so there's no single state to toggle back to. */
export default function BulkAddToCollectionDialog({ open, onClose, printIds, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const [collections, setCollections] = useState<Collection[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [addedIds, setAddedIds] = useState<ReadonlySet<string>>(new Set());
useEffect(() => {
if (!open) return;
setError(null);
setCollections(null);
setAddedIds(new Set());
collectionsApi
.list()
.then((all) => setCollections(all.filter((c) => !c.system_key)))
.catch((err) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setError(err instanceof Error ? err.message : t("models:detail.addToCollectionLoadFailed"));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const add = async (collection: Collection) => {
if (pendingId) return;
setPendingId(collection.id);
setError(null);
try {
await collectionsApi.addItems(collection.id, printIds);
setAddedIds((prev) => new Set(prev).add(collection.id));
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
setError(err instanceof Error ? err.message : t("models:detail.addToCollectionFailed"));
} finally {
setPendingId(null);
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>{t("models:selection.addToCollectionTitle", { count: printIds.length })}</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{error}
</Alert>
)}
{collections === null && !error && (
<Stack direction="row" flexWrap="wrap" gap={1}>
{[72, 96, 64, 88].map((width) => (
<Skeleton key={width} variant="rounded" width={width} height={32} sx={{ borderRadius: 4 }} />
))}
</Stack>
)}
{collections?.length === 0 && (
<Typography variant="body2" color="text.secondary">
{t("models:detail.addToCollectionEmpty")}
</Typography>
)}
{collections && collections.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1}>
{collections.map((c) => {
const added = addedIds.has(c.id);
return (
<Chip
key={c.id}
label={c.name}
clickable={!added}
disabled={pendingId === c.id}
onClick={added ? undefined : () => void add(c)}
color={added ? "primary" : "default"}
variant={added ? "filled" : "outlined"}
aria-pressed={added}
icon={
pendingId === c.id ? (
<CircularProgress size={14} color="inherit" />
) : added ? (
<CheckIcon fontSize="small" />
) : undefined
}
/>
);
})}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,370 @@
import { useEffect, useState } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { keyframes } from "@emotion/react";
import Stack from "@mui/material/Stack";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import ToggleButton from "@mui/material/ToggleButton";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import CircularProgress from "@mui/material/CircularProgress";
import HighlightAltIcon from "@mui/icons-material/HighlightAlt";
import PlaylistAddIcon from "@mui/icons-material/PlaylistAdd";
import PlaylistRemoveIcon from "@mui/icons-material/PlaylistRemove";
import StarIcon from "@mui/icons-material/Star";
import StarBorderIcon from "@mui/icons-material/StarBorder";
import DownloadIcon from "@mui/icons-material/Download";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
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 { AiCategorizationError, aiCategorizationApi } from "../../api/aiCategorization";
import { aiDescriptionsApi } from "../../api/aiDescriptions";
import { collectionsApi } from "../../api/collections";
import type { ModelSelection } from "../../hooks/useModelSelection";
import { useAiDescriptionRun, useAiRun } from "../Layout/AiRunContext";
import { useConfirm } from "../ConfirmProvider";
import { useToast } from "../ToastProvider";
import DownloadZipConfirmDialog from "../DownloadZipConfirmDialog";
import BulkAddToCollectionDialog from "./BulkAddToCollectionDialog";
type Props = {
selection: ModelSelection;
/** Models the grid has loaded: "select all" takes these. */
total: number;
/** More are still to load, so "select all" says it only takes the loaded ones. */
hasMore?: boolean;
onDeleted: (ids: string[]) => void;
onUpdated: (prints: Print[]) => void;
onUnauthorized?: () => void;
/** Only for a real collection; shows "Remove from collection". */
collectionId?: string;
onRemovedFromCollection?: (ids: string[]) => void;
};
type Busy = "favorite" | "removeFromCollection" | "delete" | "ai" | null;
const slideIn = keyframes`
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: none; }
`;
/** A grid's select-mode toggle and, while it's on, the actions for the selected models. */
export default function ModelSelectionToolbar({
selection,
total,
hasMore = false,
onDeleted,
onUpdated,
onUnauthorized,
collectionId,
onRemovedFromCollection,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const confirm = useConfirm();
const showToast = useToast();
const categorization = useAiRun();
const descriptions = useAiDescriptionRun();
const [aiEnabled, setAiEnabled] = useState(false);
const [descriptionsEnabled, setDescriptionsEnabled] = useState(false);
const [busy, setBusy] = useState<Busy>(null);
const [aiMenuAnchor, setAiMenuAnchor] = useState<HTMLElement | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const { active, selected } = selection;
const ids = selected.map((item) => item.id);
const count = ids.length;
const allSelected = count > 0 && count === total;
const allFavorite = count > 0 && selected.every((item) => item.is_favorite);
const disabled = count === 0 || busy !== null;
// Both statuses are cached for the session, so this costs nothing after the first grid.
useEffect(() => {
if (!active) return;
let live = true;
void aiCategorizationApi
.status()
.then((status) => live && setAiEnabled(status.enabled))
.catch(() => undefined);
void aiDescriptionsApi
.status()
.then((status) => live && setDescriptionsEnabled(status.enabled))
.catch(() => undefined);
return () => {
live = false;
};
}, [active]);
const failed = (err: unknown, fallback: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
console.error(err);
showToast({ message: err instanceof Error ? err.message : fallback, severity: "error" });
};
const toggleFavorite = async () => {
setBusy("favorite");
const favorite = !allFavorite;
try {
onUpdated(await printsApi.bulkFavorite(ids, favorite));
showToast({
message: t(favorite ? "models:selection.favorited" : "models:selection.unfavorited", { count }),
});
} catch (err) {
failed(err, t("models:detail.favoriteFailed"));
} finally {
setBusy(null);
}
};
const removeFromCollection = async () => {
if (!collectionId) return;
const confirmed = await confirm({
message: t("models:selection.confirmRemoveFromCollection", { count }),
confirmLabel: t("common:remove"),
});
if (!confirmed) return;
setBusy("removeFromCollection");
try {
await collectionsApi.removeItems(collectionId, ids);
onRemovedFromCollection?.(ids);
showToast({ message: t("models:selection.removedFromCollection", { count }) });
} catch (err) {
failed(err, t("models:detail.removeFromCollectionFailed"));
} finally {
setBusy(null);
}
};
const remove = async () => {
const confirmed = await confirm({
message: t("models:selection.confirmDelete", { count }),
destructive: true,
});
if (!confirmed) return;
setBusy("delete");
try {
const result = await printsApi.bulkDelete(ids);
onDeleted(result.deleted);
if (result.failed) {
showToast({ message: t("models:selection.deletePartlyFailed", { count: result.failed }), severity: "error" });
} else {
showToast({ message: t("models:selection.deleted", { count: result.deleted.length }) });
}
} catch (err) {
failed(err, t("models:detail.deleteFailed"));
} finally {
setBusy(null);
}
};
const runFailed = (err: unknown, fallback: string) => {
if (err instanceof AiCategorizationError && err.code === "running") {
showToast({ message: t("models:selection.aiRunActive"), severity: "warning" });
} else failed(err, fallback);
};
const categorize = async () => {
setAiMenuAnchor(null);
// Ones with a category picked by hand, or set before anything recorded how: replacing those is the user's call.
const handPicked = selected.filter(
(item) =>
item.category_id &&
item.category_kind !== "folder" &&
item.category_source !== "ai" &&
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"),
}));
setBusy("ai");
try {
await categorization.startRun({
include_uncategorized: true,
include_folders: true,
include_ai: true,
include_rule: true,
include_manual: includeManual,
print_ids: ids,
});
showToast({ message: t("models:selection.categorizeQueued", { count }) });
} catch (err) {
runFailed(err, t("models:aiCategorization.failed"));
} finally {
setBusy(null);
}
};
const improveDescriptions = async () => {
setAiMenuAnchor(null);
setBusy("ai");
try {
await descriptions.startRun(ids);
showToast({ message: t("models:selection.improveQueued", { count }) });
} catch (err) {
runFailed(err, t("models:aiDescriptions.failed"));
} finally {
setBusy(null);
}
};
const action = (label: string, icon: React.ReactNode, onClick: (e: React.MouseEvent<HTMLElement>) => void) => (
<Tooltip title={label}>
{/* A disabled button fires no events, so the span keeps the tooltip working. */}
<span>
<IconButton size="small" aria-label={label} disabled={disabled} onClick={onClick}>
{icon}
</IconButton>
</span>
</Tooltip>
);
const favoriteLabel = allFavorite ? t("models:detail.removeFromFavorites") : t("models:detail.addToFavorites");
const selectAllLabel = allSelected
? t("common:clearSelection")
: hasMore
? t("models:selection.selectAllLoaded")
: t("common:selectAll");
const toggleLabel = active ? t("models:selection.exit") : t("models:selection.enter");
return (
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ flexWrap: "wrap", rowGap: 0.5 }}>
<Tooltip title={toggleLabel}>
<ToggleButton
value="select"
size="small"
selected={active}
onChange={() => selection.setActive(!active)}
aria-label={toggleLabel}
sx={{ px: 1 }}
>
<HighlightAltIcon fontSize="small" />
</ToggleButton>
</Tooltip>
{active && (
<Stack
direction="row"
alignItems="center"
spacing={0.25}
role="toolbar"
aria-label={t("models:selection.toolbar")}
sx={{
animation: `${slideIn} 200ms ease-out`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}}
>
{action(t("models:detail.addToCollection"), <PlaylistAddIcon fontSize="small" />, () => setAddOpen(true))}
{collectionId &&
action(
t("models:detail.removeFromCollection"),
busy === "removeFromCollection" ? (
<CircularProgress size={18} />
) : (
<PlaylistRemoveIcon fontSize="small" />
),
() => void removeFromCollection(),
)}
{action(
favoriteLabel,
busy === "favorite" ? (
<CircularProgress size={18} />
) : allFavorite ? (
<StarIcon fontSize="small" sx={{ color: "warning.main" }} />
) : (
<StarBorderIcon fontSize="small" />
),
() => void toggleFavorite(),
)}
{action(t("common:download"), <DownloadIcon fontSize="small" />, () => setDownloadOpen(true))}
{action(
t("common:delete"),
busy === "delete" ? (
<CircularProgress size={18} />
) : (
<DeleteOutlineIcon fontSize="small" color={disabled ? undefined : "error"} />
),
() => void remove(),
)}
{(aiEnabled || descriptionsEnabled) &&
action(
t("models:selection.aiActions"),
busy === "ai" ? <CircularProgress size={18} /> : <AutoAwesomeIcon fontSize="small" />,
(e) => setAiMenuAnchor(e.currentTarget),
)}
<Divider orientation="vertical" flexItem sx={{ mx: 0.75 }} />
<Typography
variant="caption"
aria-live="polite"
sx={{ color: "text.secondary", whiteSpace: "nowrap", minWidth: 64 }}
>
{t("models:selection.count", { count })}
</Typography>
<Tooltip title={selectAllLabel}>
<span>
<IconButton
size="small"
aria-label={selectAllLabel}
disabled={total === 0 || busy !== null}
onClick={allSelected ? selection.clear : selection.selectAll}
>
{allSelected ? <DeselectIcon fontSize="small" /> : <SelectAllIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
</Stack>
)}
<Menu anchorEl={aiMenuAnchor} open={Boolean(aiMenuAnchor)} onClose={() => setAiMenuAnchor(null)}>
{aiEnabled && (
<MenuItem onClick={() => void categorize()} disabled={categorization.running}>
<ListItemIcon>
<AutoAwesomeIcon fontSize="small" />
</ListItemIcon>
<ListItemText secondary={categorization.running ? t("models:selection.aiRunActive") : undefined}>
{t("models:aiCategorization.categorize")}
</ListItemText>
</MenuItem>
)}
{descriptionsEnabled && (
<MenuItem onClick={() => void improveDescriptions()} disabled={descriptions.running}>
<ListItemIcon>
<AutoFixNormalIcon fontSize="small" />
</ListItemIcon>
<ListItemText secondary={descriptions.running ? t("models:selection.aiRunActive") : undefined}>
{t("models:selection.improveDescriptions")}
</ListItemText>
</MenuItem>
)}
</Menu>
<BulkAddToCollectionDialog
open={addOpen}
onClose={() => setAddOpen(false)}
printIds={ids}
onUnauthorized={onUnauthorized}
/>
<DownloadZipConfirmDialog
open={downloadOpen}
onClose={() => setDownloadOpen(false)}
filter={{ print_ids: ids }}
filename="thingport.zip"
title={t("models:selection.downloadTitle", { count })}
onUnauthorized={onUnauthorized}
/>
</Stack>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Print } from "../api/prints";
export type ModelSelection = {
/** Select mode: clicking a card selects it instead of opening it. */
active: boolean;
setActive: (active: boolean) => void;
selected: Print[];
isSelected: (id: string) => boolean;
/** `range` (a shift-click) also takes every card between this one and the last one clicked. */
toggle: (id: string, range?: boolean) => void;
selectAll: () => void;
clear: () => void;
};
/** Multi-select over a grid's loaded models. Ids that leave the grid (deleted, filtered out) drop out of it. */
export function useModelSelection(items: Print[]): ModelSelection {
const [active, setActiveState] = useState(false);
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const anchorRef = useRef<string | null>(null);
useEffect(() => {
setSelectedIds((prev) => {
if (!prev.size) return prev;
const present = new Set(items.map((item) => item.id));
const next = new Set([...prev].filter((id) => present.has(id)));
return next.size === prev.size ? prev : next;
});
}, [items]);
const clear = useCallback(() => {
setSelectedIds(new Set());
anchorRef.current = null;
}, []);
const setActive = useCallback(
(next: boolean) => {
setActiveState(next);
if (!next) clear();
},
[clear],
);
const toggle = useCallback(
(id: string, range = false) => {
const anchor = anchorRef.current;
anchorRef.current = id;
setSelectedIds((prev) => {
const next = new Set(prev);
const from = anchor ? items.findIndex((item) => item.id === anchor) : -1;
const to = items.findIndex((item) => item.id === id);
if (range && from >= 0 && to >= 0) {
// The range takes the state the anchor was left in, as a file manager does.
const select = prev.has(anchor!);
for (const item of items.slice(Math.min(from, to), Math.max(from, to) + 1)) {
if (select) next.add(item.id);
else next.delete(item.id);
}
} else if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[items],
);
const selectAll = useCallback(() => setSelectedIds(new Set(items.map((item) => item.id))), [items]);
// Escape leaves select mode, unless it's closing a dialog or menu opened from it.
useEffect(() => {
if (!active) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (event.target instanceof Element && event.target.closest(".MuiModal-root, .MuiPopover-root")) return;
setActive(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [active, setActive]);
const selected = useMemo(() => items.filter((item) => selectedIds.has(item.id)), [items, selectedIds]);
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
return { active, setActive, selected, isSelected, toggle, selectAll, clear };
}
/** `items` with each of `updated` put in place of the model it updates. */
export function replacePrints(items: Print[], updated: Print[]): Print[] {
const byId = new Map(updated.map((print) => [print.id, print]));
return items.map((item) => byId.get(item.id) ?? item);
}
+36
View File
@@ -3002,6 +3002,42 @@
},
"manager": {
"title": "Manage categories & folders"
},
"selection": {
"enter": "Select models",
"exit": "Done selecting",
"toolbar": "Actions for the selected models",
"count": "{{count}} selected",
"selectAllLoaded": "Select all loaded",
"aiActions": "AI actions",
"improveDescriptions": "Improve descriptions with AI",
"aiRunActive": "An AI run is already in progress",
"addToCollectionTitle_one": "Add {{count}} model to a collection",
"addToCollectionTitle_other": "Add {{count}} models to a collection",
"downloadTitle_one": "Download {{count}} model",
"downloadTitle_other": "Download {{count}} models",
"favorited_one": "Added {{count}} model to Favourites",
"favorited_other": "Added {{count}} models to Favourites",
"unfavorited_one": "Removed {{count}} model from Favourites",
"unfavorited_other": "Removed {{count}} models from Favourites",
"confirmDelete_one": "Delete {{count}} model? This can't be undone.",
"confirmDelete_other": "Delete {{count}} models? This can't be undone.",
"deleted_one": "Deleted {{count}} model",
"deleted_other": "Deleted {{count}} models",
"deletePartlyFailed_one": "{{count}} model couldn't be deleted",
"deletePartlyFailed_other": "{{count}} models couldn't be deleted",
"confirmManual_one": "{{count}} of the selected models has a category you picked yourself. Let AI recategorize it too?",
"confirmManual_other": "{{count}} of the selected models have a category you picked yourself. Let AI recategorize them too?",
"includeManual": "Include them",
"skipManual": "Skip them",
"categorizeQueued_one": "Categorizing {{count}} model with AI. Progress shows at the bottom of the page.",
"categorizeQueued_other": "Categorizing {{count}} models with AI. Progress shows at the bottom of the page.",
"improveQueued_one": "Improving {{count}} description with AI. Progress shows at the bottom of the page.",
"improveQueued_other": "Improving {{count}} descriptions with AI. Progress shows at the bottom of the page.",
"confirmRemoveFromCollection_one": "Remove {{count}} model from this collection? The model itself is kept.",
"confirmRemoveFromCollection_other": "Remove {{count}} models from this collection? The models themselves are kept.",
"removedFromCollection_one": "Removed {{count}} model from the collection",
"removedFromCollection_other": "Removed {{count}} models from the collection"
}
}
}
+48
View File
@@ -3078,6 +3078,54 @@
},
"manager": {
"title": "Kategorijų ir aplankų tvarkymas"
},
"selection": {
"enter": "Žymėti modelius",
"exit": "Baigti žymėjimą",
"toolbar": "Veiksmai su pažymėtais modeliais",
"count": "Pažymėta: {{count}}",
"selectAllLoaded": "Pažymėti visus įkeltus",
"aiActions": "DI veiksmai",
"improveDescriptions": "Patobulinti aprašymus naudojant DI",
"aiRunActive": "DI jau vykdo užduotį",
"addToCollectionTitle_one": "Pridėti {{count}} modelį prie kolekcijos",
"addToCollectionTitle_few": "Pridėti {{count}} modelius prie kolekcijos",
"addToCollectionTitle_other": "Pridėti {{count}} modelių prie kolekcijos",
"downloadTitle_one": "Atsisiųsti {{count}} modelį",
"downloadTitle_few": "Atsisiųsti {{count}} modelius",
"downloadTitle_other": "Atsisiųsti {{count}} modelių",
"favorited_one": "{{count}} modelis pridėtas prie mėgstamų",
"favorited_few": "{{count}} modeliai pridėti prie mėgstamų",
"favorited_other": "{{count}} modelių pridėta prie mėgstamų",
"unfavorited_one": "{{count}} modelis pašalintas iš mėgstamų",
"unfavorited_few": "{{count}} modeliai pašalinti iš mėgstamų",
"unfavorited_other": "{{count}} modelių pašalinta iš mėgstamų",
"confirmDelete_one": "Ištrinti {{count}} modelį? Šio veiksmo nebus galima atšaukti.",
"confirmDelete_few": "Ištrinti {{count}} modelius? Šio veiksmo nebus galima atšaukti.",
"confirmDelete_other": "Ištrinti {{count}} modelių? Šio veiksmo nebus galima atšaukti.",
"deleted_one": "Ištrintas {{count}} modelis",
"deleted_few": "Ištrinti {{count}} modeliai",
"deleted_other": "Ištrinta {{count}} modelių",
"deletePartlyFailed_one": "Nepavyko ištrinti {{count}} modelio",
"deletePartlyFailed_few": "Nepavyko ištrinti {{count}} modelių",
"deletePartlyFailed_other": "Nepavyko ištrinti {{count}} modelių",
"confirmManual_one": "{{count}} pažymėto modelio kategoriją pasirinkote patys. Leisti DI ją pakeisti?",
"confirmManual_few": "{{count}} pažymėtų modelių kategorijas pasirinkote patys. Leisti DI jas pakeisti?",
"confirmManual_other": "{{count}} pažymėtų modelių kategorijas pasirinkote patys. Leisti DI jas pakeisti?",
"includeManual": "Įtraukti juos",
"skipManual": "Praleisti juos",
"categorizeQueued_one": "DI kategorizuoja {{count}} modelį. Eiga rodoma puslapio apačioje.",
"categorizeQueued_few": "DI kategorizuoja {{count}} modelius. Eiga rodoma puslapio apačioje.",
"categorizeQueued_other": "DI kategorizuoja {{count}} modelių. Eiga rodoma puslapio apačioje.",
"improveQueued_one": "DI tobulina {{count}} aprašymą. Eiga rodoma puslapio apačioje.",
"improveQueued_few": "DI tobulina {{count}} aprašymus. Eiga rodoma puslapio apačioje.",
"improveQueued_other": "DI tobulina {{count}} aprašymų. Eiga rodoma puslapio apačioje.",
"confirmRemoveFromCollection_one": "Pašalinti {{count}} modelį iš šios kolekcijos? Pats modelis liks.",
"confirmRemoveFromCollection_few": "Pašalinti {{count}} modelius iš šios kolekcijos? Patys modeliai liks.",
"confirmRemoveFromCollection_other": "Pašalinti {{count}} modelių iš šios kolekcijos? Patys modeliai liks.",
"removedFromCollection_one": "{{count}} modelis pašalintas iš kolekcijos",
"removedFromCollection_few": "{{count}} modeliai pašalinti iš kolekcijos",
"removedFromCollection_other": "{{count}} modelių pašalinta iš kolekcijos"
}
}
}
+19 -3
View File
@@ -27,6 +27,8 @@ import ModelCard from "../ModelsPage/ModelCard";
import Skeleton from "@mui/material/Skeleton";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
import ModelSelectionToolbar from "../../components/modelSelection/ModelSelectionToolbar";
import { replacePrints, useModelSelection } from "../../hooks/useModelSelection";
const PAGE_SIZE = 24;
const COVER_HEIGHT = 184;
@@ -57,6 +59,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const selection = useModelSelection(items);
// Authors the viewer has claimed: self-mode provider chips, and whether "It's me!" shows.
const [myLinks, setMyLinks] = useState<Author[]>([]);
const [linking, setLinking] = useState(false);
@@ -326,9 +329,19 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
</Box>
<Box sx={{ minWidth: 0, pt: 3, pb: 3 }}>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
{t("models:pageTitle")}
</Typography>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{t("models:pageTitle")}
</Typography>
<ModelSelectionToolbar
selection={selection}
total={items.length}
hasMore={hasMore}
onDeleted={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
onUpdated={(updated) => setItems((prev) => replacePrints(prev, updated))}
onUnauthorized={onUnauthorized}
/>
</Stack>
{items.length ? (
<Stack spacing={2}>
<Box sx={cardGridSx(5)}>
@@ -345,6 +358,9 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
selectMode={selection.active}
selected={selection.isSelected(item.id)}
onToggleSelect={(range) => selection.toggle(item.id, range)}
/>
))}
{loadingMore && <CardSkeletons count={5} />}
@@ -22,6 +22,8 @@ import CollectionSyncIndicator from "../../components/CollectionSyncIndicator";
import InfoBanner from "../../components/InfoBanner";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
import ModelSelectionToolbar from "../../components/modelSelection/ModelSelectionToolbar";
import { replacePrints, useModelSelection } from "../../hooks/useModelSelection";
const PAGE_SIZE = 24;
@@ -51,6 +53,7 @@ export default function CollectionDetailPage({
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const selection = useModelSelection(items);
const [searchParams, setSearchParams] = useSearchParams();
const sortModeParam = searchParams.get("orderBy");
const sortMode: PrintSortMode =
@@ -177,6 +180,23 @@ export default function CollectionDetailPage({
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{collection.sync && <CollectionSyncIndicator sync={collection.sync} onClick={() => setSyncOpen(true)} />}
<ModelSelectionToolbar
selection={selection}
total={items.length}
hasMore={hasMore}
onDeleted={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
onUpdated={(updated) =>
setItems((prev) =>
// As for one card: unfavoriting inside Favourites drops them immediately.
collection.system_key === "favorites"
? replacePrints(prev, updated).filter((i) => i.is_favorite)
: replacePrints(prev, updated),
)
}
onUnauthorized={onUnauthorized}
collectionId={collection.system_key ? undefined : collection.id}
onRemovedFromCollection={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
/>
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
@@ -214,6 +234,9 @@ export default function CollectionDetailPage({
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
selectMode={selection.active}
selected={selection.isSelected(item.id)}
onToggleSelect={(range) => selection.toggle(item.id, range)}
/>
))}
{loadingMore && <CardSkeletons count={6} />}
+141 -56
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Paper from "@mui/material/Paper";
@@ -7,9 +8,10 @@ import Stack from "@mui/material/Stack";
import Avatar from "@mui/material/Avatar";
import Typography from "@mui/material/Typography";
import Tooltip from "@mui/material/Tooltip";
import { useTheme } from "@mui/material/styles";
import { alpha, useTheme } from "@mui/material/styles";
import VisibilityIcon from "@mui/icons-material/Visibility";
import PrintIcon from "@mui/icons-material/Print";
import CheckIcon from "@mui/icons-material/Check";
import { type Print, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
@@ -39,6 +41,11 @@ type Props = {
onRemovedFromCollection?: (id: string) => void;
/** Shown as the author of a direct upload, which has none. */
viewer?: AuthUser | null;
/** Select mode: a click selects the card instead of opening it, and the hover actions are gone. */
selectMode?: boolean;
selected?: boolean;
/** `range` is a shift-click. */
onToggleSelect?: (range: boolean) => void;
};
const OVERLAY_BUTTON_SIZE = 30;
@@ -64,6 +71,9 @@ export default function ModelCard({
collectionId,
onRemovedFromCollection,
viewer,
selectMode = false,
selected = false,
onToggleSelect,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
@@ -88,16 +98,28 @@ export default function ModelCard({
// Only mounted while hovered, so idle cards don't load images or tick.
const [hovered, setHovered] = useState(false);
const contextMenu = useCardContextMenu();
const toggleSelect = (event: React.MouseEvent | React.KeyboardEvent) => onToggleSelect?.(event.shiftKey);
const slideshowImages =
item.preview_images.length > 1 ? item.preview_images.map((img) => printsApi.fileUrl(img.url)) : [];
return (
<Paper
variant="outlined"
onClick={() => navigate(`/models/${item.id}`)}
onClick={selectMode ? toggleSelect : () => navigate(`/models/${item.id}`)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={contextMenu.onContextMenu}
onContextMenu={selectMode ? undefined : contextMenu.onContextMenu}
{...(selectMode && {
role: "checkbox",
"aria-checked": selected,
"aria-label": item.title || item.name,
tabIndex: 0,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
toggleSelect(event);
},
})}
sx={{
position: "relative",
cursor: "pointer",
@@ -105,66 +127,127 @@ export default function ModelCard({
borderRadius: "12px",
borderColor: "transparent",
bgcolor: muiTheme.palette.mode === "dark" ? muiTheme.thingport.pageBackground : muiTheme.palette.grey[100],
transition: "background-color .15s ease, box-shadow .15s ease, transform .15s ease",
"&:hover": {
bgcolor: "background.paper",
boxShadow: 6,
borderColor: "divider",
transform: "translateY(-2px)",
},
"&:hover .model-card-actions": { opacity: 1 },
// An outline, so selecting doesn't shift the card's contents the way a thicker border would.
outline: "2px solid",
outlineColor: selected ? muiTheme.palette.primary.main : "transparent",
outlineOffset: -2,
transition: "background-color .15s ease, box-shadow .15s ease, transform .15s ease, outline-color .15s ease",
// Shift-clicking a range would otherwise also select the text between.
...(selectMode && { userSelect: "none" }),
"&:focus-visible": { outlineColor: muiTheme.palette.primary.main },
...(!selectMode && {
"&:hover": {
bgcolor: "background.paper",
boxShadow: 6,
borderColor: "divider",
transform: "translateY(-2px)",
},
"&:hover .model-card-actions": { opacity: 1 },
}),
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
{/* zIndex 0 makes this its own stacking context, so the slideshow's layered slides stay
under the provider badge and hover actions rendered after it. */}
<Box sx={{ position: "relative", zIndex: 0, width: "100%", aspectRatio: "4 / 3" }}>
under the provider badge and hover actions rendered after it. Selected, it shrinks
inside the outline, so the selection reads at a glance across the grid. */}
<Box
sx={{
position: "relative",
zIndex: 0,
width: "100%",
aspectRatio: "4 / 3",
overflow: "hidden",
transformOrigin: "center",
transform: selected ? "scale(0.92)" : "none",
borderRadius: selected ? "8px" : 0,
transition: "transform .2s ease, border-radius .2s ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
{renderPreviewContent(item, "card", theme, t, previewMode)}
{hovered && slideshowImages.length > 0 && (
{hovered && !selectMode && slideshowImages.length > 0 && (
<HoverSlideshow images={slideshowImages} alt={item.title || item.name} />
)}
</Box>
<ProviderBadge provider={item.source_provider} sx={{ position: "absolute", top: 8, left: 8 }} />
<Stack
className="model-card-actions"
direction="row"
spacing={0.5}
onClick={(e) => e.stopPropagation()}
sx={{
position: "absolute",
top: 8,
right: 8,
opacity: 0,
transition: "opacity .15s ease",
}}
>
<Tooltip title={favoriteLabel}>
<Box>
<StarToggle
active={isFavorite}
onClick={toggleFavorite}
ariaLabel={favoriteLabel}
inactiveColor={overlayIconColor}
size={HOVER_ICON_SIZE}
sx={overlayButtonSx}
/>
</Box>
</Tooltip>
<ModelActionsMenu
print={item}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted?.(item.id)}
onUpdated={onUpdated}
collectionId={collectionId}
onRemovedFromCollection={() => onRemovedFromCollection?.(item.id)}
triggerSx={{ color: overlayIconColor, ...overlayButtonSx }}
iconFontSize={HOVER_ICON_SIZE}
viewer={viewer}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
/>
</Stack>
{selectMode && (
<Box
aria-hidden
sx={{
position: "absolute",
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: 2,
borderColor: selected ? "primary.main" : "background.paper",
// A scrim, so the empty circle shows on light and dark previews alike.
bgcolor: selected ? "primary.main" : alpha(muiTheme.palette.common.black, 0.25),
color: "primary.contrastText",
boxShadow: 1,
transition: "background-color .15s ease, border-color .15s ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
<CheckIcon
sx={{
fontSize: 16,
opacity: selected ? 1 : 0,
transform: selected ? "scale(1)" : "scale(0.4)",
transition: "opacity 150ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1)",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
/>
</Box>
)}
{!selectMode && (
<Stack
className="model-card-actions"
direction="row"
spacing={0.5}
onClick={(e) => e.stopPropagation()}
sx={{
position: "absolute",
top: 8,
right: 8,
opacity: 0,
transition: "opacity .15s ease",
}}
>
<Tooltip title={favoriteLabel}>
<Box>
<StarToggle
active={isFavorite}
onClick={toggleFavorite}
ariaLabel={favoriteLabel}
inactiveColor={overlayIconColor}
size={HOVER_ICON_SIZE}
sx={overlayButtonSx}
/>
</Box>
</Tooltip>
<ModelActionsMenu
print={item}
onUnauthorized={onUnauthorized}
onDeleted={() => onDeleted?.(item.id)}
onUpdated={onUpdated}
collectionId={collectionId}
onRemovedFromCollection={() => onRemovedFromCollection?.(item.id)}
triggerSx={{ color: overlayIconColor, ...overlayButtonSx }}
iconFontSize={HOVER_ICON_SIZE}
viewer={viewer}
contextMenuAt={contextMenu.at}
onContextMenuClose={contextMenu.close}
/>
</Stack>
)}
<Box sx={{ px: 1.5, pt: 0.75, pb: 1.5 }}>
<Typography
variant="body2"
@@ -179,7 +262,7 @@ export default function ModelCard({
<AuthorHoverCard
authorId={author ? author.id : SELF_AUTHOR_ID}
viewer={viewer}
disabled={!author && !showViewerAsAuthor}
disabled={selectMode || (!author && !showViewerAsAuthor)}
>
<Stack
direction="row"
@@ -188,10 +271,12 @@ export default function ModelCard({
sx={{
minWidth: 0,
color: "#858585",
...(author || showViewerAsAuthor ? { cursor: "pointer", "&:hover": { color: "#00b800" } } : undefined),
...((author || showViewerAsAuthor) && !selectMode
? { cursor: "pointer", "&:hover": { color: "#00b800" } }
: undefined),
}}
onClick={(e) => {
if (!author && !showViewerAsAuthor) return;
if (selectMode || (!author && !showViewerAsAuthor)) return;
e.stopPropagation();
navigate(`/authors/${author ? author.id : SELF_AUTHOR_ID}`);
}}
+18 -2
View File
@@ -22,6 +22,8 @@ import SortTabs from "./SortTabs";
import ModelsManagerDialog, { type ModelsManagerSection } from "./ModelsManagerDialog";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
import ModelSelectionToolbar from "../../components/modelSelection/ModelSelectionToolbar";
import { replacePrints, useModelSelection } from "../../hooks/useModelSelection";
const PAGE_SIZE = 24;
@@ -57,6 +59,7 @@ export default function ModelsPage({
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const selection = useModelSelection(items);
const [searchParams, setSearchParams] = useSearchParams();
const [categoriesView, setCategoriesView] = useCategoriesView();
const panelKind = categoriesView === "folders" ? "folder" : "category";
@@ -262,9 +265,19 @@ export default function ModelsPage({
return (
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2, rowGap: 1 }}>
<CategoriesViewToggle value={categoriesView} onChange={switchCategoriesView} />
<SortTabs value={sortMode} onChange={setSortMode} />
<ModelSelectionToolbar
selection={selection}
total={items.length}
hasMore={hasMore}
onDeleted={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
onUpdated={(updated) => setItems((prev) => replacePrints(prev, updated))}
onUnauthorized={onUnauthorized}
/>
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
</Box>
<ModelsManagerDialog
open={managerSection}
@@ -315,6 +328,9 @@ export default function ModelsPage({
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
selectMode={selection.active}
selected={selection.isSelected(item.id)}
onToggleSelect={(range) => selection.toggle(item.id, range)}
/>
))}
{loadingMore && <CardSkeletons count={5} />}
+18 -2
View File
@@ -15,6 +15,8 @@ import ModelCard from "../ModelsPage/ModelCard";
import SortTabs from "../ModelsPage/SortTabs";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
import ModelSelectionToolbar from "../../components/modelSelection/ModelSelectionToolbar";
import { replacePrints, useModelSelection } from "../../hooks/useModelSelection";
const PAGE_SIZE = 24;
const SORT_MODES: PrintSortMode[] = ["relevance", "newest", "popular", "downloads"];
@@ -37,6 +39,7 @@ export default function SearchResultsPage({ theme, previewMode, onUnauthorized,
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const selection = useModelSelection(items);
const sortModeParam = searchParams.get("orderBy");
const sortMode: PrintSortMode =
sortModeParam === "newest" || sortModeParam === "popular" || sortModeParam === "downloads"
@@ -125,8 +128,18 @@ export default function SearchResultsPage({ theme, previewMode, onUnauthorized,
return (
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<SortTabs value={sortMode} modes={SORT_MODES} onChange={setSortMode} />
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<ModelSelectionToolbar
selection={selection}
total={items.length}
hasMore={hasMore}
onDeleted={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
onUpdated={(updated) => setItems((prev) => replacePrints(prev, updated))}
onUnauthorized={onUnauthorized}
/>
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} modes={SORT_MODES} onChange={setSortMode} />
</Box>
</Box>
{items.length ? (
<Stack spacing={2}>
@@ -142,6 +155,9 @@ export default function SearchResultsPage({ theme, previewMode, onUnauthorized,
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
selectMode={selection.active}
selected={selection.isSelected(item.id)}
onToggleSelect={(range) => selection.toggle(item.id, range)}
/>
))}
{loadingMore && <CardSkeletons count={6} />}
+41 -11
View File
@@ -20,6 +20,8 @@ import TagBookmarkButton from "./TagBookmarkButton";
import TagActionsMenu from "./TagActionsMenu";
import { CardSkeletons } from "../../components/media/CardSkeleton";
import { cardGridSx } from "../../components/media/cardGrid";
import ModelSelectionToolbar from "../../components/modelSelection/ModelSelectionToolbar";
import { replacePrints, useModelSelection } from "../../hooks/useModelSelection";
const PAGE_SIZE = 24;
@@ -43,6 +45,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const selection = useModelSelection(items);
const [searchParams, setSearchParams] = useSearchParams();
const sortModeParam = searchParams.get("orderBy");
const sortMode: PrintSortMode =
@@ -182,23 +185,47 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
return (
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<SortTabs value={sortMode} onChange={setSortMode} />
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<ModelSelectionToolbar
selection={selection}
total={items.length}
hasMore={hasMore}
onDeleted={(ids) => setItems((prev) => prev.filter((i) => !ids.includes(i.id)))}
onUpdated={(updated) => setItems((prev) => replacePrints(prev, updated))}
onUnauthorized={onUnauthorized}
/>
<Box sx={{ ml: "auto" }}>
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
</Box>
{collections.length || items.length ? (
<Stack spacing={2}>
<Box sx={cardGridSx(6)}>
{collections.map((collection) => (
<CollectionCard
// Only models are selectable; the collections step back while select mode is on.
<Box
key={collection.id}
collection={collection}
theme={theme}
previewMode={previewMode}
onUpdated={(updated) => setCollections((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))}
onDeleted={(deletedId) => setCollections((prev) => prev.filter((c) => c.id !== deletedId))}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
/>
// inert also keeps keyboard focus out; React 18 only passes it through as a string.
{...(selection.active && { inert: "" })}
sx={{
minWidth: 0,
opacity: selection.active ? 0.35 : 1,
transition: "opacity .2s ease",
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}}
>
<CollectionCard
collection={collection}
theme={theme}
previewMode={previewMode}
onUpdated={(updated) =>
setCollections((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
}
onDeleted={(deletedId) => setCollections((prev) => prev.filter((c) => c.id !== deletedId))}
onUnauthorized={onUnauthorized}
onBookmarksChanged={onBookmarksChanged}
/>
</Box>
))}
{items.map((item) => (
<ModelCard
@@ -211,6 +238,9 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
onUpdated={(updated) => setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
selectMode={selection.active}
selected={selection.isSelected(item.id)}
onToggleSelect={(range) => selection.toggle(item.id, range)}
/>
))}
{loadingMore && <CardSkeletons count={6} />}