feat: author linking and other minor author changes

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 16:30:16 +02:00
1 parent 8ba368e209
commit 589a5f464a
23 files changed
+502 -83

No files matched your search

@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bio" TEXT,
ADD COLUMN "backgroundUrl" TEXT;
-- CreateTable
CREATE TABLE "AuthorLink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuthorLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthorLink_authorId_key" ON "AuthorLink"("authorId");
-- CreateIndex
CREATE UNIQUE INDEX "AuthorLink_userId_provider_key" ON "AuthorLink"("userId", "provider");
-- AddForeignKey
ALTER TABLE "AuthorLink" ADD CONSTRAINT "AuthorLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthorLink" ADD CONSTRAINT "AuthorLink_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "Author"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+31
View File
@@ -35,12 +35,20 @@ model User {
// slicer's own registered URL protocol (e.g. bambustudio://) -- see
// services/slicerPreferenceService.ts. Not yet acted on anywhere; just stored for now.
slicer String?
// Populated by linking an imported Author to this account (the Author page's "It's me!"
// button -- see AuthorLink below) rather than edited directly: the first link with bio/cover
// data fills these in if they're still empty, later links never overwrite them. Shown on this
// user's own "My models" author page (AuthorPage's self mode) in place of the empty state a
// plain upload-only account would otherwise show.
bio String?
backgroundUrl String?
categories Category[]
prints Print[]
collections Collection[]
importJobs ImportJob[]
notifications Notification[]
logs Log[]
authorLinks AuthorLink[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -243,12 +251,35 @@ model Author {
avatarUrl String?
backgroundUrl String?
prints Print[]
// At most one Thingport account can claim to be this real-world creator -- see AuthorLink.
link AuthorLink?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, externalId])
}
/// Claims an imported Author row as "this is me" for one Thingport account (the Author page's
/// "It's me!" button). Two constraints model the real-world rule: `authorId` is unique because a
/// given external creator account can only ever be one Thingport user; `[userId, provider]` is
/// unique because a user can only have one linked identity per provider (you can't be two
/// different Thingiverse creators). Linking merges the author's bio/backgroundUrl onto User (see
/// its own doc comment) and lets that user's "My models" page also list this author's prints,
/// alongside their own plain uploads (see routes/prints.ts's SELF_AUTHOR_ID handling).
model AuthorLink {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
authorId String @unique
author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)
// Denormalized from Author.provider purely so the [userId, provider] unique index below doesn't
// need a join to enforce "one linked identity per provider per user".
provider String
createdAt DateTime @default(now())
@@unique([userId, provider])
}
model Plate {
id String @id @default(cuid())
printId String
+13 -1
View File
@@ -13,6 +13,10 @@ export type UserOut = {
role: "ADMIN" | "MEMBER";
// Set while an email change is awaiting confirmation (see routes/auth.ts's PATCH /profile).
pending_email: string | null;
// Populated by linking an imported Author to this account (see authorService.ts's
// linkAuthorToUser) -- shown on this user's own "My models" author page.
bio: string | null;
background_url: string | null;
};
export function toUserOut(user: User): UserOut {
@@ -22,6 +26,8 @@ export function toUserOut(user: User): UserOut {
display_name: user.displayName,
role: user.role,
pending_email: user.pendingEmail,
bio: user.bio,
background_url: user.backgroundUrl,
};
}
@@ -36,9 +42,14 @@ export type AuthorOut = {
links: string[];
avatar_url: string | null;
background_url: string | null;
// Whether ANY Thingport account has claimed this author as themselves (see authorService.ts's
// isAuthorLinked) -- doesn't say *who*, just whether the Author page's "It's me!" button should
// stay hidden. Defaults to false wherever a caller doesn't look it up (e.g. every Author
// embedded in a Print DTO) -- only the standalone GET /author/:id route computes it for real.
is_linked: boolean;
};
export function toAuthorOut(author: Author): AuthorOut {
export function toAuthorOut(author: Author, isLinked = false): AuthorOut {
return {
id: author.id,
provider: author.provider,
@@ -50,6 +61,7 @@ export function toAuthorOut(author: Author): AuthorOut {
links: author.links,
avatar_url: author.avatarUrl,
background_url: author.backgroundUrl,
is_linked: isLinked,
};
}
+23 -2
View File
@@ -3,7 +3,8 @@ import { prisma } from "../db";
import { requireAuth } from "../auth";
import { HttpError } from "../utils/fileUtils";
import { asyncHandler } from "../utils/asyncHandler";
import { toAuthorOut } from "../dto";
import { toAuthorOut, toUserOut } from "../dto";
import { getLinkedAuthorsForUser, isAuthorLinked, linkAuthorToUser } from "../services/authorService";
const router = Router();
router.use(requireAuth);
@@ -17,7 +18,27 @@ router.get(
asyncHandler(async (req, res) => {
const author = await prisma.author.findUnique({ where: { id: req.params.id } });
if (!author) throw new HttpError(404, "Author not found");
res.json(toAuthorOut(author));
res.json(toAuthorOut(author, await isAuthorLinked(author.id)));
}),
);
// The Author rows the current user has claimed as themselves -- backs both the "My models" page's
// provider chips and (client-side, per author) whether to show "It's me!" on a given author page.
router.get(
"/me/author-links",
asyncHandler(async (req, res) => {
const authors = await getLinkedAuthorsForUser(req.userId!);
res.json(authors.map((a) => toAuthorOut(a, true)));
}),
);
// The Author page's "It's me!" button, after its confirmation modal. See linkAuthorToUser's doc
// comment for the two uniqueness rules this enforces.
router.post(
"/author/:id/link",
asyncHandler(async (req, res) => {
const { author, user } = await linkAuthorToUser(req.userId!, req.params.id);
res.json({ author: toAuthorOut(author, true), user: toUserOut(user) });
}),
);
+38 -12
View File
@@ -11,7 +11,7 @@ import { modelUpload } from "../uploadMiddleware";
import { createPrint, deletePlateFiles, resolvePlateFilePath, type NewPlateInput } from "../services/printCreation";
import { plateThumbPath, relocatePrint, uniqueModelName } from "../services/printService";
import { previewImagePath, deleteAllPreviewImages } from "../services/previewImageService";
import { deleteAuthorIfOrphaned } from "../services/authorService";
import { deleteAuthorIfOrphaned, getLinkedAuthorIds } from "../services/authorService";
import { toPrintOut } from "../dto";
import { loadFullPrint, printOutById } from "../services/printLoader";
import { deleteAllPrintFiles, saveFileFromTemp } from "../services/printFileService";
@@ -24,6 +24,10 @@ import type { Prisma } from "@prisma/client";
const router = Router();
router.use(requireAuth);
// Sentinel `author_id` value the "My models" author page sends to mean "prints with no real
// author at all" (see buildPrintWhere below) -- never a value a real Author id could take.
const SELF_AUTHOR_ID = "self";
// Tag matching is case-insensitive (a print tagged "Fun" should still show up under "fun"), which
// Postgres array containment (`hasEvery`/`hasSome`) doesn't support natively -- so tags are left
// out of the DB where clause and matched in JS instead, via matchesTagList below. Every route that
@@ -47,7 +51,7 @@ function isRenderableUpload(f: Express.Multer.File): boolean {
return RENDERABLE_MODEL_EXTS.has(path.extname(f.originalname).toLowerCase());
}
function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
async function buildPrintWhere(req: Request): Promise<Prisma.PrintWhereInput> {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const categoryIdParam = typeof req.query.category_id === "string" ? req.query.category_id : "";
const categoryIds = categoryIdParam
@@ -58,9 +62,28 @@ function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
const authorId = typeof req.query.author_id === "string" ? req.query.author_id.trim() : "";
const where: Prisma.PrintWhereInput = { userId: req.userId };
// Composed as top-level AND clauses (rather than each assigning `where.OR` directly) since the
// self-author case and the `q` search below each need their own OR group -- the second one
// would otherwise silently clobber the first.
const andClauses: Prisma.PrintWhereInput[] = [];
if (categoryIds.length === 1) where.categoryId = categoryIds[0];
else if (categoryIds.length > 1) where.categoryId = { in: categoryIds };
if (authorId) where.authorId = authorId;
if (authorId === SELF_AUTHOR_ID) {
// The "My models" author page (see AuthorPage's self mode): "no real author at all" (no
// Author row, no plain creator string, not resolved from a known import source -- exactly
// what ModelCard/ModelSidePanel's showViewerAsAuthor fallback covers) OR one of the Author
// rows this user has explicitly linked as themselves (the Author page's "It's me!" button --
// see authorService.ts's getLinkedAuthorIds/linkAuthorToUser).
const linkedAuthorIds = await getLinkedAuthorIds(req.userId!);
andClauses.push({
OR: [
{ authorId: null, creator: null, sourceProvider: null },
...(linkedAuthorIds.length ? [{ authorId: { in: linkedAuthorIds } }] : []),
],
});
} else if (authorId) {
where.authorId = authorId;
}
if (collectionId) {
const systemKey = systemCollectionKeyForId(collectionId);
if (systemKey === "favorites") where.favoritedAt = { not: null };
@@ -68,14 +91,17 @@ function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
else where.collectionItems = { some: { collectionId } };
}
if (q) {
where.OR = [
{ name: { contains: q, mode: "insensitive" } },
{ title: { contains: q, mode: "insensitive" } },
{ notes: { contains: q, mode: "insensitive" } },
{ creator: { contains: q, mode: "insensitive" } },
{ collection: { contains: q, mode: "insensitive" } },
];
andClauses.push({
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ title: { contains: q, mode: "insensitive" } },
{ notes: { contains: q, mode: "insensitive" } },
{ creator: { contains: q, mode: "insensitive" } },
{ collection: { contains: q, mode: "insensitive" } },
],
});
}
if (andClauses.length) where.AND = andClauses;
return where;
}
@@ -173,7 +199,7 @@ router.get(
if (!Number.isFinite(offset) || offset < 0) throw new HttpError(400, "Invalid offset");
}
const where = buildPrintWhere(req);
const where = await buildPrintWhere(req);
const tagList = parseTagList(req);
const allMatching = await prisma.print.findMany({
where,
@@ -295,7 +321,7 @@ router.post(
router.get(
"/tags",
asyncHandler(async (req, res) => {
const where = buildPrintWhere(req);
const where = await buildPrintWhere(req);
const tagList = parseTagList(req);
const allRows = await prisma.print.findMany({ where, select: { tags: true } });
const rows = tagList.length ? allRows.filter((r) => matchesTagList(r.tags, tagList)) : allRows;
+62 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "../db";
import { HttpError } from "../utils/fileUtils";
import type { ImportedAuthorInfo } from "./importResolvers";
import type { Author } from "@prisma/client";
import type { Author, User } from "@prisma/client";
export function buildAuthorId(provider: string, externalId: string): string {
return `${provider}:${externalId}`;
@@ -54,3 +55,63 @@ export async function deleteAuthorIfOrphaned(authorId: string): Promise<void> {
if (remaining > 0) return;
await prisma.author.delete({ where: { id: authorId } }).catch(() => undefined);
}
/** Whether ANY Thingport account has claimed this Author as themselves -- what the Author page
* uses to decide whether to show "It's me!" at all, without revealing *who* claimed it (Author
* rows are shared/global, so the claimant could be a different account on this instance). */
export async function isAuthorLinked(authorId: string): Promise<boolean> {
const link = await prisma.authorLink.findUnique({ where: { authorId }, select: { id: true } });
return Boolean(link);
}
/** The Author rows this user has claimed as themselves -- backs both the "My models" page's
* provider chips and (via authorIdsForSelfPrints below) its models grid, and lets the Author page
* work out locally whether to show "It's me!" (hidden once the viewer already has a different
* author linked for that same provider -- see linkAuthorToUser's PROVIDER_ALREADY_LINKED check). */
export async function getLinkedAuthorsForUser(userId: string): Promise<Author[]> {
const links = await prisma.authorLink.findMany({ where: { userId }, include: { author: true } });
return links.map((l) => l.author);
}
/** Author ids linked to this user, for the "My models" grid query (routes/prints.ts's
* SELF_AUTHOR_ID handling) -- a plain id list is all that query needs, unlike
* getLinkedAuthorsForUser's full rows for the chips UI. */
export async function getLinkedAuthorIds(userId: string): Promise<string[]> {
const links = await prisma.authorLink.findMany({ where: { userId }, select: { authorId: true } });
return links.map((l) => l.authorId);
}
/** Claims an Author row as this user's own identity (the Author page's "It's me!" button, after
* its confirmation modal). Enforces the two real-world rules AuthorLink's schema comment
* describes -- this author isn't already claimed by anyone, and the user doesn't already have a
* *different* author linked for this same provider -- then merges the author's bio/backgroundUrl
* onto the user record, but only into fields still empty (a user who links a second provider
* keeps whatever bio/cover their first link already set; see User's own doc comment). The
* author's profile link is "copied over" implicitly and unconditionally by the link relation
* itself -- unlike bio/cover, there's no "already defined" case for it to conditionally skip. */
export async function linkAuthorToUser(userId: string, authorId: string): Promise<{ author: Author; user: User }> {
const author = await prisma.author.findUnique({ where: { id: authorId } });
if (!author) throw new HttpError(404, "Author not found");
const [existingLinkForAuthor, existingLinkForProvider] = await Promise.all([
prisma.authorLink.findUnique({ where: { authorId } }),
prisma.authorLink.findUnique({ where: { userId_provider: { userId, provider: author.provider } } }),
]);
if (existingLinkForAuthor) {
throw new HttpError(409, "This author is already linked to an account", "AUTHOR_ALREADY_LINKED");
}
if (existingLinkForProvider) {
throw new HttpError(409, "You already have a linked author for this provider", "PROVIDER_ALREADY_LINKED");
}
await prisma.authorLink.create({ data: { userId, authorId, provider: author.provider } });
const currentUser = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const bio = author.bio || author.bioTranslated;
const patch: { bio?: string; backgroundUrl?: string } = {};
if (!currentUser.bio && bio) patch.bio = bio;
if (!currentUser.backgroundUrl && author.backgroundUrl) patch.backgroundUrl = author.backgroundUrl;
const user = Object.keys(patch).length ? await prisma.user.update({ where: { id: userId }, data: patch }) : currentUser;
return { author, user };
}
+1 -1
View File
@@ -140,7 +140,7 @@ function AppShell({
/>
<Route
path="/authors/:authorId"
element={<AuthorPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
element={<AuthorPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} viewer={user} onUserUpdated={onUserUpdated} />}
/>
<Route
path="/profile"
+4
View File
@@ -8,6 +8,10 @@ export type AuthUser = {
role: "ADMIN" | "MEMBER";
// Set while an email change (Profile > Change email) is awaiting confirmation.
pending_email: string | null;
// Populated by linking an imported Author to this account (AuthorPage's "It's me!" button) --
// shown on this user's own "My models" author page. See authorsApi.link.
bio: string | null;
background_url: string | null;
};
export type AuthResult = { token: string; expires_in: number; user: AuthUser };
// /register returns this instead of AuthResult when SMTP is configured -- the account exists but
+23 -1
View File
@@ -1,5 +1,6 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
import type { AuthUser } from "./auth";
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
import type { Author } from "./prints";
export type { Author };
@@ -10,4 +11,25 @@ export const authorsApi = {
assertOk(res, "Failed to load author");
return res.json();
},
/** The Author rows the current user has claimed as themselves -- backs the "My models" page's
* provider chips, and lets a real author page work out whether the viewer already has a
* different author linked for that same provider (hiding "It's me!" if so). */
myLinks: async (): Promise<Author[]> => {
const res = await fetch(`${apiBase()}/me/author-links`, { headers: authHeaders() });
assertOk(res, "Failed to load linked authors");
return res.json();
},
/** The Author page's "It's me!" button, after its confirmation modal. Throws on the backend's
* two uniqueness rules (this author already claimed by someone, or the viewer already has a
* different author linked for this provider) -- both are meant to be impossible to reach from
* the UI (the button is hidden in both cases), so callers just surface the message rather than
* branching on a specific error code. */
link: async (id: string): Promise<{ author: Author; user: AuthUser }> => {
const res = await fetch(`${apiBase()}/author/${id}/link`, { method: "POST", headers: authHeaders() });
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to link author"));
return res.json();
},
};
+4
View File
@@ -48,6 +48,10 @@ export type Author = {
links: string[];
avatar_url: string | null;
background_url: string | null;
// Whether ANY Thingport account has claimed this author as themselves -- see authorsApi.link.
// Only computed by GET /author/:id and the /me/author-links endpoints; an Author embedded in a
// Print DTO always has this false regardless of the real state (see backend dto.ts's comment).
is_linked: boolean;
};
export type Print = {
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import type { Theme } from "@mui/material/styles";
import { dividerBorderColor } from "../../../theme";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
@@ -146,7 +147,7 @@ export default function Sidebar({ isAdmin, onSelectCategory }: Props) {
display: "flex",
flexDirection: "column",
borderRight: "1px solid",
borderColor: (theme) => (theme.palette.mode === "dark" ? "transparent" : "divider"),
borderColor: dividerBorderColor,
bgcolor: "background.paper",
overflow: "hidden",
transition: (theme) => theme.transitions.create("width", { duration: theme.transitions.duration.shortest }),
@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
@@ -11,12 +12,14 @@ import MenuItem from "@mui/material/MenuItem";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import PersonIcon from "@mui/icons-material/Person";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
import PaletteIcon from "@mui/icons-material/Palette";
import CheckIcon from "@mui/icons-material/Check";
import LogoutIcon from "@mui/icons-material/Logout";
import type { AuthUser } from "../../api/auth";
import type { ThemeSelection } from "../../constants/settingsOptions";
import { IMPORT_PROVIDER_INFO } from "../../constants/importProviders";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { settingsApi } from "../../api/settings";
@@ -50,6 +53,7 @@ type Props = {
* own chip; this just brings UserMenu's chip in line with it. */
export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
const avatarUrl = useGravatarUrl(user?.email, 128);
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const [themeAnchorEl, setThemeAnchorEl] = React.useState<HTMLElement | null>(null);
@@ -120,6 +124,10 @@ export function UserMenu({ user, theme, onThemeChange, onOpenProfile, onLogout }
</Tooltip>
</Box>
<Divider />
<MenuItem onClick={() => { closeMenu(); navigate(`/authors/${SELF_AUTHOR_ID}`); }}>
<ListItemIcon><ViewInArIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("userMenu.myModels")}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { closeMenu(); onOpenProfile(); }}>
<ListItemIcon><PersonIcon fontSize="small" /></ListItemIcon>
<ListItemText>{t("profile.title")}</ListItemText>
+7
View File
@@ -0,0 +1,7 @@
/** Sentinel `:authorId`/`author_id` value meaning "this viewer's own uploads" -- matched by the
* backend's identical SELF_AUTHOR_ID in routes/prints.ts. A real Author id is always
* "<provider>:<externalId>" (see authorService.ts), so this bare string can never collide with
* one. Used both as a route param (`/authors/self`, handled by AuthorPage's self mode) and as
* the `author_id` query param sent to printsApi.list for that mode's grid.
*/
export const SELF_AUTHOR_ID = "self";
+11 -2
View File
@@ -170,7 +170,8 @@
"serviceConnected": "{{service}}: connected",
"serviceNotConnected": "{{service}}: not connected",
"makerworldDisabledReason": "MakerWorld: you haven't added a cookie in your profile yet",
"thingiverseDisabledReason": "Thingiverse: the admin hasn't configured an API key yet"
"thingiverseDisabledReason": "Thingiverse: the admin hasn't configured an API key yet",
"myModels": "My models"
},
"notifications": {
"label": "Notifications",
@@ -1071,11 +1072,19 @@
"author": {
"pageTitle": "Author",
"pageTitleWithName": "Author - {{name}}",
"myModelsPageTitle": "My models",
"bio": "Bio",
"noBio": "No bio yet.",
"viewOnProvider": "View on {{provider}}",
"notFound": "Author not found.",
"noModels": "No models from this author yet."
"noModels": "No models from this author yet.",
"itsMe": "It's me!",
"linkedBadge": "This is you",
"linkConfirmTitle": "Link this author to your account?",
"linkConfirmMessage": "This links \"{{name}}\" as your own identity. Your \"My models\" page will also list their models, and your profile bio/cover picture will be filled in from theirs if you don't already have one set.",
"linkConfirmLabel": "Yes, it's me",
"linkSuccess": "Linked \"{{name}}\" to your account",
"linkFailed": "Failed to link this author to your account"
},
"collections": {
"pageTitle": "Collections",
+11 -2
View File
@@ -170,7 +170,8 @@
"serviceConnected": "{{service}}: prijungta",
"serviceNotConnected": "{{service}}: neprijungta",
"makerworldDisabledReason": "MakerWorld: profilyje dar nepridėjote slapuko (cookie)",
"thingiverseDisabledReason": "Thingiverse: administratorius dar nesukonfigūravo API rakto"
"thingiverseDisabledReason": "Thingiverse: administratorius dar nesukonfigūravo API rakto",
"myModels": "Mano modeliai"
},
"notifications": {
"label": "Pranešimai",
@@ -1072,11 +1073,19 @@
"author": {
"pageTitle": "Autorius",
"pageTitleWithName": "Autorius - {{name}}",
"myModelsPageTitle": "Mano modeliai",
"bio": "Biografija",
"noBio": "Biografijos dar nėra.",
"viewOnProvider": "Žiūrėti {{provider}} svetainėje",
"notFound": "Autorius nerastas.",
"noModels": "Iš šio autoriaus dar nėra modelių."
"noModels": "Iš šio autoriaus dar nėra modelių.",
"itsMe": "Tai aš!",
"linkedBadge": "Tai jūs",
"linkConfirmTitle": "Susieti šį autorių su savo paskyra?",
"linkConfirmMessage": "Tai susies „{{name}}“ kaip jūsų pačių tapatybę. Jūsų „Mano modeliai“ puslapyje taip pat bus rodomi jų modeliai, o jūsų profilio biografija / viršelio nuotrauka bus užpildyta iš jų, jei dar neturite savo nustatytos.",
"linkConfirmLabel": "Taip, tai aš",
"linkSuccess": "„{{name}}“ susieta su jūsų paskyra",
"linkFailed": "Nepavyko susieti šio autoriaus su jūsų paskyra"
},
"collections": {
"pageTitle": "Kolekcijos",
+163 -41
View File
@@ -1,21 +1,30 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PersonIcon from "@mui/icons-material/Person";
import { UnauthorizedError } from "../../api/client";
import { authorsApi, type Author } from "../../api/authors";
import { type Print, printsApi } from "../../api/prints";
import { type PreviewMode } from "../../api/settings";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
import { importProviderInfo } from "../../constants/importProviders";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
import { importProviderInfo, printProviderInfo } from "../../constants/importProviders";
import { authorProfileUrl } from "../../utils/authorProfileUrl";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { dividerBorderColor } from "../../theme";
import ModelCard from "../ModelsPage/ModelCard";
const PAGE_SIZE = 24;
@@ -25,6 +34,14 @@ type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
/** Only read for the `/authors/self` "My models" route (see UserMenu's menu item and
* ModelCard/ModelSidePanel's showViewerAsAuthor click-through) -- every other author id backs
* a real, shared Author row instead, fetched below. */
viewer?: AuthUser | null;
/** Called after a successful "It's me!" link with the (possibly bio/cover-enriched) viewer --
* same shape as ChangeEmailPage's own onUserUpdated, so App.tsx's handleUserUpdated can persist
* and propagate it app-wide. */
onUserUpdated?: (user: AuthUser) => void;
};
/** An author's own page: a cover strip, then a narrow profile column (avatar, name, @handle, a
@@ -35,10 +52,21 @@ type Props = {
* divider on the profile column's right edge separates it from the models grid (matching the rest
* of the app's light-mode borders, dropped in dark mode where the two columns already contrast).
* Author rows are shared across users (not scoped to this account -- see
* authorService.ts's doc comment on the backend), so there's nothing here to edit. */
export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props) {
* authorService.ts's doc comment on the backend), so there's nothing here to edit.
*
* Doubles as the viewer's own "My models" page when `authorId === SELF_AUTHOR_ID`: a plain
* upload has no Author row at all (see ModelCard/ModelSidePanel's showViewerAsAuthor fallback),
* so there's nothing to authorsApi.get() -- the profile column instead shows the viewer's own
* identity, and the grid is fetched via the backend's matching SELF_AUTHOR_ID sentinel rather
* than a real author_id. */
export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer, onUserUpdated }: Props) {
const { authorId } = useParams<{ authorId: string }>();
const navigate = useNavigate();
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const showToast = useToast();
const isSelf = authorId === SELF_AUTHOR_ID;
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 112);
const [author, setAuthor] = useState<Author | null>(null);
const [notFound, setNotFound] = useState(false);
const [items, setItems] = useState<Print[]>([]);
@@ -46,13 +74,21 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
// The Author rows the viewer has already claimed as themselves -- backs self mode's provider
// chips and, on a real author page, whether "It's me!" should show at all (hidden once the
// viewer already has a different author linked for that same provider, or this one is already
// linked to someone). Fetched alongside everything else below regardless of mode.
const [myLinks, setMyLinks] = useState<Author[]>([]);
const [linking, setLinking] = useState(false);
const displayName = author?.name || author?.handle || t("models:card.unknownAuthor");
const displayName = isSelf
? viewer?.display_name || t("models:card.unknownAuthor")
: author?.name || author?.handle || t("models:card.unknownAuthor");
// Only overrides the title -- the route's own default onBack (see AppLayout's useRouteChrome,
// "/authors/" -> useSmartBack) already does exactly "back button goes to the previous page".
usePageHeader({
title: author ? t("models:author.pageTitleWithName", { name: displayName }) : undefined,
title: isSelf ? t("models:author.myModelsPageTitle") : author ? t("models:author.pageTitleWithName", { name: displayName }) : undefined,
});
const handleError = (err: unknown, message?: string) => {
@@ -72,11 +108,26 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
setNotFound(false);
(async () => {
try {
const [authorResult, printsResult] = await Promise.all([
if (isSelf) {
const [printsResult, linkedAuthors] = await Promise.all([
printsApi.list({ author_id: SELF_AUTHOR_ID, limit: PAGE_SIZE, offset: 0 }),
authorsApi.myLinks(),
]);
if (cancelled) return;
setAuthor(null);
setMyLinks(linkedAuthors);
setItems(printsResult.items);
setOffset(printsResult.items.length);
setHasMore(printsResult.hasMore);
return;
}
const [authorResult, printsResult, linkedAuthors] = await Promise.all([
authorsApi.get(authorId),
printsApi.list({ author_id: authorId, limit: PAGE_SIZE, offset: 0 }),
authorsApi.myLinks(),
]);
if (cancelled) return;
setMyLinks(linkedAuthors);
setAuthor(authorResult);
setItems(printsResult.items);
setOffset(printsResult.items.length);
@@ -91,13 +142,13 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authorId]);
}, [authorId, isSelf]);
const loadMore = async () => {
if (loadingMore || !hasMore || !authorId) return;
setLoadingMore(true);
try {
const result = await printsApi.list({ author_id: authorId, limit: PAGE_SIZE, offset });
const result = await printsApi.list({ author_id: isSelf ? SELF_AUTHOR_ID : authorId, limit: PAGE_SIZE, offset });
setItems(prev => [...prev, ...result.items]);
setOffset(offset + result.items.length);
setHasMore(result.hasMore);
@@ -110,6 +161,36 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
const linkedToMe = Boolean(author && myLinks.some(a => a.id === author.id));
const viewerHasProviderLinked = Boolean(author && myLinks.some(a => a.provider === author.provider));
const canClaim = !isSelf && Boolean(author) && !author!.is_linked && !viewerHasProviderLinked;
const handleClaim = async () => {
if (!author) return;
const confirmed = await confirmDialog({
title: t("models:author.linkConfirmTitle"),
message: t("models:author.linkConfirmMessage", { name: displayName }),
confirmLabel: t("models:author.linkConfirmLabel"),
});
if (!confirmed) return;
setLinking(true);
try {
const result = await authorsApi.link(author.id);
setAuthor(result.author);
setMyLinks(prev => [...prev, result.author]);
onUserUpdated?.(result.user);
showToast({ message: t("models:author.linkSuccess", { name: displayName }) });
} catch (err) {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return;
}
alert(err instanceof Error ? err.message : t("models:author.linkFailed"));
} finally {
setLinking(false);
}
};
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
@@ -118,7 +199,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
);
}
if (notFound || !author) {
if (notFound || (!isSelf && !author)) {
return (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:author.notFound")}</Typography>
@@ -126,9 +207,13 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
);
}
const providerInfo = importProviderInfo(author.provider);
const profileUrl = authorProfileUrl(author);
const bioText = author.bio || author.bio_translated;
const providerInfo = isSelf ? null : importProviderInfo(author?.provider);
const profileUrl = isSelf || !author ? null : authorProfileUrl(author);
// Self mode's bio/cover come from the viewer's own profile fields -- populated by linking an
// author elsewhere (see authorService.ts's linkAuthorToUser) rather than fetched here.
const bioText = isSelf ? viewer?.bio || null : author?.bio || author?.bio_translated;
const avatarUrl = isSelf ? viewerAvatarUrl : (author?.avatar_url ?? undefined);
const backgroundUrl = isSelf ? viewer?.background_url || null : author?.background_url;
return (
<Box sx={{ maxWidth: "1920px", mx: "auto", bgcolor: "background.paper", borderRadius: "12px", overflow: "hidden" }}>
@@ -136,10 +221,10 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
sx={{
height: `${COVER_HEIGHT}px`,
bgcolor: "action.hover",
...(author.background_url && {
...(backgroundUrl && {
// Quoted, not a bare url(...) -- an unquoted CSS url() treats the first unescaped ")"
// as its terminator, which a data: URI (or any URL containing one) would break.
backgroundImage: `url("${author.background_url}")`,
backgroundImage: `url("${backgroundUrl}")`,
backgroundSize: "cover",
backgroundPosition: "center",
}),
@@ -158,40 +243,76 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
<Box
sx={{
borderRight: { xs: "none", md: "1px solid" },
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
borderColor: dividerBorderColor,
pr: { xs: 0, md: 4 },
}}
>
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 112, height: 112, fontSize: 40 }}>
<Avatar src={avatarUrl} sx={{ width: 112, height: 112, fontSize: 40 }}>
<PersonIcon fontSize="large" />
</Avatar>
<Typography variant="h5" fontWeight={700} sx={{ mt: 2 }}>{displayName}</Typography>
{author.handle && (
{!isSelf && author?.handle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>@{author.handle}</Typography>
)}
{providerInfo && profileUrl && (
<Box
component="a"
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
title={t("models:author.viewOnProvider", { provider: providerInfo.label })}
sx={{
display: "inline-block",
mt: 1.5,
px: 1.25,
py: 0.5,
borderRadius: 1,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.6,
color: "#fff",
textDecoration: "none",
bgcolor: providerInfo.color,
}}
>
{providerInfo.label}
</Box>
{isSelf ? (
myLinks.length > 0 && (
<Stack direction="row" flexWrap="wrap" useFlexGap spacing={1} sx={{ mt: 1.5 }}>
{myLinks.map(linked => {
const info = printProviderInfo(linked.provider);
return (
<Chip
key={linked.id}
label={info.label}
size="small"
clickable
onClick={() => navigate(`/authors/${linked.id}`)}
sx={{ bgcolor: info.color, color: info.textColor ?? "#fff", fontWeight: 600 }}
/>
);
})}
</Stack>
)
) : (
(providerInfo || linkedToMe || canClaim) && (
<Stack direction="row" flexWrap="wrap" alignItems="center" spacing={1} sx={{ mt: 1.5 }}>
{providerInfo && profileUrl && (
<Box
component="a"
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
title={t("models:author.viewOnProvider", { provider: providerInfo.label })}
sx={{
display: "inline-block",
px: 1.25,
py: 0.5,
borderRadius: 1,
fontSize: 12,
fontWeight: 600,
lineHeight: 1.6,
color: "#fff",
textDecoration: "none",
bgcolor: providerInfo.color,
}}
>
{providerInfo.label}
</Box>
)}
{linkedToMe && (
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ color: "success.main" }}>
<CheckCircleIcon sx={{ fontSize: 16 }} />
<Typography variant="caption" fontWeight={600} color="inherit">
{t("models:author.linkedBadge")}
</Typography>
</Stack>
)}
{canClaim && (
<Button size="small" variant="outlined" disabled={linking} onClick={handleClaim}>
{t("models:author.itsMe")}
</Button>
)}
</Stack>
)
)}
<Typography variant="subtitle1" fontWeight={700} sx={{ mt: 3, mb: 1 }}>{t("models:author.bio")}</Typography>
@@ -226,6 +347,7 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUpdated={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
viewer={viewer}
/>
))}
</Box>
@@ -30,6 +30,7 @@ import RestartAltIcon from "@mui/icons-material/RestartAlt";
import DeleteIcon from "@mui/icons-material/Delete";
import { type Print, printsApi } from "../../api/prints";
import { categoriesApi, type Category } from "../../api/categories";
import type { AuthUser } from "../../api/auth";
import { UnauthorizedError } from "../../api/client";
import { useConfirm } from "../../components/ConfirmProvider";
import { useToast } from "../../components/ToastProvider";
@@ -65,6 +66,12 @@ type Props = {
onClose: () => void;
onUnauthorized?: () => void;
onUpdated: (print: Print) => void;
/** Only used as a fallback when the print has neither an Author nor a plain `creator` string --
* a direct upload has no import-source author at all, so this shows the viewer's own identity
* instead of "Unknown" (see ModelCard/ModelSidePanel's identical showViewerAsAuthor fallback).
* Purely a label here: there's no Author id behind it, so "Reset author" stays hidden either
* way (hasImportedAuthor already only looks at real author/creator/source_provider data). */
viewer?: AuthUser | null;
};
/** The "Edit" modal for a model: title, category (a two-level tree, but only leaf/secondary
@@ -75,7 +82,7 @@ type Props = {
* "Update" is clicked; "Cancel" discards it all, confirming first if anything was actually
* touched. Opened from ModelActionsMenu's "Edit" item, shared by the model detail page's header
* and the Models/Collection grids' per-card menu. */
export default function EditModelModal({ print, onClose, onUnauthorized, onUpdated }: Props) {
export default function EditModelModal({ print, onClose, onUnauthorized, onUpdated, viewer }: Props) {
const { t, i18n } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const showToast = useToast();
@@ -85,10 +92,19 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
const [notes, setNotes] = useState(print.notes ?? "");
const [tags, setTags] = useState<string[]>(print.tags);
const hasImportedAuthor = Boolean(print.author || print.creator || print.source_provider);
const showViewerAsAuthor = !hasImportedAuthor && Boolean(viewer);
const [authorResetPending, setAuthorResetPending] = useState(false);
const [images, setImages] = useState<ImageItem[]>(
() => print.preview_images.map((img): ImageItem => ({ kind: "existing", id: img.id, url: img.url })),
);
// Ids the user explicitly removed via the trash icon below -- handleUpdate deletes exactly
// these, rather than diffing the local `images` list against print.preview_images. A plain
// upload opens straight into this modal (see useUploadImport's post-upload redirect) before its
// automatically-generated thumbnail has necessarily finished uploading in the background; a
// diff-based delete would treat that not-yet-known image as "removed" the moment it appears and
// destroy it the instant "Update" is clicked. Tracking removals explicitly means an image this
// modal never learned about is simply never a delete candidate.
const [removedImageIds, setRemovedImageIds] = useState<Set<string>>(new Set());
const [plateItems, setPlateItems] = useState<PlateItem[]>(
() => print.plates.map((p): PlateItem => ({ kind: "existing", id: p.id, filename: p.filename })),
);
@@ -136,6 +152,10 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
markDirty();
};
const removeImage = (key: string) => {
const target = images.find((img) => imageKey(img) === key);
if (target?.kind === "existing") {
setRemovedImageIds((ids) => new Set(ids).add(target.id));
}
setImages((prev) => prev.filter((img) => imageKey(img) !== key));
markDirty();
};
@@ -191,12 +211,11 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
latest = authorRes.print ?? latest;
}
// Preview images: delete what's gone, upload what's new, then reorder to the arrangement
// the user actually left on screen.
const keptImageIds = new Set(images.filter((img) => img.kind === "existing").map((img) => img.id));
for (const original of print.preview_images) {
if (keptImageIds.has(original.id)) continue;
const res = await printsApi.deletePreviewImage(print.id, original.id);
// Preview images: delete only what the user explicitly removed (see removedImageIds' doc
// comment for why this isn't a diff against print.preview_images), upload what's new, then
// reorder to the arrangement the user actually left on screen.
for (const id of removedImageIds) {
const res = await printsApi.deletePreviewImage(print.id, id);
latest = res.print ?? latest;
}
const newImageFiles = images.filter((img) => img.kind === "new").map((img) => img.file);
@@ -212,7 +231,18 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
}
if (images.length) {
let nextNew = 0;
const finalOrder = images.map((img) => (img.kind === "existing" ? img.id : newImageIdsInOrder[nextNew++]));
const known = images.map((img) => (img.kind === "existing" ? img.id : newImageIdsInOrder[nextNew++]));
// The reorder endpoint requires an exhaustive id list (see previewImages.ts's reorder
// route). `known` may not be exhaustive -- it can't include an image this modal never
// learned about (again, the background-generated-thumbnail case) -- so anything else
// currently in `latest` gets appended after it, in its own existing order, rather than
// tripping that endpoint's 400 or (the old bug) getting silently deleted.
const knownIds = new Set(known);
const unknown = latest.preview_images
.filter((img) => !knownIds.has(img.id))
.toSorted((a, b) => a.position - b.position)
.map((img) => img.id);
const finalOrder = [...known, ...unknown];
const reorderRes = await printsApi.reorderPreviewImages(print.id, finalOrder);
latest = reorderRes.print ?? latest;
}
@@ -311,7 +341,8 @@ export default function EditModelModal({ print, onClose, onUnauthorized, onUpdat
<Typography variant="body2">
{authorResetPending
? t("models:edit.authorWillBeYou")
: (print.author?.name || print.author?.handle || print.creator || t("models:card.unknownAuthor"))}
: (print.author?.name || print.author?.handle || print.creator ||
(showViewerAsAuthor ? viewer!.display_name : null) || t("models:card.unknownAuthor"))}
</Typography>
{hasImportedAuthor && (
authorResetPending ? (
@@ -20,6 +20,7 @@ import LaunchIcon from "@mui/icons-material/Launch";
import type { SxProps, Theme } from "@mui/material/styles";
import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { collectionsApi } from "../../api/collections";
import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { useConfirm } from "../../components/ConfirmProvider";
@@ -50,6 +51,8 @@ type Props = {
* fontSize="small" (20px) used everywhere else. Only the Models grid hover overlay bumps
* this, to stay legible now that it no longer sits on a dark circular backdrop. */
iconFontSize?: number;
/** Passed straight through to EditModelModal's own `viewer` fallback -- see its doc comment. */
viewer?: AuthUser | null;
};
/** The "..." menu for a model: "Add to collection" (opens the chip-toggle picker), "Remove from
@@ -70,6 +73,7 @@ export default function ModelActionsMenu({
onRemovedFromCollection,
triggerSx,
iconFontSize,
viewer,
}: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
@@ -249,6 +253,7 @@ export default function ModelActionsMenu({
onClose={closeEdit}
onUnauthorized={onUnauthorized}
onUpdated={(updated) => onUpdated?.(updated)}
viewer={viewer}
/>
)}
</>
@@ -19,6 +19,8 @@ import { slicerLaunchUrl } from "../../utils/slicerLaunch";
import { SLICER_OPTIONS } from "../../constants/settingsOptions";
import { useSlicerPreference } from "../../hooks/useSlicerPreference";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { dividerBorderColor } from "../../theme";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useDownloadPrint } from "./useDownloadPrint";
import DownloadPickerDialog from "./DownloadPickerDialog";
@@ -80,7 +82,7 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
sx={{
p: 2.5,
borderRadius: "12px",
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
borderColor: dividerBorderColor,
position: { xs: "static", md: "sticky" },
// TopBar is sticky too (see its own doc comment) and sits above this in stacking order --
// sticking at a fixed offset from the viewport top would land this panel right underneath
@@ -102,11 +104,14 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
spacing={1}
sx={{
width: "fit-content",
cursor: print.author ? "pointer" : "default",
cursor: print.author || showViewerAsAuthor ? "pointer" : "default",
color: (muiTheme) => muiTheme.thingport.headingText,
...(print.author ? { "&:hover": { color: "primary.main" } } : undefined),
...(print.author || showViewerAsAuthor ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => {
if (print.author) navigate(`/authors/${print.author.id}`);
else if (showViewerAsAuthor) navigate(`/authors/${SELF_AUTHOR_ID}`);
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
+3 -2
View File
@@ -17,6 +17,7 @@ import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
import type { AuthUser } from "../../api/auth";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { dividerBorderColor } from "../../theme";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
@@ -57,7 +58,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
actions: print ? (
<Stack direction="row" alignItems="center" spacing={0.5}>
<FavoriteButton print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} onUpdated={setPrint} />
<ModelActionsMenu print={print} onUnauthorized={onUnauthorized} onDeleted={goBack} onUpdated={setPrint} viewer={viewer} />
</Stack>
) : undefined,
});
@@ -269,7 +270,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
mt: 3,
p: 2,
borderRadius: "12px",
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
borderColor: dividerBorderColor,
}}
>
<Typography
@@ -15,6 +15,7 @@ import SettingsIcon from "@mui/icons-material/Settings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import type { Category, CategoryMetaInput } from "../../api/categories";
import { translateCategoryDisplay } from "../../utils/translateCategoryDisplay";
import { dividerBorderColor } from "../../theme";
import CategoryManagerModal from "./CategoryManagerModal";
type Props = {
@@ -129,7 +130,7 @@ export default function CategoriesPanel({ categories, loading, selectedId, onSel
p: 1.5,
alignSelf: "flex-start",
bgcolor: "background.paper",
borderColor: (muiTheme) => (muiTheme.palette.mode === "dark" ? "transparent" : "divider"),
borderColor: dividerBorderColor,
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 0.5, pb: 1 }}>
+5 -3
View File
@@ -17,6 +17,7 @@ import { UnauthorizedError } from "../../api/client";
import { useToast } from "../../components/ToastProvider";
import { renderPreviewContent } from "../../components/media/renderPreviewContent";
import { printProviderInfo } from "../../constants/importProviders";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
@@ -210,6 +211,7 @@ export default function ModelCard({
onRemovedFromCollection={() => onRemovedFromCollection?.(item.id)}
triggerSx={{ color: overlayIconColor, ...overlayButtonSx }}
iconFontSize={HOVER_ICON_SIZE}
viewer={viewer}
/>
</Stack>
<Box sx={{ px: 1.5, pt: 0.75, pb: 1.5 }}>
@@ -230,12 +232,12 @@ export default function ModelCard({
sx={{
minWidth: 0,
color: "#858585",
...(author ? { cursor: "pointer", "&:hover": { color: "#00b800" } } : undefined),
...(author || showViewerAsAuthor ? { cursor: "pointer", "&:hover": { color: "#00b800" } } : undefined),
}}
onClick={e => {
if (!author) return;
if (!author && !showViewerAsAuthor) return;
e.stopPropagation();
navigate(`/authors/${author.id}`);
navigate(`/authors/${author ? author.id : SELF_AUTHOR_ID}`);
}}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 20, height: 20, fontSize: 11, color: "inherit !important" }}>
+11
View File
@@ -198,3 +198,14 @@ export function subtleTextColor(id: ResolvedTheme): string {
export function accentSoftColor(id: ResolvedTheme): string {
return THEME_DEFS[id].accentSoft;
}
/** The one `borderColor` every container-edge/column-divider border in the app should use: the
* theme's divider color (`palette.divider`, i.e. `theme.thingport.border`/`borderStrong`'s light-
* mode value) in light mode, invisible in dark mode -- dark mode's panels already contrast against
* the page background on their own, so an explicit border there reads as an unwanted extra line
* rather than a separator. Shared by Sidebar, CategoriesPanel, ModelSidePanel, and AuthorPage's
* profile-column divider so all four can never drift from each other; use as
* `sx={{ borderColor: dividerBorderColor, ... }}`. */
export function dividerBorderColor(theme: Theme): string {
return theme.palette.mode === "dark" ? "transparent" : "divider";
}