mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: add render colour, graphite accent and hover slideshow toggle
Appearance gains a render colour that tints generated renders, the dashboard lists, tag tiles and the 3D preview. It follows the accent until picked apart, and offers the accents plus an untinted light grey. A graphite accent joins the palette, light grey in the dark theme so it stays visible. The hover slideshow on model cards can be turned off, and the language picker moves to the top. The dashboard's own Thumbnails option is gone: its lists and the tag tiles now lead with pictures or renders as the model cards do, tags from a mosaic of the models' renders. The one-colour dashboard icon is no longer made.
This commit is contained in:
1 parent
a13176d1d3
commit
926c5d345b
36 files changed
+597
-383
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "hoverSlideshowEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "renderColor" TEXT;
|
||||
ALTER TABLE "User" DROP COLUMN IF EXISTS "dashboardThumbnails";
|
||||
@@ -129,6 +129,8 @@ model User {
|
||||
themePreference String?
|
||||
// One of ACCENT_COLORS; null means Thingport green.
|
||||
accentColor String?
|
||||
// What generated renders are tinted, one of RENDER_COLORS; null follows the accent.
|
||||
renderColor String?
|
||||
// Which tree the models page shows: "categories" | "folders"; null means categories.
|
||||
categoriesView String?
|
||||
// The dashboard widgets the user has switched on or off, as { [widgetId]: boolean }. A widget
|
||||
@@ -136,13 +138,13 @@ model User {
|
||||
dashboardWidgets Json?
|
||||
// How the dashboard arranges its boxes, one of DASHBOARD_LAYOUTS; null means "left".
|
||||
dashboardLayout String?
|
||||
// The dashboard lists' model pictures, one of DASHBOARD_THUMBNAILS; null means "photos".
|
||||
dashboardThumbnails String?
|
||||
// What model cards lead with, one of CARD_PREVIEWS; null means "images".
|
||||
cardPreview String?
|
||||
// Where the dashboard's weather is for: { lat, lon, name, source }. Null until the user sets one.
|
||||
weatherLocation Json?
|
||||
authorPreviewEnabled Boolean @default(true)
|
||||
// Model cards cycle through a model's pictures while hovered.
|
||||
hoverSlideshowEnabled Boolean @default(true)
|
||||
// Filled from the first linked Author with this data; later links never overwrite it.
|
||||
bio String?
|
||||
backgroundUrl String?
|
||||
|
||||
+2
-1
@@ -216,7 +216,8 @@ function previewGlbUrl(plate: Plate): string | null {
|
||||
return `/plate/${plate.id}/preview.glb?v=${mtime}`;
|
||||
}
|
||||
|
||||
function renderUrl(plate: Plate): string | null {
|
||||
/** The plate's grey render for the browser to tint; unversioned until it exists, as asking for it queues it. */
|
||||
export function renderUrl(plate: { id: string; filename: string }): string | null {
|
||||
if (!canRenderModelImage(plate.filename)) return null;
|
||||
const file = modelImagePath(plate.id, "render");
|
||||
return fs.existsSync(file)
|
||||
|
||||
@@ -25,7 +25,7 @@ function modelOut(m: DashboardModel) {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
thumb_url: m.thumbUrl,
|
||||
model_image_url: m.modelImageUrl,
|
||||
render_url: m.renderUrl,
|
||||
view_count: m.viewCount,
|
||||
print_count: m.printCount,
|
||||
created_at: m.createdAt,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
generateModelPreviewGlb,
|
||||
modelPreviewGlbPath,
|
||||
modelPreviewState,
|
||||
generateModelImage,
|
||||
modelImagePath,
|
||||
modelImageState,
|
||||
type ModelImageKind,
|
||||
@@ -211,8 +210,7 @@ function modelPictureHandler(kind: ModelImageKind) {
|
||||
}
|
||||
const srcPath = resolvePlateFilePath(plate);
|
||||
if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED");
|
||||
if (kind === "render") void generateCardRender(plate.id, srcPath, plate.filename);
|
||||
else void generateModelImage(plate.id, srcPath, plate.filename);
|
||||
void generateCardRender(plate.id, srcPath, plate.filename);
|
||||
const state = modelImageState(plate.id, kind);
|
||||
if (state === "unsupported") {
|
||||
throw new HttpError(404, "This file's layout can't be drawn by the server", "PREVIEW_UNSUPPORTED");
|
||||
@@ -229,9 +227,7 @@ function modelPictureHandler(kind: ModelImageKind) {
|
||||
});
|
||||
}
|
||||
|
||||
// The model drawn in one colour, for small slots like the dashboard lists.
|
||||
router.get("/plate/:plateId/model.png", modelPictureHandler("icon"));
|
||||
// The model in grey shading on transparent, which model cards tint with the accent.
|
||||
// The model in grey shading on transparent, which the browser tints with the render colour.
|
||||
router.get("/plate/:plateId/render.png", modelPictureHandler("render"));
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -93,13 +93,10 @@ import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPref
|
||||
import { CATEGORIES_VIEWS, getCategoriesView, setCategoriesView } from "../services/categoriesViewService";
|
||||
import {
|
||||
DASHBOARD_LAYOUTS,
|
||||
DASHBOARD_THUMBNAILS,
|
||||
DASHBOARD_WIDGETS,
|
||||
getDashboardLayout,
|
||||
getDashboardThumbnails,
|
||||
getDashboardWidgets,
|
||||
setDashboardLayout,
|
||||
setDashboardThumbnails,
|
||||
setDashboardWidgets,
|
||||
} from "../services/dashboardWidgetsService";
|
||||
import {
|
||||
@@ -113,11 +110,13 @@ import {
|
||||
import { CONSUME_DIR } from "../config";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
RENDER_COLORS,
|
||||
THEME_SELECTIONS,
|
||||
getUserThemePreferences,
|
||||
setUserThemePreferences,
|
||||
} from "../services/themePreferenceService";
|
||||
import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../services/authorPreviewPreferenceService";
|
||||
import { getHoverSlideshowEnabled, setHoverSlideshowEnabled } from "../services/hoverSlideshowService";
|
||||
import { checkForUpdates } from "../services/versionService";
|
||||
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
|
||||
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
|
||||
@@ -981,6 +980,7 @@ router.get(
|
||||
const themeSettingsSchema = z.object({
|
||||
theme: z.enum(THEME_SELECTIONS).nullable().optional(),
|
||||
accent: z.enum(ACCENT_COLORS).nullable().optional(),
|
||||
renderColor: z.enum(RENDER_COLORS).nullable().optional(),
|
||||
});
|
||||
router.patch(
|
||||
"/settings/theme",
|
||||
@@ -1068,22 +1068,6 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/dashboard-thumbnails",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ thumbnails: await getDashboardThumbnails(req.userId!) });
|
||||
}),
|
||||
);
|
||||
|
||||
const dashboardThumbnailsSchema = z.object({ thumbnails: z.enum(DASHBOARD_THUMBNAILS) });
|
||||
router.patch(
|
||||
"/settings/dashboard-thumbnails",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(dashboardThumbnailsSchema, req.body);
|
||||
res.json({ thumbnails: await setDashboardThumbnails(req.userId!, body.thumbnails) });
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/card-preview",
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -1133,6 +1117,22 @@ router.patch(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/settings/hover-slideshow",
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({ enabled: await getHoverSlideshowEnabled(req.userId!) });
|
||||
}),
|
||||
);
|
||||
|
||||
const hoverSlideshowSchema = z.object({ enabled: z.boolean() });
|
||||
router.patch(
|
||||
"/settings/hover-slideshow",
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(hoverSlideshowSchema, req.body);
|
||||
res.json({ enabled: await setHoverSlideshowEnabled(req.userId!, body.enabled) });
|
||||
}),
|
||||
);
|
||||
|
||||
function aiCategorizationPrefsOut(prefs: AiCategorizationPrefs) {
|
||||
return {
|
||||
enabled: prefs.enabled,
|
||||
|
||||
+12
-11
@@ -14,6 +14,7 @@ import {
|
||||
removeTagMosaics,
|
||||
tagMosaicUrl,
|
||||
tagPictures,
|
||||
type MosaicKind,
|
||||
} from "../services/tagMosaicService";
|
||||
|
||||
const router = Router();
|
||||
@@ -53,11 +54,9 @@ router.get(
|
||||
);
|
||||
const page = matching.slice(offset, offset + limit);
|
||||
|
||||
// Pictures for this page's tags alone, joined in one query.
|
||||
const pictures = await tagPictures(
|
||||
prints,
|
||||
page.map(([name]) => name),
|
||||
);
|
||||
// Pictures for this page's tags alone, joined in one query; the browser picks which mosaic to show.
|
||||
const names = page.map(([name]) => name);
|
||||
const [pictures, renders] = await Promise.all([tagPictures(prints, names), tagPictures(prints, names, "renders")]);
|
||||
|
||||
res.json({
|
||||
items: page.map(([name, count]) => ({
|
||||
@@ -65,6 +64,7 @@ router.get(
|
||||
count,
|
||||
bookmarked: bookmarked.has(name),
|
||||
mosaic_url: tagMosaicUrl(name, pictures.get(name) ?? []),
|
||||
render_mosaic_url: tagMosaicUrl(name, renders.get(name) ?? [], "renders"),
|
||||
})),
|
||||
total: matching.length,
|
||||
// Every tag, whatever its count: tells "none match the filter" from "no tags at all".
|
||||
@@ -75,16 +75,17 @@ router.get(
|
||||
);
|
||||
|
||||
// The tag's tile picture; its URL carries a version, so it's cached for good and asked for anew when it changes.
|
||||
router.get(
|
||||
"/tags/:tag/mosaic.webp",
|
||||
asyncHandler(async (req, res) => {
|
||||
const file = await ensureTagMosaic(req.userId!, req.params.tag);
|
||||
function mosaicHandler(kind: MosaicKind) {
|
||||
return asyncHandler(async (req, res) => {
|
||||
const file = await ensureTagMosaic(req.userId!, req.params.tag, kind);
|
||||
if (!file) throw new HttpError(404, "Not found");
|
||||
res.setHeader("Content-Type", "image/webp");
|
||||
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
||||
res.sendFile(file);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
router.get("/tags/:tag/mosaic.webp", mosaicHandler("images"));
|
||||
router.get("/tags/:tag/render-mosaic.webp", mosaicHandler("renders"));
|
||||
|
||||
// Just the bookmarked names, without scanning every print like /tags/summary.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { prisma } from "../db";
|
||||
import { RENDERABLE_MODEL_EXTS } from "../config";
|
||||
import { canRenderModelImage, modelImagePath } from "./modelPreviewCache";
|
||||
import { renderUrl } from "../dto";
|
||||
import { plateThumbExists, plateThumbPath } from "./printService";
|
||||
|
||||
const TOP_MODELS_PREVIEW = 3;
|
||||
@@ -15,8 +15,8 @@ export type DashboardModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbUrl: string | null;
|
||||
/** The model drawn in one colour. Unversioned until it exists: asking for it queues it. */
|
||||
modelImageUrl: string | null;
|
||||
/** The first plate's render for the browser to tint. Unversioned until it exists: asking for it queues it. */
|
||||
renderUrl: string | null;
|
||||
viewCount: number;
|
||||
printCount: number;
|
||||
createdAt: Date;
|
||||
@@ -56,14 +56,6 @@ type PrintForModelSummary = {
|
||||
plates: { id: string; filename: string }[];
|
||||
};
|
||||
|
||||
function modelImageUrl(plate: { id: string; filename: string }): string | null {
|
||||
if (!canRenderModelImage(plate.filename)) return null;
|
||||
const file = modelImagePath(plate.id);
|
||||
return fs.existsSync(file)
|
||||
? `/plate/${plate.id}/model.png?v=${fs.statSync(file).mtimeMs}`
|
||||
: `/plate/${plate.id}/model.png`;
|
||||
}
|
||||
|
||||
export function toModelSummary(print: PrintForModelSummary): DashboardModel {
|
||||
const first = print.plates[0];
|
||||
const plateId = first?.id ?? null;
|
||||
@@ -76,7 +68,7 @@ export function toModelSummary(print: PrintForModelSummary): DashboardModel {
|
||||
id: print.id,
|
||||
name: print.name,
|
||||
thumbUrl,
|
||||
modelImageUrl: first ? modelImageUrl(first) : null,
|
||||
renderUrl: first ? renderUrl(first) : null,
|
||||
viewCount: print.viewCount,
|
||||
printCount: print.printCount,
|
||||
createdAt: print.createdAt,
|
||||
|
||||
@@ -64,21 +64,3 @@ export async function setDashboardLayout(userId: string, layout: DashboardLayout
|
||||
await prisma.user.update({ where: { id: userId }, data: { dashboardLayout: layout } });
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Kept in sync by hand with the frontend's DashboardThumbnails. "monochrome" is the server's own
|
||||
// one-colour picture of the model, "photos" its preview image.
|
||||
export const DASHBOARD_THUMBNAILS = ["none", "photos", "monochrome"] as const;
|
||||
export type DashboardThumbnails = (typeof DASHBOARD_THUMBNAILS)[number];
|
||||
|
||||
export async function getDashboardThumbnails(userId: string): Promise<DashboardThumbnails> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { dashboardThumbnails: true } });
|
||||
return DASHBOARD_THUMBNAILS.find((option) => option === user?.dashboardThumbnails) ?? "photos";
|
||||
}
|
||||
|
||||
export async function setDashboardThumbnails(
|
||||
userId: string,
|
||||
thumbnails: DashboardThumbnails,
|
||||
): Promise<DashboardThumbnails> {
|
||||
await prisma.user.update({ where: { id: userId }, data: { dashboardThumbnails: thumbnails } });
|
||||
return thumbnails;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { prisma } from "../db";
|
||||
|
||||
export async function getHoverSlideshowEnabled(userId: string): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { hoverSlideshowEnabled: true } });
|
||||
return user?.hoverSlideshowEnabled ?? true;
|
||||
}
|
||||
|
||||
export async function setHoverSlideshowEnabled(userId: string, enabled: boolean): Promise<boolean> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { hoverSlideshowEnabled: enabled },
|
||||
select: { hoverSlideshowEnabled: true },
|
||||
});
|
||||
return user.hoverSlideshowEnabled;
|
||||
}
|
||||
@@ -265,26 +265,21 @@ async function renderPlateFile<T extends { status: string }>(
|
||||
return moved !== srcPath && fsSync.existsSync(moved) ? render(moved) : outcome;
|
||||
}
|
||||
|
||||
// Pictures of a model, drawn on the server in one colour on transparent: an "icon" for small slots like the dashboard
|
||||
// lists, and a "render" for model cards. They share the GLB queue and memory limit, so the two never render at once,
|
||||
// but wait behind GLBs.
|
||||
// Pictures of a model, drawn on the server in one colour on transparent: a "render" for model cards, the dashboard
|
||||
// lists and tag mosaics. They share the GLB queue and memory limit, so they wait behind GLBs.
|
||||
|
||||
/** Server-side picture formats; STEP needs a CAD kernel the server doesn't have. */
|
||||
export const MODEL_IMAGE_EXTS = new Set([".stl", ".obj", ".3mf"]);
|
||||
export type ModelImageKind = "icon" | "render";
|
||||
export type ModelImageKind = "render";
|
||||
type ModelImageKindDef = {
|
||||
/** Part of the filename, with a version, so changing a look regenerates older pictures on next request. */
|
||||
file: string;
|
||||
options: ModelImageOptions;
|
||||
timeoutSeconds: number;
|
||||
};
|
||||
// A dashboard icon isn't worth holding up the queue the way a model's own 3D preview is; a card's render is the
|
||||
// model's main picture, so a heavy model gets longer.
|
||||
// A card's render is the model's main picture, so a heavy model gets a while.
|
||||
const MODEL_IMAGE_KINDS: Record<ModelImageKind, ModelImageKindDef> = {
|
||||
// Shading scales the colour down, so it comes out between near-black in shadow and charcoal in the light. The
|
||||
// dashboard inverts it for the dark theme.
|
||||
icon: { file: "image.v2", options: { size: 128, color: "#5a5a5a" }, timeoutSeconds: 20 },
|
||||
// Grey shading the cards tint with the accent, so one picture serves every theme and accent.
|
||||
// Grey shading the browser tints with the render colour, so one picture serves every theme and colour.
|
||||
render: {
|
||||
file: "render.v2",
|
||||
options: { size: 512, color: "#ffffff", view: "threeQuarter", ambient: 0.35, fill: 0.2 },
|
||||
@@ -292,7 +287,7 @@ const MODEL_IMAGE_KINDS: Record<ModelImageKind, ModelImageKindDef> = {
|
||||
},
|
||||
};
|
||||
|
||||
export function modelImagePath(plateId: string, kind: ModelImageKind = "icon"): string {
|
||||
export function modelImagePath(plateId: string, kind: ModelImageKind = "render"): string {
|
||||
return path.join(MODEL_PREVIEWS, `${plateId}.${MODEL_IMAGE_KINDS[kind].file}.png`);
|
||||
}
|
||||
function modelImageErrorPath(plateId: string, kind: ModelImageKind): string {
|
||||
@@ -311,7 +306,7 @@ export async function generateModelImage(
|
||||
plateId: string,
|
||||
srcPath: string,
|
||||
filename: string,
|
||||
kind: ModelImageKind = "icon",
|
||||
kind: ModelImageKind = "render",
|
||||
onReady?: (imagePath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const errorPath = modelImageErrorPath(plateId, kind);
|
||||
@@ -369,7 +364,7 @@ export async function generateModelImage(
|
||||
/** "unsupported" is a file the renderer read but couldn't draw, e.g. a 3MF layout it doesn't know. */
|
||||
export function modelImageState(
|
||||
plateId: string,
|
||||
kind: ModelImageKind = "icon",
|
||||
kind: ModelImageKind = "render",
|
||||
): "ready" | "generating" | "failed" | "unsupported" {
|
||||
if (fsSync.existsSync(modelImagePath(plateId, kind))) return "ready";
|
||||
if (imagesInFlight.has(`${kind}:${plateId}`)) return "generating";
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
renderPlateStoragePath,
|
||||
saveThumbFromFile,
|
||||
} from "./printService";
|
||||
import { generateModelPreviewGlb, canRenderModelImage, generateModelImage } from "./modelPreviewCache";
|
||||
import { generateModelPreviewGlb, canRenderModelImage } from "./modelPreviewCache";
|
||||
import { generateCardRender } from "./previewImageService";
|
||||
import { deleteNormalized3mf } from "./normalized3mfCache";
|
||||
import { getPreviewMode } from "./settingsService";
|
||||
@@ -101,7 +101,6 @@ async function thumbnailAndSniff(plateId: string, filename: string, mime: string
|
||||
}
|
||||
// Queued behind the GLB, which the model page needs first.
|
||||
if (canRenderModelImage(filename) && (await getPreviewMode()) === "automatic") {
|
||||
void generateModelImage(plateId, effectivePath, filename);
|
||||
void generateCardRender(plateId, effectivePath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { TAG_MOSAICS } from "../config";
|
||||
import { prisma } from "../db";
|
||||
import { printCover } from "../dto";
|
||||
import { modelImagePath } from "./modelPreviewCache";
|
||||
import { normalizeTag } from "../utils/tagNormalization";
|
||||
|
||||
// A tag's tile shows one picture built here from its newest models' pictures, rather than ten pictures
|
||||
// the browser fetches and lays out itself. It is made the first time it's asked for and kept on disk,
|
||||
// under a name that changes with the pictures in it, so tagging and untagging models, a new render or a
|
||||
// replaced image each give it a new URL with nothing to invalidate.
|
||||
//
|
||||
// "images" mosaics are the models' pictures. "renders" mosaics are their grey renders alone, laid out on
|
||||
// transparent, so the browser tints the whole tile with the render colour as it does a card.
|
||||
|
||||
/** Pictures in a tag's mosaic, from its newest models. */
|
||||
export const TAG_MOSAIC_PICTURES = 10;
|
||||
@@ -36,6 +41,7 @@ export const MOSAICS: string[][] = [
|
||||
["aabc", "aade", "fghh", "fijj"],
|
||||
];
|
||||
|
||||
export type MosaicKind = "images" | "renders";
|
||||
type Picture = { url: string; file: string };
|
||||
export type TaggedPrint = { id: string; tags: string[] };
|
||||
|
||||
@@ -60,8 +66,23 @@ async function printsTagged(userId: string, tag: string): Promise<TaggedPrint[]>
|
||||
return rows.map((row) => ({ id: row.id, tags: [tag] }));
|
||||
}
|
||||
|
||||
/** A plate's render, only once it's on disk: tags don't queue renders, which cards and uploads do. */
|
||||
function renderCover(plateId: string | null): Picture | null {
|
||||
if (!plateId) return null;
|
||||
const file = modelImagePath(plateId, "render");
|
||||
try {
|
||||
return { url: `/plate/${plateId}/render.png?v=${fsSync.statSync(file).mtimeMs}`, file };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Each of `tags`' pictures, from its newest models that have one; joins images and plates for those alone. */
|
||||
export async function tagPictures(prints: TaggedPrint[], tags: string[]): Promise<Map<string, Picture[]>> {
|
||||
export async function tagPictures(
|
||||
prints: TaggedPrint[],
|
||||
tags: string[],
|
||||
kind: MosaicKind = "images",
|
||||
): Promise<Map<string, Picture[]>> {
|
||||
const candidates = new Map(tags.map((tag) => [tag, [] as string[]]));
|
||||
for (const print of prints) {
|
||||
for (const tag of new Set(print.tags.map(normalizeTag))) {
|
||||
@@ -83,7 +104,11 @@ export async function tagPictures(prints: TaggedPrint[], tags: string[]): Promis
|
||||
const coverOf = (id: string) => {
|
||||
if (!covers.has(id)) {
|
||||
const row = byId.get(id);
|
||||
covers.set(id, row ? printCover(row.previewImages[0]?.id ?? null, row.plates[0]?.id ?? null) : null);
|
||||
const plateId = row?.plates[0]?.id ?? null;
|
||||
covers.set(
|
||||
id,
|
||||
!row ? null : kind === "renders" ? renderCover(plateId) : printCover(row.previewImages[0]?.id ?? null, plateId),
|
||||
);
|
||||
}
|
||||
return covers.get(id);
|
||||
};
|
||||
@@ -110,11 +135,15 @@ function mosaicVersion(pictures: Picture[]): string {
|
||||
}
|
||||
|
||||
/** Where the tag's mosaic is served from, or null while none of its models has a picture. */
|
||||
export function tagMosaicUrl(tag: string, pictures: Picture[]): string | null {
|
||||
export function tagMosaicUrl(tag: string, pictures: Picture[], kind: MosaicKind = "images"): string | null {
|
||||
if (!pictures.length) return null;
|
||||
return `/tags/${encodeURIComponent(tag)}/mosaic.webp?v=${mosaicVersion(pictures)}`;
|
||||
const file = kind === "renders" ? "render-mosaic.webp" : "mosaic.webp";
|
||||
return `/tags/${encodeURIComponent(tag)}/${file}?v=${mosaicVersion(pictures)}`;
|
||||
}
|
||||
|
||||
// Render mosaics are named apart, so making one kind never clears the other as an older version.
|
||||
const RENDERS_INFIX = "r-";
|
||||
|
||||
function userDir(userId: string): string {
|
||||
return path.join(TAG_MOSAICS, `u-${userId}`);
|
||||
}
|
||||
@@ -137,8 +166,9 @@ function cells(layout: string[]): [number, number, number, number][] {
|
||||
});
|
||||
}
|
||||
|
||||
/** The mosaic of `pictures` (one to ten files), as a square WebP with see-through gaps. */
|
||||
export async function renderTagMosaic(pictures: { file: string }[]): Promise<Buffer> {
|
||||
/** The mosaic of `pictures` (one to ten files), as a square WebP with see-through gaps. Renders are fitted whole on
|
||||
* transparent rather than cropped to fill their cell. */
|
||||
export async function renderTagMosaic(pictures: { file: string }[], kind: MosaicKind = "images"): Promise<Buffer> {
|
||||
const layout = MOSAICS[pictures.length - 1];
|
||||
const step = (SIZE + GAP) / 4;
|
||||
const pieces = await Promise.all(
|
||||
@@ -146,7 +176,14 @@ export async function renderTagMosaic(pictures: { file: string }[]): Promise<Buf
|
||||
const [x, y] = [Math.round(left * step), Math.round(top * step)];
|
||||
const w = Math.round((left + width) * step - GAP) - x;
|
||||
const h = Math.round((top + height) * step - GAP) - y;
|
||||
const input = await sharp(pictures[index].file).rotate().resize(w, h, { fit: "cover" }).toBuffer();
|
||||
const input = await sharp(pictures[index].file)
|
||||
.rotate()
|
||||
.resize(
|
||||
w,
|
||||
h,
|
||||
kind === "renders" ? { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } } : { fit: "cover" },
|
||||
)
|
||||
.toBuffer();
|
||||
return { input, left: x, top: y };
|
||||
}),
|
||||
);
|
||||
@@ -158,14 +195,18 @@ export async function renderTagMosaic(pictures: { file: string }[]): Promise<Buf
|
||||
}
|
||||
|
||||
/** The tag's mosaic file, made now if its pictures have changed since it was last made; null with no pictures. */
|
||||
export async function ensureTagMosaic(userId: string, rawTag: string): Promise<string | null> {
|
||||
export async function ensureTagMosaic(
|
||||
userId: string,
|
||||
rawTag: string,
|
||||
kind: MosaicKind = "images",
|
||||
): Promise<string | null> {
|
||||
const tag = normalizeTag(rawTag);
|
||||
if (!tag) return null;
|
||||
const pictures = (await tagPictures(await printsTagged(userId, tag), [tag])).get(tag) ?? [];
|
||||
const pictures = (await tagPictures(await printsTagged(userId, tag), [tag], kind)).get(tag) ?? [];
|
||||
if (!pictures.length) return null;
|
||||
const dir = userDir(userId);
|
||||
const prefix = tagPrefix(tag);
|
||||
const file = path.join(dir, `${prefix}-${mosaicVersion(pictures)}.webp`);
|
||||
const file = path.join(dir, `${prefix}-${kind === "renders" ? RENDERS_INFIX : ""}${mosaicVersion(pictures)}.webp`);
|
||||
try {
|
||||
await fs.access(file);
|
||||
return file;
|
||||
@@ -175,9 +216,9 @@ export async function ensureTagMosaic(userId: string, rawTag: string): Promise<s
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
// Written aside and moved into place, so a request in the meantime never reads half a file.
|
||||
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(temporary, await renderTagMosaic(pictures));
|
||||
await fs.writeFile(temporary, await renderTagMosaic(pictures, kind));
|
||||
await fs.rename(temporary, file);
|
||||
await removeTagMosaics(userId, tag, file);
|
||||
await removeTagMosaics(userId, tag, { keep: file, kind });
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -186,8 +227,13 @@ export async function removeUserTagMosaics(userId: string): Promise<void> {
|
||||
await fs.rm(userDir(userId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Drops the tag's mosaics, all but `keep`: older versions, or every one when the tag is deleted. */
|
||||
export async function removeTagMosaics(userId: string, rawTag: string, keep?: string): Promise<void> {
|
||||
/** Drops the tag's mosaics: with `older`, that kind's versions other than `older.keep`; without, every one, as when
|
||||
* the tag is deleted. */
|
||||
export async function removeTagMosaics(
|
||||
userId: string,
|
||||
rawTag: string,
|
||||
older?: { keep: string; kind: MosaicKind },
|
||||
): Promise<void> {
|
||||
const dir = userDir(userId);
|
||||
const prefix = `${tagPrefix(normalizeTag(rawTag))}-`;
|
||||
let names: string[];
|
||||
@@ -198,7 +244,12 @@ export async function removeTagMosaics(userId: string, rawTag: string, keep?: st
|
||||
}
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((name) => name.startsWith(prefix) && name.endsWith(".webp") && path.join(dir, name) !== keep)
|
||||
.filter((name) => {
|
||||
if (!name.startsWith(prefix) || !name.endsWith(".webp")) return false;
|
||||
if (!older) return true;
|
||||
const isRenders = name.startsWith(`${prefix}${RENDERS_INFIX}`);
|
||||
return isRenders === (older.kind === "renders") && path.join(dir, name) !== older.keep;
|
||||
})
|
||||
.map((name) => fs.rm(path.join(dir, name), { force: true })),
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { prisma } from "../db";
|
||||
// Kept in sync by hand with the frontend's ThemeSelection and AccentColor.
|
||||
export const THEME_SELECTIONS = ["light", "dark", "system"] as const;
|
||||
export type ThemeSelection = (typeof THEME_SELECTIONS)[number];
|
||||
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple"] as const;
|
||||
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple", "graphite"] as const;
|
||||
export type AccentColor = (typeof ACCENT_COLORS)[number];
|
||||
// The accents, then light grey, which leaves the render's own grey shading all but untinted.
|
||||
export const RENDER_COLORS = [...ACCENT_COLORS, "light"] as const;
|
||||
export type RenderColor = (typeof RENDER_COLORS)[number];
|
||||
|
||||
function isThemeSelection(value: string): value is ThemeSelection {
|
||||
return (THEME_SELECTIONS as readonly string[]).includes(value);
|
||||
@@ -14,30 +17,50 @@ function isAccentColor(value: string): value is AccentColor {
|
||||
return (ACCENT_COLORS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type ThemePreferences = { theme: ThemeSelection | null; accent: AccentColor | null };
|
||||
function isRenderColor(value: string): value is RenderColor {
|
||||
return (RENDER_COLORS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type ThemePreferences = {
|
||||
theme: ThemeSelection | null;
|
||||
accent: AccentColor | null;
|
||||
renderColor: RenderColor | null;
|
||||
};
|
||||
|
||||
export async function getUserThemePreferences(userId: string): Promise<ThemePreferences> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { themePreference: true, accentColor: true },
|
||||
select: { themePreference: true, accentColor: true, renderColor: true },
|
||||
});
|
||||
const theme = user?.themePreference;
|
||||
const accent = user?.accentColor;
|
||||
const renderColor = user?.renderColor;
|
||||
return {
|
||||
theme: theme && isThemeSelection(theme) ? theme : null,
|
||||
accent: accent && isAccentColor(accent) ? accent : null,
|
||||
renderColor: renderColor && isRenderColor(renderColor) ? renderColor : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the fields given change; null clears one back to the frontend's default. */
|
||||
/** Only the fields given change; null clears one back to the frontend's default. A new accent puts the render
|
||||
* colour back to following it, unless the same patch sets one. */
|
||||
export async function setUserThemePreferences(
|
||||
userId: string,
|
||||
patch: { theme?: string | null; accent?: string | null },
|
||||
patch: { theme?: string | null; accent?: string | null; renderColor?: string | null },
|
||||
): Promise<ThemePreferences> {
|
||||
const data: { themePreference?: ThemeSelection | null; accentColor?: AccentColor | null } = {};
|
||||
const data: {
|
||||
themePreference?: ThemeSelection | null;
|
||||
accentColor?: AccentColor | null;
|
||||
renderColor?: RenderColor | null;
|
||||
} = {};
|
||||
if (patch.theme !== undefined)
|
||||
data.themePreference = patch.theme && isThemeSelection(patch.theme) ? patch.theme : null;
|
||||
if (patch.accent !== undefined) data.accentColor = patch.accent && isAccentColor(patch.accent) ? patch.accent : null;
|
||||
if (patch.accent !== undefined) {
|
||||
data.accentColor = patch.accent && isAccentColor(patch.accent) ? patch.accent : null;
|
||||
data.renderColor = null;
|
||||
}
|
||||
if (patch.renderColor !== undefined)
|
||||
data.renderColor = patch.renderColor && isRenderColor(patch.renderColor) ? patch.renderColor : null;
|
||||
await prisma.user.update({ where: { id: userId }, data });
|
||||
return getUserThemePreferences(userId);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ import { settingsApi, type PreviewMode } from "./api/settings";
|
||||
import { clearToken, clearUser, readToken, readUser, storeToken, storeUser } from "./utils/auth";
|
||||
import { type AppSettings, type MakerWorldSettings, loadSettings, saveSettings } from "./utils/settings";
|
||||
import { useResolvedTheme } from "./hooks/useResolvedTheme";
|
||||
import { setCachedAccentColor, useAccentColor } from "./hooks/useAccentColor";
|
||||
import { setCachedAccentColor, setCachedRenderColor, useAccentColor } from "./hooks/useAccentColor";
|
||||
import { resetCardPreview } from "./hooks/useCardPreview";
|
||||
import { DEFAULT_ACCENT, type ResolvedTheme, type ThemeSelection } from "./constants/settingsOptions";
|
||||
import { buildTheme } from "./theme";
|
||||
@@ -336,6 +336,7 @@ export default function App() {
|
||||
// The accent is the account's, so another account signing in on this tab doesn't inherit it.
|
||||
if (!token) {
|
||||
setCachedAccentColor(DEFAULT_ACCENT);
|
||||
setCachedRenderColor(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
@@ -347,6 +348,7 @@ export default function App() {
|
||||
// Null means never set: the theme stays as it is, the accent is the default.
|
||||
if (saved.theme) setThemeSelection(saved.theme);
|
||||
setCachedAccentColor(saved.accent ?? DEFAULT_ACCENT);
|
||||
setCachedRenderColor(saved.renderColor);
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
|
||||
@@ -6,8 +6,8 @@ export type DashboardModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
thumb_url: string | null;
|
||||
/** The model drawn in one colour, made by the server. Asking for one that isn't ready queues it. */
|
||||
model_image_url: string | null;
|
||||
/** The first plate's render for the browser to tint; asking for one that isn't ready queues it. */
|
||||
render_url: string | null;
|
||||
view_count: number;
|
||||
print_count: number;
|
||||
created_at: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, assertOk, readErrorMessage, UnauthorizedError } from "./client";
|
||||
import type { AccentColor, ThemeSelection } from "../constants/settingsOptions";
|
||||
import type { AccentColor, RenderColor, ThemeSelection } from "../constants/settingsOptions";
|
||||
import { invalidateAiCategorizationStatus } from "./aiCategorization";
|
||||
|
||||
export type StorageToken =
|
||||
@@ -75,9 +75,8 @@ export type DashboardWidgetId =
|
||||
export type WeatherLocation = { lat: number; lon: number; name: string | null; source: "device" | "manual" };
|
||||
// Kept in sync by hand with the backend's DASHBOARD_LAYOUTS.
|
||||
export type DashboardLayout = "left" | "top" | "right" | "bottom" | "topTwo" | "puzzle";
|
||||
// Kept in sync by hand with the backend's DASHBOARD_THUMBNAILS.
|
||||
export type DashboardThumbnails = "none" | "photos" | "monochrome";
|
||||
/** What model cards lead with: the model's own pictures, or our render tinted with the accent. */
|
||||
/** What model pictures lead with, on cards, dashboard lists and tag tiles: the model's own pictures, or our render
|
||||
* tinted with the render colour. */
|
||||
export type CardPreview = "images" | "renders";
|
||||
/** Only the widgets the user has switched; the rest follow their default. */
|
||||
export type DashboardWidgetChoices = Partial<Record<DashboardWidgetId, boolean>>;
|
||||
@@ -186,7 +185,12 @@ export const FRONTEND_VERSION: string = import.meta.env.VITE_APP_VERSION as stri
|
||||
|
||||
export const FRONTEND_GIT_SHA: string | null = (import.meta.env.VITE_GIT_SHA as string | undefined) || null;
|
||||
|
||||
export type ThemePreferences = { theme: ThemeSelection | null; accent: AccentColor | null };
|
||||
export type ThemePreferences = {
|
||||
theme: ThemeSelection | null;
|
||||
accent: AccentColor | null;
|
||||
/** Null follows the accent. */
|
||||
renderColor: RenderColor | null;
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
getAiCategorization: async (): Promise<AiCategorizationSettings> => {
|
||||
@@ -746,23 +750,6 @@ export const settingsApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getDashboardThumbnails: async (): Promise<{ thumbnails: DashboardThumbnails }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/dashboard-thumbnails`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load the dashboard thumbnails setting");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateDashboardThumbnails: async (thumbnails: DashboardThumbnails): Promise<{ thumbnails: DashboardThumbnails }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/dashboard-thumbnails`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ thumbnails }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the dashboard thumbnails setting"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getCardPreview: async (): Promise<{ preview: CardPreview }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/card-preview`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load the model card preview setting");
|
||||
@@ -832,4 +819,23 @@ export const settingsApi = {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
|
||||
getHoverSlideshow: async (): Promise<{ enabled: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/hover-slideshow`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load hover slideshow setting");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
updateHoverSlideshow: async (enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const res = await fetch(`${apiBase()}/settings/hover-slideshow`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) {
|
||||
throw new Error(await readErrorMessage(res, "Failed to update hover slideshow setting"));
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,8 @@ export type TagSummary = {
|
||||
bookmarked: boolean;
|
||||
/** One picture of its newest models, built and cached by the server; null while none has a picture. */
|
||||
mosaic_url: string | null;
|
||||
/** The same of their grey renders alone, for the browser to tint; null while none has a render yet. */
|
||||
render_mosaic_url: string | null;
|
||||
};
|
||||
|
||||
export type TagSummaryPage = {
|
||||
|
||||
@@ -7,11 +7,25 @@ import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../../api/client";
|
||||
import { settingsApi, type CardPreview } from "../../../api/settings";
|
||||
import { matchLanguage, SUPPORTED_LANGUAGES } from "../../../constants/languages";
|
||||
import { ACCENT_COLORS, type AccentColor, type ThemeSelection } from "../../../constants/settingsOptions";
|
||||
import { setCachedAccentColor, useAccentColor } from "../../../hooks/useAccentColor";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
RENDER_COLORS,
|
||||
renderColorLabelKey,
|
||||
type AccentColor,
|
||||
type RenderColor,
|
||||
type ThemeSelection,
|
||||
} from "../../../constants/settingsOptions";
|
||||
import {
|
||||
getCachedRenderColor,
|
||||
setCachedAccentColor,
|
||||
setCachedRenderColor,
|
||||
useAccentColor,
|
||||
useRenderColor,
|
||||
} from "../../../hooks/useAccentColor";
|
||||
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
|
||||
import { useCardPreview } from "../../../hooks/useCardPreview";
|
||||
import { accentSwatch } from "../../../theme";
|
||||
import { setCachedHoverSlideshowEnabled, useHoverSlideshowEnabled } from "../../../hooks/useHoverSlideshowEnabled";
|
||||
import { accentSwatch, renderSwatch } from "../../../theme";
|
||||
import ColorSwatches from "../../controls/ColorSwatches";
|
||||
import Segmented from "../../controls/Segmented";
|
||||
import HelpTip from "../../HelpTip";
|
||||
@@ -27,7 +41,7 @@ type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Theme, accent, model card pictures, language and author preview, each applied as it's changed. */
|
||||
/** Language, theme, accent, render colour, model card pictures and hover slideshow, and author preview, each applied as it's changed. */
|
||||
export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation("app");
|
||||
const authorPreview = useAuthorPreviewEnabled();
|
||||
@@ -36,9 +50,15 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
const accent = useAccentColor();
|
||||
const [cardPreview, setCardPreview] = useCardPreview();
|
||||
const [accentError, setAccentError] = React.useState<string | null>(null);
|
||||
const renderColor = useRenderColor();
|
||||
const [renderColorError, setRenderColorError] = React.useState<string | null>(null);
|
||||
// Like the accent's: only the latest pick's failure may put the colour back.
|
||||
const latestRenderColorSave = React.useRef(0);
|
||||
// Picks can overlap; only the latest one's failure may put the accent back.
|
||||
const latestAccentSave = React.useRef(0);
|
||||
const [cardPreviewError, setCardPreviewError] = React.useState<string | null>(null);
|
||||
const hoverSlideshow = useHoverSlideshowEnabled();
|
||||
const [hoverSlideshowError, setHoverSlideshowError] = React.useState<string | null>(null);
|
||||
|
||||
const changeCardPreview = async (next: CardPreview) => {
|
||||
setCardPreviewError(null);
|
||||
@@ -52,20 +72,53 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
|
||||
const changeAccent = async (next: AccentColor) => {
|
||||
const previous = accent;
|
||||
const previousRender = getCachedRenderColor();
|
||||
const save = ++latestAccentSave.current;
|
||||
setAccentError(null);
|
||||
// The whole app recolours straight away; put back if the save fails.
|
||||
// The whole app recolours straight away, renders included, as the server puts the render colour back to
|
||||
// following the accent; put both back if the save fails.
|
||||
setCachedAccentColor(next);
|
||||
setCachedRenderColor(null);
|
||||
try {
|
||||
await settingsApi.updateTheme({ accent: next });
|
||||
} catch (err) {
|
||||
if (save !== latestAccentSave.current) return;
|
||||
setCachedAccentColor(previous);
|
||||
setCachedRenderColor(previousRender);
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setAccentError(t("appearance.accentFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const changeRenderColor = async (next: RenderColor) => {
|
||||
const previous = getCachedRenderColor();
|
||||
const save = ++latestRenderColorSave.current;
|
||||
setRenderColorError(null);
|
||||
setCachedRenderColor(next);
|
||||
try {
|
||||
await settingsApi.updateTheme({ renderColor: next });
|
||||
} catch (err) {
|
||||
if (save !== latestRenderColorSave.current) return;
|
||||
setCachedRenderColor(previous);
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setRenderColorError(t("appearance.renderColor.failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleHoverSlideshow = async (enabled: boolean) => {
|
||||
setHoverSlideshowError(null);
|
||||
// Shown straight away; put back if the save fails.
|
||||
setCachedHoverSlideshowEnabled(enabled);
|
||||
try {
|
||||
const res = await settingsApi.updateHoverSlideshow(enabled);
|
||||
setCachedHoverSlideshowEnabled(res.enabled);
|
||||
} catch (err) {
|
||||
setCachedHoverSlideshowEnabled(!enabled);
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else setHoverSlideshowError(t("appearance.hoverSlideshow.failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAuthorPreview = async (enabled: boolean) => {
|
||||
setAuthorPreviewError(null);
|
||||
// Shown straight away; put back if the save fails.
|
||||
@@ -84,6 +137,31 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
<>
|
||||
<PanelHeader title={t("appearance.title")} subtitle={t("appearance.subtitle")} />
|
||||
<Stack spacing={2.5}>
|
||||
<div>
|
||||
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
|
||||
{SUPPORTED_LANGUAGES.length > MAX_SEGMENTED_LANGUAGES ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={language}
|
||||
onChange={(e) => void i18n.changeLanguage(e.target.value)}
|
||||
inputProps={{ "aria-label": t("profile.language.heading") }}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<MenuItem key={lang.code} value={lang.code} lang={lang.code}>
|
||||
{lang.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Segmented
|
||||
label={t("profile.language.heading")}
|
||||
value={language}
|
||||
onChange={(code) => void i18n.changeLanguage(code)}
|
||||
options={SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: lang.label }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>{t("userMenu.theme")}</SectionLabel>
|
||||
<Segmented
|
||||
@@ -110,6 +188,26 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t("appearance.renderColor.heading")}
|
||||
<HelpTip text={t("appearance.renderColor.help")} />
|
||||
</SectionLabel>
|
||||
<ColorSwatches
|
||||
label={t("appearance.renderColor.heading")}
|
||||
value={renderColor}
|
||||
onChange={(next) => void changeRenderColor(next)}
|
||||
options={RENDER_COLORS.map((value) => {
|
||||
const { color, contrast } = renderSwatch(value);
|
||||
return { value, label: t(renderColorLabelKey(value)), color, contrast };
|
||||
})}
|
||||
/>
|
||||
{renderColorError && (
|
||||
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
|
||||
{renderColorError}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>
|
||||
{t("appearance.cardPreview.heading")}
|
||||
@@ -132,28 +230,23 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
|
||||
{SUPPORTED_LANGUAGES.length > MAX_SEGMENTED_LANGUAGES ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={language}
|
||||
onChange={(e) => void i18n.changeLanguage(e.target.value)}
|
||||
inputProps={{ "aria-label": t("profile.language.heading") }}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<MenuItem key={lang.code} value={lang.code} lang={lang.code}>
|
||||
{lang.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Segmented
|
||||
label={t("profile.language.heading")}
|
||||
value={language}
|
||||
onChange={(code) => void i18n.changeLanguage(code)}
|
||||
options={SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: lang.label }))}
|
||||
/>
|
||||
<SectionLabel>
|
||||
{t("appearance.hoverSlideshow.heading")}
|
||||
<HelpTip text={t("appearance.hoverSlideshow.help")} />
|
||||
</SectionLabel>
|
||||
<Segmented
|
||||
label={t("appearance.hoverSlideshow.heading")}
|
||||
value={hoverSlideshow ? "on" : "off"}
|
||||
onChange={(value) => void toggleHoverSlideshow(value === "on")}
|
||||
options={[
|
||||
{ value: "on", label: t("appearance.on") },
|
||||
{ value: "off", label: t("appearance.off") },
|
||||
]}
|
||||
/>
|
||||
{hoverSlideshowError && (
|
||||
<Typography variant="caption" color="error" sx={{ display: "block", mt: 1 }}>
|
||||
{hoverSlideshowError}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Box from "@mui/material/Box";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { printsApi } from "../../api/prints";
|
||||
import { useRenderColor } from "../../hooks/useAccentColor";
|
||||
import { renderSwatch } from "../../theme";
|
||||
|
||||
// A render the server is still making is asked for again after these waits, then the card gives up on it. A heavy
|
||||
// model waits behind its own 3D preview in the server's queue, so this runs to a few minutes.
|
||||
@@ -25,11 +27,12 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The server's render of a model, in the theme's accent on a soft glow of it. The render is grey shading on
|
||||
* The server's render of a model, in the user's render colour (the accent unless picked apart) on a soft glow of it. The render is grey shading on
|
||||
* transparent: the accent fills the model's silhouette and the shading is multiplied over it, so one picture serves
|
||||
* every theme and accent and a switch needs nothing new from the server.
|
||||
* every theme and colour and a switch needs nothing new from the server.
|
||||
*/
|
||||
export default function ModelRender({ url, alt, onUnavailable, placeholder }: Props) {
|
||||
const tint = renderSwatch(useRenderColor()).color;
|
||||
// Failed loads so far; past the last retry the card falls back.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
@@ -71,7 +74,7 @@ export default function ModelRender({ url, alt, onUnavailable, placeholder }: Pr
|
||||
// Its own stacking context, so the multiply only blends with the silhouette under it.
|
||||
isolation: "isolate",
|
||||
background: (theme) =>
|
||||
`radial-gradient(circle at 50% 48%, ${alpha(theme.palette.primary.main, theme.palette.mode === "dark" ? 0.2 : 0.14)} 0%, transparent 68%)`,
|
||||
`radial-gradient(circle at 50% 48%, ${alpha(tint, theme.palette.mode === "dark" ? 0.2 : 0.14)} 0%, transparent 68%)`,
|
||||
}}
|
||||
>
|
||||
{!loaded &&
|
||||
@@ -83,7 +86,7 @@ export default function ModelRender({ url, alt, onUnavailable, placeholder }: Pr
|
||||
aria-hidden
|
||||
sx={{
|
||||
...layerSx,
|
||||
bgcolor: "primary.main",
|
||||
bgcolor: tint,
|
||||
mask: `url("${src}") center / contain no-repeat`,
|
||||
WebkitMask: `url("${src}") center / contain no-repeat`,
|
||||
animation: `${fadeIn} 250ms ease-out`,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
|
||||
type Props = {
|
||||
/** Grey shading on transparent, like the server's renders and render mosaics. */
|
||||
src: string;
|
||||
tint: string;
|
||||
/** Placed absolutely over a `position: relative` parent; this styles that layer. */
|
||||
sx?: SxProps<Theme>;
|
||||
loading?: "lazy" | "eager";
|
||||
};
|
||||
|
||||
/** Fills the picture's silhouette with `tint` and multiplies its shading over it, so one grey picture shows in any
|
||||
* colour. Its own stacking context, so the multiply blends only with the fill under it. */
|
||||
export default function TintedRender({ src, tint, sx, loading }: Props) {
|
||||
const layer = { position: "absolute", inset: 0, width: "100%", height: "100%" } as const;
|
||||
return (
|
||||
<Box aria-hidden sx={[{ ...layer, isolation: "isolate" }, ...(Array.isArray(sx) ? sx : [sx])]}>
|
||||
<Box
|
||||
sx={{
|
||||
...layer,
|
||||
bgcolor: tint,
|
||||
mask: `url("${src}") center / contain no-repeat`,
|
||||
WebkitMask: `url("${src}") center / contain no-repeat`,
|
||||
transition: "background-color 200ms",
|
||||
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
component="img"
|
||||
src={src}
|
||||
alt=""
|
||||
loading={loading}
|
||||
decoding="async"
|
||||
sx={{ ...layer, display: "block", objectFit: "contain", mixBlendMode: "multiply" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,20 @@ export const NORMALIZE_3MF_SLICER_IDS: ReadonlySet<string> = new Set([
|
||||
/** "system" follows prefers-color-scheme; resolve with useResolvedTheme before rendering. */
|
||||
export type ThemeSelection = "light" | "dark" | "system";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
/** Kept in sync by hand with the backend's ACCENT_COLORS. Thingport green first, the rest from providers' brands. */
|
||||
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple"] as const;
|
||||
/** Kept in sync by hand with the backend's ACCENT_COLORS. Thingport green first, then providers' brands, then a
|
||||
* near-black graphite. */
|
||||
export const ACCENT_COLORS = ["green", "blue", "orange", "amber", "teal", "purple", "graphite"] as const;
|
||||
export type AccentColor = (typeof ACCENT_COLORS)[number];
|
||||
export const DEFAULT_ACCENT: AccentColor = "green";
|
||||
/** Kept in sync by hand with the backend's RENDER_COLORS: the accents, then a light grey that leaves the render's
|
||||
* own shading all but untinted. */
|
||||
export const RENDER_COLORS = [...ACCENT_COLORS, "light"] as const;
|
||||
export type RenderColor = (typeof RENDER_COLORS)[number];
|
||||
|
||||
/** The `t()` key naming a render colour. */
|
||||
export function renderColorLabelKey(color: RenderColor): string {
|
||||
return color === "light" ? "app:appearance.renderColor.light" : `app:appearance.accents.${color}`;
|
||||
}
|
||||
export type ThemeOption = { id: ThemeSelection; label: string; description: string };
|
||||
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { DEFAULT_ACCENT, type AccentColor } from "../constants/settingsOptions";
|
||||
import { DEFAULT_ACCENT, type AccentColor, type RenderColor } from "../constants/settingsOptions";
|
||||
|
||||
// One value for the whole app: App builds the theme from it and the Appearance panel changes it, so a pick
|
||||
// recolours everything at once. App loads it with the theme.
|
||||
let current: AccentColor = DEFAULT_ACCENT;
|
||||
// The render colour picked apart from the accent; null follows the accent.
|
||||
let renderOverride: RenderColor | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = () => listeners.forEach((listener) => listener());
|
||||
|
||||
export function setCachedAccentColor(accent: AccentColor) {
|
||||
current = accent;
|
||||
listeners.forEach((listener) => listener());
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setCachedRenderColor(color: RenderColor | null) {
|
||||
renderOverride = color;
|
||||
notify();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
@@ -21,3 +30,13 @@ function subscribe(listener: () => void) {
|
||||
export function useAccentColor(): AccentColor {
|
||||
return useSyncExternalStore(subscribe, () => current);
|
||||
}
|
||||
|
||||
/** What generated renders are tinted: the picked render colour, or the accent. */
|
||||
export function useRenderColor(): RenderColor {
|
||||
return useSyncExternalStore(subscribe, () => renderOverride ?? current);
|
||||
}
|
||||
|
||||
/** The render colour as saved, null while it follows the accent. */
|
||||
export function getCachedRenderColor(): RenderColor | null {
|
||||
return renderOverride;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { settingsApi, type DashboardThumbnails } from "../api/settings";
|
||||
|
||||
// Saved on the account so every device shows the same pictures. Shared by every user of
|
||||
// the hook so there's one request, and a switch shows everywhere at once.
|
||||
let cached: DashboardThumbnails | undefined;
|
||||
let inFlight: Promise<DashboardThumbnails> | null = null;
|
||||
const listeners = new Set<(value: DashboardThumbnails) => void>();
|
||||
|
||||
function load(): Promise<DashboardThumbnails> {
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
// A switch made while this is in flight is newer than the server's answer, so it wins.
|
||||
inFlight ??= settingsApi
|
||||
.getDashboardThumbnails()
|
||||
.then((res) => (cached ??= res.thumbnails))
|
||||
.catch((): DashboardThumbnails => cached ?? "photos")
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function publish(value: DashboardThumbnails) {
|
||||
cached = value;
|
||||
listeners.forEach((listener) => listener(value));
|
||||
}
|
||||
|
||||
/** Null until the saved choice is known, so the lists don't flash the wrong pictures. */
|
||||
export function useDashboardThumbnails(): [DashboardThumbnails | null, (thumbnails: DashboardThumbnails) => void] {
|
||||
const [value, setValue] = useState<DashboardThumbnails | null>(cached ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void load().then((v) => {
|
||||
if (!cancelled) setValue(cached ?? v);
|
||||
});
|
||||
listeners.add(setValue);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
listeners.delete(setValue);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const update = (thumbnails: DashboardThumbnails) => {
|
||||
publish(thumbnails);
|
||||
// The switch already happened here; a failed save only means other devices won't follow.
|
||||
settingsApi.updateDashboardThumbnails(thumbnails).catch(() => {});
|
||||
};
|
||||
|
||||
return [value, update];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { settingsApi } from "../api/settings";
|
||||
|
||||
// Shared by every model card so there's one request, and saves propagate to all of them.
|
||||
let cached: boolean | undefined; // undefined = not loaded yet
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
const listeners = new Set<(value: boolean) => void>();
|
||||
|
||||
function load(): Promise<boolean> {
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
if (!inFlight) {
|
||||
inFlight = settingsApi
|
||||
.getHoverSlideshow()
|
||||
.then((res) => {
|
||||
cached = res.enabled;
|
||||
return cached;
|
||||
})
|
||||
// A failed load shouldn't silently switch the feature off.
|
||||
.catch(() => true)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function setCachedHoverSlideshowEnabled(value: boolean) {
|
||||
cached = value;
|
||||
listeners.forEach((listener) => listener(value));
|
||||
}
|
||||
|
||||
export function useHoverSlideshowEnabled(): boolean {
|
||||
const [value, setValue] = useState<boolean>(cached ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void load().then((v) => {
|
||||
if (!cancelled) setValue(v);
|
||||
});
|
||||
listeners.add(setValue);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
listeners.delete(setValue);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -512,8 +512,7 @@
|
||||
"allHidden": "Every dashboard box is hidden.",
|
||||
"sections": {
|
||||
"layout": "Layout",
|
||||
"panels": "Panels",
|
||||
"thumbnails": "Thumbnails"
|
||||
"panels": "Panels"
|
||||
},
|
||||
"layoutSubtitle": "Choose how your dashboard arranges its boxes. The coloured part shows where the counts go.",
|
||||
"layouts": {
|
||||
@@ -523,17 +522,6 @@
|
||||
"bottom": "Counts at the bottom, three columns",
|
||||
"topTwo": "Counts on top, two columns",
|
||||
"puzzle": "Puzzle"
|
||||
},
|
||||
"thumbnailsSubtitle": "The pictures beside models in the dashboard lists.",
|
||||
"thumbnails": {
|
||||
"none": "Hidden",
|
||||
"photos": "Photos",
|
||||
"monochrome": "Monochrome"
|
||||
},
|
||||
"thumbnailsHelp": {
|
||||
"none": "The lists show names only.",
|
||||
"photos": "Each model's preview image.",
|
||||
"monochrome": "Each model drawn in one tone on a transparent background, made by the server the first time it's needed. Files it can't draw show their photo."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1583,7 +1571,8 @@
|
||||
"orange": "Printables orange",
|
||||
"amber": "Thangs yellow",
|
||||
"teal": "MyMiniFactory teal",
|
||||
"purple": "Cults3D purple"
|
||||
"purple": "Cults3D purple",
|
||||
"graphite": "Graphite"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Model cards",
|
||||
@@ -1591,6 +1580,17 @@
|
||||
"renders": "3D renders",
|
||||
"failed": "Couldn't save the model card choice.",
|
||||
"help": "What model cards show first. Images: the pictures a model came with, or a 3D render where it has none. 3D renders: a render of every model in your accent color, or its pictures where the file can't be drawn."
|
||||
},
|
||||
"hoverSlideshow": {
|
||||
"heading": "Hover slideshow",
|
||||
"failed": "Couldn't save the hover slideshow choice.",
|
||||
"help": "Model cards cycle through a model's pictures while the pointer rests on them."
|
||||
},
|
||||
"renderColor": {
|
||||
"heading": "Render color",
|
||||
"light": "Light grey (no tint)",
|
||||
"failed": "Couldn't save the render color.",
|
||||
"help": "The color generated 3D renders and the 3D preview are drawn in. Picking an accent sets it to match; pick here to change it separately."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -512,8 +512,7 @@
|
||||
"allHidden": "Todos los bloques del panel están ocultos.",
|
||||
"sections": {
|
||||
"layout": "Disposición",
|
||||
"panels": "Paneles",
|
||||
"thumbnails": "Miniaturas"
|
||||
"panels": "Paneles"
|
||||
},
|
||||
"layoutSubtitle": "Elige cómo organiza el panel sus bloques. La parte coloreada indica dónde van los recuentos.",
|
||||
"layouts": {
|
||||
@@ -523,17 +522,6 @@
|
||||
"bottom": "Contadores abajo, en tres columnas",
|
||||
"topTwo": "Contadores arriba, en dos columnas",
|
||||
"puzzle": "Puzle"
|
||||
},
|
||||
"thumbnailsSubtitle": "Las imágenes junto a los modelos en las listas del panel.",
|
||||
"thumbnails": {
|
||||
"none": "Ocultas",
|
||||
"photos": "Fotos",
|
||||
"monochrome": "Monocromo"
|
||||
},
|
||||
"thumbnailsHelp": {
|
||||
"none": "Las listas muestran solo los nombres.",
|
||||
"photos": "La imagen de vista previa de cada modelo.",
|
||||
"monochrome": "Cada modelo dibujado en un solo tono sobre fondo transparente, generado por el servidor la primera vez que hace falta. Los archivos que no puede dibujar muestran su foto."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1583,7 +1571,8 @@
|
||||
"orange": "Naranja Printables",
|
||||
"amber": "Amarillo Thangs",
|
||||
"teal": "Turquesa MyMiniFactory",
|
||||
"purple": "Morado Cults3D"
|
||||
"purple": "Morado Cults3D",
|
||||
"graphite": "Grafito"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Tarjetas de modelos",
|
||||
@@ -1591,6 +1580,17 @@
|
||||
"renders": "Renders 3D",
|
||||
"failed": "No se pudo guardar la opción de las tarjetas de modelos.",
|
||||
"help": "Lo que muestran primero las tarjetas de modelos. Imágenes: las imágenes que trae el modelo, o un render 3D si no tiene ninguna. Renders 3D: un render de cada modelo en tu color de acento, o sus imágenes si el archivo no se puede dibujar."
|
||||
},
|
||||
"hoverSlideshow": {
|
||||
"heading": "Presentación al pasar el cursor",
|
||||
"failed": "No se pudo guardar la opción de la presentación al pasar el cursor.",
|
||||
"help": "Las tarjetas de modelos van pasando las imágenes del modelo mientras el puntero está sobre ellas."
|
||||
},
|
||||
"renderColor": {
|
||||
"heading": "Color del render",
|
||||
"light": "Gris claro (sin tinte)",
|
||||
"failed": "No se pudo guardar el color del render.",
|
||||
"help": "El color en que se dibujan los renders 3D generados y la vista previa 3D. Al elegir un color de acento cambia con él; elige aquí para cambiarlo por separado."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -512,8 +512,7 @@
|
||||
"allHidden": "Tous les blocs du tableau de bord sont masqués.",
|
||||
"sections": {
|
||||
"layout": "Disposition",
|
||||
"panels": "Panneaux",
|
||||
"thumbnails": "Miniatures"
|
||||
"panels": "Panneaux"
|
||||
},
|
||||
"layoutSubtitle": "Choisissez la disposition des blocs du tableau de bord. La partie colorée indique l’emplacement des compteurs.",
|
||||
"layouts": {
|
||||
@@ -523,17 +522,6 @@
|
||||
"bottom": "Compteurs en bas, sur trois colonnes",
|
||||
"topTwo": "Compteurs en haut, sur deux colonnes",
|
||||
"puzzle": "Puzzle"
|
||||
},
|
||||
"thumbnailsSubtitle": "Les images à côté des modèles dans les listes du tableau de bord.",
|
||||
"thumbnails": {
|
||||
"none": "Masquées",
|
||||
"photos": "Photos",
|
||||
"monochrome": "Monochrome"
|
||||
},
|
||||
"thumbnailsHelp": {
|
||||
"none": "Les listes n’affichent que les noms.",
|
||||
"photos": "L’image d’aperçu de chaque modèle.",
|
||||
"monochrome": "Chaque modèle dessiné dans une seule teinte sur fond transparent, généré par le serveur la première fois que c’est nécessaire. Les fichiers qu’il ne peut pas dessiner affichent leur photo."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1596,7 +1584,8 @@
|
||||
"orange": "Orange Printables",
|
||||
"amber": "Jaune Thangs",
|
||||
"teal": "Turquoise MyMiniFactory",
|
||||
"purple": "Violet Cults3D"
|
||||
"purple": "Violet Cults3D",
|
||||
"graphite": "Graphite"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Cartes des modèles",
|
||||
@@ -1604,6 +1593,17 @@
|
||||
"renders": "Rendus 3D",
|
||||
"failed": "Impossible d’enregistrer le choix pour les cartes des modèles.",
|
||||
"help": "Ce que les cartes des modèles affichent en premier. Images : les images fournies avec le modèle, ou un rendu 3D s’il n’en a pas. Rendus 3D : un rendu de chaque modèle dans votre couleur d’accent, ou ses images si le fichier ne peut pas être dessiné."
|
||||
},
|
||||
"hoverSlideshow": {
|
||||
"heading": "Diaporama au survol",
|
||||
"failed": "Impossible d’enregistrer le choix pour le diaporama au survol.",
|
||||
"help": "Les cartes des modèles font défiler les images du modèle tant que le pointeur reste dessus."
|
||||
},
|
||||
"renderColor": {
|
||||
"heading": "Couleur du rendu",
|
||||
"light": "Gris clair (sans teinte)",
|
||||
"failed": "Impossible d’enregistrer la couleur du rendu.",
|
||||
"help": "La couleur des rendus 3D générés et de l’aperçu 3D. Choisir une couleur d’accent la change aussi ; choisissez ici pour la modifier séparément."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -518,8 +518,7 @@
|
||||
"allHidden": "Visi valdymo skydo langeliai paslėpti.",
|
||||
"sections": {
|
||||
"layout": "Išdėstymas",
|
||||
"panels": "Langeliai",
|
||||
"thumbnails": "Miniatiūros"
|
||||
"panels": "Langeliai"
|
||||
},
|
||||
"layoutSubtitle": "Pasirinkite, kaip išdėstyti valdymo skydo langelius. Spalvota dalis rodo, kur bus skaičiai.",
|
||||
"layouts": {
|
||||
@@ -529,17 +528,6 @@
|
||||
"bottom": "Skaičiai apačioje, trys stulpeliai",
|
||||
"topTwo": "Skaičiai viršuje, du stulpeliai",
|
||||
"puzzle": "Dėlionė"
|
||||
},
|
||||
"thumbnailsSubtitle": "Paveikslėliai šalia modelių valdymo skydo sąrašuose.",
|
||||
"thumbnails": {
|
||||
"none": "Paslėpti",
|
||||
"photos": "Nuotraukos",
|
||||
"monochrome": "Vienspalvės"
|
||||
},
|
||||
"thumbnailsHelp": {
|
||||
"none": "Sąrašuose rodomi tik pavadinimai.",
|
||||
"photos": "Kiekvieno modelio peržiūros paveikslėlis.",
|
||||
"monochrome": "Kiekvienas modelis nupieštas vienu tonu permatomame fone; serveris jį sukuria, kai prireikia pirmą kartą. Failai, kurių nupiešti negalima, rodo nuotrauką."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1649,7 +1637,8 @@
|
||||
"orange": "Printables oranžinė",
|
||||
"amber": "Thangs geltona",
|
||||
"teal": "MyMiniFactory žydra",
|
||||
"purple": "Cults3D violetinė"
|
||||
"purple": "Cults3D violetinė",
|
||||
"graphite": "Grafitas"
|
||||
},
|
||||
"cardPreview": {
|
||||
"heading": "Modelių kortelės",
|
||||
@@ -1657,6 +1646,17 @@
|
||||
"renders": "3D vaizdai",
|
||||
"failed": "Nepavyko išsaugoti modelių kortelių pasirinkimo.",
|
||||
"help": "Ką modelių kortelės rodo pirmiausia. Nuotraukos: paveikslėliai, su kuriais modelis atkeliavo, arba 3D vaizdas, jei jų nėra. 3D vaizdai: kiekvieno modelio vaizdas jūsų akcento spalva arba jo nuotraukos, jei failo nupiešti nepavyksta."
|
||||
},
|
||||
"hoverSlideshow": {
|
||||
"heading": "Skaidrės užvedus",
|
||||
"failed": "Nepavyko išsaugoti skaidrių užvedus nustatymo.",
|
||||
"help": "Užvedus žymeklį ant modelio kortelės, joje keičiasi modelio nuotraukos."
|
||||
},
|
||||
"renderColor": {
|
||||
"heading": "Atvaizdo spalva",
|
||||
"light": "Šviesiai pilka (be atspalvio)",
|
||||
"failed": "Nepavyko išsaugoti atvaizdo spalvos.",
|
||||
"help": "Spalva, kuria piešiami sugeneruoti 3D atvaizdai ir 3D peržiūra. Pasirinkus akcento spalvą, ji pasikeičia kartu; čia galite ją pakeisti atskirai."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -5,23 +5,14 @@ import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import ViewQuiltOutlinedIcon from "@mui/icons-material/ViewQuiltOutlined";
|
||||
import ToggleOnOutlinedIcon from "@mui/icons-material/ToggleOnOutlined";
|
||||
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
DashboardThumbnails,
|
||||
DashboardWidgetChoices,
|
||||
DashboardWidgetId,
|
||||
} from "../../api/settings";
|
||||
import type { DashboardLayout, DashboardWidgetChoices, DashboardWidgetId } from "../../api/settings";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import Segmented from "../../components/controls/Segmented";
|
||||
import SelectableTile from "../../components/controls/SelectableTile";
|
||||
import { useDashboardThumbnails } from "../../hooks/useDashboardThumbnails";
|
||||
import LayoutPicker from "./LayoutPicker";
|
||||
import PanelSketch from "./PanelSketch";
|
||||
import { DASHBOARD_WIDGETS, isWidgetEnabled } from "./widgets";
|
||||
|
||||
type Section = "layout" | "panels" | "thumbnails";
|
||||
const THUMBNAILS: DashboardThumbnails[] = ["none", "photos", "monochrome"];
|
||||
type Section = "layout" | "panels";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -36,7 +27,6 @@ type Props = {
|
||||
export default function DashboardCustomizeDialog({ open, layout, onLayoutChange, choices, onToggle, onClose }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const [section, setSection] = useState<Section>("layout");
|
||||
const [thumbnails, setThumbnails] = useDashboardThumbnails();
|
||||
|
||||
// Each opening starts on Layout; kept while closing, so the section doesn't switch during the fade.
|
||||
useEffect(() => {
|
||||
@@ -54,11 +44,6 @@ export default function DashboardCustomizeDialog({ open, layout, onLayoutChange,
|
||||
label: t("dashboard.widgets.sections.panels"),
|
||||
icon: <ToggleOnOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
{
|
||||
id: "thumbnails" as const,
|
||||
label: t("dashboard.widgets.sections.thumbnails"),
|
||||
icon: <ImageOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -101,25 +86,6 @@ export default function DashboardCustomizeDialog({ open, layout, onLayoutChange,
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
{section === "thumbnails" && (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("dashboard.widgets.thumbnailsSubtitle")}
|
||||
</Typography>
|
||||
{/* A column Stack would stretch the switch across the panel; as in Configuration, it fits its options. */}
|
||||
<div>
|
||||
<Segmented
|
||||
label={t("dashboard.widgets.sections.thumbnails")}
|
||||
value={thumbnails ?? "photos"}
|
||||
onChange={setThumbnails}
|
||||
options={THUMBNAILS.map((value) => ({ value, label: t(`dashboard.widgets.thumbnails.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t(`dashboard.widgets.thumbnailsHelp.${thumbnails ?? "photos"}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</SectionedDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
||||
import ViewInArIcon from "@mui/icons-material/ViewInAr";
|
||||
import type { DashboardModel } from "../../api/dashboard";
|
||||
import { printsApi } from "../../api/prints";
|
||||
import { useDashboardThumbnails } from "../../hooks/useDashboardThumbnails";
|
||||
import ModelRender from "../../components/media/ModelRender";
|
||||
import { useCardPreview } from "../../hooks/useCardPreview";
|
||||
|
||||
const SIZE = 40;
|
||||
|
||||
/**
|
||||
* A dashboard list row's picture, as the user chose: none, the model's photo, or the server's
|
||||
* one-colour drawing of it. Until a drawing is made (the first request queues it) or for files it
|
||||
* can't draw, the photo stands in.
|
||||
* A dashboard list row's picture, led as the model cards are: the model's picture, or its render tinted with the
|
||||
* render colour. Either stands in for the other where it's missing.
|
||||
*/
|
||||
// A drawing that isn't ready yet is asked for again after these waits, then the photo stays.
|
||||
const RETRY_DELAYS_MS = [2000, 4000, 8000, 16000];
|
||||
|
||||
export default function DashboardModelThumb({ model }: { model: DashboardModel }) {
|
||||
const [thumbnails] = useDashboardThumbnails();
|
||||
// Failed loads so far; past the last retry the photo stays for good.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const pictureFailed = attempt > RETRY_DELAYS_MS.length;
|
||||
const [preference] = useCardPreview();
|
||||
const [renderUnavailable, setRenderUnavailable] = useState(false);
|
||||
const onUnavailable = useCallback(() => setRenderUnavailable(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const timer = window.setTimeout(() => setWaiting(false), RETRY_DELAYS_MS[attempt - 1]);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [waiting, attempt]);
|
||||
|
||||
// Hidden until the choice is known, so the lists don't flash photos first.
|
||||
if (thumbnails === null || thumbnails === "none") return null;
|
||||
const url = thumbnails === "monochrome" && !pictureFailed && !waiting ? model.model_image_url : null;
|
||||
// Each retry asks afresh, so a cached "not ready" answer isn't reused.
|
||||
const picture = url
|
||||
? printsApi.fileUrl(attempt ? `${url}${url.includes("?") ? "&" : "?"}attempt=${attempt}` : url)
|
||||
: null;
|
||||
// Hidden until the choice is known, so the lists don't flash the wrong picture.
|
||||
if (preference === null) return <ListItemAvatar sx={{ minWidth: 48 }} />;
|
||||
const photo = model.thumb_url ? printsApi.fileUrl(model.thumb_url) : undefined;
|
||||
const photoAvatar = (
|
||||
<Avatar src={photo} variant="rounded" sx={{ width: SIZE, height: SIZE, color: "text.disabled" }}>
|
||||
<ViewInArIcon fontSize="small" />
|
||||
</Avatar>
|
||||
);
|
||||
const showRender = Boolean(model.render_url) && !renderUnavailable && (preference === "renders" || !photo);
|
||||
return (
|
||||
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||
<Avatar
|
||||
src={picture ?? photo}
|
||||
variant="rounded"
|
||||
slotProps={{
|
||||
img: {
|
||||
// Rows scrolled out of a long list don't ask the server to draw anything yet.
|
||||
loading: "lazy",
|
||||
onError: () => {
|
||||
if (!picture) return;
|
||||
setAttempt((count) => count + 1);
|
||||
setWaiting(true);
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
bgcolor: picture ? "transparent" : undefined,
|
||||
color: "text.disabled",
|
||||
// The drawing is dark; on a dark card it's turned light instead.
|
||||
"& img": picture ? { filter: (theme) => (theme.palette.mode === "dark" ? "invert(1)" : "none") } : undefined,
|
||||
}}
|
||||
>
|
||||
<ViewInArIcon fontSize="small" />
|
||||
</Avatar>
|
||||
{showRender ? (
|
||||
<Box sx={{ width: SIZE, height: SIZE, borderRadius: 1, overflow: "hidden" }}>
|
||||
<ModelRender
|
||||
key={model.render_url}
|
||||
url={model.render_url!}
|
||||
alt={model.name}
|
||||
onUnavailable={onUnavailable}
|
||||
placeholder={photo ? photoAvatar : undefined}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
photoAvatar
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,9 @@ import ModelViewer, {
|
||||
} from "../../components/media/ModelViewer";
|
||||
import type { PlateSummary } from "../../utils/bambuThreeMf";
|
||||
import PreviewToolbar from "./PreviewToolbar";
|
||||
import type { AccentColor } from "../../constants/settingsOptions";
|
||||
import { useAccentColor } from "../../hooks/useAccentColor";
|
||||
import { accentSwatch } from "../../theme";
|
||||
import type { RenderColor } from "../../constants/settingsOptions";
|
||||
import { useRenderColor } from "../../hooks/useAccentColor";
|
||||
import { renderSwatch } from "../../theme";
|
||||
import { fileRowText } from "./fileRowText";
|
||||
|
||||
// Light mode keeps a product-shot style grey; dark mode uses the page background so it doesn't glare.
|
||||
@@ -68,10 +68,10 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
// Lives here so it survives the viewer remounting per file.
|
||||
const viewerRef = useRef<ModelViewerHandle>(null);
|
||||
const [renderStyle, setRenderStyle] = useState<RenderStyle>("solid");
|
||||
// Follows the accent until another colour is picked for this viewing.
|
||||
const accent = useAccentColor();
|
||||
const [pickedColor, setPickedColor] = useState<AccentColor | null>(null);
|
||||
const modelColor = pickedColor ?? accent;
|
||||
// Follows the render colour until another is picked for this viewing.
|
||||
const renderColor = useRenderColor();
|
||||
const [pickedColor, setPickedColor] = useState<RenderColor | null>(null);
|
||||
const modelColor = pickedColor ?? renderColor;
|
||||
const [cameraView, setCameraView] = useState<CameraView>("topFront");
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [spin, setSpin] = useState(true);
|
||||
@@ -225,7 +225,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
ext={ext}
|
||||
initialCameraView={cameraView}
|
||||
theme={themeMode}
|
||||
colorOverride={accentSwatch(modelColor).color}
|
||||
colorOverride={renderSwatch(modelColor).color}
|
||||
renderStyle={renderStyle}
|
||||
showBuildPlate={showGrid}
|
||||
buildPlateForMeshes
|
||||
|
||||
@@ -9,8 +9,8 @@ import GridOnIcon from "@mui/icons-material/GridOn";
|
||||
import ThreeSixtyIcon from "@mui/icons-material/ThreeSixty";
|
||||
import type { CameraView, RenderStyle } from "../../components/media/ModelViewer";
|
||||
import ColorSwatches from "../../components/controls/ColorSwatches";
|
||||
import { ACCENT_COLORS, type AccentColor } from "../../constants/settingsOptions";
|
||||
import { accentSwatch } from "../../theme";
|
||||
import { RENDER_COLORS, renderColorLabelKey, type RenderColor } from "../../constants/settingsOptions";
|
||||
import { renderSwatch } from "../../theme";
|
||||
|
||||
const CAMERA_VIEWS: CameraView[] = ["topFront", "front", "side", "top", "bottom"];
|
||||
const RENDER_STYLES: RenderStyle[] = ["solid", "wire", "xray"];
|
||||
@@ -20,8 +20,8 @@ type Props = {
|
||||
onCameraView: (view: CameraView) => void;
|
||||
renderStyle: RenderStyle;
|
||||
onRenderStyleChange: (style: RenderStyle) => void;
|
||||
color: AccentColor;
|
||||
onColorChange: (color: AccentColor) => void;
|
||||
color: RenderColor;
|
||||
onColorChange: (color: RenderColor) => void;
|
||||
showGrid: boolean;
|
||||
onShowGridChange: (show: boolean) => void;
|
||||
spin: boolean;
|
||||
@@ -93,15 +93,15 @@ export default function PreviewToolbar({
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
{/* The accent palette, so the model can be seen in any of the app's colours. */}
|
||||
{/* The render palette, so the model can be seen in any of the app's colours. */}
|
||||
<ColorSwatches
|
||||
bare
|
||||
label={t("models:detail.previewToolbar.color")}
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
options={ACCENT_COLORS.map((value) => {
|
||||
const { color: swatch, contrast } = accentSwatch(value);
|
||||
return { value, label: t(`app:appearance.accents.${value}`), color: swatch, contrast };
|
||||
options={RENDER_COLORS.map((value) => {
|
||||
const { color: swatch, contrast } = renderSwatch(value);
|
||||
return { value, label: t(renderColorLabelKey(value)), color: swatch, contrast };
|
||||
})}
|
||||
/>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
|
||||
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
|
||||
import StarToggle from "../../components/StarToggle";
|
||||
import HoverSlideshow from "../../components/media/HoverSlideshow";
|
||||
import { useHoverSlideshowEnabled } from "../../hooks/useHoverSlideshowEnabled";
|
||||
import AuthorHoverCard from "../../components/AuthorHoverCard";
|
||||
import RollingNumber from "../../components/RollingNumber";
|
||||
import { useFavoriteToggle } from "../../hooks/useFavoriteToggle";
|
||||
@@ -97,6 +98,7 @@ export default function ModelCard({
|
||||
const authorAvatarUrl = author?.avatar_url || (showViewerAsAuthor ? viewerAvatarUrl : undefined);
|
||||
// Only mounted while hovered, so idle cards don't load images or tick.
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const slideshowEnabled = useHoverSlideshowEnabled();
|
||||
const contextMenu = useCardContextMenu();
|
||||
const toggleSelect = (event: React.MouseEvent | React.KeyboardEvent) => onToggleSelect?.(event.shiftKey);
|
||||
const slideshowImages =
|
||||
@@ -165,7 +167,7 @@ export default function ModelCard({
|
||||
}}
|
||||
>
|
||||
{renderPreviewContent(item, "card", theme, t, previewMode)}
|
||||
{hovered && !selectMode && slideshowImages.length > 0 && (
|
||||
{hovered && slideshowEnabled && !selectMode && slideshowImages.length > 0 && (
|
||||
<HoverSlideshow images={slideshowImages} alt={item.title || item.name} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -10,6 +10,10 @@ import Skeleton from "@mui/material/Skeleton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { printsApi } from "../../api/prints";
|
||||
import TintedRender from "../../components/media/TintedRender";
|
||||
import { useRenderColor } from "../../hooks/useAccentColor";
|
||||
import { useCardPreview } from "../../hooks/useCardPreview";
|
||||
import { renderSwatch } from "../../theme";
|
||||
import type { TagSummary } from "../../api/tags";
|
||||
import { colorForTag } from "../../utils/tagColors";
|
||||
import TagActionsMenu from "../TagDetailPage/TagActionsMenu";
|
||||
@@ -39,6 +43,10 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
|
||||
const fallback = colorForTag(tag.name);
|
||||
const contextMenu = useCardContextMenu();
|
||||
const bookmarkLabel = tag.bookmarked ? t("models:tags.unbookmarkTag") : t("models:tags.bookmarkTag");
|
||||
const [preference] = useCardPreview();
|
||||
const tint = renderSwatch(useRenderColor()).color;
|
||||
// Led as the model cards are: renders when chosen and some are made, the pictures otherwise.
|
||||
const renderMosaic = preference === "renders" ? tag.render_mosaic_url : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -53,7 +61,7 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
|
||||
bgcolor: tag.mosaic_url ? "divider" : fallback.bg,
|
||||
transition: "transform 150ms, box-shadow 150ms",
|
||||
"&:hover": { transform: "translateY(-2px)", boxShadow: "0 6px 16px rgba(0, 0, 0, 0.16)" },
|
||||
"&:hover .tag-mosaic img": { transform: "scale(1.04)" },
|
||||
"&:hover .tag-mosaic > *": { transform: "scale(1.04)" },
|
||||
"&:hover .tag-tile-menu, &:focus-within .tag-tile-menu": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
@@ -70,7 +78,16 @@ function TagTile({ tag, onToggleBookmark, onDeleted, onUnauthorized }: Props) {
|
||||
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: -3 },
|
||||
}}
|
||||
>
|
||||
{tag.mosaic_url ? (
|
||||
{renderMosaic ? (
|
||||
<Box className="tag-mosaic" aria-hidden sx={{ position: "absolute", inset: 0, overflow: "hidden" }}>
|
||||
<TintedRender
|
||||
src={printsApi.fileUrl(renderMosaic)}
|
||||
tint={tint}
|
||||
loading="lazy"
|
||||
sx={{ opacity: 0.75, transition: "transform 300ms" }}
|
||||
/>
|
||||
</Box>
|
||||
) : tag.mosaic_url ? (
|
||||
// Built on the server from the tag's newest models: one picture per tile, however many models.
|
||||
<Box className="tag-mosaic" aria-hidden sx={{ position: "absolute", inset: 0, overflow: "hidden" }}>
|
||||
<Box
|
||||
|
||||
+19
-3
@@ -1,5 +1,5 @@
|
||||
import { alpha, createTheme, darken, lighten, type Theme, type ThemeOptions } from "@mui/material/styles";
|
||||
import { DEFAULT_ACCENT, type AccentColor, type ResolvedTheme } from "./constants/settingsOptions";
|
||||
import { DEFAULT_ACCENT, type AccentColor, type RenderColor, type ResolvedTheme } from "./constants/settingsOptions";
|
||||
|
||||
export type { AccentColor, ResolvedTheme };
|
||||
|
||||
@@ -120,6 +120,9 @@ type AccentDef = {
|
||||
contrastText?: string;
|
||||
/** Light mode's selected-menu text, for an accent too pale to read as text on white. */
|
||||
lightText?: string;
|
||||
/** Dark mode's main and the text on it, for an accent too dark to see on a dark page. */
|
||||
darkMain?: string;
|
||||
darkContrastText?: string;
|
||||
/** Light mode's sub-menu background: a shade under the page, faintly tinted, so it's neither the white sidebar
|
||||
* nor the content. Defaults to a little of the accent in grey. */
|
||||
lightSubnav?: string;
|
||||
@@ -134,14 +137,23 @@ const ACCENTS: Record<AccentColor, AccentDef> = {
|
||||
amber: { main: "#f2a900", contrastText: "#1f1f1f", lightText: "#8a6000" }, // Thangs, a shade deeper to read on white
|
||||
teal: { main: "#00b39a" }, // MyMiniFactory, likewise
|
||||
purple: { main: "#822ef5" }, // Cults3D
|
||||
graphite: { main: "#2b2b2b", light: "#6b6b6b", darkMain: "#c9ccd3", darkContrastText: "#1f1f1f" },
|
||||
};
|
||||
|
||||
// Light grey barely changes the render's grey shading, so it reads as no tint at all.
|
||||
const UNTINTED_RENDER = "#d6d6d6";
|
||||
|
||||
/** The accent's colour and the colour that reads on it, for pickers. */
|
||||
export function accentSwatch(accent: AccentColor): { color: string; contrast: string } {
|
||||
const { main, contrastText = "#ffffff" } = ACCENTS[accent];
|
||||
return { color: main, contrast: contrastText };
|
||||
}
|
||||
|
||||
/** What a generated render is tinted, and the colour that reads on it, for pickers. The same in both themes. */
|
||||
export function renderSwatch(color: RenderColor): { color: string; contrast: string } {
|
||||
return color === "light" ? { color: UNTINTED_RENDER, contrast: "#1f1f1f" } : accentSwatch(color);
|
||||
}
|
||||
|
||||
/** `weight` of `color` over `base`, both hex. */
|
||||
function mixHex(base: string, color: string, weight: number): string {
|
||||
const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
@@ -157,7 +169,11 @@ function mixHex(base: string, color: string, weight: number): string {
|
||||
}
|
||||
|
||||
function withAccent(d: BaseThemeDef, accent: AccentColor): ThemeDef {
|
||||
const a = ACCENTS[accent];
|
||||
const def = ACCENTS[accent];
|
||||
const a =
|
||||
d.mode === "dark" && def.darkMain
|
||||
? { ...def, main: def.darkMain, light: undefined, contrastText: def.darkContrastText ?? def.contrastText }
|
||||
: def;
|
||||
const light = a.light ?? lighten(a.main, 0.35);
|
||||
return {
|
||||
...d,
|
||||
@@ -167,7 +183,7 @@ function withAccent(d: BaseThemeDef, accent: AccentColor): ThemeDef {
|
||||
accentLight: light,
|
||||
accentStrong: darken(a.main, 0.15),
|
||||
accentSoft: alpha(a.main, 0.16),
|
||||
accentContrast: accentSwatch(accent).contrast,
|
||||
accentContrast: a.contrastText ?? "#ffffff",
|
||||
selectedNavText: d.mode === "light" ? (a.lightText ?? a.main) : a.main,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user