mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-20 21:59:53 -05:00
feat: author linking and other minor author changes
This commit is contained in:
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;
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user