mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
fix: open in slicer now provides a choice of available files
This commit is contained in:
1 parent
1fa98a830e
commit
f3b3fb78e3
6 files changed
+166
-26
No files matched your search
@@ -1147,6 +1147,7 @@
|
||||
"openInProvider": "Open in {{provider}}",
|
||||
"openInSlicer": "Open in {{slicer}}",
|
||||
"openInSlicerGeneric": "Open in Slicer",
|
||||
"preparedPrintFile": "Prepared print",
|
||||
"downloadPickerTitle": "Download",
|
||||
"downloadAllZip": "Download all {{count}} files (.zip)",
|
||||
"orDownloadOne": "or download one file",
|
||||
|
||||
@@ -1149,6 +1149,7 @@
|
||||
"openInProvider": "Atverti „{{provider}}“",
|
||||
"openInSlicer": "Atverti su {{slicer}}",
|
||||
"openInSlicerGeneric": "Atverti pjaustyklėje",
|
||||
"preparedPrintFile": "Paruoštas spaudinys",
|
||||
"downloadPickerTitle": "Atsisiųsti",
|
||||
"downloadAllZip": "Atsisiųsti visus failus ({{count}}, .zip)",
|
||||
"orDownloadOne": "arba atsisiųsti vieną failą",
|
||||
|
||||
@@ -22,12 +22,11 @@ import { UnauthorizedError } from "../../api/client";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { collectionsApi } from "../../api/collections";
|
||||
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { importProviderInfo } from "../../constants/importProviders";
|
||||
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
|
||||
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
|
||||
import { useDownloadPrint } from "./useDownloadPrint";
|
||||
import { useOpenInSlicer } from "./useOpenInSlicer";
|
||||
import SlicerFileMenu from "./SlicerFileMenu";
|
||||
import DownloadPickerDialog from "./DownloadPickerDialog";
|
||||
import AddToCollectionModal from "./AddToCollectionModal";
|
||||
import EditModelModal from "./EditModelModal";
|
||||
@@ -77,10 +76,10 @@ export default function ModelActionsMenu({
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const confirmDialog = useConfirm();
|
||||
const slicerPreference = useSlicerPreference();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [removingFromCollection, setRemovingFromCollection] = useState(false);
|
||||
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
|
||||
@@ -156,11 +155,8 @@ export default function ModelActionsMenu({
|
||||
};
|
||||
|
||||
const providerInfo = importProviderInfo(print.source_provider);
|
||||
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
|
||||
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
|
||||
const openInSlicerHref = slicerOption && print.slicer_url
|
||||
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
|
||||
: undefined;
|
||||
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
|
||||
const openInSlicerHref = slicerTargets.length === 1 ? slicerTargets[0].href : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -207,14 +203,21 @@ export default function ModelActionsMenu({
|
||||
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
{/* One target: a plain link to it. Several: this menu hands over to SlicerFileMenu,
|
||||
anchored on the same trigger, to pick which. */}
|
||||
<MenuItem
|
||||
component="a"
|
||||
href={openInSlicerHref}
|
||||
onClick={() => {
|
||||
if (slicerTargets.length > 1) {
|
||||
setSlicerMenuAnchor(anchorEl);
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
recordUse();
|
||||
}}
|
||||
disabled={!openInSlicerHref}
|
||||
disabled={slicerTargets.length === 0}
|
||||
>
|
||||
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>
|
||||
@@ -237,6 +240,16 @@ export default function ModelActionsMenu({
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
{slicerOption && slicerTargets.length > 1 && (
|
||||
<SlicerFileMenu
|
||||
anchorEl={slicerMenuAnchor}
|
||||
onClose={() => setSlicerMenuAnchor(null)}
|
||||
slicerLabel={slicerOption.label}
|
||||
targets={slicerTargets}
|
||||
onOpen={recordUse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DownloadPickerDialog
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Paper from "@mui/material/Paper";
|
||||
@@ -10,19 +11,18 @@ import ButtonBase from "@mui/material/ButtonBase";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import type { Print } from "../../api/prints";
|
||||
import { printsApi } from "../../api/prints";
|
||||
import type { AuthUser } from "../../api/auth";
|
||||
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
|
||||
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
|
||||
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import { dividerBorderColor } from "../../theme";
|
||||
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useDownloadPrint } from "./useDownloadPrint";
|
||||
import { useOpenInSlicer } from "./useOpenInSlicer";
|
||||
import SlicerFileMenu from "./SlicerFileMenu";
|
||||
import RollingNumber from "../../components/RollingNumber";
|
||||
import { formatFileSize } from "../../utils/fileSize";
|
||||
import AuthorHoverCard from "../../components/AuthorHoverCard";
|
||||
@@ -43,23 +43,20 @@ type Props = {
|
||||
/** The model detail page's right-hand summary card, sticky so it stays in view while the
|
||||
* description/tags column scrolls: the full title, author (jumps to their author page), category (jumps back
|
||||
* to the Models grid filtered to it), "Open in {Slicer}" (only when both a preference is set
|
||||
* and this print has a slicer_url -- same condition ModelActionsMenu's menu item uses),
|
||||
* and this print has a slicer_url, and a pick of which file when there are several -- see
|
||||
* useOpenInSlicer, shared with ModelActionsMenu's menu item),
|
||||
* "Download model files" (the same picker-or-direct-download flow as ModelActionsMenu's
|
||||
* Download, via useDownloadPrint so the two can't drift), view/print counts, and -- only for an
|
||||
* actually-imported print, per source_provider -- when it was imported. */
|
||||
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, onUpdated, viewer }: Props) {
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const slicerPreference = useSlicerPreference();
|
||||
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 56);
|
||||
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates, recordUse } =
|
||||
useDownloadPrint(print, onUnauthorized, onUpdated);
|
||||
|
||||
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
|
||||
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
|
||||
const openInSlicerHref = slicerOption && print.slicer_url
|
||||
? slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(print.slicer_url), print.slicer_filename ?? undefined)
|
||||
: undefined;
|
||||
const { slicerOption, targets: slicerTargets } = useOpenInSlicer(print);
|
||||
const [slicerMenuAnchor, setSlicerMenuAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const goToCategory = () => {
|
||||
if (!print.category_id) return;
|
||||
@@ -188,11 +185,12 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{openInSlicerHref && (
|
||||
{slicerOption && slicerTargets.length > 0 && (
|
||||
<Button
|
||||
component="a"
|
||||
href={openInSlicerHref}
|
||||
onClick={recordUse}
|
||||
// One target: a plain link to it. Several: pick which (see SlicerFileMenu).
|
||||
{...(slicerTargets.length === 1
|
||||
? { component: "a" as const, href: slicerTargets[0].href, onClick: recordUse }
|
||||
: { onClick: (e: React.MouseEvent<HTMLElement>) => setSlicerMenuAnchor(e.currentTarget), endIcon: <ArrowDropDownIcon /> })}
|
||||
startIcon={<LaunchIcon fontSize="small" />}
|
||||
fullWidth
|
||||
sx={{
|
||||
@@ -203,9 +201,19 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
|
||||
"&:hover": { bgcolor: "action.hover", borderColor: "primary.dark" },
|
||||
}}
|
||||
>
|
||||
{t("models:detail.openInSlicer", { slicer: slicerOption!.label })}
|
||||
{t("models:detail.openInSlicer", { slicer: slicerOption.label })}
|
||||
</Button>
|
||||
)}
|
||||
{slicerOption && slicerTargets.length > 1 && (
|
||||
<SlicerFileMenu
|
||||
anchorEl={slicerMenuAnchor}
|
||||
onClose={() => setSlicerMenuAnchor(null)}
|
||||
slicerLabel={slicerOption.label}
|
||||
targets={slicerTargets}
|
||||
onOpen={recordUse}
|
||||
matchAnchorWidth
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 ListSubheader from "@mui/material/ListSubheader";
|
||||
import Box from "@mui/material/Box";
|
||||
import PrintIcon from "@mui/icons-material/Print";
|
||||
import PlateThumbnail from "../../components/media/PlateThumbnail";
|
||||
import { fileRowText } from "./fileRowText";
|
||||
import type { SlicerTarget } from "./useOpenInSlicer";
|
||||
|
||||
type Props = {
|
||||
anchorEl: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
slicerLabel: string;
|
||||
targets: SlicerTarget[];
|
||||
/** Called as a target's link is followed (e.g. to bump the print count). */
|
||||
onOpen: () => void;
|
||||
/** Opens directly below the anchor at exactly its width -- for a full-width button, so the menu
|
||||
* reads as that button's dropdown. Off for a small anchor like an icon button. */
|
||||
matchAnchorWidth?: boolean;
|
||||
};
|
||||
|
||||
/** "Open in {Slicer}" for a model with more than one file: pick which one to hand the slicer,
|
||||
* rather than always the first. Each row is a real link to the slicer's URL protocol. */
|
||||
export default function SlicerFileMenu({ anchorEl, onClose, slicerLabel, targets, onOpen, matchAnchorWidth = false }: Props) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
const paperSx = matchAnchorWidth && anchorEl ? { width: anchorEl.offsetWidth } : { maxWidth: 360 };
|
||||
return (
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={onClose}
|
||||
{...(matchAnchorWidth && {
|
||||
anchorOrigin: { vertical: "bottom", horizontal: "left" },
|
||||
transformOrigin: { vertical: "top", horizontal: "left" },
|
||||
})}
|
||||
slotProps={{ paper: { sx: paperSx } }}
|
||||
>
|
||||
<ListSubheader sx={{ lineHeight: "32px" }}>{t("models:detail.openInSlicer", { slicer: slicerLabel })}</ListSubheader>
|
||||
{targets.map(target => {
|
||||
const text = target.plate
|
||||
? fileRowText(t, target.filename, target.index)
|
||||
: { primary: t("models:detail.preparedPrintFile"), secondary: target.filename };
|
||||
return (
|
||||
<MenuItem
|
||||
key={target.key}
|
||||
component="a"
|
||||
href={target.href}
|
||||
title={target.filename}
|
||||
onClick={() => {
|
||||
onOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 44 }}>
|
||||
{target.plate ? (
|
||||
<PlateThumbnail plate={target.plate} />
|
||||
) : (
|
||||
<Box sx={{ width: 32, height: 32, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<PrintIcon fontSize="small" />
|
||||
</Box>
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
{...text}
|
||||
primaryTypographyProps={{ variant: "body2", noWrap: true }}
|
||||
secondaryTypographyProps={{ variant: "caption", noWrap: true }}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import { type Plate, type Print, printsApi } from "../../api/prints";
|
||||
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
|
||||
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
|
||||
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
|
||||
|
||||
/** One thing "Open in {Slicer}" can open: the attached prepared print, or one of the model's
|
||||
* files (`plate` set). */
|
||||
export type SlicerTarget = { key: string; href: string; filename: string; index: number; plate: Plate | null };
|
||||
|
||||
/** Everything "Open in {Slicer}" needs, shared by ModelSidePanel's button and ModelActionsMenu's
|
||||
* item. `slicerOption` is null without a usable preference ("other" has no URL protocol to
|
||||
* launch). With one target the caller links straight to it; with several (a model with multiple
|
||||
* files, e.g. several MakerWorld print profiles, or a prepared print alongside the files) it
|
||||
* offers a pick instead of silently opening the first. */
|
||||
export function useOpenInSlicer(print: Print) {
|
||||
const slicerPreference = useSlicerPreference();
|
||||
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other") ?? null;
|
||||
|
||||
const targets = useMemo<SlicerTarget[]>(() => {
|
||||
if (!slicerOption || !print.slicer_url) return [];
|
||||
const launch = (url: string, filename: string) => slicerLaunchUrl(slicerOption.id, printsApi.fileUrl(url), filename);
|
||||
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
|
||||
const out: SlicerTarget[] = [];
|
||||
// The backend's slicer_url is an attached prepared print, or else the first file -- which,
|
||||
// when that file is itself sliced, comes with a slicer_filename carrying the right suffix
|
||||
// (e.g. .gcode.3mf) for the slicer to treat it as such. Both are kept as-is here.
|
||||
const slicerUrlIsPlate = sortedPlates.some(p => p.url === print.slicer_url);
|
||||
if (!slicerUrlIsPlate) {
|
||||
const filename = print.slicer_filename ?? "";
|
||||
out.push({ key: "prepared", href: launch(print.slicer_url, filename), filename, index: -1, plate: null });
|
||||
}
|
||||
sortedPlates.forEach((plate, index) => {
|
||||
const filename = plate.url === print.slicer_url && print.slicer_filename ? print.slicer_filename : plate.filename;
|
||||
out.push({ key: plate.id, href: launch(plate.url, filename), filename: plate.filename, index, plate });
|
||||
});
|
||||
return out;
|
||||
}, [print.plates, print.slicer_url, print.slicer_filename, slicerOption]);
|
||||
|
||||
return { slicerOption, targets };
|
||||
}
|
||||
Reference in new issue
Block a user