feat: remake profile page and add configuration modal

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-07 22:45:52 +02:00
1 parent 6533781a21
commit 049efdedb6
28 files changed
+1503 -641

No files matched your search

+9 -16
View File
@@ -8,7 +8,6 @@ import Snackbar from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert";
import AppLayout from "./components/Layout/AppLayout";
import DashboardPage from "./pages/DashboardPage";
import ActivityPage from "./pages/ActivityPage";
import ModelsPage from "./pages/ModelsPage";
import ModelDetailPage from "./pages/ModelDetailPage";
import CollectionsPage from "./pages/CollectionsPage";
@@ -36,7 +35,7 @@ import { healthApi, type HealthInfo } from "./api/health";
import { authApi, type AuthUser } from "./api/auth";
import { settingsApi, type PreviewMode } from "./api/settings";
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
import { type AppSettings, loadSettings, saveSettings } from "./utils/settings";
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
import { useResolvedTheme } from "./hooks/useResolvedTheme";
import type { ResolvedTheme, ThemeSelection } from "./constants/settingsOptions";
import { buildTheme } from "./theme";
@@ -99,6 +98,12 @@ function AppShell({
setNonce((n) => n + 1);
}, []);
const updateMakerWorld = React.useCallback(
(patch: Partial<MakerWorldSettings>) =>
setSettings((prev) => ({ ...prev, makerworld: { ...prev.makerworld, ...patch } })),
[setSettings],
);
return (
<AppLayout
muiTheme={muiTheme}
@@ -113,6 +118,7 @@ function AppShell({
onOpenProfile={() => navigate("/profile")}
onLogout={onLogout}
makerworldCookie={settings.makerworld.cookie}
onUpdateMakerWorld={updateMakerWorld}
user={user}
onThemeChange={onThemeChange}
>
@@ -196,26 +202,13 @@ function AppShell({
/>
}
/>
<Route
path="/profile"
element={
<ProfilePage
user={user}
makerworldCookie={settings.makerworld.cookie}
onUpdateMakerWorld={(patch) =>
setSettings((prev) => ({ ...prev, makerworld: { ...prev.makerworld, ...patch } }))
}
onUnauthorized={onUnauthorized}
/>
}
/>
<Route path="/profile" element={<ProfilePage user={user} onUnauthorized={onUnauthorized} />} />
<Route
path="/profile/email"
element={<ChangeEmailPage user={user} onUserUpdated={onUserUpdated} onUnauthorized={onUnauthorized} />}
/>
<Route path="/profile/password" element={<ChangePasswordPage onUnauthorized={onUnauthorized} />} />
<Route path="/downloads" element={<DownloadPage />} />
<Route path="/activity" element={<ActivityPage onUnauthorized={onUnauthorized} />} />
<Route
path="/admin"
element={isAdmin ? <AdminPage onUnauthorized={onUnauthorized} /> : <Navigate to="/" replace />}
+5 -4
View File
@@ -17,6 +17,7 @@ import { ImportJobProvider } from "./ImportJobContext";
import { NotificationsProvider, useNotifications } from "./NotificationsContext";
import type { ThemeSelection } from "../../constants/settingsOptions";
import type { AuthUser } from "../../api/auth";
import type { MakerWorldSettings } from "../../utils/settings";
type AppLayoutProps = {
muiTheme: Theme;
@@ -32,6 +33,7 @@ type AppLayoutProps = {
onOpenProfile: () => void;
onLogout: () => void;
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
user: AuthUser | null;
onThemeChange: (theme: ThemeSelection) => void;
children: React.ReactNode;
@@ -88,9 +90,6 @@ function useRouteChrome() {
} else if (path === "/profile") {
title = t("profile.title");
onBack = goBack;
} else if (path === "/activity") {
title = t("activity.title");
onBack = goBack;
} else if (path === "/downloads") {
title = t("sidebar.downloads");
onBack = () => navigate("/");
@@ -141,6 +140,7 @@ function AppLayoutShell({
onOpenProfile,
onLogout,
makerworldCookie,
onUpdateMakerWorld,
user,
onThemeChange,
children,
@@ -165,7 +165,7 @@ function AppLayoutShell({
}}
>
<Sidebar isAdmin={isAdmin} onSelectCategory={onSelectCategory} bookmarksVersion={bookmarksVersion} />
<Box component="main" sx={{ flex: 1, p: 2, pt: 0 }}>
<Box component="main" sx={{ flex: 1, minWidth: 0, p: 2, pt: 0 }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{t("shell.apiUnreachable")}
@@ -178,6 +178,7 @@ function AppLayoutShell({
actions={pageHeader?.actions}
categoryId={categoryId}
makerworldCookie={makerworldCookie}
onUpdateMakerWorld={onUpdateMakerWorld}
onUploaded={onPrintsChanged}
onUnauthorized={onUnauthorized}
user={user}
@@ -0,0 +1,179 @@
import React from "react";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
const TILE = 44;
const BADGE = 16;
const hoverBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.04)");
export function AppGrid({ children }: { children: React.ReactNode }) {
return <Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, 76px)", gap: 1 }}>{children}</Box>;
}
type TileProps = {
name: string;
logo: React.ReactNode;
/** Coloured when on, grey when off. */
on: boolean;
/** A tick or a cross in the corner; none when undefined. */
badge?: boolean;
/** Shown as if hovered, while its menu or box is open. */
highlighted?: boolean;
tooltip?: string;
ariaLabel: string;
onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
/** Double-click or Enter. */
onActivate: () => void;
};
/** An app icon with its name under it. */
export function AppTile({
name,
logo,
on,
badge,
highlighted,
tooltip,
ariaLabel,
onContextMenu,
onActivate,
}: TileProps) {
return (
<Tooltip title={tooltip ?? ""} placement="bottom">
<ButtonBase
onContextMenu={onContextMenu}
onDoubleClick={onActivate}
onKeyDown={(event) => {
if (event.key === "Enter") onActivate();
}}
aria-label={ariaLabel}
aria-haspopup="menu"
sx={{
flexDirection: "column",
justifyContent: "flex-start",
gap: 0.75,
px: 0.5,
py: 1,
borderRadius: "10px",
bgcolor: highlighted ? hoverBg : "transparent",
"&:hover": { bgcolor: hoverBg },
"&.Mui-focusVisible": { outline: 2, outlineColor: "primary.main", outlineOffset: -2 },
}}
>
<Box sx={{ position: "relative" }}>
<Box
sx={{
width: TILE,
height: TILE,
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "#ffffff",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
filter: on ? "none" : "grayscale(1)",
opacity: on ? 1 : 0.45,
transition: "filter 200ms, opacity 200ms",
}}
>
{logo}
</Box>
{badge !== undefined && <StatusBadge ok={badge} />}
</Box>
<Typography
variant="caption"
sx={{
fontSize: "0.7rem",
lineHeight: 1.25,
maxWidth: "100%",
textAlign: "center",
// Long names take two lines, then are cut short.
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
color: on ? "text.primary" : "text.secondary",
}}
>
{name}
</Typography>
</ButtonBase>
</Tooltip>
);
}
/** A circle with a tick or a cross. SVG, so dark mode's square-corners rule leaves it round. */
function StatusBadge({ ok }: { ok: boolean }) {
return (
<Box
component="svg"
viewBox="0 0 16 16"
aria-hidden="true"
sx={{
position: "absolute",
top: -BADGE / 3,
right: -BADGE / 3,
width: BADGE,
height: BADGE,
color: "background.paper",
}}
>
<circle
cx="8"
cy="8"
r="7.25"
fill={ok ? "#16a34a" : "#dc2626"}
// A ring in the panel's colour sets it off the tile.
stroke="currentColor"
strokeWidth="1.5"
/>
{ok ? (
<path
d="M4.75 8.25 7 10.5l4.25-4.75"
fill="none"
stroke="#fff"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path d="M5.5 5.5l5 5m0-5-5 5" fill="none" stroke="#fff" strokeWidth="1.75" strokeLinecap="round" />
)}
</Box>
);
}
/** Where a tile's context menu opens: at the pointer, or under the tile when opened from the keyboard. */
export function menuPosition(event: React.MouseEvent<HTMLElement>): { top: number; left: number } {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const fromKeyboard = event.clientX === 0 && event.clientY === 0;
return { top: fromKeyboard ? rect.bottom : event.clientY, left: fromKeyboard ? rect.left : event.clientX };
}
/** The top of a tile's context menu: which app it's for, and its state. */
export function TileMenuHeader({ logo, name, status }: { logo: React.ReactNode; name: string; status: string }) {
return (
<Stack
direction="row"
spacing={1.25}
alignItems="center"
sx={{ px: 2, pt: 0.75, pb: 1.25, mb: 0.5, borderBottom: 1, borderColor: "divider" }}
>
{logo}
<Box>
<Typography variant="body2" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{name}
</Typography>
<Typography variant="caption" color="text.secondary">
{status}
</Typography>
</Box>
</Stack>
);
}
@@ -0,0 +1,85 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import { SUPPORTED_LANGUAGES, type LanguageCode } from "../../../constants/languages";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
import Segmented from "../../controls/Segmented";
import { PanelHeader, SectionLabel } from "./parts";
const THEMES: ThemeSelection[] = ["light", "dark", "system"];
type Props = {
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onUnauthorized?: () => void;
};
/** Theme, language and author preview, each applied as it's changed. */
export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }: Props) {
const { t, i18n } = useTranslation("app");
const authorPreview = useAuthorPreviewEnabled();
const [authorPreviewError, setAuthorPreviewError] = React.useState<string | null>(null);
const language = (SUPPORTED_LANGUAGES.find((lang) => (i18n.resolvedLanguage ?? i18n.language).startsWith(lang.code))
?.code ?? "en") as LanguageCode;
const toggleAuthorPreview = async (enabled: boolean) => {
setAuthorPreviewError(null);
// Shown straight away; put back if the save fails.
setCachedAuthorPreviewEnabled(enabled);
try {
const res = await settingsApi.updateAuthorPreview(enabled);
setCachedAuthorPreviewEnabled(res.enabled);
} catch (err) {
setCachedAuthorPreviewEnabled(!enabled);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setAuthorPreviewError(t("profile.authorPreview.failed"));
}
};
return (
<>
<PanelHeader title={t("appearance.title")} subtitle={t("appearance.subtitle")} />
<Stack spacing={2.5}>
<div>
<SectionLabel>{t("userMenu.theme")}</SectionLabel>
<Segmented
label={t("userMenu.theme")}
value={theme}
onChange={onThemeChange}
options={THEMES.map((value) => ({ value, label: t(`appearance.themes.${value}`) }))}
/>
</div>
<div>
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
<Segmented
label={t("profile.language.heading")}
value={language}
onChange={(code) => void i18n.changeLanguage(code)}
options={SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: lang.label }))}
/>
</div>
<div>
<SectionLabel>{t("profile.authorPreview.heading")}</SectionLabel>
<Segmented
label={t("profile.authorPreview.heading")}
value={authorPreview ? "on" : "off"}
onChange={(value) => void toggleAuthorPreview(value === "on")}
options={[
{ value: "on", label: t("appearance.on") },
{ value: "off", label: t("appearance.off") },
]}
/>
{authorPreviewError && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{authorPreviewError}
</Typography>
)}
</div>
</Stack>
</>
);
}
@@ -0,0 +1,345 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import LinkIcon from "@mui/icons-material/Link";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import type { MakerWorldSettings } from "../../../utils/settings";
import { useConfirm } from "../../ConfirmProvider";
import { AppGrid, AppTile, menuPosition, TileMenuHeader } from "./AppTiles";
import { PanelHeader } from "./parts";
import { ProviderLogo, type ProviderId } from "./providerLogos";
const PROVIDERS: { id: ProviderId; name: string; comingSoon?: boolean }[] = [
{ id: "makerworld", name: "MakerWorld" },
{ id: "thingiverse", name: "Thingiverse" },
{ id: "printables", name: "Printables" },
{ id: "thangs", name: "Thangs", comingSoon: true },
];
const isComingSoon = (id: ProviderId) => PROVIDERS.some((provider) => provider.id === id && provider.comingSoon);
type Props = {
isAdmin: boolean;
cookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
onUnauthorized?: () => void;
};
/** The import sites as app icons: right-click one to connect or disconnect it, or double-click a
* disconnected one to connect it. Stored credentials are never shown, only whether one is set. */
export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const confirm = useConfirm();
// Thingiverse is unknown until loaded, so a non-admin doesn't briefly see it locked.
const [connected, setConnected] = React.useState<Record<ProviderId, boolean | null>>({
makerworld: Boolean(cookie.trim()),
thingiverse: null,
printables: true,
thangs: false,
});
const [menu, setMenu] = React.useState<{ id: ProviderId; top: number; left: number } | null>(null);
const [connecting, setConnecting] = React.useState<ProviderId | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi
.getMakerworld()
.then((res) => active && setConnected((current) => ({ ...current, makerworld: res.configured })))
.catch(() => undefined);
settingsApi
.getThingiverse()
.then((res) => active && setConnected((current) => ({ ...current, thingiverse: res.configured })))
.catch(() => undefined);
return () => {
active = false;
};
}, []);
const nameOf = (id: ProviderId) => PROVIDERS.find((provider) => provider.id === id)?.name ?? id;
// Why this user can't connect or disconnect it, if they can't.
const lockedReason = (id: ProviderId) => {
if (isComingSoon(id)) return t("providers.comingSoon");
if (id === "printables") return t("providers.printables.alwaysConnected");
if (id === "thingiverse" && !isAdmin) return t("providers.thingiverse.adminOnly");
return null;
};
/** Saves a credential, or removes it with null. */
const save = async (id: ProviderId, value: string | null) => {
if (id === "makerworld") {
const result = await settingsApi.updateMakerworld(value);
onUpdateMakerWorld({ cookie: value ?? "" });
setConnected((current) => ({ ...current, makerworld: result.configured }));
} else if (id === "thingiverse") {
const result = await settingsApi.updateThingiverse(value);
setConnected((current) => ({ ...current, thingiverse: result.configured }));
}
};
const startConnect = (id: ProviderId) => {
setError(null);
setNotice(null);
setConnecting(id);
};
// Double-click or Enter: connects a disconnected site, or says why this one can't be.
const activate = (id: ProviderId) => {
if (isComingSoon(id)) return;
const locked = lockedReason(id);
if (locked) {
setConnecting(null);
setNotice(locked);
} else if (connected[id] === false) {
startConnect(id);
}
};
const disconnect = async (id: ProviderId) => {
const name = nameOf(id);
const ok = await confirm({
title: t("providers.disconnectTitle", { name }),
message:
id === "thingiverse"
? t("providers.disconnectMessageShared", { name })
: t("providers.disconnectMessage", { name }),
confirmLabel: t("providers.disconnect"),
destructive: true,
});
if (!ok) return;
setError(null);
try {
await save(id, null);
if (connecting === id) setConnecting(null);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("providers.failed"));
}
};
const openMenu = (id: ProviderId, event: React.MouseEvent<HTMLElement>) => {
setNotice(null);
setMenu({ id, ...menuPosition(event) });
};
const menuId = menu?.id;
const menuConnected = menuId ? Boolean(connected[menuId]) : false;
const menuLocked = menuId ? lockedReason(menuId) : null;
return (
<>
<PanelHeader title={t("providers.title")} subtitle={t("providers.subtitle")} />
<AppGrid>
{PROVIDERS.map(({ id, name }) => {
const isConnected = Boolean(connected[id]);
const locked = lockedReason(id);
const status = connected[id] === null ? null : isConnected;
const tooltip = isComingSoon(id)
? t("providers.comingSoon")
: id === "thingiverse" && locked && !isConnected
? locked
: isConnected
? t("providers.connected")
: t("providers.notConnected");
return (
<AppTile
key={id}
name={name}
logo={<ProviderLogo id={id} size={26} />}
on={isConnected}
badge={status === null || isComingSoon(id) ? undefined : status}
highlighted={connecting === id || menuId === id}
tooltip={status === null ? undefined : tooltip}
ariaLabel={`${name}: ${isConnected ? t("providers.connected") : t("providers.notConnected")}`}
onContextMenu={(event) => openMenu(id, event)}
onActivate={() => activate(id)}
/>
);
})}
</AppGrid>
<Menu
open={Boolean(menu)}
onClose={() => setMenu(null)}
anchorReference="anchorPosition"
anchorPosition={menu ? { top: menu.top, left: menu.left } : undefined}
slotProps={{ paper: { sx: { minWidth: 220 } } }}
>
{menuId && (
<TileMenuHeader
logo={<ProviderLogo id={menuId} size={20} />}
name={nameOf(menuId)}
status={
isComingSoon(menuId)
? t("providers.comingSoon")
: menuConnected
? t("providers.connected")
: t("providers.notConnected")
}
/>
)}
{menuId && (
<MenuItem
disabled={Boolean(menuLocked)}
onClick={() => {
setMenu(null);
if (menuConnected) void disconnect(menuId);
else startConnect(menuId);
}}
>
<ListItemIcon>
{menuConnected ? <LinkOffIcon fontSize="small" /> : <LinkIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{menuConnected ? t("providers.disconnect") : t("providers.connect")}</ListItemText>
</MenuItem>
)}
{/* Coming soon is already said in the header. */}
{menuLocked && menuId && !isComingSoon(menuId) && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", px: 2, pt: 0.5, pb: 1, maxWidth: 240 }}
>
{menuLocked}
</Typography>
)}
</Menu>
{error && (
<Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{notice && (
<Alert severity="info" sx={{ mt: 2 }} onClose={() => setNotice(null)}>
{notice}
</Alert>
)}
{connecting && (
<ConnectBox
// Starts afresh for each site.
key={connecting}
id={connecting}
name={nameOf(connecting)}
onCancel={() => setConnecting(null)}
onConnect={async (value) => {
await save(connecting, value);
setConnecting(null);
}}
onUnauthorized={onUnauthorized}
/>
)}
</>
);
}
type ConnectBoxProps = {
id: ProviderId;
name: string;
onCancel: () => void;
onConnect: (value: string) => Promise<void>;
onUnauthorized?: () => void;
};
function ConnectBox({ id, name, onCancel, onConnect, onUnauthorized }: ConnectBoxProps) {
const { t } = useTranslation(["app", "common"]);
const [draft, setDraft] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const steps = t(`providers.${id}.steps`, { returnObjects: true }) as string[];
const multiline = id === "makerworld";
const rootRef = React.useRef<HTMLDivElement | null>(null);
// It opens below the icons, often past the bottom of the panel.
React.useEffect(() => {
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, []);
const submit = async () => {
const value = draft.trim();
if (!value) return;
setSaving(true);
setError(null);
try {
await onConnect(value);
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("providers.failed"));
setSaving(false);
}
};
return (
<Box ref={rootRef} sx={{ mt: 2.5, p: 2, border: 1, borderColor: "divider", borderRadius: "12px" }}>
<Stack direction="row" alignItems="center" spacing={1.25} sx={{ mb: 1.5 }}>
<ProviderLogo id={id} size={20} />
<Typography
variant="body2"
fontWeight={600}
sx={{ flex: 1, color: (muiTheme) => muiTheme.thingport.headingText }}
>
{t("providers.connectTitle", { name })}
</Typography>
<IconButton size="small" onClick={onCancel} aria-label={t("common:close") ?? undefined}>
<CloseIcon fontSize="small" />
</IconButton>
</Stack>
<Box
component="ol"
sx={{ m: 0, mb: 1.5, pl: 2.5, color: "text.secondary", fontSize: "0.75rem", "& li": { mb: 0.5 } }}
>
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</Box>
{id === "thingiverse" && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.5 }}>
{t("providers.thingiverse.shared")}
</Typography>
)}
<TextField
fullWidth
multiline={multiline}
minRows={multiline ? 3 : undefined}
size="small"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (!multiline && e.key === "Enter") void submit();
}}
placeholder={t(`providers.${id}.placeholder`)}
disabled={saving}
autoComplete="off"
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
{error && (
<Alert severity="error" sx={{ mt: 1.5 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button size="small" variant="contained" onClick={() => void submit()} disabled={saving || !draft.trim()}>
{saving ? t("providers.connecting") : t("providers.connect")}
</Button>
<Button size="small" onClick={onCancel} disabled={saving}>
{t("common:cancel")}
</Button>
</Stack>
</Box>
);
}
@@ -0,0 +1,140 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { SLICER_OPTIONS } from "../../../constants/settingsOptions";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import { setCachedSlicerPreference } from "../../../hooks/useSlicerPreference";
import { isBridgedSlicer } from "../../../utils/slicerLaunch";
import { AppGrid, AppTile, menuPosition, TileMenuHeader } from "./AppTiles";
import { PanelHeader } from "./parts";
import { SlicerLogo } from "./slicerLogos";
const labelOf = (id: string) => SLICER_OPTIONS.find((option) => option.id === id)?.label ?? id;
type Props = {
/** Called on following the bridge link, which leaves for another page. */
onNavigate: () => void;
onUnauthorized?: () => void;
};
/** The slicer models open in, as app icons: right-click one and Activate it, or double-click it. */
export default function SlicerPanel({ onNavigate, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [value, setValue] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [menu, setMenu] = React.useState<{ id: string; top: number; left: number } | null>(null);
React.useEffect(() => {
let active = true;
settingsApi
.getSlicer()
.then((res) => {
if (active) setValue(res.slicer ?? null);
})
.catch((err) => {
if (active && err instanceof UnauthorizedError) onUnauthorized?.();
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized]);
const activate = async (next: string) => {
if (loading || next === value) return;
const previous = value;
// Shown straight away; put back if the save fails.
setValue(next);
setError(null);
try {
const res = await settingsApi.updateSlicer(next);
setValue(res.slicer ?? null);
setCachedSlicerPreference(res.slicer ?? null);
} catch (err) {
setValue(previous);
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("profile.slicer.failed"));
}
};
const statusOf = (id: string) => (id === value ? t("slicer.active") : t("slicer.notActive"));
const menuId = menu?.id;
return (
<>
<PanelHeader title={t("configuration.tabs.slicer")} subtitle={t("slicer.subtitle")} />
<AppGrid>
{SLICER_OPTIONS.map(({ id, label }) => (
<AppTile
key={id}
name={label}
logo={<SlicerLogo id={id} size={26} />}
on={id === value}
badge={id === value ? true : undefined}
highlighted={menuId === id}
tooltip={loading ? undefined : `${label}: ${statusOf(id)}`}
ariaLabel={`${label}: ${statusOf(id)}`}
onContextMenu={(event) => setMenu({ id, ...menuPosition(event) })}
onActivate={() => void activate(id)}
/>
))}
</AppGrid>
<Menu
open={Boolean(menu)}
onClose={() => setMenu(null)}
anchorReference="anchorPosition"
anchorPosition={menu ? { top: menu.top, left: menu.left } : undefined}
slotProps={{ paper: { sx: { minWidth: 220 } } }}
>
{menuId && (
<TileMenuHeader
logo={<SlicerLogo id={menuId} size={20} />}
name={labelOf(menuId)}
status={statusOf(menuId)}
/>
)}
{menuId && (
<MenuItem
disabled={menuId === value || loading}
onClick={() => {
setMenu(null);
void activate(menuId);
}}
>
<ListItemIcon>
<CheckCircleOutlineIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("slicer.activate")}</ListItemText>
</MenuItem>
)}
</Menu>
{value && isBridgedSlicer(value) && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 2 }}>
{t("profile.slicer.bridgeRequiredPrefix", { slicer: labelOf(value) })}{" "}
<Link component={RouterLink} to="/downloads" onClick={onNavigate}>
{t("profile.slicer.bridgeRequiredLink")}
</Link>
</Typography>
)}
{error && (
<Alert severity="error" sx={{ mt: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
</>
);
}
@@ -0,0 +1,186 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Dialog from "@mui/material/Dialog";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import type { Theme } from "@mui/material/styles";
import CloseIcon from "@mui/icons-material/Close";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import HubOutlinedIcon from "@mui/icons-material/HubOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import type { MakerWorldSettings } from "../../../utils/settings";
import AppearancePanel from "./AppearancePanel";
import ProvidersPanel from "./ProvidersPanel";
import SlicerPanel from "./SlicerPanel";
export type ConfigurationTab = "appearance" | "providers" | "slicer";
const TABS: { id: ConfigurationTab; icon: React.ReactNode }[] = [
{ id: "appearance", icon: <PaletteOutlinedIcon fontSize="small" /> },
{ id: "providers", icon: <HubOutlinedIcon fontSize="small" /> },
{ id: "slicer", icon: <LayersOutlinedIcon fontSize="small" /> },
];
const selectedBg = (theme: Theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.06)";
const hoverBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.04)" : "rgba(0, 0, 0, 0.03)");
type Props = {
/** The tab to open on, or null when closed. */
open: ConfigurationTab | null;
onClose: () => void;
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
isAdmin: boolean;
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
onUnauthorized?: () => void;
};
/** Settings by section: the sections on the left, the chosen one on the right. */
export default function ConfigurationDialog({
open,
onClose,
theme,
onThemeChange,
isAdmin,
makerworldCookie,
onUpdateMakerWorld,
onUnauthorized,
}: Props) {
const { t } = useTranslation(["app", "common"]);
const [tab, setTab] = React.useState<ConfigurationTab>("appearance");
// Kept while closing, so the panel doesn't switch during the fade.
React.useEffect(() => {
if (open) setTab(open);
}, [open]);
const tabButton = (id: ConfigurationTab, icon: React.ReactNode, compact: boolean) => {
const chosen = id === tab;
return (
<ButtonBase
key={id}
aria-pressed={chosen}
onClick={() => setTab(id)}
sx={{
display: "flex",
alignItems: "center",
justifyContent: compact ? "center" : "flex-start",
gap: 1.25,
flex: compact ? 1 : undefined,
width: compact ? undefined : "100%",
px: 1.5,
py: compact ? 1.25 : 1,
borderRadius: "8px",
fontSize: "0.8rem",
fontWeight: 500,
color: chosen ? (muiTheme) => muiTheme.thingport.headingText : "text.secondary",
bgcolor: chosen ? selectedBg : "transparent",
transition: "background-color 150ms",
"&:hover": { bgcolor: chosen ? selectedBg : hoverBg },
}}
>
{!compact && icon}
{t(`configuration.tabs.${id}`)}
</ButtonBase>
);
};
return (
<Dialog
open={Boolean(open)}
onClose={onClose}
maxWidth={false}
slotProps={{
paper: {
sx: {
width: 620,
maxWidth: "calc(100% - 32px)",
height: { xs: "75vh", md: 520 },
maxHeight: "calc(100% - 32px)",
m: 2,
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
}}
>
<Box
sx={{
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography
variant="body2"
fontWeight={600}
component="h2"
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
>
{t("configuration.title")}
</Typography>
<IconButton size="small" onClick={onClose} aria-label={t("common:close") ?? undefined}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
{/* Phones: the sections as tabs along the top. */}
<Box
sx={{
display: { xs: "flex", md: "none" },
flexShrink: 0,
gap: 0.5,
px: 1.5,
py: 1,
borderBottom: 1,
borderColor: "divider",
}}
>
{TABS.map(({ id, icon }) => tabButton(id, icon, true))}
</Box>
<Box sx={{ display: "flex", flex: 1, minHeight: 0 }}>
<Box
component="nav"
sx={{
display: { xs: "none", md: "flex" },
flexDirection: "column",
gap: 0.25,
width: 170,
flexShrink: 0,
p: 1.25,
borderRight: 1,
borderColor: "divider",
}}
>
{TABS.map(({ id, icon }) => tabButton(id, icon, false))}
</Box>
<Box sx={{ flex: 1, minWidth: 0, overflowY: "auto", px: { xs: 2, md: 2.5 }, pt: 1.75, pb: 2.5 }}>
{tab === "appearance" && (
<AppearancePanel theme={theme} onThemeChange={onThemeChange} onUnauthorized={onUnauthorized} />
)}
{tab === "providers" && (
<ProvidersPanel
isAdmin={isAdmin}
cookie={makerworldCookie}
onUpdateMakerWorld={onUpdateMakerWorld}
onUnauthorized={onUnauthorized}
/>
)}
{tab === "slicer" && <SlicerPanel onNavigate={onClose} onUnauthorized={onUnauthorized} />}
</Box>
</Box>
</Dialog>
);
}
@@ -0,0 +1,35 @@
import React from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export function PanelHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<Box sx={{ mb: 2.5 }}>
<Typography variant="body1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{title}
</Typography>
<Typography variant="caption" color="text.secondary">
{subtitle}
</Typography>
</Box>
);
}
export function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography
variant="caption"
sx={{
display: "block",
mb: 1,
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "text.secondary",
}}
>
{children}
</Typography>
);
}
@@ -0,0 +1,31 @@
// Glyphs from Simple Icons (CC0). MakerWorld has none of its own there, so it takes Bambu Lab's,
// whose site it is.
export type ProviderId = "makerworld" | "thingiverse" | "printables" | "thangs";
const LOGOS: Record<ProviderId, { path: string; color: string }> = {
makerworld: {
color: "#00AE42",
path: "M12.662 24V8.959l8.535 3.369V24zm-9.859-.003v-7.521l8.534-3.371-.001 10.892zM2.803 0h8.533l.001 11.672-8.534 3.369zm9.859 0h8.535v10.892l-8.535-3.371z",
},
thingiverse: {
color: "#248BFB",
path: "M11.955.005C5.425-.152-.091 5.485.007 11.805c-.235 6.756 5.537 12.25 12.052 12.196C18.621 23.9 23.912 18.595 24 12.03 24.031 5.483 18.505-.18 11.955.005zm-.047 1.701a10.276 10.276 0 0 1 7.36 17.529 10.275 10.275 0 0 1-17.556-7.287C1.71 6.308 6.268 1.728 11.907 1.706zm-5.55 4.781c-.322 0-.358.033-.358.361v2.248c0 .351.04.391.398.391h3.823c.274 0 .274.004.274.265v9.736a.176.176 0 0 0 .051.146c.04.038.093.059.148.053h2.555c.247-.003.283-.035.283-.28v-9.32c0-.124.004-.239 0-.39s.055-.21.218-.21h3.9c.319.004.35-.032.35-.344V6.855c0-.34-.024-.363-.37-.363h-5.626z",
},
thangs: {
color: "#FFBC00",
path: "M20.18 13.902a1.902 1.902 0 1 1 0-3.804 1.902 1.902 0 0 1 0 3.804m0-5.722A3.82 3.82 0 0 0 16.36 12v3.82h3.82a3.82 3.82 0 1 0 0-7.64m-8.656 5.722A1.907 1.907 0 0 1 9.611 12c0-1.05.857-1.902 1.913-1.902s1.913.852 1.913 1.902c0 1.05-.857 1.902-1.913 1.902m-7.683 0A1.907 1.907 0 0 1 1.928 12c0-1.05.857-1.902 1.913-1.902 1.057 0 1.913.852 1.913 1.902 0 1.05-.856 1.902-1.913 1.902m11.514-2.107-.002-.032a3.76 3.76 0 0 0-.04-.37l-.004-.019a3.693 3.693 0 0 0-.081-.373V11a3.836 3.836 0 0 0-3.704-2.82H3.84a3.833 3.833 0 0 0-3.794 3.254l-.001.008c-.018.118-.03.236-.036.357l-.002.02v.016C.006 11.89 0 11.945 0 12c0 .07.007.137.01.206l.002.032c.008.124.021.248.041.37l.003.018a3.833 3.833 0 0 0 3.786 3.194h11.523V12c0-.07-.006-.137-.01-.205",
},
printables: {
color: "#FA6831",
path: "M3.678 4.8 12 9.6v9.6l8.322-4.8V4.8L12 0ZM12 19.2l-8.322-4.8V24Z",
},
};
export function ProviderLogo({ id, size }: { id: ProviderId; size: number }) {
const { path, color } = LOGOS[id];
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" style={{ display: "block" }}>
<path d={path} fill={color} />
</svg>
);
}
@@ -0,0 +1,119 @@
// Each slicer's mark, drawn on a white tile. Bambu Studio, Anycubic and Elegoo use their makers'
// Simple Icons glyphs (CC0); OrcaSlicer, PrusaSlicer, Cura, Creality Print and Snapmaker Orca come
// from each project's own app icon, without its background. "Other" is a generic layers glyph.
type Logo = { viewBox: string; paths: { d: string; fill: string }[] };
const LOGOS: Record<string, Logo> = {
bambustudio: {
viewBox: "0 0 24 24",
paths: [
{
fill: "#00AE42",
d: "M12.662 24V8.959l8.535 3.369V24zm-9.859-.003v-7.521l8.534-3.371-.001 10.892zM2.803 0h8.533l.001 11.672-8.534 3.369zm9.859 0h8.535v10.892l-8.535-3.371z",
},
],
},
orcaslicer: {
viewBox: "0 0 64 64",
paths: [
{ fill: "#292826", d: "M13.84,50.354a19.7,19.7,0,0,0,13.883,5.79A19.944,19.944,0,0,0,41.858,22.182Z" },
{
fill: "#009789",
d: "M41.858,22.181,13.84,50.354l.061.059A220.548,220.548,0,0,0,46.378,29.277a19.964,19.964,0,0,0-4.52-7.1",
},
{
fill: "#292826",
d: "M36.381,7.856A19.943,19.943,0,0,0,22.327,41.818L50.345,13.646a19.693,19.693,0,0,0-13.964-5.79",
},
{
fill: "#262523",
d: "M36.381,7.856A19.636,19.636,0,0,0,26.04,10.782a22.742,22.742,0,0,0-5.91-.745,23.084,23.084,0,0,0-9.477,2.124.632.632,0,0,0,.129,1.191,13.52,13.52,0,0,1,8.069,5.137A20.06,20.06,0,0,0,17.41,33.534a19.873,19.873,0,0,1,2-3.488c1.819-2.5,3.743-3.8,6.585-5.723,2.093-1.416,5-3.077,13.359-6.512a28.421,28.421,0,0,0,6.12-2.821c1.4-.831,2.461-1.615,2.8-2.842.024-.086.045-.172.065-.256A19.655,19.655,0,0,0,36.381,7.856",
},
{
fill: "#fff",
d: "M39.69,14.551c.727,1.285-.728,3.495-3.249,4.937s-5.154,1.569-5.88.284.727-3.495,3.248-4.937,5.154-1.569,5.881-.284",
},
],
},
prusaslicer: {
viewBox: "0 0 800 800",
paths: [
{ fill: "#363636", d: "M599.3,186.8c-93.9-93.9-246.1-93.9-340,0s-93.9,246.1,0,340Z" },
{ fill: "#ed6b21", d: "M202.7,612.5c93.9,93.9,246.1,93.9,340,0s93.9-246.1,0-340" },
],
},
cura: {
viewBox: "0 0 512 512",
paths: [
{ fill: "#196EF0", d: "M461.002 364.531L364.531 461.002H51V147.471L147.471 51H461.002V364.531Z" },
{
fill: "#FAFAFA",
d: "M193.027 188.44C212.068 170.759 237.671 161 264.154 161H338V191H264.154C244.939 191 226.714 198.098 213.441 210.423C200.205 222.714 193 239.135 193 256C193 272.865 200.205 289.286 213.441 301.577C226.714 313.902 244.939 321 264.154 321H338V351H264.154C237.671 351 212.068 341.241 193.027 323.561C173.949 305.845 163 281.569 163 256C163 230.431 173.949 206.155 193.027 188.44Z",
},
],
},
crealityprintlink: {
viewBox: "0 0 512 512",
paths: [
{
fill: "#6FA23A",
d: "M505.937,487.410 C501.861,492.095 496.072,495.000 489.653,495.000 C486.603,495.000 483.702,494.332 481.055,493.136 L480.891,493.066 L296.441,413.607 C283.996,408.254 270.333,405.274 256.122,405.274 L256.004,405.274 L255.996,405.274 L255.879,405.274 C241.667,405.274 228.004,408.254 215.559,413.607 L31.109,493.066 L30.946,493.136 C28.299,494.332 25.396,495.000 22.348,495.000 C8.720,495.000 -0.000,482.799 -0.000,470.885 C-0.000,466.645 1.030,462.635 2.807,459.172 L219.566,40.028 C219.567,40.028 219.597,40.025 219.602,40.024 C226.785,26.308 240.378,17.033 255.996,17.001 L255.996,17.000 C255.997,17.000 255.999,17.001 256.000,17.001 C256.001,17.001 256.002,17.000 256.004,17.000 L256.004,17.001 C271.621,17.033 285.213,26.308 292.399,40.024 C292.404,40.025 292.434,40.028 292.434,40.028 L509.194,459.172 C510.971,462.635 512.000,466.645 512.000,470.885 C512.000,477.283 509.701,483.093 505.937,487.410 ZM359.384,336.450 L270.546,144.562 C267.815,138.848 262.363,134.902 256.000,134.901 C249.638,134.902 244.186,138.848 241.455,144.562 L152.617,336.450 C151.981,337.841 151.613,339.412 151.613,341.069 C151.613,346.825 155.933,351.492 161.259,351.492 C162.970,351.492 164.578,351.011 165.972,350.165 C165.972,350.165 219.631,318.057 223.184,315.932 C223.305,315.859 237.503,306.695 256.000,306.695 C274.636,306.695 288.817,315.932 289.166,316.141 C294.969,319.613 346.028,350.165 346.028,350.165 C347.422,351.011 349.029,351.492 350.741,351.492 C356.068,351.492 360.387,346.825 360.387,341.069 C360.387,339.412 360.019,337.841 359.384,336.450 Z",
},
{
fill: "#2A8540",
d: "M350.701,356.106 C347.816,356.106 345.139,355.159 342.915,353.538 L293.849,319.178 C292.167,317.969 290.414,316.868 288.631,315.821 C286.240,314.417 283.762,313.163 281.210,312.068 C286.073,314.142 289.010,316.048 289.166,316.141 C294.969,319.613 346.028,350.165 346.028,350.165 C347.422,351.011 349.029,351.492 350.741,351.492 C356.068,351.492 360.387,346.825 360.387,341.069 C360.387,339.412 360.019,337.841 359.384,336.450 L270.546,144.562 C267.815,138.848 262.363,134.902 256.000,134.901 C249.638,134.902 244.186,138.848 241.455,144.562 L152.617,336.450 C151.981,337.841 151.613,339.412 151.613,341.069 C151.613,346.825 155.933,351.492 161.259,351.492 C162.970,351.492 164.578,351.011 165.972,350.165 C165.972,350.165 219.631,318.057 223.184,315.932 C223.238,315.899 226.060,314.081 230.792,312.067 C228.240,313.163 225.761,314.417 223.368,315.821 C221.587,316.868 219.833,317.969 218.151,319.178 L169.086,353.538 C166.862,355.159 164.184,356.106 161.300,356.106 C153.592,356.106 147.344,349.354 147.344,341.025 C147.344,339.225 147.639,337.499 148.175,335.898 C148.555,334.727 148.967,333.533 149.441,332.293 L243.140,61.850 C244.962,56.246 249.821,52.201 255.579,51.976 C255.718,51.971 255.856,51.956 255.996,51.956 C255.997,51.956 255.999,51.956 256.000,51.956 C256.001,51.956 256.002,51.956 256.004,51.956 C256.155,51.957 256.302,51.971 256.452,51.978 C256.671,51.987 256.891,51.990 257.106,52.010 C257.139,52.013 257.170,52.020 257.202,52.023 C262.616,52.566 267.121,56.501 268.861,61.850 L362.559,332.293 C363.033,333.533 363.445,334.727 363.826,335.898 C364.362,337.499 364.656,339.225 364.656,341.025 C364.656,349.354 358.407,356.106 350.701,356.106 ZM277.158,310.485 C270.523,308.150 263.445,306.880 256.089,306.880 L256.000,306.882 L255.911,306.880 C248.590,306.880 241.547,308.140 234.941,310.453 C240.578,308.464 247.873,306.695 256.000,306.695 C264.174,306.695 271.489,308.472 277.158,310.485 ZM281.010,311.983 C281.076,312.011 281.143,312.039 281.210,312.068 C281.144,312.040 281.076,312.011 281.010,311.983 ZM277.253,310.519 C278.605,311.001 279.858,311.496 281.010,311.983 C279.774,311.458 278.522,310.968 277.253,310.519 ZM277.158,310.485 C277.190,310.496 277.221,310.508 277.253,310.519 C277.220,310.508 277.190,310.496 277.158,310.485 ZM234.595,310.579 C234.710,310.537 234.825,310.494 234.941,310.453 C234.823,310.495 234.711,310.537 234.595,310.579 ZM231.011,311.975 C232.110,311.512 233.307,311.041 234.595,310.579 C233.385,311.010 232.190,311.474 231.011,311.975 ZM230.792,312.067 C230.865,312.036 230.938,312.006 231.011,311.975 C230.939,312.005 230.864,312.036 230.792,312.067 Z",
},
{
fill: "#5E9A38",
d: "M489.685,495.000 C486.635,495.000 483.733,494.332 481.085,493.137 L480.922,493.068 L296.446,413.657 C284.000,408.307 270.335,405.329 256.122,405.329 L256.004,405.329 L255.996,405.329 L255.879,405.329 C241.665,405.329 228.000,408.307 215.553,413.657 L31.078,493.068 L30.916,493.137 C28.268,494.332 25.365,495.000 22.316,495.000 C15.894,495.000 10.106,492.097 6.029,487.415 L43.194,462.456 L233.793,334.620 L233.870,334.616 C240.268,330.209 247.846,327.622 255.996,327.611 C255.997,327.611 255.999,327.611 256.000,327.611 C256.001,327.611 256.002,327.611 256.004,327.611 C264.155,327.622 271.733,330.209 278.129,334.616 L278.206,334.620 L468.806,462.456 L505.972,487.415 C501.895,492.097 496.105,495.000 489.685,495.000 Z",
},
],
},
anycubicslicernext: {
viewBox: "0 0 24 24",
paths: [
{
fill: "#476695",
d: "m6.762.534 8.728 3.481 8.469 7.449-6.494-.631L6.762.534Zm10.72 10.463 6.518.581-7.826 8.749-8.649 3.139 9.957-12.469ZM6.592.601l10.699 10.331L7.355 23.44 0 12.465 6.592.601Z",
},
],
},
elegooslicer: {
viewBox: "0 0 24 24",
paths: [
{
fill: "#2C3A83",
d: "M12.686 7.479c.54.829 1.032 1.665 1.476 2.505.64-1.217 1.849-2.086 3.328-2.086 2.217 0 3.826 1.954 3.826 4.102 0 2.149-1.609 4.102-3.826 4.102-.656 0-1.26-.171-1.784-.467l-.001-.001c-.635-.36-1.153-.905-1.509-1.553-.484-.804-.725-1.706-.991-2.657-.598-2.134-1.252-3.773-3.194-4.988-1.001-.626-2.196-.985-3.501-.985C2.815 5.451 0 8.323 0 12c0 3.727 2.761 6.549 6.51 6.549 1.955 0 3.639-.766 4.805-2.027-.543-.83-1.034-1.664-1.477-2.505-.641 1.217-1.849 2.085-3.328 2.085-2.218 0-3.827-1.953-3.827-4.102 0-2.148 1.609-4.102 3.827-4.102.655 0 1.26.171 1.783.469h.001c.635.36 1.154.904 1.509 1.553.574.951.807 2.041 1.144 3.188.555 1.89 1.285 3.339 3.002 4.432 1.008.642 2.217 1.009 3.541 1.009 3.694 0 6.51-2.872 6.51-6.549 0-3.727-2.76-6.549-6.51-6.549-1.954 0-3.64.766-4.804 2.028Z",
},
],
},
"snapmaker-orca": {
viewBox: "0 0 140 140",
paths: [
{
fill: "#171717",
d: "M82.49 33.08L82.33 33.08L82.33 33.23L81.10 33.23L81.10 33.38L80.33 33.38L80.33 33.54L79.10 33.69L79.10 33.85L78.33 34.00L78.33 34.15L77.86 34.15L77.86 34.31L77.56 34.31L77.56 34.46L77.25 34.46L77.25 34.62L76.32 34.92L76.32 35.08L75.86 35.23L75.70 35.54L75.40 35.54L75.09 36.00L74.78 36.00L74.78 36.15L72.62 38.15L72.62 38.46L72.47 38.46L72.47 38.62L72.16 38.77L72.16 39.08L71.70 39.38L71.54 40.00L71.23 40.15L71.08 40.77L70.77 40.92L70.77 41.23L70.62 41.23L70.62 41.54L70.46 41.54L70.46 41.85L70.31 41.85L70.31 42.15L70.15 42.15L70.15 42.62L70.00 42.62L70.00 42.92L69.85 42.92L69.85 43.23L69.69 43.23L69.69 43.69L69.54 43.69L69.54 44.15L69.38 44.15L69.38 44.46L69.23 44.46L69.07 45.54L68.92 45.54L68.92 46.00L68.77 46.00L68.77 46.62L68.61 46.62L68.61 47.08L68.46 47.08L68.46 47.85L68.30 47.85L68.30 48.46L68.15 48.46L68.15 49.23L68.00 49.23L68.00 49.85L67.84 49.85L67.84 50.62L67.69 50.62L67.69 51.23L67.53 51.23L67.53 52.00L67.38 52.00L67.38 52.62L67.22 52.62L67.22 53.38L67.07 53.38L67.07 54.00L66.92 54.00L66.92 54.77L66.76 54.77L66.76 55.38L66.61 55.38L66.61 56.15L66.45 56.15L66.30 57.54L66.15 57.54L66.15 58.15L65.99 58.15L65.99 58.92L65.84 58.92L65.84 59.54L65.68 59.54L65.68 60.31L65.53 60.31L65.53 60.92L65.37 60.92L65.37 61.69L65.22 61.69L65.22 62.31L65.07 62.31L64.91 63.85L64.76 63.85L64.76 64.46L64.60 64.46L64.60 65.23L64.45 65.23L64.30 66.62L64.14 66.62L64.14 67.23L63.99 67.23L63.99 68.00L63.83 68.00L63.83 68.62L63.68 68.62L63.68 69.38L63.52 69.38L63.52 70.00L63.37 70.00L63.37 70.77L63.22 70.77L63.22 71.38L63.06 71.38L62.91 72.92L62.75 72.92L62.75 73.54L62.60 73.54L62.60 74.31L62.44 74.31L62.44 74.92L62.29 74.92L62.29 75.69L62.14 75.69L62.14 76.31L61.98 76.31L61.98 77.08L61.83 77.08L61.83 77.69L61.67 77.69L61.67 78.46L61.52 78.46L61.52 79.08L61.37 79.08L61.37 79.85L61.21 79.85L61.21 80.46L61.06 80.46L61.06 81.23L60.90 81.23L60.90 81.85L60.75 81.85L60.75 82.62L60.59 82.62L60.59 83.23L60.44 83.23L60.44 83.69L60.29 83.69L60.29 84.31L60.13 84.31L59.98 85.23L59.82 85.23L59.82 85.54L59.67 85.54L59.67 86.00L59.52 86.00L59.52 86.31L59.36 86.31L59.05 87.23L58.74 87.38L58.74 87.69L58.28 88.00L58.28 88.31L58.13 88.31L57.51 89.08L57.20 89.08L57.05 89.38L56.74 89.38L56.74 89.54L56.43 89.54L56.43 89.69L56.12 89.69L56.12 89.85L54.89 90.00L54.89 90.15L51.96 90.00L51.96 89.85L51.34 89.85L51.34 89.69L50.73 89.69L50.73 89.54L50.26 89.54L50.26 89.38L48.88 89.08L48.88 88.92L48.11 88.77L48.11 88.62L47.80 88.62L47.80 88.46L47.49 88.46L47.49 88.31L46.87 88.31L46.87 89.08L46.72 89.08L46.72 89.69L46.56 89.69L46.56 90.46L46.41 90.46L46.41 91.08L46.26 91.08L46.26 91.85L46.10 91.85L46.10 92.46L45.95 92.46L45.95 93.23L45.79 93.23L45.64 94.62L45.48 94.62L45.48 95.23L45.33 95.23L45.33 96.00L45.18 96.00L45.02 96.92L45.48 97.08L45.48 97.23L45.79 97.23L45.79 97.38L46.10 97.38L46.10 97.54L46.56 97.54L46.56 97.69L46.87 97.69L46.87 97.85L47.33 97.85L47.33 98.00L47.80 98.00L47.80 98.15L48.26 98.15L48.26 98.31L48.72 98.31L48.72 98.46L49.34 98.46L49.34 98.62L49.96 98.62L49.96 98.77L50.57 98.77L50.57 98.92L51.34 98.92L51.34 99.08L52.11 99.08L52.11 99.23L53.04 99.23L53.04 99.38L54.43 99.38L54.43 99.54L57.36 99.54L57.36 99.38L59.67 99.23L59.67 99.08L60.29 99.08L60.29 98.92L60.75 98.92L60.75 98.77L61.37 98.77L61.37 98.62L61.67 98.62L61.67 98.46L62.14 98.46L62.14 98.31L62.44 98.31L62.44 98.15L63.37 97.85L63.52 97.54L64.14 97.38L64.14 97.23L64.60 97.08L64.91 96.62L65.22 96.62L65.22 96.46L67.38 94.46L67.38 94.15L67.84 93.85L67.84 93.54L68.00 93.54L68.00 93.38L68.30 93.23L68.30 92.92L68.61 92.77L68.77 92.15L69.07 92.00L69.07 91.69L69.23 91.69L69.23 91.38L69.38 91.38L69.38 91.08L69.54 91.08L69.54 90.77L69.69 90.77L69.69 90.46L69.85 90.46L69.85 90.15L70.00 90.15L70.00 89.69L70.15 89.69L70.15 89.38L70.31 89.38L70.31 88.92L70.46 88.92L70.46 88.62L70.62 88.62L70.62 88.15L70.77 88.15L70.77 87.69L70.93 87.69L70.93 87.23L71.08 87.23L71.08 86.62L71.23 86.62L71.23 86.15L71.39 86.15L71.54 84.92L71.70 84.92L71.70 84.15L71.85 84.15L72.00 82.77L72.16 82.77L72.16 82.15L72.31 82.15L72.31 81.38L72.47 81.38L72.47 80.77L72.62 80.77L72.78 79.23L72.93 79.23L72.93 78.62L73.08 78.62L73.08 77.85L73.24 77.85L73.24 77.23L73.39 77.23L73.39 76.46L73.55 76.46L73.55 75.85L73.70 75.85L73.70 75.08L73.85 75.08L73.85 74.46L74.01 74.46L74.01 73.69L74.16 73.69L74.32 72.31L74.47 72.31L74.47 71.69L74.63 71.69L74.63 70.92L74.78 70.92L74.78 70.31L74.93 70.31L74.93 69.54L75.09 69.54L75.09 68.92L75.24 68.92L75.24 68.15L75.40 68.15L75.40 67.54L75.55 67.54L75.70 66.00L75.86 66.00L75.86 65.38L76.01 65.38L76.01 64.62L76.17 64.62L76.17 64.00L76.32 64.00L76.32 63.23L76.48 63.23L76.48 62.62L76.63 62.62L76.63 61.85L76.78 61.85L76.78 61.23L76.94 61.23L76.94 60.46L77.09 60.46L77.09 59.85L77.25 59.85L77.25 59.08L77.40 59.08L77.40 58.46L77.56 58.46L77.56 57.69L77.71 57.69L77.71 57.08L77.86 57.08L77.86 56.31L78.02 56.31L78.02 55.69L78.17 55.69L78.17 54.92L78.33 54.92L78.33 54.31L78.48 54.31L78.48 53.54L78.63 53.54L78.63 52.92L78.79 52.92L78.79 52.15L78.94 52.15L78.94 51.54L79.10 51.54L79.10 50.77L79.25 Line truncated
},
],
},
other: {
viewBox: "0 0 24 24",
paths: [
{
fill: "#6B7280",
d: "m11.99 18.54-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27z",
},
],
},
};
export function SlicerLogo({ id, size }: { id: string; size: number }) {
const logo = LOGOS[id] ?? LOGOS.other;
return (
<svg width={size} height={size} viewBox={logo.viewBox} aria-hidden="true" style={{ display: "block" }}>
{logo.paths.map((path) => (
<path key={path.d} d={path.d} fill={path.fill} />
))}
</svg>
);
}
@@ -20,10 +20,13 @@ import SettingsBrightnessOutlinedIcon from "@mui/icons-material/SettingsBrightne
import LogoutIcon from "@mui/icons-material/Logout";
import LinkIcon from "@mui/icons-material/Link";
import TranslateIcon from "@mui/icons-material/Translate";
import PaletteIcon from "@mui/icons-material/Palette";
import SettingsIcon from "@mui/icons-material/Settings";
import HubIcon from "@mui/icons-material/Hub";
import LayersIcon from "@mui/icons-material/Layers";
import SpaceDashboardIcon from "@mui/icons-material/SpaceDashboard";
import DownloadIcon from "@mui/icons-material/Download";
import PersonIcon from "@mui/icons-material/Person";
import InsightsIcon from "@mui/icons-material/Insights";
import WebAssetIcon from "@mui/icons-material/WebAsset";
import { UnauthorizedError } from "../../../api/client";
import { type SearchResult, searchApi } from "../../../api/search";
@@ -34,6 +37,7 @@ import { detectImportProvider } from "../../../utils/importLinkDetection";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { useDebouncedValue } from "../../../hooks/useDebouncedValue";
import { veilColor, VEIL_BLUR } from "../../../theme";
import type { ConfigurationTab } from "../ConfigurationDialog";
import Keys, { chipBg, MOD_KEY, isMac } from "./Keys";
import { bangArgument, bangText, isBangQuery, matchBang, type Command } from "./commands";
import { bestScore, minScoreFor } from "./match";
@@ -55,6 +59,7 @@ type Props = {
onLogout: () => void;
/** Opens the import dialog, or with a link imports it (see AddMenuHandle). */
onImport: (link?: string) => void;
onOpenConfiguration: (tab: ConfigurationTab) => void;
};
type Item = {
@@ -87,7 +92,6 @@ const PAGE_ICONS: Record<PageId, ReactNode> = {
tags: <LocalOfferIcon />,
downloads: <DownloadIcon />,
myModels: <ViewInArIcon />,
activity: <InsightsIcon />,
profile: <PersonIcon />,
};
@@ -118,7 +122,14 @@ const selectedRowBg = (theme: Theme) =>
* Command palette: focusing it dims the page and lists recents and commands; typing searches
* models, collections and tags (ranked server-side, searchService.ts), pages and commands.
*/
export default function GlobalSearch({ onUnauthorized, theme, onThemeChange, onLogout, onImport }: Props) {
export default function GlobalSearch({
onUnauthorized,
theme,
onThemeChange,
onLogout,
onImport,
onOpenConfiguration,
}: Props) {
const { t, i18n } = useTranslation(["app", "common", "models"]);
const navigate = useNavigate();
const location = useLocation();
@@ -129,7 +140,10 @@ export default function GlobalSearch({ onUnauthorized, theme, onThemeChange, onL
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<SearchResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [recents, setRecents] = useState<RecentEntry[]>(readRecents);
// A page that no longer exists is dropped.
const [recents, setRecents] = useState<RecentEntry[]>(() =>
readRecents().filter((entry) => entry.kind !== "page" || pageFor(entry)),
);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
// Until the user arrows away, the selection tracks the top row, so results that arrive late take it.
const userMovedRef = useRef(false);
@@ -267,6 +281,38 @@ export default function GlobalSearch({ onUnauthorized, theme, onThemeChange, onL
icon: <TranslateIcon />,
run: () => void i18n.changeLanguage(code),
})),
{
id: "configuration",
bang: "configuration",
label: t("app:search.commands.configuration"),
keywords: ["configuration", "configure", "settings", "preferences", "konfigūracija", "nustatymai"],
icon: <SettingsIcon />,
run: () => onOpenConfiguration("appearance"),
},
{
id: "appearance",
bang: "appearance",
label: t("app:search.commands.appearance"),
keywords: ["appearance", "theme", "language", "author preview", "look"],
icon: <PaletteIcon />,
run: () => onOpenConfiguration("appearance"),
},
{
id: "providers",
bang: "providers",
label: t("app:search.commands.providers"),
keywords: ["providers", "makerworld", "cookie"],
icon: <HubIcon />,
run: () => onOpenConfiguration("providers"),
},
{
id: "slicer",
bang: "slicer",
label: t("app:search.commands.slicer"),
keywords: ["slicer", "bambu studio", "orca", "prusaslicer", "cura"],
icon: <LayersIcon />,
run: () => onOpenConfiguration("slicer"),
},
importCommand(),
{
id: "logout",
@@ -8,7 +8,6 @@ const EN: Record<PageId, string> = {
tags: "Tags",
downloads: "Downloads",
myModels: "My models",
activity: "Activity",
profile: "Profile",
};
const ids = (query: string) => matchPages(query, EN).map((page) => page.id);
@@ -2,8 +2,7 @@ import { SELF_AUTHOR_ID } from "../../../constants/selfAuthor";
import { bestScore, minScoreFor } from "./match";
// The app's own views. Admin pages are left out on purpose.
export type PageId =
"dashboard" | "models" | "collections" | "tags" | "downloads" | "myModels" | "activity" | "profile";
export type PageId = "dashboard" | "models" | "collections" | "tags" | "downloads" | "myModels" | "profile";
export type SearchPage = {
id: PageId;
@@ -31,17 +30,11 @@ export const SEARCH_PAGES: SearchPage[] = [
labelKey: "app:userMenu.myModels",
terms: ["my models", "my uploads", "uploads", "mine"],
},
{
id: "activity",
path: "/activity",
labelKey: "app:activity.title",
terms: ["activity", "heatmap", "calendar", "stats", "contributions"],
},
{
id: "profile",
path: "/profile",
labelKey: "app:profile.title",
terms: ["profile", "account", "settings", "preferences", "email", "password"],
terms: ["profile", "account", "email", "password", "activity", "heatmap", "contributions"],
},
];
+18 -8
View File
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef } from "react";
import React, { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
@@ -8,7 +8,9 @@ import Typography from "@mui/material/Typography";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import type { AuthUser } from "../../api/auth";
import type { ThemeSelection } from "../../constants/settingsOptions";
import type { MakerWorldSettings } from "../../utils/settings";
import AddMenu, { type AddMenuHandle } from "./AddMenu";
import ConfigurationDialog, { type ConfigurationTab } from "./ConfigurationDialog";
import NotificationBell from "./NotificationBell";
import { UserMenu } from "./UserMenu";
import GlobalSearch from "./GlobalSearch";
@@ -21,6 +23,7 @@ type Props = {
actions?: React.ReactNode;
categoryId: string | null;
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
onUploaded: () => void;
onUnauthorized?: () => void;
user: AuthUser | null;
@@ -38,6 +41,7 @@ export default function TopBar({
actions,
categoryId,
makerworldCookie,
onUpdateMakerWorld,
onUploaded,
onUnauthorized,
user,
@@ -49,6 +53,7 @@ export default function TopBar({
const { t } = useTranslation("app");
const rootRef = useRef<HTMLDivElement | null>(null);
const addMenuRef = useRef<AddMenuHandle | null>(null);
const [configTab, setConfigTab] = useState<ConfigurationTab | null>(null);
// Publishes the bar's real height (it can wrap) as a CSS var for sticky elements below it.
useLayoutEffect(() => {
@@ -110,6 +115,7 @@ export default function TopBar({
onThemeChange={onThemeChange}
onLogout={onLogout}
onImport={(link) => addMenuRef.current?.importFromCommand(link)}
onOpenConfiguration={setConfigTab}
/>
</Box>
<Stack direction="row" alignItems="center" spacing={1} minWidth={0} justifySelf="end">
@@ -121,14 +127,18 @@ export default function TopBar({
onUnauthorized={onUnauthorized}
/>
<NotificationBell />
<UserMenu
user={user}
theme={theme}
onThemeChange={onThemeChange}
onOpenProfile={onOpenProfile}
onLogout={onLogout}
/>
<UserMenu user={user} onOpenConfiguration={setConfigTab} onOpenProfile={onOpenProfile} onLogout={onLogout} />
</Stack>
<ConfigurationDialog
open={configTab}
onClose={() => setConfigTab(null)}
theme={theme}
onThemeChange={onThemeChange}
isAdmin={user?.role === "ADMIN"}
makerworldCookie={makerworldCookie}
onUpdateMakerWorld={onUpdateMakerWorld}
onUnauthorized={onUnauthorized}
/>
</Box>
);
}
+38 -61
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
@@ -13,19 +14,15 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import PersonIcon from "@mui/icons-material/Person";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import InsightsIcon from "@mui/icons-material/Insights";
import PaletteIcon from "@mui/icons-material/Palette";
import CheckIcon from "@mui/icons-material/Check";
import SettingsIcon from "@mui/icons-material/Settings";
import LogoutIcon from "@mui/icons-material/Logout";
import type { AuthUser } from "../../api/auth";
import type { ThemeSelection } from "../../constants/settingsOptions";
import type { ConfigurationTab } from "./ConfigurationDialog";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { settingsApi } from "../../api/settings";
const THEME_MODES: ThemeSelection[] = ["light", "dark"];
type ServiceChipDef = {
key: "makerworld" | "thingiverse" | "printables";
label: string;
@@ -37,24 +34,25 @@ type ServiceChipDef = {
type Props = {
user: AuthUser | null;
theme: ThemeSelection;
onThemeChange: (theme: ThemeSelection) => void;
onOpenConfiguration: (tab: ConfigurationTab) => void;
onOpenProfile: () => void;
onLogout: () => void;
};
/** Identity, quick settings and import-provider connection chips. Connection status comes from the
* backend, not localStorage, which may disagree with the DB. */
export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }: Props) {
export function UserMenu({ user, onOpenConfiguration, onOpenProfile, onLogout }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
const avatarUrl = useGravatarUrl(user?.email, 128);
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [themeAnchorEl, setThemeAnchorEl] = React.useState<HTMLElement | null>(null);
const [makerworldConfigured, setMakerworldConfigured] = React.useState(false);
const [thingiverseConfigured, setThingiverseConfigured] = React.useState(false);
const menuOpen = Boolean(anchorEl);
// Checked on every open: the cookie can change in Configuration while the menu stays mounted.
React.useEffect(() => {
if (!menuOpen) return;
let active = true;
settingsApi
.getMakerworld()
@@ -71,16 +69,9 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
return () => {
active = false;
};
}, []);
}, [menuOpen]);
const closeMenu = () => setAnchorEl(null);
const closeThemeMenu = () => setThemeAnchorEl(null);
const handleThemeSelect = (mode: ThemeSelection) => {
closeThemeMenu();
closeMenu();
if (mode !== theme) onThemeChange(mode);
};
const services: ServiceChipDef[] = [
{
@@ -112,14 +103,19 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
open={menuOpen}
onClose={closeMenu}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<Box sx={{ pl: 2, pr: 1, py: 1.25, minWidth: 220, display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" fontWeight={600} noWrap>
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
>
{user?.display_name}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
@@ -139,6 +135,17 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
</Tooltip>
</Box>
<Divider />
<MenuItem
onClick={() => {
closeMenu();
onOpenProfile();
}}
>
<ListItemIcon>
<PersonIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("profile.title")}</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
closeMenu();
@@ -153,36 +160,23 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
<MenuItem
onClick={() => {
closeMenu();
navigate("/activity");
onOpenConfiguration("appearance");
}}
>
<ListItemIcon>
<InsightsIcon fontSize="small" />
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("activity.title")}</ListItemText>
</MenuItem>
<MenuItem
onClick={() => {
closeMenu();
onOpenProfile();
}}
>
<ListItemIcon>
<PersonIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("profile.title")}</ListItemText>
</MenuItem>
<MenuItem onClick={(e) => setThemeAnchorEl(e.currentTarget)}>
<ListItemIcon>
<PaletteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("userMenu.theme")}</ListItemText>
<ListItemText>{t("configuration.title")}</ListItemText>
</MenuItem>
<Divider />
<Box sx={{ px: 2, py: 1.25, display: "flex", gap: 0.75 }}>
{services.map((svc) => (
<Box
<ButtonBase
key={svc.key}
onClick={() => {
closeMenu();
onOpenConfiguration("providers");
}}
title={
svc.connected
? t("userMenu.serviceConnected", { service: svc.label })
@@ -200,32 +194,15 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
// Grey rather than dimmed so the two states are unmistakable.
bgcolor: svc.connected ? svc.color : "grey.500",
opacity: svc.connected ? 1 : 0.5,
transition: "filter 150ms",
"&:hover": { filter: "brightness(1.1)" },
}}
>
{svc.label}
</Box>
</ButtonBase>
))}
</Box>
</Menu>
<Menu
anchorEl={themeAnchorEl}
open={Boolean(themeAnchorEl)}
onClose={closeThemeMenu}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
>
{THEME_MODES.map((mode) => (
<MenuItem key={mode} selected={theme === mode} onClick={() => handleThemeSelect(mode)}>
<ListItemIcon>{theme === mode && <CheckIcon fontSize="small" />}</ListItemIcon>
<ListItemText>{t(`userMenu.${mode}`)}</ListItemText>
</MenuItem>
))}
<Divider />
<MenuItem selected={theme === "system"} onClick={() => handleThemeSelect("system")}>
<ListItemIcon>{theme === "system" && <CheckIcon fontSize="small" />}</ListItemIcon>
<ListItemText>{t("userMenu.system")}</ListItemText>
</MenuItem>
</Menu>
</>
);
}
@@ -0,0 +1,60 @@
import Box from "@mui/material/Box";
import type { Theme } from "@mui/material/styles";
const trackBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.06)" : "rgba(0, 0, 0, 0.05)");
const chosenBg = (theme: Theme) => (theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.16)" : "#ffffff");
type Props<T extends string> = {
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
label: string;
};
/** One track, the chosen option raised on a lighter pill. */
export default function Segmented<T extends string>({ value, options, onChange, label }: Props<T>) {
return (
<Box
aria-label={label}
sx={{
display: "inline-flex",
gap: "2px",
p: "3px",
borderRadius: "10px",
bgcolor: trackBg,
border: 1,
borderColor: "divider",
}}
>
{options.map((option) => {
const chosen = option.value === value;
return (
<Box
key={option.value}
component="button"
type="button"
aria-pressed={chosen}
onClick={() => onChange(option.value)}
sx={{
border: 0,
cursor: "pointer",
px: 1.75,
py: 0.625,
borderRadius: "7px",
font: "inherit",
fontSize: "0.8rem",
color: chosen ? "text.primary" : "text.secondary",
bgcolor: chosen ? chosenBg : "transparent",
boxShadow: chosen ? "0 1px 3px rgba(0, 0, 0, 0.2)" : "none",
transition: "background-color 150ms, color 150ms",
"&:hover": { color: "text.primary" },
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 1 },
}}
>
{option.label}
</Box>
);
})}
</Box>
);
}
+70 -19
View File
@@ -178,27 +178,13 @@
"heading": "Language",
"subtitle": "Choose the language used throughout Thingport."
},
"makerworld": {
"heading": "MakerWorld",
"description": "Required to import links or models from MakerWorld. Paste the Cookie header from a logged-in makerworld.com request.",
"added": "Added",
"notAdded": "Not added",
"add": "Add",
"placeholder": "example: mw_session=...; mw_token=...;",
"failed": "Failed to save -- try again."
},
"slicer": {
"heading": "Slicer",
"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",
"failed": "Failed to save -- try again.",
"bridgeRequiredPrefix": "{{slicer}} needs the Thingport Bridge helper app installed to open files from here --",
"bridgeRequiredLink": "get it from the Download page."
"bridgeRequiredPrefix": "For a model's \"Open in {{slicer}}\" button to work, install Thingport Bridge, a small helper app for your computer.",
"bridgeRequiredLink": "Download it"
},
"authorPreview": {
"heading": "Author preview",
"description": "Show a preview card with the author's cover, details and latest models when you hover an author's name or avatar.",
"label": "Show author preview on hover",
"failed": "Failed to save -- try again."
}
},
@@ -229,7 +215,7 @@
"system": "Adapt to system",
"serviceConnected": "{{service}}: connected",
"serviceNotConnected": "{{service}}: not connected",
"makerworldDisabledReason": "MakerWorld: you haven't added a cookie in your profile yet",
"makerworldDisabledReason": "MakerWorld: not connected",
"thingiverseDisabledReason": "Thingiverse: the admin hasn't configured an API key yet",
"myModels": "My models"
},
@@ -276,7 +262,11 @@
"import": "Import from a link",
"importFrom": "Import from {{provider}}",
"importNotSupported": "Not a MakerWorld, Printables or Thingiverse link; opens the import window",
"language": "Language: {{name}}"
"language": "Language: {{name}}",
"appearance": "Open Appearance",
"providers": "Open Providers",
"slicer": "Open Slicer",
"configuration": "Open Configuration"
},
"searching": "Searching…",
"keepTyping": "Keep typing to search models, collections and tags, or type ! for commands.",
@@ -796,7 +786,6 @@
"loadFailed": "Couldn't load the image. Try a new one."
},
"activity": {
"title": "Activity",
"totalLastYear_one": "{{count}} activity in the last year",
"totalLastYear_other": "{{count}} activities in the last year",
"totalInYear_one": "{{count}} activity in {{year}}",
@@ -843,6 +832,68 @@
"fromDay": "From {{date}}",
"emptyUntil": "No activity this month up to that day."
}
},
"appearance": {
"title": "Appearance",
"subtitle": "Choose how Thingport looks and behaves.",
"themes": {
"light": "Light",
"dark": "Dark",
"system": "System"
},
"on": "On",
"off": "Off"
},
"configuration": {
"title": "Configuration",
"tabs": {
"appearance": "Appearance",
"providers": "Providers",
"slicer": "Slicer"
}
},
"providers": {
"title": "Providers",
"subtitle": "The sites you import models from. Right-click one to connect or disconnect it.",
"connected": "Connected",
"notConnected": "Not connected",
"connect": "Connect",
"connecting": "Connecting…",
"disconnect": "Disconnect",
"connectTitle": "Connect {{name}}",
"disconnectTitle": "Disconnect {{name}}?",
"disconnectMessage": "Your {{name}} key is removed, and imports from {{name}} stop working until you connect it again.",
"disconnectMessageShared": "The {{name}} token is removed for everyone on this Thingport, and imports from {{name}} stop working until it's connected again.",
"failed": "Failed to save -- try again.",
"makerworld": {
"placeholder": "example: mw_session=...; mw_token=...;",
"steps": [
"Sign in at makerworld.com in your browser.",
"Open the developer tools (F12 or ⌥⌘I) and go to the Network tab.",
"Reload the page and pick any request to makerworld.com.",
"Under Request Headers, copy the whole value of the Cookie header and paste it below."
]
},
"thingiverse": {
"placeholder": "Paste the App Access Token",
"steps": [
"Sign in at thingiverse.com and open thingiverse.com/apps/create.",
"Create an app of any type, such as \"Desktop app\".",
"Copy the App Access Token it shows and paste it below."
],
"shared": "This token is shared by everyone on this Thingport.",
"adminOnly": "Only an admin of this Thingport can set this up."
},
"printables": {
"alwaysConnected": "Printables is connected by default: it needs no account or key."
},
"comingSoon": "Coming soon"
},
"slicer": {
"subtitle": "The slicer a model's \"Open in Slicer\" opens it in. Right-click one and Activate it, or double-click it.",
"active": "Active",
"notActive": "Not active",
"activate": "Activate"
}
},
"common": {
+70 -19
View File
@@ -178,27 +178,13 @@
"heading": "Kalba",
"subtitle": "Pasirinkite kalbą, naudojamą visame Thingport."
},
"makerworld": {
"heading": "MakerWorld",
"description": "Reikalinga norint importuoti nuorodas ar modelius iš MakerWorld. Įklijuokite Cookie antraštę iš prisijungusio makerworld.com užklausos.",
"added": "Pridėta",
"notAdded": "Nepridėta",
"add": "Pridėti",
"placeholder": "pavyzdys: mw_session=...; mw_token=...;",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą."
},
"slicer": {
"heading": "Pjaustyklė",
"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ė",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą.",
"bridgeRequiredPrefix": "{{slicer}} failams atverti iš čia reikalinga įdiegta Thingport Bridge pagalbinė programa --",
"bridgeRequiredLink": "atsisiųskite ją atsisiuntimo puslapyje."
"bridgeRequiredPrefix": "Kad modelio mygtukas „Atverti su {{slicer}}“ veiktų, įdiekite Thingport Bridge – nedidelę pagalbinę programą jūsų kompiuteriui.",
"bridgeRequiredLink": "Atsisiųsti"
},
"authorPreview": {
"heading": "Autoriaus peržiūra",
"description": "Užvedus pelę ant autoriaus vardo ar avataro, rodyti kortelę su autoriaus viršelio nuotrauka, informacija ir naujausiais modeliais.",
"label": "Rodyti autoriaus peržiūrą užvedus pelę",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą."
}
},
@@ -229,7 +215,7 @@
"system": "Pagal sistemą",
"serviceConnected": "{{service}}: prijungta",
"serviceNotConnected": "{{service}}: neprijungta",
"makerworldDisabledReason": "MakerWorld: profilyje dar nepridėjote slapuko (cookie)",
"makerworldDisabledReason": "MakerWorld: neprijungta",
"thingiverseDisabledReason": "Thingiverse: administratorius dar nesukonfigūravo API rakto",
"myModels": "Mano modeliai"
},
@@ -276,7 +262,11 @@
"import": "Importuoti iš nuorodos",
"importFrom": "Importuoti iš {{provider}}",
"importNotSupported": "Ne MakerWorld, Printables ar Thingiverse nuoroda; atidaromas importavimo langas",
"language": "Kalba: {{name}}"
"language": "Kalba: {{name}}",
"appearance": "Atidaryti išvaizdą",
"providers": "Atidaryti šaltinius",
"slicer": "Atidaryti pjaustyklę",
"configuration": "Atidaryti konfigūraciją"
},
"searching": "Ieškoma…",
"keepTyping": "Rašykite toliau, kad ieškotumėte modelių, kolekcijų ir žymų, arba įveskite ! komandoms.",
@@ -798,7 +788,6 @@
"loadFailed": "Nepavyko įkelti paveikslėlio. Bandykite naują."
},
"activity": {
"title": "Veikla",
"totalLastYear_one": "{{count}} veiksmas per paskutinius metus",
"totalLastYear_few": "{{count}} veiksmai per paskutinius metus",
"totalLastYear_other": "{{count}} veiksmų per paskutinius metus",
@@ -870,6 +859,68 @@
"fromDay": "Nuo {{date}}",
"emptyUntil": "Šį mėnesį iki tos dienos veiklos nebuvo."
}
},
"appearance": {
"title": "Išvaizda",
"subtitle": "Pasirinkite, kaip Thingport atrodo ir veikia.",
"themes": {
"light": "Šviesi",
"dark": "Tamsi",
"system": "Pagal sistemą"
},
"on": "Įjungta",
"off": "Išjungta"
},
"configuration": {
"title": "Konfigūracija",
"tabs": {
"appearance": "Išvaizda",
"providers": "Šaltiniai",
"slicer": "Pjaustyklė"
}
},
"providers": {
"title": "Šaltiniai",
"subtitle": "Svetainės, iš kurių importuojate modelius. Spustelėkite dešiniuoju pelės mygtuku, kad prijungtumėte ar atjungtumėte.",
"connected": "Prijungta",
"notConnected": "Neprijungta",
"connect": "Prijungti",
"connecting": "Jungiama…",
"disconnect": "Atjungti",
"connectTitle": "Prijungti {{name}}",
"disconnectTitle": "Atjungti {{name}}?",
"disconnectMessage": "Jūsų {{name}} raktas bus pašalintas, o importavimas iš {{name}} neveiks, kol vėl prijungsite.",
"disconnectMessageShared": "{{name}} raktas bus pašalintas visiems šio Thingport naudotojams, o importavimas iš {{name}} neveiks, kol jis vėl bus prijungtas.",
"failed": "Nepavyko išsaugoti -- bandykite dar kartą.",
"makerworld": {
"placeholder": "pavyzdys: mw_session=...; mw_token=...;",
"steps": [
"Naršyklėje prisijunkite prie makerworld.com.",
"Atverkite kūrėjo įrankius (F12 arba ⌥⌘I) ir eikite į skirtuką „Network“.",
"Perkraukite puslapį ir pasirinkite bet kurią užklausą į makerworld.com.",
"Skiltyje „Request Headers“ nukopijuokite visą Cookie antraštės reikšmę ir įklijuokite ją žemiau."
]
},
"thingiverse": {
"placeholder": "Įklijuokite App Access Token",
"steps": [
"Prisijunkite prie thingiverse.com ir atverkite thingiverse.com/apps/create.",
"Sukurkite bet kokio tipo programą, pvz. „Desktop app“.",
"Nukopijuokite rodomą App Access Token ir įklijuokite jį žemiau."
],
"shared": "Šis raktas bendras visiems šio Thingport naudotojams.",
"adminOnly": "Tai nustatyti gali tik šio Thingport administratorius."
},
"printables": {
"alwaysConnected": "Printables prijungta iš karto: nereikia paskyros ar rakto."
},
"comingSoon": "Jau netrukus"
},
"slicer": {
"subtitle": "Pjaustyklė, kurioje modelį atveria „Atverti pjaustyklėje“. Spustelėkite dešiniuoju pelės mygtuku ir pasirinkite „Aktyvuoti“ arba spustelėkite dukart.",
"active": "Aktyvi",
"notActive": "Neaktyvi",
"activate": "Aktyvuoti"
}
},
"common": {
@@ -1,97 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import { setCachedAuthorPreviewEnabled } from "../../hooks/useAuthorPreviewEnabled";
type Props = {
onUnauthorized?: () => void;
};
export default function AuthorPreviewSetting({ onUnauthorized }: Props) {
const { t } = useTranslation(["app"]);
const [enabled, setEnabled] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi
.getAuthorPreview()
.then((res) => {
if (active) setEnabled(res.enabled);
})
.catch((err) => {
if (active && err instanceof UnauthorizedError) onUnauthorized?.();
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized]);
const handleChange = async (next: boolean) => {
const previous = enabled;
setEnabled(next);
setSaving(true);
setStatus(null);
try {
const res = await settingsApi.updateAuthorPreview(next);
setEnabled(res.enabled);
setCachedAuthorPreviewEnabled(res.enabled);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setEnabled(previous);
setStatus(err instanceof Error ? err.message : t("profile.authorPreview.failed"));
} finally {
setSaving(false);
}
};
return (
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("profile.authorPreview.heading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.authorPreview.description")}
</Typography>
<Paper
variant="outlined"
sx={{ p: 2.5, borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider") }}
>
<Stack direction="row" spacing={1.5} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled}
disabled={loading || saving}
onChange={(e) => void handleChange(e.target.checked)}
/>
}
label={t("profile.authorPreview.label")}
/>
{saving && <CircularProgress size={16} />}
</Stack>
{status && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{status}
</Typography>
)}
</Paper>
</Box>
);
}
@@ -1,57 +0,0 @@
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";
import CheckIcon from "@mui/icons-material/Check";
import { SUPPORTED_LANGUAGES } from "../../constants/languages";
/** Applies immediately on click. */
export default function LanguagePicker() {
const { t, i18n } = useTranslation("app");
return (
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("profile.language.heading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.language.subtitle")}
</Typography>
<Paper
variant="outlined"
sx={{ p: 2.5, borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider") }}
>
<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,154 +0,0 @@
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";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Alert from "@mui/material/Alert";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import type { MakerWorldSettings } from "../../utils/settings";
type Props = {
cookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
onUnauthorized?: () => void;
};
/** Never displays the stored cookie. Saves locally and syncs to the backend. */
export default function MakerworldCookieSection({ cookie, onUpdateMakerWorld, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [configured, setConfigured] = React.useState(Boolean(cookie.trim()));
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi
.getMakerworld()
.then((res) => {
if (active) setConfigured(res.configured);
})
.catch(() => undefined);
return () => {
active = false;
};
}, []);
const startEdit = () => {
setDraft("");
setError(null);
setEditing(true);
};
const cancelEdit = () => {
setEditing(false);
setDraft("");
setError(null);
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const result = await settingsApi.updateMakerworld(draft.trim() || null);
onUpdateMakerWorld({ cookie: draft });
setConfigured(result.configured);
setEditing(false);
setDraft("");
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setError(err instanceof Error ? err.message : t("profile.makerworld.failed"));
} finally {
setSaving(false);
}
};
const handleRemove = async () => {
setSaving(true);
setError(null);
try {
const result = await settingsApi.updateMakerworld(null);
onUpdateMakerWorld({ cookie: "" });
setConfigured(result.configured);
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
setError(err instanceof Error ? err.message : t("profile.makerworld.failed"));
} finally {
setSaving(false);
}
};
return (
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("profile.makerworld.heading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.makerworld.description")}
</Typography>
<Paper
variant="outlined"
sx={{ p: 2.5, borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider") }}
>
{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}
// 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 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")}
</Button>
)}
</Stack>
)}
{error && (
<Alert severity="error" sx={{ mt: 1.5 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
</Paper>
</Box>
);
}
@@ -1,120 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
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 FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select, { type SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import CircularProgress from "@mui/material/CircularProgress";
import Link from "@mui/material/Link";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import { setCachedSlicerPreference } from "../../hooks/useSlicerPreference";
import { isBridgedSlicer } from "../../utils/slicerLaunch";
type Props = {
onUnauthorized?: () => void;
};
export default function SlicerPicker({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const [value, setValue] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [status, setStatus] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
settingsApi
.getSlicer()
.then((res) => {
if (!active) return;
setValue(res.slicer ?? "");
})
.catch((err) => {
if (active && err instanceof UnauthorizedError) onUnauthorized?.();
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized]);
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(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);
}
};
return (
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{t("profile.slicer.heading")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{t("profile.slicer.description")}
</Typography>
<Paper
variant="outlined"
sx={{ p: 2.5, borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider") }}
>
<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>
{isBridgedSlicer(value) && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
{t("profile.slicer.bridgeRequiredPrefix", {
slicer: SLICER_OPTIONS.find((opt) => opt.id === value)?.label ?? value,
})}{" "}
<Link component={RouterLink} to="/downloads">
{t("profile.slicer.bridgeRequiredLink")}
</Link>
</Typography>
)}
{status && (
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
{status}
</Typography>
)}
</Paper>
</Box>
);
}
@@ -6,9 +6,9 @@ import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { activityApi, type ActivitySummary } from "../../api/activity";
import { dividerBorderColor } from "../../theme";
import { UnauthorizedError } from "../../../api/client";
import { activityApi, type ActivitySummary } from "../../../api/activity";
import { dividerBorderColor } from "../../../theme";
import Heatmap from "./Heatmap";
import Timeline from "./Timeline";
import { lastYearRange, localToday, yearRange } from "./calendar";
@@ -18,7 +18,7 @@ type Props = {
};
/** The signed-in user's own activity, as a year of days. Opens on the last 12 months, like GitHub. */
export default function ActivityPage({ onUnauthorized }: Props) {
export default function ActivitySection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
// Null is the last 12 months; a year is that calendar year.
const [year, setYear] = React.useState<number | null>(null);
@@ -70,7 +70,7 @@ export default function ActivityPage({ onUnauthorized }: Props) {
const years = loaded?.data.years ?? [new Date().getFullYear()];
return (
<Box sx={{ p: { xs: 0, md: 1 } }}>
<Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
@@ -98,8 +98,12 @@ export default function ActivityPage({ onUnauthorized }: Props) {
onSelectDay={selectDay}
/>
</Paper>
{/* Clear of the sticky top bar when scrolled to. */}
<Box ref={timelineRef} sx={{ mt: 3, scrollMarginTop: "calc(var(--topbar-height, 64px) + 8px)" }}>
{/* Clear of the sticky top bar when scrolled to. Zero width with a 100% minimum: as wide as
the heatmap, so a long model name is cut short rather than widening the page. */}
<Box
ref={timelineRef}
sx={{ mt: 3, width: 0, minWidth: "100%", scrollMarginTop: "calc(var(--topbar-height, 64px) + 8px)" }}
>
<Timeline
startMonth={startMonth}
fromDay={selectedDay}
@@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { alpha, type Theme } from "@mui/material/styles";
import type { ActivityDay, ActivityKind } from "../../api/activity";
import type { ActivityDay, ActivityKind } from "../../../api/activity";
import { buildWeeks, level, monthStarts, type DateRange } from "./calendar";
const CELL = 11;
@@ -17,10 +17,10 @@ import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import { UnauthorizedError } from "../../api/client";
import { activityApi, type ActivityItem, type ActivityKind, type ActivityMonth } from "../../api/activity";
import { printsApi } from "../../api/prints";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
import { UnauthorizedError } from "../../../api/client";
import { activityApi, type ActivityItem, type ActivityKind, type ActivityMonth } from "../../../api/activity";
import { printsApi } from "../../../api/prints";
import { useInfiniteScroll } from "../../../hooks/useInfiniteScroll";
const KIND_ICONS: Record<ActivityKind, React.ReactNode> = {
import: <CloudDownloadOutlinedIcon fontSize="small" />,
+45 -60
View File
@@ -5,85 +5,70 @@ import Box from "@mui/material/Box";
import Avatar from "@mui/material/Avatar";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import type { AuthUser } from "../../api/auth";
import type { MakerWorldSettings } from "../../utils/settings";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import LanguagePicker from "./LanguagePicker";
import MakerworldCookieSection from "./MakerworldCookieSection";
import SlicerPicker from "./SlicerPicker";
import AuthorPreviewSetting from "./AuthorPreviewSetting";
import ActivitySection from "./activity/ActivitySection";
type Props = {
user: AuthUser | null;
makerworldCookie: string;
onUpdateMakerWorld: (patch: Partial<MakerWorldSettings>) => void;
onUnauthorized?: () => void;
};
export default function ProfilePage({ user, makerworldCookie, onUpdateMakerWorld, onUnauthorized }: Props) {
export default function ProfilePage({ user, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const navigate = useNavigate();
const avatarUrl = useGravatarUrl(user?.email, 128);
return (
<Stack spacing={4} sx={{ maxWidth: 560 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Avatar
alt={user?.display_name}
src={avatarUrl}
sx={{ width: 64, height: 64, bgcolor: "primary.main", fontSize: 24 }}
>
{user?.display_name?.[0]?.toUpperCase()}
</Avatar>
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}>
{user?.display_name}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
</Box>
</Stack>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
// As wide as the activity below it, and centred in the page.
<Stack spacing={4} sx={{ width: "fit-content", maxWidth: "100%", mx: "auto" }}>
<Stack spacing={4}>
<Stack direction="row" spacing={2} alignItems="center">
<Avatar
alt={user?.display_name}
src={avatarUrl}
sx={{ width: 64, height: 64, bgcolor: "primary.main", fontSize: 24 }}
>
{user?.display_name?.[0]?.toUpperCase()}
</Avatar>
<Box>
<Typography variant="body1">{user?.email}</Typography>
{user?.pending_email && (
<Typography variant="caption" color="text.secondary">
{t("profile.pendingEmail", { email: user.pending_email })}
</Typography>
)}
<Typography
variant="subtitle1"
fontWeight={600}
sx={{ color: (muiTheme) => muiTheme.thingport.headingText }}
>
{user?.display_name}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
</Box>
<Button variant="text" onClick={() => navigate("/profile/email")}>
{t("profile.changeEmailLink")}
</Button>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Typography variant="body1">••••••••</Typography>
<Button variant="text" onClick={() => navigate("/profile/password")}>
{t("profile.changePasswordLink")}
</Button>
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Box>
<Typography variant="body1">{user?.email}</Typography>
{user?.pending_email && (
<Typography variant="caption" color="text.secondary">
{t("profile.pendingEmail", { email: user.pending_email })}
</Typography>
)}
</Box>
<Button variant="text" onClick={() => navigate("/profile/email")}>
{t("profile.changeEmailLink")}
</Button>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
<Typography variant="body1">••••••••</Typography>
<Button variant="text" onClick={() => navigate("/profile/password")}>
{t("profile.changePasswordLink")}
</Button>
</Stack>
</Stack>
</Stack>
<Divider />
<SlicerPicker onUnauthorized={onUnauthorized} />
<Divider />
<MakerworldCookieSection
cookie={makerworldCookie}
onUpdateMakerWorld={onUpdateMakerWorld}
onUnauthorized={onUnauthorized}
/>
<Divider />
<LanguagePicker />
<Divider />
<AuthorPreviewSetting onUnauthorized={onUnauthorized} />
<ActivitySection onUnauthorized={onUnauthorized} />
</Stack>
);
}