feat: eabling opening model

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-09 16:49:39 +02:00
1 parent 106205556e
commit 4d61c902e6
9 files changed
+182 -120

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { settingsApi } from "../api/settings";
// Module-level cache so every mounted "Open in {Slicer}" menu item (one per model card) shares
// a single GET /settings/slicer instead of each firing its own request, and so SlicerPicker can
// push a freshly-saved value into all of them immediately via setCachedSlicerPreference below.
let cached: string | null | undefined; // undefined = not loaded yet
let inFlight: Promise<string | null> | null = null;
const listeners = new Set<(value: string | null) => void>();
function load(): Promise<string | null> {
if (cached !== undefined) return Promise.resolve(cached);
if (!inFlight) {
inFlight = settingsApi.getSlicer()
.then(res => { cached = res.slicer ?? null; return cached; })
.catch(() => null)
.finally(() => { inFlight = null; });
}
return inFlight;
}
export function setCachedSlicerPreference(value: string | null) {
cached = value;
listeners.forEach(listener => listener(value));
}
/** The user's preferred slicer id (or null if unset/unknown), for the "Open in {Slicer}" model
* menu action. Kept up to date across every mounted instance when the Profile page's
* SlicerPicker saves a change. */
export function useSlicerPreference(): string | null {
const [value, setValue] = useState<string | null>(cached ?? null);
useEffect(() => {
let cancelled = false;
void load().then(v => { if (!cancelled) setValue(v); });
listeners.add(setValue);
return () => {
cancelled = true;
listeners.delete(setValue);
};
}, []);
return value;
}
+3 -4
View File
@@ -152,8 +152,6 @@
},
"profile": {
"title": "Profile",
"displayNameLabel": "Display Name",
"displayNameHelper": "Display name can't be changed",
"changeEmailLink": "Change email",
"changePasswordLink": "Change password",
"pendingEmail": "Pending confirmation for {{email}}",
@@ -188,9 +186,8 @@
},
"slicer": {
"heading": "Slicer",
"description": "Your preferred slicer, for opening models directly via that slicer's own link (e.g. bambustudio://) -- coming soon.",
"description": "Your preferred slicer, used by a model's \"Open in Slicer\" action to launch it directly via that slicer's own link (e.g. bambustudio://).",
"label": "Slicer",
"saved": "Saved.",
"failed": "Failed to save -- try again."
}
},
@@ -633,6 +630,8 @@
"deleteFailed": "Failed to delete the model.",
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
"openInProvider": "Open in {{provider}}",
"openInSlicer": "Open in {{slicer}}",
"openInSlicerGeneric": "Open in Slicer",
"downloadPickerTitle": "Download",
"downloadAllZip": "Download all {{count}} plates (.zip)",
"orDownloadOne": "or download one plate",
+3 -4
View File
@@ -152,8 +152,6 @@
},
"profile": {
"title": "Profilis",
"displayNameLabel": "Rodomas vardas",
"displayNameHelper": "Rodomo vardo pakeisti negalima",
"changeEmailLink": "Keisti el. paštą",
"changePasswordLink": "Keisti slaptažodį",
"pendingEmail": "Laukiama patvirtinimo adresu {{email}}",
@@ -188,9 +186,8 @@
},
"slicer": {
"heading": "Pjaustyklė",
"description": "Jūsų pageidaujama pjaustyklė, skirta ateityje atidaryti modelius tiesiogiai per tos pjaustyklės nuorodą (pvz. bambustudio://) -- greitai.",
"description": "Jūsų pageidaujama pjaustyklė, naudojama modelio veiksmo „Atverti pjaustyklėje“, kad modelis būtų atidarytas tiesiogiai per tos pjaustyklės nuorodą (pvz. bambustudio://).",
"label": "Pjaustyklė",
"saved": "Išsaugota.",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą."
}
},
@@ -634,6 +631,8 @@
"deleteFailed": "Nepavyko ištrinti modelio.",
"confirmDelete": "Ištrinti „{{name}}“? Šio veiksmo nebus galima atšaukti.",
"openInProvider": "Atverti „{{provider}}“",
"openInSlicer": "Atverti su {{slicer}}",
"openInSlicerGeneric": "Atverti pjaustyklėje",
"downloadPickerTitle": "Atsisiųsti",
"downloadAllZip": "Atsisiųsti visas {{count}} plokštes (.zip)",
"orDownloadOne": "arba atsisiųsti vieną plokštę",
@@ -20,12 +20,16 @@ import DownloadIcon from "@mui/icons-material/Download";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LaunchIcon from "@mui/icons-material/Launch";
import type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
import { type Plate, type Print, printsApi } from "../../api/prints";
import { saveResponseToDisk } from "../../utils/downloadResponse";
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";
type Props = {
print: Print;
@@ -36,14 +40,16 @@ type Props = {
triggerSx?: SxProps<Theme>;
};
/** The "..." menu for a model: Download (single file, or a plate picker / zip-all for
* multi-plate models), Edit (disabled for now), Delete (confirm, then delete), and -- only for
* an imported print -- a divider then "Open in {Provider}" linking back to the original model
* page. Shared by the model detail page's header and the Models/Collection grids' per-card
* hover overlay. */
/** The "..." menu for a model: "Open in {Slicer}" (launches the user's preferred slicer via its
* own URL protocol -- disabled when no slicer is set, or it's set to "Other"), Download (single
* file, or a plate picker / zip-all for multi-plate models), Edit (disabled for now), Delete
* (confirm, then delete), and -- only for an imported print -- a divider then "Open in
* {Provider}" linking back to the original model page. Shared by the model detail page's header
* and the Models/Collection grids' per-card hover overlay. */
export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, triggerSx }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const slicerPreference = useSlicerPreference();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
@@ -125,6 +131,11 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
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))
: undefined;
return (
<>
@@ -138,6 +149,14 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted, tri
{deleting ? <CircularProgress size={18} /> : <MoreVertIcon fontSize="small" />}
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem component="a" href={openInSlicerHref} onClick={closeMenu} disabled={!openInSlicerHref}>
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
<ListItemText>
{slicerOption
? t("models:detail.openInSlicer", { slicer: slicerOption.label })
: t("models:detail.openInSlicerGeneric")}
</ListItemText>
</MenuItem>
<MenuItem onClick={handleDownload} disabled={downloading}>
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("common:download")}</ListItemText>
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import ButtonBase from "@mui/material/ButtonBase";
@@ -14,33 +15,35 @@ export default function LanguagePicker() {
<Box>
<Typography variant="subtitle1" fontWeight={600}>{t("profile.language.heading")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("profile.language.subtitle")}</Typography>
<Stack spacing={1}>
{SUPPORTED_LANGUAGES.map(lang => {
const selected = (i18n.resolvedLanguage || i18n.language || "").startsWith(lang.code);
return (
<ButtonBase
key={lang.code}
onClick={() => { void i18n.changeLanguage(lang.code); }}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
textAlign: "left",
borderRadius: 2,
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
bgcolor: selected ? "action.selected" : "transparent",
p: 1.5,
}}
>
<Typography variant="body2" fontWeight={selected ? 600 : 400}>
{lang.label}
</Typography>
{selected && <CheckIcon fontSize="small" color="primary" />}
</ButtonBase>
);
})}
</Stack>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={1}>
{SUPPORTED_LANGUAGES.map(lang => {
const selected = (i18n.resolvedLanguage || i18n.language || "").startsWith(lang.code);
return (
<ButtonBase
key={lang.code}
onClick={() => { void i18n.changeLanguage(lang.code); }}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
textAlign: "left",
borderRadius: 2,
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
bgcolor: selected ? "action.selected" : "transparent",
p: 1.5,
}}
>
<Typography variant="body2" fontWeight={selected ? 600 : 400}>
{lang.label}
</Typography>
{selected && <CheckIcon fontSize="small" color="primary" />}
</ButtonBase>
);
})}
</Stack>
</Paper>
</Box>
);
}
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
@@ -95,46 +96,48 @@ export default function MakerworldCookieSection({ cookie, onUpdateMakerWorld, on
{t("profile.makerworld.description")}
</Typography>
{editing ? (
<Stack spacing={1.5}>
<TextField
multiline
minRows={3}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={t("profile.makerworld.placeholder") ?? undefined}
disabled={saving}
// Deliberate: focus the cookie field the moment editing starts.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" onClick={handleSave} disabled={saving || !draft.trim()}>
{saving ? t("profile.saving") : t("common:save")}
</Button>
<Button size="small" onClick={cancelEdit} disabled={saving}>{t("common:cancel")}</Button>
<Paper variant="outlined" sx={{ p: 2.5 }}>
{editing ? (
<Stack spacing={1.5}>
<TextField
multiline
minRows={3}
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={t("profile.makerworld.placeholder") ?? undefined}
disabled={saving}
// Deliberate: focus the cookie field the moment editing starts.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" onClick={handleSave} disabled={saving || !draft.trim()}>
{saving ? t("profile.saving") : t("common:save")}
</Button>
<Button size="small" onClick={cancelEdit} disabled={saving}>{t("common:cancel")}</Button>
</Stack>
</Stack>
</Stack>
) : (
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={configured ? t("profile.makerworld.added") : t("profile.makerworld.notAdded")}
size="small"
color={configured ? "success" : "default"}
variant={configured ? "filled" : "outlined"}
/>
<Button size="small" onClick={startEdit}>
{configured ? t("common:edit") : t("profile.makerworld.add")}
</Button>
{configured && (
<Button size="small" color="error" onClick={handleRemove} disabled={saving}>
{t("common:remove")}
) : (
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Chip
label={configured ? t("profile.makerworld.added") : t("profile.makerworld.notAdded")}
size="small"
color={configured ? "success" : "default"}
variant={configured ? "filled" : "outlined"}
/>
<Button size="small" onClick={startEdit}>
{configured ? t("common:edit") : t("profile.makerworld.add")}
</Button>
)}
</Stack>
)}
{configured && (
<Button size="small" color="error" onClick={handleRemove} disabled={saving}>
{t("common:remove")}
</Button>
)}
</Stack>
)}
{error && <Alert severity="error" sx={{ mt: 1.5 }} onClose={() => setError(null)}>{error}</Alert>}
{error && <Alert severity="error" sx={{ mt: 1.5 }} onClose={() => setError(null)}>{error}</Alert>}
</Paper>
</Box>
);
}
+32 -31
View File
@@ -1,29 +1,28 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select from "@mui/material/Select";
import Select, { type SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import CircularProgress from "@mui/material/CircularProgress";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import { setCachedSlicerPreference } from "../../hooks/useSlicerPreference";
type Props = {
onUnauthorized?: () => void;
};
/** Stored server-side, not just locally -- prep for a future "open this model in {slicer}"
* launch via that slicer's own registered URL protocol (e.g. bambustudio://). Not acted on
* anywhere yet; this is just where the preference is set. */
/** Stored server-side, not just locally -- used by the model menu's "Open in {Slicer}" action,
* which launches that slicer's own registered URL protocol (e.g. bambustudio://). */
export default function SlicerPicker({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [value, setValue] = React.useState("");
const [saved, setSaved] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
@@ -34,7 +33,6 @@ export default function SlicerPicker({ onUnauthorized }: Props) {
.then(res => {
if (!active) return;
setValue(res.slicer ?? "");
setSaved(res.slicer ?? "");
})
.catch(err => {
if (active && err instanceof UnauthorizedError) onUnauthorized?.();
@@ -43,18 +41,22 @@ export default function SlicerPicker({ onUnauthorized }: Props) {
return () => { active = false; };
}, [onUnauthorized]);
const handleSave = async () => {
const handleChange = async (e: SelectChangeEvent) => {
const next = e.target.value;
const previous = value;
setValue(next);
setSaving(true);
setStatus(null);
try {
const res = await settingsApi.updateSlicer(value || null);
setSaved(res.slicer ?? "");
setStatus(t("profile.slicer.saved"));
const res = await settingsApi.updateSlicer(next || null);
setValue(res.slicer ?? "");
setCachedSlicerPreference(res.slicer ?? null);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setValue(previous);
setStatus(err instanceof Error ? err.message : t("profile.slicer.failed"));
} finally {
setSaving(false);
@@ -67,26 +69,25 @@ export default function SlicerPicker({ onUnauthorized }: Props) {
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.slicer.description")}
</Typography>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<FormControl size="small" disabled={loading || saving} sx={{ minWidth: 220 }}>
<InputLabel id="profile-slicer-select-label">{t("profile.slicer.label")}</InputLabel>
<Select
labelId="profile-slicer-select-label"
label={t("profile.slicer.label")}
value={value}
onChange={e => setValue(e.target.value)}
>
{SLICER_OPTIONS.map(opt => (
<MenuItem key={opt.id} value={opt.id}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
<Button variant="contained" size="small" onClick={handleSave} disabled={loading || saving || value === saved}>
{saving ? t("profile.saving") : t("common:save")}
</Button>
{saving && <CircularProgress size={16} />}
</Stack>
{status && <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>{status}</Typography>}
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<FormControl size="small" disabled={loading || saving} sx={{ minWidth: 220 }}>
<InputLabel id="profile-slicer-select-label">{t("profile.slicer.label")}</InputLabel>
<Select
labelId="profile-slicer-select-label"
label={t("profile.slicer.label")}
value={value}
onChange={handleChange}
>
{SLICER_OPTIONS.map(opt => (
<MenuItem key={opt.id} value={opt.id}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
{saving && <CircularProgress size={16} />}
</Stack>
{status && <Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>{status}</Typography>}
</Paper>
</Box>
);
}
-12
View File
@@ -4,7 +4,6 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Avatar from "@mui/material/Avatar";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import type { AuthUser } from "../../api/auth";
@@ -42,17 +41,6 @@ export default function ProfilePage({ user, makerworldCookie, onUpdateMakerWorld
</Box>
</Stack>
<TextField
label={t("profile.displayNameLabel")}
value={user?.display_name ?? ""}
disabled
fullWidth
size="small"
helperText={t("profile.displayNameHelper")}
/>
<Divider />
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Box>
+6
View File
@@ -0,0 +1,6 @@
// Builds the URL for a slicer's own registered protocol handler (e.g. bambustudio://), which is
// how "Open in {Slicer}" hands a remote model file to the desktop app. Only meaningful for the
// SLICER_OPTIONS ids that register one -- never call this for "other".
export function slicerLaunchUrl(slicerId: string, fileUrl: string): string {
return `${slicerId}://open?file=${encodeURIComponent(fileUrl)}`;
}