mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
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:
1 parent
72408abbea
commit
fe5dbecdc5
22 files changed
+1096
-107
No files matched your search
@@ -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) });
|
||||
|
||||
@@ -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) });
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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}`);
|
||||
}}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in new issue
Block a user