Art & tier list maker tweaks

This commit is contained in:
Kalle
2026-09-20 11:54:46 +03:00
parent a3a44aacf4
commit d782e698bc
37 changed files with 146 additions and 56 deletions

View File

@@ -1,3 +1,15 @@
.thumbnailButton {
all: unset;
display: block;
cursor: pointer;
/* thumbnails have square corners, a rounded ring would leave the art's corners outside it */
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
}
.thumbnail {
cursor: pointer;
transition: all 0.2s ease;

View File

@@ -211,20 +211,31 @@ function ImagePreview({
const { t } = useTranslation(["common", "art"]);
const formatDistanceToNow = useFormatDistanceToNow();
const img = (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
const image = (
<img
alt=""
src={previewUrl(art.url)}
loading="lazy"
onClick={onClick}
onPointerEnter={enablePreview ? () => preloadImage(art.url) : undefined}
ref={imageRef}
className={enablePreview ? styles.thumbnail : undefined}
data-testid="art-image"
/>
);
const img = onClick ? (
<button
type="button"
onClick={onClick}
onPointerEnter={() => preloadImage(art.url)}
className={styles.thumbnailButton}
aria-label={art.description || t("art:openImage")}
>
{image}
</button>
) : (
image
);
if (!art.author && canEdit) {
return (
<div>

View File

@@ -2,7 +2,11 @@ import { HardDriveDownload } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction, ShouldRevalidateFunction } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import {
SendouButton,
type SendouButtonProps,
} from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
@@ -63,6 +67,7 @@ export default function CompAnalyzerShell() {
}
function CompAnalyzerPage() {
const { t } = useTranslation(["common", "analyzer"]);
const [selectedWeaponIds, setSelectedWeaponIds] = useSelectedWeapons();
const [categorization, setCategorization] = useCategorization();
const [isGridCollapsed, setIsGridCollapsed] = useState(
@@ -99,14 +104,18 @@ function CompAnalyzerPage() {
onRemove={handleRemoveWeapon}
onReorder={setSelectedWeaponIds}
/>
{selectedWeaponIds.length >= MAX_WEAPONS ? (
<div className="stack horizontal justify-end">
<div className="stack horizontal justify-end">
{selectedWeaponIds.length >= MAX_WEAPONS ? (
<CompExportDialog
weaponIds={selectedWeaponIds}
excludedDamageKeys={excludedDamageKeys}
/>
</div>
) : null}
) : (
<SendouPopover trigger={<ExportButton />}>
{t("analyzer:comp.exportHint", { max: MAX_WEAPONS })}
</SendouPopover>
)}
</div>
<WeaponCategories selectedWeaponIds={selectedWeaponIds} />
<WeaponGrid
selectedWeaponIds={selectedWeaponIds}
@@ -139,15 +148,7 @@ function CompExportDialog({ weaponIds, excludedDamageKeys }: CompExportProps) {
return (
<ImageExportDialog
trigger={
<SendouButton
size="small"
variant="outlined"
icon={<HardDriveDownload />}
>
{t("common:imageExport.export")}
</SendouButton>
}
trigger={<ExportButton />}
heading={t("common:imageExport.export")}
filename="comp"
settings={
@@ -212,3 +213,18 @@ function CompGraphicWithCombos({
/>
);
}
function ExportButton(props: SendouButtonProps) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
{...props}
size="small"
variant="outlined"
icon={<HardDriveDownload />}
>
{t("common:imageExport.export")}
</SendouButton>
);
}

View File

@@ -203,7 +203,10 @@ describe("isLightColor", () => {
{ hex: "#8b0000", expected: false, why: "dark red" },
{ hex: "#4169e1", expected: false, why: "royal blue" },
{ hex: "#000000", expected: false, why: "black" },
{ hex: "#fff", expected: false, why: "unsupported short form" },
{ hex: "#fff", expected: true, why: "short form white" },
{ hex: "#000", expected: false, why: "short form black" },
{ hex: "#ffd", expected: true, why: "short form light yellow" },
{ hex: "not-a-color", expected: false, why: "garbage" },
])("$why -> $expected", ({ hex, expected }) => {
expect(isLightColor(hex)).toBe(expected);
});

View File

@@ -87,20 +87,36 @@ export function tierNameFontSize(name: string) {
const LIGHT_COLOR_LUMINANCE_THRESHOLD = 0.5;
/** Whether dark text/icons read better on the given `#rrggbb` color than light ones. */
/** Whether dark text/icons read better on the given `#rgb` or `#rrggbb` color than light ones. */
export function isLightColor(hex: string) {
const channels = hex.replace("#", "").match(/.{2}/g);
if (!channels || channels.length < 3) return false;
const channels = hexChannels(hex);
if (!channels) return false;
const [r, g, b] = channels.map((channel) => Number.parseInt(channel, 16));
const [r, g, b] = channels;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > LIGHT_COLOR_LUMINANCE_THRESHOLD;
}
/** Text color that stays readable on a tier's `#rrggbb` background color. */
/** Text color that stays readable on a tier's `#rgb` or `#rrggbb` background color. */
export function tierTextColor(hex: string) {
return isLightColor(hex)
? "var(--color-text-on-light)"
: "var(--color-text-on-dark)";
}
function hexChannels(hex: string) {
const digits = hex.replace("#", "");
const channels =
digits.length === 3
? digits.split("").map((digit) => `${digit}${digit}`)
: digits.match(/.{2}/g);
if (!channels || channels.length < 3) return null;
const parsed = channels
.slice(0, 3)
.map((channel) => Number.parseInt(channel, 16));
return parsed.some(Number.isNaN) ? null : parsed;
}

View File

@@ -219,5 +219,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -23,5 +23,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -219,5 +219,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -23,5 +23,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -219,5 +219,6 @@
"comp.removeAll": "Remove all",
"comp.addAll": "Add all",
"comp.singleWeaponCombos": "Single weapon combos",
"comp.graphicSubtitle": "Composition Analyzer"
"comp.graphicSubtitle": "Composition Analyzer",
"comp.exportHint": "Pick {{max}} weapons to export an image"
}

View File

@@ -23,5 +23,6 @@
"delete.title": "Are you sure you want to delete the art?",
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?",
"noArtForTag": "No uploaded art found for #{{tag}}",
"imageFailed": "Image could not be loaded"
"imageFailed": "Image could not be loaded",
"openImage": "Open image"
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "Eliminar todos",
"comp.addAll": "Añadir todos",
"comp.singleWeaponCombos": "Combos de un arma",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "¿Seguro que quieres eliminar este arte?",
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "Eliminar todos",
"comp.addAll": "Añadir todos",
"comp.singleWeaponCombos": "Combos de un arma",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "¿Seguro que quieres eliminar este arte?",
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
"noArtForTag": "No se ha encontrado arte subido para #{{tag}}",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?",
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "הסר הכל",
"comp.addAll": "הוסף הכל",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?",
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -211,5 +211,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -21,5 +21,6 @@
"delete.title": "本当にこの作品を削除しますか?",
"unlink.title": "本当にこの作品をプロフィールから削除しますか?{{username}}しか再び追加することはできません。",
"noArtForTag": "#{{tag}}の作品が見つかりませんでした。",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -211,5 +211,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -21,5 +21,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -219,5 +219,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -23,5 +23,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -227,5 +227,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -25,5 +25,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -223,5 +223,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -24,5 +24,6 @@
"delete.title": "",
"unlink.title": "",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -227,5 +227,6 @@
"comp.removeAll": "",
"comp.addAll": "",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -25,5 +25,6 @@
"delete.title": "Вы точно хотите удалить этот арт?",
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?",
"noArtForTag": "",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}

View File

@@ -214,5 +214,6 @@
"comp.removeAll": "移除所有",
"comp.addAll": "添加所有",
"comp.singleWeaponCombos": "",
"comp.graphicSubtitle": ""
"comp.graphicSubtitle": "",
"comp.exportHint": ""
}

View File

@@ -22,5 +22,6 @@
"delete.title": "您确定要删除这件作品吗?",
"unlink.title": "您确定要将这件作品从个人资料中移除吗(只有 {{username}} 可以重新添加)?",
"noArtForTag": "未能找到包含 #{{tag}} 的作品。",
"imageFailed": ""
"imageFailed": "",
"openImage": ""
}