mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: eabling opening model
This commit is contained in:
1 parent
106205556e
commit
4d61c902e6
9 files changed
+182
-120
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in new issue
Block a user