mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 06:32:27 -05:00
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:
1 parent
233c794065
commit
e76fbf3b58
11 files changed
+1335
-17
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user