feat: add an About view for each provider

A dialog with the provider's description, dependencies, imports and setup
steps, an animation of its import path in the provider's colour, and a table
comparing all providers. Opens from Configuration > Providers (About), the
logo or name in Administration > Providers, the dashboard's provider chips and
model provider badges.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 05:57:41 +02:00
1 parent 233c794065
commit e76fbf3b58
11 files changed
+1335 -17

No files matched your search

+4 -1
View File
@@ -15,6 +15,7 @@ import { StorageMoveProvider } from "./StorageMoveContext";
import BackToTopButton from "./BackToTopButton";
import { ConfirmProvider } from "../ConfirmProvider";
import { ToastProvider } from "../ToastProvider";
import { ProviderAboutProvider } from "../providerAbout/ProviderAboutProvider";
import { PageHeaderContext, type PageHeader } from "./PageHeaderContext";
import { NavigationHistoryProvider, useSmartBack } from "./NavigationHistoryContext";
import { ImportJobProvider } from "./ImportJobContext";
@@ -236,7 +237,9 @@ export default function AppLayout({ muiTheme, onUnauthorized, ...shellProps }: A
<ConfirmProvider>
<ToastProvider>
<NotificationsProvider onUnauthorized={onUnauthorized}>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
<ProviderAboutProvider>
<AppLayoutShell onUnauthorized={onUnauthorized} {...shellProps} />
</ProviderAboutProvider>
</NotificationsProvider>
</ToastProvider>
</ConfirmProvider>
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Divider from "@mui/material/Divider";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
@@ -15,6 +16,7 @@ import CloseIcon from "@mui/icons-material/Close";
import LinkIcon from "@mui/icons-material/Link";
import LinkOffIcon from "@mui/icons-material/LinkOff";
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi, type Cults3dSettings } from "../../../api/settings";
import { cults3dReady, cults3dStatus } from "./cults3dStatus";
@@ -24,6 +26,7 @@ import { AppGrid, AppTile, menuPosition, reopenContextMenuBelow, TileMenuHeader
import { PanelHeader } from "./parts";
import { COMING_SOON_PROVIDERS, ProviderLogo, type ProviderId } from "./providerLogos";
import { useDisabledProviders } from "../../../hooks/useDisabledProviders";
import { useProviderAbout } from "../../providerAbout/ProviderAboutProvider";
import type { ImportProvider } from "../../../api/settings";
const PROVIDERS: { id: ProviderId; name: string }[] = [
@@ -365,6 +368,8 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
}
/>
)}
{menuId && <AboutMenuItem id={menuId} onClick={() => setMenu(null)} />}
{menuId && !isComingSoon(menuId) && <Divider />}
{menuId && menuExpired && (
<MenuItem
onClick={() => {
@@ -464,6 +469,25 @@ export default function ProvidersPanel({ isAdmin, cookie, onUpdateMakerWorld, on
);
}
function AboutMenuItem({ id, onClick }: { id: ProviderId; onClick: () => void }) {
const { t } = useTranslation("app");
const openAbout = useProviderAbout(id);
if (!openAbout) return null;
return (
<MenuItem
onClick={() => {
onClick();
openAbout();
}}
>
<ListItemIcon>
<InfoOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("providerAbout.about")}</ListItemText>
</MenuItem>
);
}
type ConnectBoxProps = {
id: ProviderId;
/** "session": Cults3D's browser sign-in, pasted, rather than its API key. */
+27 -5
View File
@@ -1,3 +1,4 @@
import type { KeyboardEvent, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
@@ -6,6 +7,7 @@ import markColor from "../assets/logos/thingport-mark-color.svg";
import markOnDark from "../assets/logos/thingport-mark-on-dark.svg";
import { printProviderInfo, providerChipSx } from "../constants/importProviders";
import { hasProviderLogo, ProviderLogo } from "./Layout/ConfigurationDialog/providerLogos";
import { useProviderAbout } from "./providerAbout/ProviderAboutProvider";
// Read by screen readers, not shown.
const visuallyHidden = {
@@ -25,10 +27,12 @@ type Props = {
sx?: SxProps<Theme>;
};
/** Where a model came from, as the provider's logo on a round badge; its chip shows on hover or focus. */
/** Where a model came from, as the provider's logo on a round badge; its chip shows on hover or focus. A click opens
* the provider's About dialog; a direct upload's Thingport badge has none. */
export default function ProviderBadge({ provider, size = 28, sx }: Props) {
const { t } = useTranslation("models");
const { t } = useTranslation(["models", "app"]);
const theme = useTheme();
const openAbout = useProviderAbout(provider);
const info = printProviderInfo(provider);
const label = provider ? t("card.importedFrom", { provider: info.label }) : t("card.uploadedDirectly");
const logoSize = Math.round(size * 0.6);
@@ -54,6 +58,21 @@ export default function ProviderBadge({ provider, size = 28, sx }: Props) {
>
<Box
tabIndex={0}
{...(openAbout && {
role: "button",
"aria-label": `${label}. ${t("app:providerAbout.aboutName", { name: info.label })}`,
onClick: (event: MouseEvent) => {
// The badge sits on a card that opens the model.
event.stopPropagation();
openAbout();
},
onKeyDown: (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
openAbout();
},
})}
sx={[
{
width: size,
@@ -66,6 +85,7 @@ export default function ProviderBadge({ provider, size = 28, sx }: Props) {
border: 1,
borderColor: "divider",
boxShadow: 2,
cursor: openAbout ? "pointer" : "default",
transition: "transform 150ms ease",
"&:hover, &:focus-visible": { transform: "scale(1.08)" },
"&:focus-visible": { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 },
@@ -77,9 +97,11 @@ export default function ProviderBadge({ provider, size = 28, sx }: Props) {
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box component="span" sx={visuallyHidden}>
{label}
</Box>
{!openAbout && (
<Box component="span" sx={visuallyHidden}>
{label}
</Box>
)}
{hasProviderLogo(provider) ? (
<ProviderLogo id={provider} size={logoSize} />
) : (
@@ -0,0 +1,486 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
import MenuBookOutlinedIcon from "@mui/icons-material/MenuBookOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RemoveIcon from "@mui/icons-material/Remove";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import {
type Need,
PROVIDER_FACTS,
PROVIDER_ORDER,
providerAccent,
providerAccentText,
providerDocsUrl,
} from "../../constants/providerFacts";
import { useDisabledProviders } from "../../hooks/useDisabledProviders";
import type { ImportProvider } from "../../api/settings";
import Segmented from "../controls/Segmented";
import HelpTip from "../HelpTip";
import { ProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
import ProviderFlowAnimation from "./ProviderFlowAnimation";
type View = "about" | "compare";
const fadeUp = keyframes`
from { transform: translateY(6px); opacity: 0; }
to { transform: none; opacity: 1; }
`;
const enter = {
animation: `${fadeUp} 220ms ease-out both`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
} as const;
const FEATURES = ["profiles", "collections", "likes", "paid"] as const;
type Props = {
/** The provider shown, or null when closed. */
provider: ProviderId | null;
onClose: () => void;
};
/** A provider in short: what it is, what it leans on, what it imports and how to set it up, beside all the others. */
export default function ProviderAboutDialog({ provider, onClose }: Props) {
const { t } = useTranslation(["app", "common"]);
const theme = useTheme();
const [id, setId] = React.useState<ProviderId>(provider ?? "makerworld");
const [view, setView] = React.useState<View>("about");
// Kept while closing, so the dialog doesn't change during the fade.
React.useEffect(() => {
if (provider) {
setId(provider);
setView("about");
}
}, [provider]);
const name = IMPORT_PROVIDER_INFO[id].label;
const accent = providerAccent(id, theme);
const host = new URL(PROVIDER_FACTS[id].site).hostname.replace(/^www\./, "");
return (
<Dialog
open={Boolean(provider)}
onClose={onClose}
fullWidth
maxWidth={view === "compare" ? "md" : "sm"}
aria-labelledby="provider-about-title"
slotProps={{ paper: { sx: { overflow: "hidden" } } }}
>
<Box
key={id}
sx={{
position: "relative",
flexShrink: 0,
px: 3,
pt: 2.5,
pb: 2,
background: `linear-gradient(160deg, ${alpha(accent, 0.2)} 0%, ${alpha(accent, 0.03)} 70%)`,
borderBottom: `1px solid ${alpha(accent, 0.25)}`,
}}
>
<IconButton
aria-label={t("common:close")}
onClick={onClose}
size="small"
sx={{ position: "absolute", right: 12, top: 12 }}
>
<CloseIcon fontSize="small" />
</IconButton>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ pr: 5, mb: 2 }}>
<Box
sx={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: "11px",
display: "grid",
placeItems: "center",
bgcolor: "#ffffff",
boxShadow: `inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 2px 8px ${alpha(accent, 0.3)}`,
}}
>
<ProviderLogo id={id} size={24} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography
id="provider-about-title"
variant="h6"
fontWeight={700}
sx={{ lineHeight: 1.2, color: (muiTheme) => muiTheme.thingport.headingText }}
>
{name}
</Typography>
<Link
href={PROVIDER_FACTS[id].site}
target="_blank"
rel="noopener"
variant="caption"
underline="hover"
sx={{ color: "text.secondary", display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
{host}
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Link>
</Box>
</Stack>
<ProviderFlowAnimation id={id} name={name} accent={accent} textAccent={providerAccentText(id, theme)} />
</Box>
<Box sx={{ px: 3, pt: 2, pb: 3, overflowY: "auto", flex: "1 1 auto", minHeight: 0 }}>
<Box sx={{ mb: 2 }}>
<Segmented<View>
value={view}
onChange={setView}
label={t("providerAbout.tabsLabel")}
options={[
{ value: "about", label: t("providerAbout.tabs.about") },
{ value: "compare", label: t("providerAbout.tabs.compare") },
]}
/>
</Box>
{view === "about" ? (
<AboutView key={id} id={id} accent={providerAccentText(id, theme)} onClose={onClose} />
) : (
<CompareView
current={id}
onPick={(next) => {
setId(next);
setView("about");
}}
/>
)}
</Box>
</Dialog>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography
variant="overline"
sx={{ display: "block", color: "text.secondary", fontWeight: 700, lineHeight: 2, mt: 2.5, mb: 0.5 }}
>
{children}
</Typography>
);
}
function NeedPill({ need, accent }: { need: Need; accent: string }) {
const { t } = useTranslation("app");
const strong = need === "required" || need === "recommended";
return (
<Box
component="span"
sx={{
display: "inline-block",
px: 1,
py: 0.125,
borderRadius: "999px",
fontSize: "0.7rem",
fontWeight: need === "required" ? 700 : 600,
whiteSpace: "nowrap",
color: strong ? accent : "text.secondary",
bgcolor: strong ? alpha(accent, need === "required" ? 0.2 : 0.1) : "action.hover",
border: 1,
borderColor: strong ? alpha(accent, 0.4) : "divider",
}}
>
{t(`providerAbout.need.${need}`)}
</Box>
);
}
function AboutView({ id, accent, onClose }: { id: ProviderId; accent: string; onClose: () => void }) {
const { t } = useTranslation("app");
const facts = PROVIDER_FACTS[id];
const key = `providerAbout.providers.${id}`;
const setup = t(`${key}.setup`, { returnObjects: true }) as string[];
const flareNote = t(`${key}.flaresolverr`, { defaultValue: t(`providerAbout.flareNote.${facts.flaresolverr}`) });
const rows: { label: string; help?: string; pill: React.ReactNode; note: string }[] = [
{
label: t("providerAbout.rows.signIn"),
pill: (
<Typography variant="body2" fontWeight={600} sx={{ fontSize: "0.8rem" }}>
{t(`providerAbout.signIn.${facts.signIn}`)}
</Typography>
),
note: t(`${key}.signIn`),
},
{
label: t("providerAbout.rows.grab"),
help: t("providerAbout.rowHelp.grab"),
pill: <NeedPill need={facts.grab} accent={accent} />,
note: t(`${key}.grab`),
},
{
label: t("providerAbout.rows.flaresolverr"),
help: t("providerAbout.rowHelp.flaresolverr"),
pill: <NeedPill need={facts.flaresolverr} accent={accent} />,
note: flareNote,
},
];
const features = [
{ key: "models", on: true },
...FEATURES.map((feature) => ({ key: feature, on: facts[feature] })),
{ key: "sync", on: true },
];
return (
<Box sx={enter}>
<Typography variant="body2" color="text.secondary">
{t(`${key}.summary`)}
</Typography>
<SectionLabel>{t("providerAbout.sections.needs")}</SectionLabel>
<Box sx={{ border: 1, borderColor: "divider", borderRadius: "12px", overflow: "hidden" }}>
{rows.map((row) => (
<Box
key={row.label}
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "150px 1fr" },
gap: { xs: 0.5, sm: 1.5 },
px: 1.75,
py: 1.25,
"&:not(:last-of-type)": { borderBottom: 1, borderColor: "divider" },
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, display: "flex", alignItems: "center" }}>
{row.label}
{row.help && <HelpTip text={row.help} />}
</Typography>
<Box>
{row.pill}
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.5 }}>
{row.note}
</Typography>
</Box>
</Box>
))}
</Box>
<SectionLabel>{t("providerAbout.sections.imports")}</SectionLabel>
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={0.75}>
{features.map(({ key: feature, on }, index) => (
<Box
key={feature}
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 1.25,
py: 0.375,
borderRadius: "999px",
fontSize: "0.75rem",
fontWeight: 600,
border: 1,
borderColor: on ? alpha(accent, 0.4) : "divider",
bgcolor: on ? alpha(accent, 0.1) : "transparent",
color: on ? "text.primary" : "text.disabled",
textDecoration: on ? "none" : "line-through",
...enter,
animationDelay: `${index * 40}ms`,
}}
>
{on ? (
<CheckIcon sx={{ fontSize: 14, color: accent }} />
) : (
<RemoveIcon sx={{ fontSize: 14 }} aria-label={t("providerAbout.no")} />
)}
{t(`providerAbout.features.${feature}`)}
</Box>
))}
</Stack>
<SectionLabel>{t("providerAbout.sections.setup")}</SectionLabel>
<Box component="ol" sx={{ m: 0, p: 0, listStyle: "none", counterReset: "step" }}>
{setup.map((step) => (
<Box
component="li"
key={step}
sx={{
position: "relative",
pl: 4,
mb: 1,
fontSize: "0.8125rem",
color: "text.secondary",
counterIncrement: "step",
"&::before": {
content: "counter(step)",
position: "absolute",
left: 0,
top: -1,
width: 22,
height: 22,
borderRadius: "50%",
display: "grid",
placeItems: "center",
fontSize: "0.7rem",
fontWeight: 700,
color: accent,
bgcolor: alpha(accent, 0.12),
},
}}
>
{step}
</Box>
))}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mt: 2 }}>
<Button
size="small"
variant="outlined"
startIcon={<MenuBookOutlinedIcon fontSize="small" />}
href={providerDocsUrl(id)}
target="_blank"
rel="noopener"
>
{t("providerAbout.guide")}
</Button>
{facts.grab !== "none" && (
<Button
size="small"
startIcon={<ExtensionOutlinedIcon fontSize="small" />}
component={RouterLink}
to="/downloads"
onClick={onClose}
>
{t("providerAbout.getGrab")}
</Button>
)}
</Stack>
</Box>
);
}
function CompareView({ current, onPick }: { current: ProviderId; onPick: (id: ProviderId) => void }) {
const { t } = useTranslation("app");
const theme = useTheme();
const disabled = useDisabledProviders();
const columns = [
t("providerAbout.rows.signIn"),
t("providerAbout.rows.grab"),
t("providerAbout.rows.flaresolverr"),
...FEATURES.map((feature) => t(`providerAbout.features.${feature}`)),
];
const cell = { px: 1, py: 0.75, fontSize: "0.75rem", whiteSpace: "nowrap", textAlign: "center" } as const;
const tick = (on: boolean, accent: string) =>
on ? (
<CheckIcon sx={{ fontSize: 16, color: accent, verticalAlign: "middle" }} aria-label={t("providerAbout.yes")} />
) : (
<RemoveIcon
sx={{ fontSize: 16, color: "text.disabled", verticalAlign: "middle" }}
aria-label={t("providerAbout.no")}
/>
);
return (
<Box sx={{ ...enter, overflowX: "auto", border: 1, borderColor: "divider", borderRadius: "12px" }}>
<Box
component="table"
aria-label={t("providerAbout.compareLabel")}
sx={{ width: "100%", borderCollapse: "collapse", "& th, & td": { borderBottom: 1, borderColor: "divider" } }}
>
<thead>
<tr>
<Box component="th" sx={cell} />
{columns.map((column) => (
<Box
component="th"
key={column}
scope="col"
sx={{ ...cell, whiteSpace: "normal", fontWeight: 700, color: "text.secondary", lineHeight: 1.25 }}
>
{column}
</Box>
))}
</tr>
</thead>
<tbody>
{PROVIDER_ORDER.map((id, index) => {
const facts = PROVIDER_FACTS[id];
const accent = providerAccentText(id, theme);
const chosen = id === current;
const off = disabled.has(id as ImportProvider);
return (
<Box
component="tr"
key={id}
tabIndex={0}
onClick={() => onPick(id)}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onPick(id);
}
}}
aria-current={chosen || undefined}
sx={{
cursor: "pointer",
opacity: off ? 0.5 : 1,
bgcolor: chosen ? alpha(accent, 0.1) : "transparent",
transition: "background-color 150ms",
"&:hover": { bgcolor: alpha(accent, 0.08) },
"&:focus-visible": { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: -2 },
"&:last-of-type td, &:last-of-type th": { borderBottom: 0 },
...enter,
animationDelay: `${index * 25}ms`,
}}
>
<Box
component="th"
scope="row"
sx={{
...cell,
textAlign: "left",
boxShadow: chosen ? `inset 3px 0 0 ${accent}` : "none",
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<ProviderLogo id={id} size={16} />
<Typography variant="body2" sx={{ fontSize: "0.78rem", fontWeight: chosen ? 700 : 600 }}>
{IMPORT_PROVIDER_INFO[id].label}
</Typography>
{off && (
<Typography variant="caption" color="text.secondary">
({t("providerAbout.off")})
</Typography>
)}
</Stack>
</Box>
<Box component="td" sx={cell}>
{t(`providerAbout.signIn.${facts.signIn}`)}
</Box>
<Box component="td" sx={cell}>
<NeedPill need={facts.grab} accent={accent} />
</Box>
<Box component="td" sx={cell}>
<NeedPill need={facts.flaresolverr} accent={accent} />
</Box>
{FEATURES.map((feature) => (
<Box component="td" key={feature} sx={cell}>
{tick(facts[feature], accent)}
</Box>
))}
</Box>
);
})}
</tbody>
</Box>
</Box>
);
}
@@ -0,0 +1,26 @@
import React from "react";
import { hasProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
import ProviderAboutDialog from "./ProviderAboutDialog";
type OpenAbout = (provider: ProviderId) => void;
const ProviderAboutContext = React.createContext<OpenAbout | null>(null);
/** One About dialog for the whole app, opened from wherever a provider is shown. */
export function ProviderAboutProvider({ children }: { children: React.ReactNode }) {
const [provider, setProvider] = React.useState<ProviderId | null>(null);
return (
<ProviderAboutContext.Provider value={setProvider}>
{children}
<ProviderAboutDialog provider={provider} onClose={() => setProvider(null)} />
</ProviderAboutContext.Provider>
);
}
/** Opens a provider's About dialog. Null for a direct upload or an unknown source, which have none, and outside the
* provider. */
export function useProviderAbout(provider: string | null | undefined): (() => void) | null {
const open = React.useContext(ProviderAboutContext);
if (!open || !hasProviderLogo(provider)) return null;
return () => open(provider);
}
@@ -0,0 +1,221 @@
import type { ReactNode } from "react";
import { keyframes } from "@emotion/react";
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { alpha, useTheme } from "@mui/material/styles";
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import markColor from "../../assets/logos/thingport-mark-color.svg";
import markOnDark from "../../assets/logos/thingport-mark-on-dark.svg";
import { type Need, PROVIDER_FACTS } from "../../constants/providerFacts";
import { ProviderLogo, type ProviderId } from "../Layout/ConfigurationDialog/providerLogos";
// Models leaving the provider, passing whatever it leans on (FlareSolverr, Thingport Grab), and landing in
// Thingport: the provider's import path, drawn in its own colour. Optional helpers are dashed and faint; ones
// it doesn't use aren't drawn.
const NODE = 52;
const MODELS = 3;
const CYCLE_S = 2.6;
const START_S = 0.4;
// A model reaches Thingport every PERIOD_S; its tile bumps (92% into `land`) as each one arrives.
const PERIOD_S = CYCLE_S / MODELS;
const popIn = keyframes`
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
`;
const ripple = keyframes`
from { transform: scale(1); opacity: 0.55; }
to { transform: scale(1.7); opacity: 0; }
`;
const travel = keyframes`
0% { transform: translateX(0); opacity: 0; }
10%, 88% { opacity: 1; }
100% { transform: translateX(100%); opacity: 0; }
`;
const land = keyframes`
0%, 84%, 100% { transform: scale(1); }
92% { transform: scale(1.08); }
`;
type Node = { key: string; label: string; need?: Need; icon: ReactNode };
type Props = {
id: ProviderId;
name: string;
accent: string;
/** The accent where it's text: some brand colours don't read on the paper. */
textAccent: string;
};
export default function ProviderFlowAnimation({ id, name, accent, textAccent }: Props) {
const { t } = useTranslation("app");
const theme = useTheme();
const facts = PROVIDER_FACTS[id];
const helperIcon = { fontSize: 26, color: "text.secondary" } as const;
const nodes: Node[] = [
{ key: "provider", label: name, icon: <ProviderLogo id={id} size={30} /> },
...(facts.flaresolverr !== "none"
? [
{
key: "flaresolverr",
label: "FlareSolverr",
need: facts.flaresolverr,
icon: <ShieldOutlinedIcon sx={helperIcon} />,
},
]
: []),
...(facts.grab !== "none"
? [
{
key: "grab",
label: "Thingport Grab",
need: facts.grab,
icon: <ExtensionOutlinedIcon sx={helperIcon} />,
},
]
: []),
{
key: "thingport",
label: "Thingport",
icon: (
<Box
component="img"
src={theme.palette.mode === "dark" ? markOnDark : markColor}
alt=""
sx={{ width: 30, height: 30, display: "block" }}
/>
),
},
];
return (
<Box
aria-hidden
sx={{
position: "relative",
display: "flex",
justifyContent: "space-between",
maxWidth: 440,
mx: "auto",
"@media (prefers-reduced-motion: reduce)": { "& *": { animation: "none !important" } },
}}
>
{/* The path, from the first node's middle to the last's, with models running along it. */}
<Box
sx={{
position: "absolute",
left: NODE / 2,
right: NODE / 2,
top: NODE / 2 - 1,
height: 2,
backgroundImage: `linear-gradient(90deg, ${alpha(accent, 0.45)} 50%, transparent 50%)`,
backgroundSize: "8px 2px",
}}
>
{Array.from({ length: MODELS }, (_, i) => (
<Box
key={i}
sx={{
position: "absolute",
inset: 0,
opacity: 0,
animation: `${travel} ${CYCLE_S}s linear ${START_S + i * PERIOD_S}s infinite`,
}}
>
<Box
sx={{
position: "absolute",
left: -5,
top: -4,
width: 10,
height: 10,
borderRadius: "3px",
bgcolor: accent,
boxShadow: `0 0 8px ${alpha(accent, 0.6)}`,
transform: "rotate(45deg)",
}}
/>
</Box>
))}
</Box>
{nodes.map((node, index) => {
const faint = node.need === "optional";
const isProvider = node.key === "provider";
const isThingport = node.key === "thingport";
return (
<Box
key={node.key}
sx={{
position: "relative",
zIndex: 1,
width: 84,
mx: "-16px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0.75,
animation: `${popIn} 260ms cubic-bezier(.2,.9,.3,1.3) ${index * 90}ms both`,
}}
>
<Box sx={{ position: "relative", width: NODE, height: NODE }}>
{isProvider && (
<Box
sx={{
position: "absolute",
inset: 0,
borderRadius: "14px",
border: `2px solid ${accent}`,
animation: `${ripple} ${PERIOD_S}s ease-out infinite`,
}}
/>
)}
<Box
sx={{
position: "relative",
width: NODE,
height: NODE,
borderRadius: "14px",
display: "grid",
placeItems: "center",
// The logos are drawn for a white tile, as in Configuration.
bgcolor: isProvider ? "#ffffff" : "background.paper",
border: faint ? `1.5px dashed ${theme.palette.divider}` : `1.5px solid ${alpha(accent, 0.5)}`,
opacity: faint ? 0.7 : 1,
boxShadow: faint ? "none" : `0 4px 14px ${alpha(accent, 0.25)}`,
animation: isThingport
? `${land} ${PERIOD_S}s ease-in-out ${START_S - PERIOD_S * 0.92}s infinite`
: "none",
}}
>
{node.icon}
</Box>
</Box>
<Typography
variant="caption"
sx={{ fontSize: "0.68rem", fontWeight: 600, lineHeight: 1.2, textAlign: "center" }}
>
{node.label}
</Typography>
{node.need && (
<Typography
variant="caption"
sx={{
mt: -0.5,
fontSize: "0.62rem",
lineHeight: 1.2,
color: node.need === "optional" ? "text.secondary" : textAccent,
fontWeight: node.need === "required" ? 700 : 500,
}}
>
{t(`providerAbout.need.${node.need}`)}
</Typography>
)}
</Box>
);
})}
</Box>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { darken, getContrastRatio, lighten, type Theme } from "@mui/material/styles";
import type { ProviderId } from "../components/Layout/ConfigurationDialog/providerLogos";
import { IMPORT_PROVIDER_INFO } from "./importProviders";
import { THINGPORT_WEBSITE_URL } from "./website";
/** How much a provider leans on a helper: Thingport Grab or FlareSolverr. */
export type Need = "required" | "recommended" | "optional" | "none";
/** What Thingport needs before importing: nothing, the admin's instance key, each user's own sign-in, or both. */
export type SignIn = "none" | "admin" | "user" | "adminUser" | "userOptional";
export type ProviderFacts = {
site: string;
/** The page under docs/providers/ on the website. */
docs: string;
signIn: SignIn;
grab: Need;
flaresolverr: Need;
collections: boolean;
likes: boolean;
profiles: boolean;
paid: boolean;
};
// Mirrors docs/PROVIDER_SETUP.md's table; the legacy three come first, as everywhere in the docs.
export const PROVIDER_FACTS: Record<ProviderId, ProviderFacts> = {
makerworld: {
site: "https://makerworld.com",
docs: "makerworld",
signIn: "user",
grab: "recommended",
flaresolverr: "optional",
collections: true,
likes: false,
profiles: true,
paid: false,
},
printables: {
site: "https://www.printables.com",
docs: "printables",
signIn: "none",
grab: "optional",
flaresolverr: "recommended",
collections: true,
likes: true,
profiles: false,
paid: false,
},
thingiverse: {
site: "https://www.thingiverse.com",
docs: "thingiverse",
signIn: "admin",
grab: "optional",
flaresolverr: "recommended",
collections: true,
likes: true,
profiles: false,
paid: false,
},
myminifactory: {
site: "https://www.myminifactory.com",
docs: "myminifactory",
signIn: "adminUser",
grab: "optional",
flaresolverr: "recommended",
collections: true,
likes: false,
profiles: false,
paid: true,
},
snapmaker: {
site: "https://space.snapmaker.com",
docs: "snapmaker-space",
signIn: "user",
grab: "recommended",
flaresolverr: "none",
collections: true,
likes: true,
profiles: true,
paid: false,
},
makeronline: {
site: "https://www.makeronline.com",
docs: "makeronline",
signIn: "userOptional",
grab: "required",
flaresolverr: "none",
collections: true,
likes: true,
profiles: true,
paid: false,
},
crealitycloud: {
site: "https://www.crealitycloud.com",
docs: "creality-cloud",
signIn: "user",
grab: "recommended",
flaresolverr: "none",
collections: true,
likes: true,
profiles: true,
paid: false,
},
nexprint: {
site: "https://www.nexprint.com",
docs: "nexprint",
signIn: "user",
grab: "recommended",
flaresolverr: "none",
collections: true,
likes: false,
profiles: true,
paid: false,
},
thangs: {
site: "https://thangs.com",
docs: "thangs",
signIn: "user",
grab: "recommended",
flaresolverr: "optional",
collections: true,
likes: true,
profiles: false,
paid: true,
},
cults3d: {
site: "https://cults3d.com",
docs: "cults3d",
signIn: "user",
grab: "recommended",
flaresolverr: "none",
collections: true,
likes: true,
profiles: false,
paid: true,
},
};
export const PROVIDER_ORDER = Object.keys(PROVIDER_FACTS) as ProviderId[];
export const providerDocsUrl = (id: ProviderId) => `${THINGPORT_WEBSITE_URL}docs/providers/${PROVIDER_FACTS[id].docs}/`;
/** The provider's brand colour; the black-and-white marks take the text colour, so they show in both themes. */
export function providerAccent(id: ProviderId, theme: Theme): string {
const { color } = IMPORT_PROVIDER_INFO[id];
return color.startsWith("#") ? color : theme.palette.text.primary;
}
/** The accent as text or a thin mark on the paper: brand colours like Thangs' yellow on white, or Cults3D's purple on
* navy, are shifted until they read (WCAG's 3:1 for large text and icons). */
export function providerAccentText(id: ProviderId, theme: Theme): string {
const paper = theme.palette.background.paper;
const shift = theme.palette.mode === "dark" ? lighten : darken;
let color = providerAccent(id, theme);
for (let step = 0; step < 6 && getContrastRatio(color, paper) < 3.5; step++) color = shift(color, 0.15);
return color;
}
+157 -1
View File
@@ -1581,7 +1581,7 @@
},
"providers": {
"title": "Providers",
"subtitle": "The sites you import models from. Right-click one to connect or disconnect it.",
"subtitle": "The sites you import models from. Right-click one to connect it, disconnect it or read about it.",
"connected": "Connected",
"notConnected": "Not connected",
"connect": "Connect",
@@ -1774,6 +1774,162 @@
"extensionRecommended": "Thingport Grab, the browser extension, is the recommended way to import: it works right on the model page, brings in whole collections, and some sites only work with it. The bookmarklet is a lighter alternative for one model at a time.",
"getExtension": "Get Grab"
}
},
"providerAbout": {
"about": "About",
"aboutName": "About {{name}}",
"tabsLabel": "View",
"tabs": {
"about": "About",
"compare": "Compare"
},
"sections": {
"needs": "Needs",
"imports": "Imports",
"setup": "Setup"
},
"rows": {
"signIn": "Sign-in",
"grab": "Thingport Grab",
"flaresolverr": "FlareSolverr"
},
"rowHelp": {
"grab": "Thingport's browser extension. It imports from a model's page and hands your browser's sign-in to Thingport.",
"flaresolverr": "Part of the Docker stack. It gets past Cloudflare's checks with a real browser; set it in Administration → Connections."
},
"need": {
"required": "Required",
"recommended": "Recommended",
"optional": "Optional",
"none": "Not used"
},
"signIn": {
"none": "None",
"admin": "Admin's key",
"user": "Your sign-in",
"adminUser": "Admin's key + yours",
"userOptional": "Optional"
},
"features": {
"models": "Models",
"profiles": "Print profiles",
"collections": "Collections",
"likes": "Likes",
"sync": "Sync",
"paid": "Paid models"
},
"flareNote": {
"recommended": "Gets past Cloudflare when it challenges the server. Without it, those imports fail.",
"optional": "Only a fallback, if Cloudflare ever steps in.",
"none": "Not needed."
},
"guide": "Full guide",
"getGrab": "Get Thingport Grab",
"visit": "Open {{host}}",
"compareLabel": "What each provider needs and imports",
"off": "Turned off",
"yes": "Yes",
"no": "No",
"providers": {
"makerworld": {
"summary": "Bambu Lab's platform for sharing 3D printable models, many with tuned print profiles. Open to any printer, with MakerLab tools for customising models.",
"signIn": "Your own MakerWorld session cookie.",
"grab": "Reads the cookie for you, and imports whole collections without running into MakerWorld's rate limits.",
"setup": [
"Install Thingport Grab and import once from a MakerWorld page while signed in: it connects MakerWorld for you.",
"Or by hand: copy the token cookie from your browser's developer tools (Application → Cookies → makerworld.com).",
"Right-click MakerWorld in Configuration → Providers, choose Connect and paste it."
]
},
"printables": {
"summary": "Prusa Research's free model repository, formerly PrusaPrinters, with contests and Prusameters rewards. Its files work on any printer.",
"signIn": "Nothing to connect.",
"grab": "Needed only for your private collections and likes, which Printables shows to you alone.",
"setup": [
"Nothing to set up: paste a Printables link, or import from its page with Thingport Grab.",
"For your private collections and likes, visit Printables signed in, with Thingport Grab."
]
},
"thingiverse": {
"summary": "One of the oldest and largest libraries of user-made 3D printable designs, most of them free and openly licensed.",
"signIn": "One App Token for everyone, set by an admin.",
"grab": "Handy for importing from Thingiverse's pages, but not needed.",
"setup": [
"Sign in at thingiverse.com and open thingiverse.com/apps/create.",
"Create an app of a type without a redirect URL, such as Desktop app, and copy its App Token.",
"An admin pastes it in Configuration → Providers → Thingiverse, or in Administration → Providers."
]
},
"myminifactory": {
"summary": "A curated marketplace of free and paid 3D printable files, best known for tabletop miniatures. Models are checked before they're listed.",
"signIn": "An API key set by an admin; your own sign-in adds the models you bought.",
"grab": "Connects your account when you just visit myminifactory.com.",
"setup": [
"An admin creates an application at myminifactory.com/settings/developer and saves its API key in Administration → Providers.",
"For bought models, open myminifactory.com signed in with Thingport Grab, or paste your PHPSESSID cookie in Configuration → Providers."
]
},
"snapmaker": {
"summary": "Snapmaker's model platform, built around its U1 printer. Featured models are test-printed, with profiles tuned for multi-colour prints.",
"signIn": "Your own sign-in. It lasts a day.",
"grab": "The only thing that renews the sign-in. Without it, you paste a new one every day.",
"setup": [
"Install Thingport Grab, enable it on Snapmaker Space and open space.snapmaker.com signed in.",
"Grab then keeps the sign-in fresh while your browser is open.",
"By hand: paste the login_access_token cookie in Configuration → Providers. It lasts a day."
]
},
"makeronline": {
"summary": "Anycubic's model platform: free and paid models, designer contests and an AI text-to-3D tool.",
"signIn": "None to import; your sign-in adds your likes and private collections.",
"grab": "Grab downloads every MakerOnline file, in a makeronline.com page. Imports started elsewhere wait for it.",
"setup": [
"Install Thingport Grab and enable it on MakerOnline.",
"Import from a model page, or open any makeronline.com page to bring in the models waiting for Grab.",
"Signed in there, Grab also connects your account for your likes and private collections."
]
},
"crealitycloud": {
"summary": "Creality's 3D printing platform: a large library of free and premium models, with cloud slicing and remote printing.",
"signIn": "Your own sign-in. It lasts a week and isn't renewed.",
"grab": "Sends each new sign-in as you sign in on crealitycloud.com.",
"setup": [
"Install Thingport Grab, enable it on Creality Cloud and open crealitycloud.com signed in.",
"By hand: copy the Cookie header of a crealitycloud.com/api request and paste it in Configuration → Providers.",
"After a week, sign in there again to reconnect."
]
},
"nexprint": {
"summary": "Elegoo's open model platform, launched in 2025. It mixes Elegoo's own library with community designs, and works with Elegoo Slicer and OrcaSlicer.",
"signIn": "Your own sign-in, which the site renews as you use it.",
"grab": "Sends the renewed sign-in as you visit nexprint.com.",
"setup": [
"Install Thingport Grab, enable it on NexPrint and open nexprint.com signed in.",
"By hand: copy the Authorization header of a nexprint.com/gateway request and paste it in Configuration → Providers."
]
},
"thangs": {
"summary": "A 3D model search engine by Physna that finds models by their shape as well as their name, with a marketplace and designer memberships.",
"signIn": "Your own sign-in; Thingport renews it for about a year.",
"grab": "Connects your account in one visit to thangs.com.",
"setup": [
"Install Thingport Grab, enable it on Thangs and open thangs.com signed in.",
"By hand: copy the Cookie header of a thangs.com/api request and paste it in Configuration → Providers.",
"Free accounts download a limited number of models a day."
]
},
"cults3d": {
"summary": "A French marketplace for free and paid 3D printable designs, founded in 2014, and a social network for makers.",
"signIn": "Your own API key, and your browser's sign-in for files.",
"grab": "Sends the browser's sign-in and Cloudflare clearance. Thingport must reach the internet from that browser's IP address.",
"flaresolverr": "Doesn't help: its browser can't hand over a download link.",
"setup": [
"Make an API key at cults3d.com/en/api/keys.",
"Right-click Cults3D in Configuration → Providers, choose Connect, and enter your nickname and the key.",
"Open cults3d.com signed in with Thingport Grab, so its files can download."
]
}
}
}
},
"common": {
+157 -1
View File
@@ -1637,7 +1637,7 @@
},
"providers": {
"title": "Šaltiniai",
"subtitle": "Svetainės, iš kurių importuojate modelius. Spustelėkite dešiniuoju pelės mygtuku, kad prijungtumėte ar atjungtumėte.",
"subtitle": "Svetainės, iš kurių importuojate modelius. Spustelėkite dešiniuoju pelės mygtuku, kad prijungtumėte, atjungtumėte ar sužinotumėte daugiau.",
"connected": "Prijungta",
"notConnected": "Neprijungta",
"connect": "Prijungti",
@@ -1830,6 +1830,162 @@
"extensionRecommended": "Rekomenduojamas importavimo būdas yra naršyklės plėtinys Thingport Grab: jis veikia tiesiai modelio puslapyje, importuoja ištisas kolekcijas, o kai kurios svetainės veikia tik su juo. Žymių programėlė yra paprastesnė alternatyva vienam modeliui importuoti.",
"getExtension": "Gauti Grab"
}
},
"providerAbout": {
"about": "Apie",
"aboutName": "Apie {{name}}",
"tabsLabel": "Rodinys",
"tabs": {
"about": "Apie",
"compare": "Palyginti"
},
"sections": {
"needs": "Reikia",
"imports": "Importuoja",
"setup": "Nustatymas"
},
"rows": {
"signIn": "Prisijungimas",
"grab": "Thingport Grab",
"flaresolverr": "FlareSolverr"
},
"rowHelp": {
"grab": "Thingport naršyklės plėtinys. Jis importuoja tiesiai iš modelio puslapio ir perduoda Thingport jūsų naršyklės prisijungimą.",
"flaresolverr": "Docker rinkinio dalis. Tikra naršykle jis įveikia Cloudflare patikras; nustatomas skiltyje Administravimas → Ryšiai."
},
"need": {
"required": "Būtinas",
"recommended": "Rekomenduojamas",
"optional": "Nebūtinas",
"none": "Nenaudojamas"
},
"signIn": {
"none": "Nereikia",
"admin": "Administratoriaus raktas",
"user": "Jūsų prisijungimas",
"adminUser": "Administratoriaus ir jūsų",
"userOptional": "Nebūtinas"
},
"features": {
"models": "Modeliai",
"profiles": "Spausdinimo profiliai",
"collections": "Kolekcijos",
"likes": "Pamėgti",
"sync": "Sinchronizavimas",
"paid": "Mokami modeliai"
},
"flareNote": {
"recommended": "Įveikia Cloudflare, kai šis patikrina serverį. Be jo tokie importai nepavyksta.",
"optional": "Tik atsarginis variantas, jei Cloudflare kada įsikištų.",
"none": "Nereikia."
},
"guide": "Visas vadovas",
"getGrab": "Gauti Thingport Grab",
"visit": "Atverti {{host}}",
"compareLabel": "Ko reikia kiekvienam šaltiniui ir ką jis importuoja",
"off": "Išjungtas",
"yes": "Taip",
"no": "Ne",
"providers": {
"makerworld": {
"summary": "Bambu Lab platforma 3D spausdinimo modeliams dalytis, daugelis jų – su suderintais spausdinimo profiliais. Tinka bet kuriam spausdintuvui, o MakerLab įrankiais modelius galima pritaikyti.",
"signIn": "Jūsų MakerWorld sesijos slapukas.",
"grab": "Pats nuskaito slapuką ir importuoja ištisas kolekcijas, neperžengdamas MakerWorld užklausų ribų.",
"setup": [
"Įdiekite Thingport Grab ir vieną kartą importuokite iš MakerWorld puslapio prisijungę: jis pats prijungs MakerWorld.",
"Arba rankiniu būdu: nukopijuokite token slapuką iš naršyklės kūrėjo įrankių (Application → Cookies → makerworld.com).",
"Spustelėkite MakerWorld dešiniuoju mygtuku skiltyje Konfigūracija → Šaltiniai, pasirinkite Prijungti ir įklijuokite."
]
},
"printables": {
"summary": "Nemokama Prusa Research modelių saugykla, anksčiau vadinta PrusaPrinters, su konkursais ir Prusameters apdovanojimais. Jos failai tinka bet kuriam spausdintuvui.",
"signIn": "Nieko jungti nereikia.",
"grab": "Reikia tik jūsų privačioms kolekcijoms ir pamėgtiems modeliams, kuriuos Printables rodo tik jums.",
"setup": [
"Nieko nustatyti nereikia: įklijuokite Printables nuorodą arba importuokite iš jos puslapio su Thingport Grab.",
"Privačioms kolekcijoms ir pamėgtiems modeliams atverkite Printables prisijungę, su Thingport Grab."
]
},
"thingiverse": {
"summary": "Viena seniausių ir didžiausių naudotojų sukurtų 3D spausdinimo dizainų bibliotekų; dauguma jų nemokami ir atvirai licencijuoti.",
"signIn": "Vienas programėlės raktas visiems, jį nustato administratorius.",
"grab": "Patogu importuoti iš Thingiverse puslapių, bet nebūtina.",
"setup": [
"Prisijunkite prie thingiverse.com ir atverkite thingiverse.com/apps/create.",
"Sukurkite programėlę, kuriai nereikia peradresavimo adreso, pvz., Desktop app, ir nukopijuokite jos App Token.",
"Administratorius jį įklijuoja skiltyje Konfigūracija → Šaltiniai → Thingiverse arba Administravimas → Šaltiniai."
]
},
"myminifactory": {
"summary": "Atrinktų nemokamų ir mokamų 3D spausdinimo failų parduotuvė, labiausiai žinoma stalo žaidimų miniatiūromis. Modeliai patikrinami prieš juos paskelbiant.",
"signIn": "API raktas, kurį nustato administratorius; jūsų prisijungimas prideda nusipirktus modelius.",
"grab": "Prijungia jūsų paskyrą, kai tiesiog atveriate myminifactory.com.",
"setup": [
"Administratorius sukuria programėlę myminifactory.com/settings/developer ir išsaugo jos API raktą skiltyje Administravimas → Šaltiniai.",
"Nusipirktiems modeliams atverkite myminifactory.com prisijungę su Thingport Grab arba įklijuokite PHPSESSID slapuką skiltyje Konfigūracija → Šaltiniai."
]
},
"snapmaker": {
"summary": "Snapmaker modelių platforma, sukurta jos U1 spausdintuvui. Rekomenduojami modeliai išbandomi spausdinant, profiliai suderinti spalvotam spausdinimui.",
"signIn": "Jūsų prisijungimas. Jis galioja parą.",
"grab": "Tik jis atnaujina prisijungimą. Be jo naują tektų įklijuoti kasdien.",
"setup": [
"Įdiekite Thingport Grab, įjunkite jį Snapmaker Space ir atverkite space.snapmaker.com prisijungę.",
"Tada Grab palaiko prisijungimą galiojantį, kol naršyklė atverta.",
"Rankiniu būdu: įklijuokite login_access_token slapuką skiltyje Konfigūracija → Šaltiniai. Jis galioja parą."
]
},
"makeronline": {
"summary": "Anycubic modelių platforma: nemokami ir mokami modeliai, dizainerių konkursai ir DI įrankis 3D modeliams iš teksto kurti.",
"signIn": "Importuoti nereikia; prisijungimas prideda jūsų pamėgtus modelius ir privačias kolekcijas.",
"grab": "Kiekvieną MakerOnline failą atsisiunčia Grab, makeronline.com puslapyje. Kitur pradėti importai jo laukia.",
"setup": [
"Įdiekite Thingport Grab ir įjunkite jį MakerOnline.",
"Importuokite iš modelio puslapio arba atverkite bet kurį makeronline.com puslapį, kad atkeliautų Grab laukiantys modeliai.",
"Jei ten prisijungę, Grab prijungia ir jūsų paskyrą pamėgtiems modeliams ir privačioms kolekcijoms."
]
},
"crealitycloud": {
"summary": "Creality 3D spausdinimo platforma: didelė nemokamų ir aukščiausios kokybės modelių biblioteka, pjaustymas debesyje ir nuotolinis spausdinimas.",
"signIn": "Jūsų prisijungimas. Jis galioja savaitę ir nėra atnaujinamas.",
"grab": "Perduoda kiekvieną naują prisijungimą, kai prisijungiate crealitycloud.com.",
"setup": [
"Įdiekite Thingport Grab, įjunkite jį Creality Cloud ir atverkite crealitycloud.com prisijungę.",
"Rankiniu būdu: nukopijuokite crealitycloud.com/api užklausos Cookie antraštę ir įklijuokite skiltyje Konfigūracija → Šaltiniai.",
"Po savaitės prisijunkite ten vėl, kad prijungtumėte iš naujo."
]
},
"nexprint": {
"summary": "Atvira Elegoo modelių platforma, pradėta 2025 m. Joje Elegoo biblioteka sujungta su bendruomenės dizainais, ji veikia su Elegoo Slicer ir OrcaSlicer.",
"signIn": "Jūsų prisijungimas, kurį svetainė atnaujina, kol ja naudojatės.",
"grab": "Perduoda atnaujintą prisijungimą, kai lankotės nexprint.com.",
"setup": [
"Įdiekite Thingport Grab, įjunkite jį NexPrint ir atverkite nexprint.com prisijungę.",
"Rankiniu būdu: nukopijuokite nexprint.com/gateway užklausos Authorization antraštę ir įklijuokite skiltyje Konfigūracija → Šaltiniai."
]
},
"thangs": {
"summary": "Physna sukurta 3D modelių paieškos sistema, randanti modelius ne tik pagal pavadinimą, bet ir pagal formą, su parduotuve ir dizainerių narystėmis.",
"signIn": "Jūsų prisijungimas; Thingport jį atnaujina maždaug metus.",
"grab": "Prijungia jūsų paskyrą vienu apsilankymu thangs.com.",
"setup": [
"Įdiekite Thingport Grab, įjunkite jį Thangs ir atverkite thangs.com prisijungę.",
"Rankiniu būdu: nukopijuokite thangs.com/api užklausos Cookie antraštę ir įklijuokite skiltyje Konfigūracija → Šaltiniai.",
"Nemokamos paskyros per dieną gali atsisiųsti ribotą modelių skaičių."
]
},
"cults3d": {
"summary": "Prancūziška nemokamų ir mokamų 3D spausdinimo dizainų parduotuvė, įkurta 2014 m., kartu ir socialinis tinklas kūrėjams.",
"signIn": "Jūsų API raktas ir naršyklės prisijungimas failams.",
"grab": "Perduoda naršyklės prisijungimą ir Cloudflare leidimą. Thingport turi jungtis į internetą iš tos naršyklės IP adreso.",
"flaresolverr": "Nepadeda: jo naršyklė negali perduoti atsisiuntimo nuorodos.",
"setup": [
"Sukurkite API raktą cults3d.com/en/api/keys.",
"Spustelėkite Cults3D dešiniuoju mygtuku skiltyje Konfigūracija → Šaltiniai, pasirinkite Prijungti ir įveskite slapyvardį bei raktą.",
"Atverkite cults3d.com prisijungę su Thingport Grab, kad failus būtų galima atsisiųsti."
]
}
}
}
},
"common": {
@@ -2,6 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
@@ -21,6 +22,7 @@ import { useConfirm } from "../../components/ConfirmProvider";
import { COMING_SOON_PROVIDERS, ProviderLogo } from "../../components/Layout/ConfigurationDialog/providerLogos";
import SectionHeader from "../../components/SectionHeader";
import { publishProviders } from "../../hooks/useDisabledProviders";
import { useProviderAbout } from "../../components/providerAbout/ProviderAboutProvider";
import ProviderKeySection, { type KeyedProvider } from "./ProviderKeySection";
type Props = {
@@ -147,13 +149,17 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
alignItems="center"
sx={{ px: 2, py: 1.5, "&:not(:last-of-type)": { borderBottom: 1, borderColor: "divider" } }}
>
<Box sx={{ flexShrink: 0, lineHeight: 0, opacity: enabled ? 1 : 0.45 }}>
<ProviderLogo id={id} size={32} />
</Box>
<AboutButton id={id} name={NAMES[id]}>
<Box sx={{ flexShrink: 0, lineHeight: 0, opacity: enabled ? 1 : 0.45 }}>
<ProviderLogo id={id} size={32} />
</Box>
</AboutButton>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body1" fontWeight={600}>
{NAMES[id]}
</Typography>
<AboutButton id={id} name={NAMES[id]} tabIndex={-1}>
<Typography variant="body1" fontWeight={600}>
{NAMES[id]}
</Typography>
</AboutButton>
<Typography variant="body2" color="text.secondary">
{t(`adminSettings.providers.descriptions.${id}`)}
</Typography>
@@ -234,3 +240,39 @@ export default function ProvidersPage({ onUnauthorized }: Props) {
</Stack>
);
}
/** The logo or name of a provider: opens its About dialog. The name repeats the logo's button, so it's skipped by Tab. */
function AboutButton({
id,
name,
tabIndex,
children,
}: {
id: ImportProvider;
name: string;
tabIndex?: number;
children: React.ReactNode;
}) {
const { t } = useTranslation("app");
const openAbout = useProviderAbout(id);
if (!openAbout) return <>{children}</>;
return (
<Tooltip title={t("providerAbout.aboutName", { name })}>
<ButtonBase
onClick={openAbout}
tabIndex={tabIndex}
aria-label={t("providerAbout.aboutName", { name })}
sx={{
borderRadius: "8px",
textAlign: "left",
transition: "transform 150ms ease",
"&:hover": { transform: "translateY(-1px)", "& .MuiTypography-root": { textDecoration: "underline" } },
"&.Mui-focusVisible": { outline: 2, outlineColor: "primary.main", outlineOffset: 2 },
"@media (prefers-reduced-motion: reduce)": { transition: "none", "&:hover": { transform: "none" } },
}}
>
{children}
</ButtonBase>
</Tooltip>
);
}
@@ -9,12 +9,13 @@ import Chip from "@mui/material/Chip";
import PublicIcon from "@mui/icons-material/Public";
import { printProviderInfo, providerChipSx } from "../../constants/importProviders";
import type { DashboardProvider } from "../../api/dashboard";
import { useProviderAbout } from "../../components/providerAbout/ProviderAboutProvider";
type Props = {
providers: DashboardProvider[];
};
/** A small fixed set, so no "see more" and no clickable rows. */
/** A small fixed set, so no "see more". A provider's chip opens its About dialog; Thingport's own (uploads) has none. */
export default function ProviderListCard({ providers }: Props) {
const { t } = useTranslation("app");
@@ -42,11 +43,10 @@ export default function ProviderListCard({ providers }: Props) {
) : (
<List dense disablePadding>
{providers.map((p) => {
const info = printProviderInfo(p.provider);
return (
<ListItem key={p.provider} sx={{ px: 1 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: "100%" }}>
<Chip label={info.label} size="small" sx={{ ...providerChipSx(info), fontWeight: 600 }} />
<ProviderChip provider={p.provider} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{t("dashboard.topProviders.modelCount", { count: p.model_count })}
</Typography>
@@ -59,3 +59,28 @@ export default function ProviderListCard({ providers }: Props) {
</Paper>
);
}
function ProviderChip({ provider }: { provider: string | null }) {
const { t } = useTranslation("app");
const info = printProviderInfo(provider);
const openAbout = useProviderAbout(provider);
return (
<Chip
label={info.label}
size="small"
onClick={openAbout ?? undefined}
aria-label={openAbout ? t("providerAbout.aboutName", { name: info.label }) : undefined}
sx={{
...providerChipSx(info),
fontWeight: 600,
// MUI's clickable chip darkens on hover; keep the brand colour and lift it instead.
"&.MuiChip-clickable:hover": { bgcolor: info.color, transform: "translateY(-1px)", boxShadow: 2 },
transition: "transform 150ms ease, box-shadow 150ms ease",
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
"&.MuiChip-clickable:hover": { transform: "none" },
},
}}
/>
);
}