Download tier list as img (#2638)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

Co-authored-by: hfcRed <hfcred@gmx.net>
This commit is contained in:
Kalle 2025-11-17 18:40:22 +02:00 committed by GitHub
parent 187e1aa105
commit 84b4c1d67f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 274 additions and 59 deletions

View File

@ -34,6 +34,7 @@ interface ImageProps {
testId?: string;
onClick?: () => void;
loading?: "lazy";
forcePng?: boolean;
}
export function Image({
@ -50,7 +51,32 @@ export function Image({
containerStyle,
onClick,
loading,
forcePng,
}: ImageProps) {
if (forcePng) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
<div
title={title}
className={containerClassName}
style={containerStyle}
onClick={onClick}
>
<img
alt={alt}
src={`${path}.png`}
className={className}
width={size ?? width}
height={size ?? height}
style={style}
draggable="false"
loading={loading}
data-testid={testId}
/>
</div>
);
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
<picture

View File

@ -7,9 +7,10 @@ import { TierListItemImage } from "./TierListItemImage";
interface DraggableItemProps {
item: TierListItem;
forcePng?: boolean;
}
export function DraggableItem({ item }: DraggableItemProps) {
export function DraggableItem({ item, forcePng }: DraggableItemProps) {
const uniqueId = tierListItemId(item);
const {
@ -32,7 +33,7 @@ export function DraggableItem({ item }: DraggableItemProps) {
return (
<div ref={setNodeRef} className={styles.item} style={style}>
<div data-item-id={uniqueId} {...listeners} {...attributes}>
<TierListItemImage item={item} />
<TierListItemImage item={item} forcePng={forcePng} />
</div>
</div>
);

View File

@ -11,38 +11,58 @@ import styles from "./TierListItemImage.module.css";
interface TierListItemImageProps {
item: TierListItem;
forcePng?: boolean;
}
export function TierListItemImage({ item }: TierListItemImageProps) {
export function TierListItemImage({ item, forcePng }: TierListItemImageProps) {
switch (item.type) {
case "main-weapon":
return (
<div className={styles.imageWrapper}>
<WeaponImage weaponSplId={item.id} variant="badge" size={48} />
<WeaponImage
weaponSplId={item.id}
variant="badge"
size={48}
forcePng={forcePng}
/>
</div>
);
case "sub-weapon":
return (
<div className={styles.imageWrapper}>
<SubWeaponImage subWeaponId={item.id} size={48} />
<SubWeaponImage subWeaponId={item.id} size={48} forcePng={forcePng} />
</div>
);
case "special-weapon":
return (
<div className={styles.imageWrapper}>
<SpecialWeaponImage specialWeaponId={item.id} size={48} />
<SpecialWeaponImage
specialWeaponId={item.id}
size={48}
forcePng={forcePng}
/>
</div>
);
case "stage":
return (
<div className={styles.imageWrapper}>
<StageImage stageId={item.id} width={80} className="rounded-sm" />
<StageImage
stageId={item.id}
width={80}
className="rounded-sm"
forcePng={forcePng}
/>
</div>
);
case "mode":
return (
<div className={styles.imageWrapper}>
<ModeImage mode={item.id} width={48} height={48} />
<ModeImage
mode={item.id}
width={48}
height={48}
forcePng={forcePng}
/>
</div>
);
case "stage-mode": {
@ -51,12 +71,18 @@ export function TierListItemImage({ item }: TierListItemImageProps) {
return (
<div className={styles.imageWrapper}>
<div className="relative">
<StageImage stageId={stageId} width={80} className="rounded-sm" />
<StageImage
stageId={stageId}
width={80}
className="rounded-sm"
forcePng={forcePng}
/>
<ModeImage
mode={mode as ModeShort}
width={24}
height={24}
className={styles.modeOverlay}
forcePng={forcePng}
/>
</div>
</div>

View File

@ -46,7 +46,7 @@
flex-wrap: wrap;
gap: var(--s-1-5);
padding: var(--s-2);
background: var(--bg-lighter);
background: var(--bg-lighter-solid);
border: 2px dashed transparent;
border-radius: var(--rounded-sm) 0 0 var(--rounded-sm);
align-content: flex-start;
@ -58,10 +58,6 @@
background: var(--bg-lightest);
}
.targetZoneFullRadius {
border-radius: var(--rounded-sm);
}
.emptyMessage {
width: 100%;
height: 100%;

View File

@ -31,7 +31,7 @@ export function TierRow({ tier }: TierRowProps) {
handleMoveTierUp,
handleMoveTierDown,
showTierHeaders,
showArrowControls,
screenshotMode,
} = useTierListState();
const items = getItemsInTier(tier.id);
@ -95,28 +95,34 @@ export function TierRow({ tier }: TierRowProps) {
<div
ref={setNodeRef}
style={{
borderRadius: screenshotMode ? "var(--rounded-sm)" : undefined,
}}
className={clsx(styles.targetZone, {
[styles.targetZoneOver]: isOver,
[styles.targetZoneFullRadius]: !showArrowControls,
})}
>
{items.length === 0 ? (
{items.length === 0 && !screenshotMode ? (
<div className={styles.emptyMessage}>
{t("tier-list-maker:dropItems")}
</div>
) : (
) : items.length > 0 ? (
<SortableContext
items={items.map(tierListItemId)}
strategy={horizontalListSortingStrategy}
>
{items.map((item) => (
<DraggableItem key={tierListItemId(item)} item={item} />
<DraggableItem
key={tierListItemId(item)}
item={item}
forcePng={screenshotMode}
/>
))}
</SortableContext>
)}
) : null}
</div>
{showArrowControls ? (
{!screenshotMode ? (
<div className={styles.arrowControls}>
<button
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}

View File

@ -1,16 +1,22 @@
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { createContext, useContext, useState } from "react";
import { useTierList } from "../hooks/useTierListState";
type TierListContextType = ReturnType<typeof useTierList>;
type TierListContextType = ReturnType<typeof useTierList> & {
screenshotMode: boolean;
setScreenshotMode: (value: boolean) => void;
};
const TierListContext = createContext<TierListContextType | null>(null);
export function TierListProvider({ children }: { children: ReactNode }) {
const state = useTierList();
const [screenshotMode, setScreenshotMode] = useState(false);
return (
<TierListContext.Provider value={state}>
<TierListContext.Provider
value={{ ...state, screenshotMode, setScreenshotMode }}
>
{children}
</TierListContext.Provider>
);

View File

@ -66,10 +66,10 @@ export function useTierList() {
revive: (value) => value === "true",
});
const [showArrowControls, setShowArrowControls] = useSearchParamState({
name: "showArrowControls",
defaultValue: true,
revive: (value) => value === "true",
const [title, setTitle] = useSearchParamState({
name: "title",
defaultValue: "",
revive: (value) => value,
});
const parseItemFromId = (id: string): TierListItem | null => {
@ -423,8 +423,8 @@ export function useTierList() {
setCanAddDuplicates,
showTierHeaders,
setShowTierHeaders,
showArrowControls,
setShowArrowControls,
title,
setTitle,
};
}

View File

@ -14,7 +14,63 @@
margin-bottom: var(--s-6);
}
.tierListScreenshotMode {
padding: var(--s-4);
max-width: 720px;
min-width: 720px;
}
.filters {
display: flex;
gap: var(--s-4);
}
.titleInput {
width: 100%;
padding: 0 var(--s-2) var(--s-2) var(--s-2);
font-size: var(--fonts-lg);
font-weight: 600;
text-align: center;
background: transparent;
border: none;
color: var(--text);
outline: none;
}
.titleInput::placeholder {
color: var(--text-lighter);
opacity: 0.3;
}
.titleInput:focus::placeholder {
opacity: 0.5;
}
.authorSection {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: var(--s-0-5);
margin-bottom: var(--s-3);
margin-top: -15px;
}
.authorBy {
font-size: var(--fonts-xxs);
font-weight: var(--bold);
color: var(--text-lighter);
text-transform: lowercase;
}
.authorInfo {
display: flex;
align-items: center;
gap: var(--s-2);
}
.authorUsername {
font-weight: var(--bold);
font-size: var(--fonts-xs);
color: var(--text);
}

View File

@ -9,8 +9,12 @@ import {
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import type { MetaFunction } from "@remix-run/react";
import { snapdom } from "@zumer/snapdom";
import clsx from "clsx";
import { useRef } from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
@ -20,10 +24,12 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { DownloadIcon } from "~/components/icons/Download";
import { PlusIcon } from "~/components/icons/Plus";
import { RefreshIcon } from "~/components/icons/Refresh";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -76,6 +82,8 @@ export default function TierListMakerPage() {
function TierListMakerContent() {
const { t } = useTranslation(["tier-list-maker"]);
const user = useUser();
const {
itemType,
setItemType,
@ -94,8 +102,10 @@ function TierListMakerContent() {
setCanAddDuplicates,
showTierHeaders,
setShowTierHeaders,
showArrowControls,
setShowArrowControls,
title,
setTitle,
screenshotMode,
setScreenshotMode,
} = useTierListState();
const sensors = useSensors(
@ -105,12 +115,44 @@ function TierListMakerContent() {
}),
);
const tierListRef = useRef<HTMLDivElement>(null);
const handleDownload = async () => {
if (!tierListRef.current) return;
flushSync(() => setScreenshotMode(true));
await snapdom.download(tierListRef.current, {
format: "png",
filename: "tier-list",
quality: 1,
scale: 1.75,
embedFonts: true,
backgroundColor: getComputedStyle(document.body).backgroundColor,
});
setScreenshotMode(false);
};
return (
<Main bigger className={clsx(styles.container, "stack lg")}>
<div className={styles.header}>
<SendouButton onPress={handleAddTier} size="small" icon={<PlusIcon />}>
{t("tier-list-maker:addTier")}
</SendouButton>
<div className="stack horizontal md">
<SendouButton
onPress={handleAddTier}
size="small"
icon={<PlusIcon />}
>
{t("tier-list-maker:addTier")}
</SendouButton>
<SendouButton
onPress={handleDownload}
size="small"
icon={<DownloadIcon />}
>
{t("tier-list-maker:download")}
</SendouButton>
</div>
<ResetPopover key={state.tierItems.size} handleReset={handleReset} />
</div>
@ -122,7 +164,30 @@ function TierListMakerContent() {
onDragEnd={handleDragEnd}
>
<div className="stack">
<div className={styles.tierList}>
<div
className={clsx(styles.tierList, {
[styles.tierListScreenshotMode]: screenshotMode,
})}
ref={tierListRef}
>
{title || !screenshotMode ? (
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tier-list-maker:titlePlaceholder")}
className={clsx(styles.titleInput, "plain")}
/>
) : null}
{screenshotMode && title && user ? (
<div className={styles.authorSection}>
<div className={styles.authorBy}>{t("tier-list-maker:by")}</div>
<div className={styles.authorInfo}>
<Avatar user={user} size="xxxs" alt={user.username} />
<span className={styles.authorUsername}>{user.username}</span>
</div>
</div>
) : null}
{state.tiers.map((tier) => (
<TierRow key={tier.id} tier={tier} />
))}
@ -143,13 +208,6 @@ function TierListMakerContent() {
>
{t("tier-list-maker:showTierHeaders")}
</SendouSwitch>
<SendouSwitch
isSelected={showArrowControls}
onChange={setShowArrowControls}
size="small"
>
{t("tier-list-maker:showArrowControls")}
</SendouSwitch>
</div>
</div>

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "Add tier",
"download": "Download (.png)",
"mainWeapons": "Main Weapons",
"subWeapons": "Sub Weapons",
"specialWeapons": "Special Weapons",
@ -13,5 +14,6 @@
"resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.",
"allowDuplicates": "Allow duplicates",
"showTierHeaders": "Show tier headers",
"showArrowControls": "Show arrow controls"
"titlePlaceholder": "Click to add title...",
"by": "Made by"
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

View File

@ -1,5 +1,6 @@
{
"addTier": "",
"download": "",
"mainWeapons": "",
"subWeapons": "",
"specialWeapons": "",
@ -13,5 +14,6 @@
"resetConfirmation": "",
"allowDuplicates": "",
"showTierHeaders": "",
"showArrowControls": ""
"titlePlaceholder": "",
"by": ""
}

7
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"@tldraw/tldraw": "^3.12.1",
"@zumer/snapdom": "^1.9.14",
"aws-sdk": "^2.1692.0",
"better-sqlite3": "^11.9.1",
"clsx": "^2.1.1",
@ -7986,6 +7987,12 @@
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"license": "MIT"
},
"node_modules/@zumer/snapdom": {
"version": "1.9.14",
"resolved": "https://registry.npmjs.org/@zumer/snapdom/-/snapdom-1.9.14.tgz",
"integrity": "sha512-kQARFS/jf+fsIFv9qxfNp8YMeun5tTyhFDk3iv47Lywk5YRldhINWicEZI15fP3FDUCeo8ok+BP0CtnDRFMFRg==",
"license": "MIT"
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",

View File

@ -42,6 +42,7 @@
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"@tldraw/tldraw": "^3.12.1",
"@zumer/snapdom": "^1.9.14",
"aws-sdk": "^2.1692.0",
"better-sqlite3": "^11.9.1",
"clsx": "^2.1.1",