feat: show model sources as provider badges

Cards and the model page show the provider's logo on a round badge instead of a text chip; the chip shows in its tooltip.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-19 23:44:03 +02:00
1 parent 70e3821e1a
commit 0aff9e9a9e
4 files changed
+111 -27

No files matched your search

@@ -92,6 +92,11 @@ const LOGOS: Record<ProviderId, Logo> = {
},
};
/** Whether a provider name, such as a model's source, is one there's a logo for. */
export function hasProviderLogo(id: string | null | undefined): id is ProviderId {
return !!id && Object.hasOwn(LOGOS, id);
}
export function ProviderLogo({ id, size }: { id: ProviderId; size: number }) {
const { viewBox = "0 0 24 24", color, path, fillRule, layers } = LOGOS[id];
return (
+97
View File
@@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import { useTheme, type SxProps, type Theme } from "@mui/material/styles";
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";
// Read by screen readers, not shown.
const visuallyHidden = {
position: "absolute",
width: "1px",
height: "1px",
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
} as const;
type Props = {
/** The model's source provider; null for a direct upload, shown with Thingport's own mark. */
provider: string | null | undefined;
/** The badge's width and height, in px. */
size?: number;
sx?: SxProps<Theme>;
};
/** Where a model came from, as the provider's logo on a round badge; its chip shows on hover or focus. */
export default function ProviderBadge({ provider, size = 28, sx }: Props) {
const { t } = useTranslation("models");
const theme = useTheme();
const info = printProviderInfo(provider);
const label = provider ? t("card.importedFrom", { provider: info.label }) : t("card.uploadedDirectly");
const logoSize = Math.round(size * 0.6);
return (
<Tooltip
title={
<Box
sx={{
px: 1,
py: 0.375,
borderRadius: 1,
fontSize: 11,
fontWeight: 600,
lineHeight: 1.4,
...providerChipSx(info),
}}
>
{info.label}
</Box>
}
// The chip is the whole tooltip, so the tooltip's own box stays out of sight.
slotProps={{ tooltip: { sx: { bgcolor: "transparent", p: 0, boxShadow: 3, borderRadius: 1 } } }}
>
<Box
tabIndex={0}
sx={[
{
width: size,
height: size,
display: "grid",
placeItems: "center",
borderRadius: "50%",
bgcolor: "background.paper",
color: "text.primary",
border: 1,
borderColor: "divider",
boxShadow: 2,
transition: "transform 150ms ease",
"&:hover, &:focus-visible": { transform: "scale(1.08)" },
"&:focus-visible": { outline: `2px solid ${theme.palette.primary.main}`, outlineOffset: 2 },
"@media (prefers-reduced-motion: reduce)": {
transition: "none",
"&:hover, &:focus-visible": { transform: "none" },
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box component="span" sx={visuallyHidden}>
{label}
</Box>
{hasProviderLogo(provider) ? (
<ProviderLogo id={provider} size={logoSize} />
) : (
<Box
component="img"
src={theme.palette.mode === "dark" ? markOnDark : markColor}
alt=""
aria-hidden="true"
sx={{ width: logoSize, height: logoSize, objectFit: "contain", display: "block" }}
/>
)}
</Box>
</Tooltip>
);
}
+7 -1
View File
@@ -19,6 +19,7 @@ import { type ResolvedTheme } from "../../constants/settingsOptions";
import { dividerBorderColor } from "../../theme";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import ProviderBadge from "../../components/ProviderBadge";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { ModelSnapshot } from "../../components/media/ModelViewer/ModelSnapshot";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
@@ -167,7 +168,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
with huge intrinsic pixel dimensions could still force this column wider than its "2fr"
share (and skew the whole two-column layout) even though the image itself is capped
below. */}
<Box sx={{ minWidth: 0 }}>
<Box sx={{ minWidth: 0, position: "relative" }}>
<Box
sx={{
position: "relative",
@@ -273,6 +274,11 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
</Button>
)}
</Box>
<ProviderBadge
provider={print.source_provider}
size={40}
sx={{ position: "absolute", top: 12, left: 12, zIndex: 1 }}
/>
{images.length > 1 && (
<Stack direction="row" spacing={1} sx={{ mt: 1.5, overflowX: "auto", pb: 0.5 }}>
+2 -26
View File
@@ -14,7 +14,7 @@ import { type Print, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { printProviderInfo, providerChipSx } from "../../constants/importProviders";
import ProviderBadge from "../../components/ProviderBadge";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
@@ -85,7 +85,6 @@ export default function ModelCard({
const authorName =
author?.name || author?.handle || item.creator || (showViewerAsAuthor ? viewer!.display_name : null);
const authorAvatarUrl = author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
const providerInfo = printProviderInfo(item.source_provider);
// Only mounted while hovered, so idle cards don't load images or tick.
const [hovered, setHovered] = useState(false);
const contextMenu = useCardContextMenu();
@@ -125,30 +124,7 @@ export default function ModelCard({
)}
</Box>
<Tooltip
title={
item.source_provider
? t("models:card.importedFrom", { provider: providerInfo.label })
: t("models:card.uploadedDirectly")
}
>
<Box
sx={{
position: "absolute",
top: 8,
left: 8,
px: 1,
py: 0.375,
borderRadius: 1,
fontSize: 11,
fontWeight: 600,
lineHeight: 1.4,
...providerChipSx(providerInfo),
}}
>
{providerInfo.label}
</Box>
</Tooltip>
<ProviderBadge provider={item.source_provider} sx={{ position: "absolute", top: 8, left: 8 }} />
<Stack
className="model-card-actions"