diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index f4198bb..03eb01c 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -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 - + + + diff --git a/frontend/src/components/Layout/ConfigurationDialog/ProvidersPanel.tsx b/frontend/src/components/Layout/ConfigurationDialog/ProvidersPanel.tsx index 4cba3b2..edb5c40 100644 --- a/frontend/src/components/Layout/ConfigurationDialog/ProvidersPanel.tsx +++ b/frontend/src/components/Layout/ConfigurationDialog/ProvidersPanel.tsx @@ -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 && setMenu(null)} />} + {menuId && !isComingSoon(menuId) && } {menuId && menuExpired && ( { @@ -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 ( + { + onClick(); + openAbout(); + }} + > + + + + {t("providerAbout.about")} + + ); +} + type ConnectBoxProps = { id: ProviderId; /** "session": Cults3D's browser sign-in, pasted, rather than its API key. */ diff --git a/frontend/src/components/ProviderBadge.tsx b/frontend/src/components/ProviderBadge.tsx index 8c35f4f..713ea52 100644 --- a/frontend/src/components/ProviderBadge.tsx +++ b/frontend/src/components/ProviderBadge.tsx @@ -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; }; -/** 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) { > { + // 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]), ]} > - - {label} - + {!openAbout && ( + + {label} + + )} {hasProviderLogo(provider) ? ( ) : ( diff --git a/frontend/src/components/providerAbout/ProviderAboutDialog.tsx b/frontend/src/components/providerAbout/ProviderAboutDialog.tsx new file mode 100644 index 0000000..aca1afc --- /dev/null +++ b/frontend/src/components/providerAbout/ProviderAboutDialog.tsx @@ -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(provider ?? "makerworld"); + const [view, setView] = React.useState("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 ( + + + + + + + + + + + muiTheme.thingport.headingText }} + > + {name} + + + {host} + + + + + + + + + + + value={view} + onChange={setView} + label={t("providerAbout.tabsLabel")} + options={[ + { value: "about", label: t("providerAbout.tabs.about") }, + { value: "compare", label: t("providerAbout.tabs.compare") }, + ]} + /> + + {view === "about" ? ( + + ) : ( + { + setId(next); + setView("about"); + }} + /> + )} + + + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function NeedPill({ need, accent }: { need: Need; accent: string }) { + const { t } = useTranslation("app"); + const strong = need === "required" || need === "recommended"; + return ( + + {t(`providerAbout.need.${need}`)} + + ); +} + +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: ( + + {t(`providerAbout.signIn.${facts.signIn}`)} + + ), + note: t(`${key}.signIn`), + }, + { + label: t("providerAbout.rows.grab"), + help: t("providerAbout.rowHelp.grab"), + pill: , + note: t(`${key}.grab`), + }, + { + label: t("providerAbout.rows.flaresolverr"), + help: t("providerAbout.rowHelp.flaresolverr"), + pill: , + note: flareNote, + }, + ]; + const features = [ + { key: "models", on: true }, + ...FEATURES.map((feature) => ({ key: feature, on: facts[feature] })), + { key: "sync", on: true }, + ]; + + return ( + + + {t(`${key}.summary`)} + + + {t("providerAbout.sections.needs")} + + {rows.map((row) => ( + + + {row.label} + {row.help && } + + + {row.pill} + + {row.note} + + + + ))} + + + {t("providerAbout.sections.imports")} + + {features.map(({ key: feature, on }, index) => ( + + {on ? ( + + ) : ( + + )} + {t(`providerAbout.features.${feature}`)} + + ))} + + + {t("providerAbout.sections.setup")} + + {setup.map((step) => ( + + {step} + + ))} + + + + + {facts.grab !== "none" && ( + + )} + + + ); +} + +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 ? ( + + ) : ( + + ); + + return ( + + + + + + {columns.map((column) => ( + + {column} + + ))} + + + + {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 ( + 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`, + }} + > + + + + + {IMPORT_PROVIDER_INFO[id].label} + + {off && ( + + ({t("providerAbout.off")}) + + )} + + + + {t(`providerAbout.signIn.${facts.signIn}`)} + + + + + + + + {FEATURES.map((feature) => ( + + {tick(facts[feature], accent)} + + ))} + + ); + })} + + + + ); +} diff --git a/frontend/src/components/providerAbout/ProviderAboutProvider.tsx b/frontend/src/components/providerAbout/ProviderAboutProvider.tsx new file mode 100644 index 0000000..8181dee --- /dev/null +++ b/frontend/src/components/providerAbout/ProviderAboutProvider.tsx @@ -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(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(null); + return ( + + {children} + setProvider(null)} /> + + ); +} + +/** 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); +} diff --git a/frontend/src/components/providerAbout/ProviderFlowAnimation.tsx b/frontend/src/components/providerAbout/ProviderFlowAnimation.tsx new file mode 100644 index 0000000..2d8bb4b --- /dev/null +++ b/frontend/src/components/providerAbout/ProviderFlowAnimation.tsx @@ -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: }, + ...(facts.flaresolverr !== "none" + ? [ + { + key: "flaresolverr", + label: "FlareSolverr", + need: facts.flaresolverr, + icon: , + }, + ] + : []), + ...(facts.grab !== "none" + ? [ + { + key: "grab", + label: "Thingport Grab", + need: facts.grab, + icon: , + }, + ] + : []), + { + key: "thingport", + label: "Thingport", + icon: ( + + ), + }, + ]; + + return ( + + {/* The path, from the first node's middle to the last's, with models running along it. */} + + {Array.from({ length: MODELS }, (_, i) => ( + + + + ))} + + + {nodes.map((node, index) => { + const faint = node.need === "optional"; + const isProvider = node.key === "provider"; + const isThingport = node.key === "thingport"; + return ( + + + {isProvider && ( + + )} + + {node.icon} + + + + {node.label} + + {node.need && ( + + {t(`providerAbout.need.${node.need}`)} + + )} + + ); + })} + + ); +} diff --git a/frontend/src/constants/providerFacts.ts b/frontend/src/constants/providerFacts.ts new file mode 100644 index 0000000..248f8c3 --- /dev/null +++ b/frontend/src/constants/providerFacts.ts @@ -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 = { + 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; +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 741e68e..ed6778d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index b5ae785..09a214d 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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": { diff --git a/frontend/src/pages/AdminSettingsPage/ProvidersPage.tsx b/frontend/src/pages/AdminSettingsPage/ProvidersPage.tsx index 88f5478..3d1b26c 100644 --- a/frontend/src/pages/AdminSettingsPage/ProvidersPage.tsx +++ b/frontend/src/pages/AdminSettingsPage/ProvidersPage.tsx @@ -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" } }} > - - - + + + + + - - {NAMES[id]} - + + + {NAMES[id]} + + {t(`adminSettings.providers.descriptions.${id}`)} @@ -234,3 +240,39 @@ export default function ProvidersPage({ onUnauthorized }: Props) { ); } + +/** 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 ( + + + {children} + + + ); +} diff --git a/frontend/src/pages/DashboardPage/ProviderListCard.tsx b/frontend/src/pages/DashboardPage/ProviderListCard.tsx index 0b82008..caf1176 100644 --- a/frontend/src/pages/DashboardPage/ProviderListCard.tsx +++ b/frontend/src/pages/DashboardPage/ProviderListCard.tsx @@ -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) { ) : ( {providers.map((p) => { - const info = printProviderInfo(p.provider); return ( - + {t("dashboard.topProviders.modelCount", { count: p.model_count })} @@ -59,3 +59,28 @@ export default function ProviderListCard({ providers }: Props) { ); } + +function ProviderChip({ provider }: { provider: string | null }) { + const { t } = useTranslation("app"); + const info = printProviderInfo(provider); + const openAbout = useProviderAbout(provider); + return ( + + ); +}