diff --git a/frontend/src/hooks/useSlicerPreference.ts b/frontend/src/hooks/useSlicerPreference.ts new file mode 100644 index 0000000..301eac3 --- /dev/null +++ b/frontend/src/hooks/useSlicerPreference.ts @@ -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 | null = null; +const listeners = new Set<(value: string | null) => void>(); + +function load(): Promise { + 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(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; +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2a3ce99..6fe40ce 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 0df4fd9..e15d7f2 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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ę", diff --git a/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx b/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx index 1174997..61bd5dd 100644 --- a/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx +++ b/frontend/src/pages/ModelDetailPage/ModelActionsMenu.tsx @@ -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; }; -/** 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(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 ? : } + + + + {slicerOption + ? t("models:detail.openInSlicer", { slicer: slicerOption.label }) + : t("models:detail.openInSlicerGeneric")} + + {t("common:download")} diff --git a/frontend/src/pages/ProfilePage/LanguagePicker.tsx b/frontend/src/pages/ProfilePage/LanguagePicker.tsx index 7b1fa3f..d427f0e 100644 --- a/frontend/src/pages/ProfilePage/LanguagePicker.tsx +++ b/frontend/src/pages/ProfilePage/LanguagePicker.tsx @@ -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() { {t("profile.language.heading")} {t("profile.language.subtitle")} - - {SUPPORTED_LANGUAGES.map(lang => { - const selected = (i18n.resolvedLanguage || i18n.language || "").startsWith(lang.code); - return ( - { 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, - }} - > - - {lang.label} - - {selected && } - - ); - })} - + + + {SUPPORTED_LANGUAGES.map(lang => { + const selected = (i18n.resolvedLanguage || i18n.language || "").startsWith(lang.code); + return ( + { 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, + }} + > + + {lang.label} + + {selected && } + + ); + })} + + ); } diff --git a/frontend/src/pages/ProfilePage/MakerworldCookieSection.tsx b/frontend/src/pages/ProfilePage/MakerworldCookieSection.tsx index 0ea5325..9c2c14a 100644 --- a/frontend/src/pages/ProfilePage/MakerworldCookieSection.tsx +++ b/frontend/src/pages/ProfilePage/MakerworldCookieSection.tsx @@ -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")} - {editing ? ( - - 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 - /> - - - + + {editing ? ( + + 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 + /> + + + + - - ) : ( - - - - {configured && ( - - )} - - )} + {configured && ( + + )} + + )} - {error && setError(null)}>{error}} + {error && setError(null)}>{error}} + ); } diff --git a/frontend/src/pages/ProfilePage/SlicerPicker.tsx b/frontend/src/pages/ProfilePage/SlicerPicker.tsx index 5dc4cc1..a6e9cf7 100644 --- a/frontend/src/pages/ProfilePage/SlicerPicker.tsx +++ b/frontend/src/pages/ProfilePage/SlicerPicker.tsx @@ -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(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) { {t("profile.slicer.description")} - - - {t("profile.slicer.label")} - - - - {saving && } - - {status && {status}} + + + + {t("profile.slicer.label")} + + + {saving && } + + {status && {status}} + ); } diff --git a/frontend/src/pages/ProfilePage/index.tsx b/frontend/src/pages/ProfilePage/index.tsx index c91fb57..b2d2210 100644 --- a/frontend/src/pages/ProfilePage/index.tsx +++ b/frontend/src/pages/ProfilePage/index.tsx @@ -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 - - - - diff --git a/frontend/src/utils/slicerLaunch.ts b/frontend/src/utils/slicerLaunch.ts new file mode 100644 index 0000000..ae50586 --- /dev/null +++ b/frontend/src/utils/slicerLaunch.ts @@ -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)}`; +}