From 1a2dd1fc173f54cf49da7cb91deb4ec6c6f142dd Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Tue, 19 May 2026 20:39:54 -0400 Subject: [PATCH 1/2] feat(game): adjust player-facing screenshot upload validation copy --- lang/en_US.json | 11 +- .../getIsNativeScreenshotResolution.test.ts | 48 +++++ .../utils/getIsNativeScreenshotResolution.ts | 36 ++++ .../getIsValidScreenshotResolution.test.ts | 8 + .../utils/getIsValidScreenshotResolution.ts | 11 +- .../utils/screenshotSmpteResolutions.ts | 11 + .../GameScreenshotUploadDialog.test.tsx | 5 +- .../UploadForm/ScreenshotDropZone.test.tsx | 108 +++++++--- .../UploadForm/ScreenshotDropZone.tsx | 95 ++++---- .../UploadForm/ScreenshotPreviewMeta.test.tsx | 203 +++++++++++++++++- .../UploadForm/ScreenshotPreviewMeta.tsx | 80 ++++++- .../components/UploadForm/UploadForm.test.tsx | 66 ++---- .../components/UploadForm/UploadForm.tsx | 25 +-- .../UploadForm/useGameScreenshotUploadForm.ts | 24 +-- 14 files changed, 573 insertions(+), 158 deletions(-) create mode 100644 resources/js/common/utils/getIsNativeScreenshotResolution.test.ts create mode 100644 resources/js/common/utils/getIsNativeScreenshotResolution.ts create mode 100644 resources/js/common/utils/screenshotSmpteResolutions.ts diff --git a/lang/en_US.json b/lang/en_US.json index 9c8c25060f..a877434fa5 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -1412,8 +1412,13 @@ "No pending submissions for this game.": "No pending submissions for this game.", "Submit another": "Submit another", "Back to game": "Back to game", - "Expected Resolutions: {{resolutions}}": "Expected Resolutions: {{resolutions}}", - "Expected Resolutions: {{resolutions}} (or 2x/3x multiples)": "Expected Resolutions: {{resolutions}} (or 2x/3x multiples)", + "Supported resolutions: {{resolutions}}": "Supported resolutions: {{resolutions}}", + "Upscaled screenshots look sharper. Render at 2x or 3x in your emulator.": "Upscaled screenshots look sharper. Render at 2x or 3x in your emulator.", + "1x capture, render at 2x or 3x for a sharper screenshot": "1x capture, render at 2x or 3x for a sharper screenshot", + "Use your emulator's screenshot tool, ideally at 2x or 3x internal resolution.": "Use your emulator's screenshot tool, ideally at 2x or 3x internal resolution.", + "Use your emulator's screenshot tool, not a desktop capture.": "Use your emulator's screenshot tool, not a desktop capture.", + "Resolution doesn't match. See the preview above.": "Resolution doesn't match. See the preview above.", + "or 2x or 3x of any of these": "or 2x or 3x of any of these", "Upload screenshot file": "Upload screenshot file", "Valid resolution": "Valid resolution", "Invalid resolution": "Invalid resolution", @@ -1427,8 +1432,6 @@ "Click or drag to replace": "Click or drag to replace", "Drop your screenshot here, or click to browse": "Drop your screenshot here, or click to browse", "Primary screenshot has an incorrect resolution": "Primary screenshot has an incorrect resolution", - "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}}.": "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}}.", - "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}} (or 2x/3x multiples).": "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}} (or 2x/3x multiples).", "Cancel submission": "Cancel submission", "No emulators matched your search.": "No emulators matched your search.", "This system only accepts PNG screenshots.": "This system only accepts PNG screenshots.", diff --git a/resources/js/common/utils/getIsNativeScreenshotResolution.test.ts b/resources/js/common/utils/getIsNativeScreenshotResolution.test.ts new file mode 100644 index 0000000000..354973a617 --- /dev/null +++ b/resources/js/common/utils/getIsNativeScreenshotResolution.test.ts @@ -0,0 +1,48 @@ +import { getIsNativeScreenshotResolution } from './getIsNativeScreenshotResolution'; + +describe('Util: getIsNativeScreenshotResolution', () => { + const baseResolutions = [{ width: 256, height: 224 }]; + + it('given an exact 1x native match, returns true', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(256, 224, baseResolutions)).toEqual(true); + }); + + it('given a 1px tolerance match against a native resolution, returns true', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(257, 225, baseResolutions)).toEqual(true); + }); + + it('given a 2x or 3x scaled match, returns false', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(512, 448, baseResolutions)).toEqual(false); + expect(getIsNativeScreenshotResolution(768, 672, baseResolutions)).toEqual(false); + }); + + it('given non-matching dimensions, returns false', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(1920, 1080, baseResolutions)).toEqual(false); + }); + + it('given an empty native list with no analog TV output, returns false', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(320, 240, [])).toEqual(false); + }); + + it('given an exact SMPTE 601 size, matches only when analog TV output is enabled', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(720, 480, baseResolutions, true)).toEqual(true); + expect(getIsNativeScreenshotResolution(720, 480, baseResolutions, false)).toEqual(false); + }); + + it('given a 1px deviation from an SMPTE 601 size with analog output enabled, returns false', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(720, 481, baseResolutions, true)).toEqual(false); + }); + + it('given an empty native list with analog output, only exact SMPTE sizes match', () => { + // ASSERT + expect(getIsNativeScreenshotResolution(720, 576, [], true)).toEqual(true); + expect(getIsNativeScreenshotResolution(1920, 1080, [], true)).toEqual(false); + }); +}); diff --git a/resources/js/common/utils/getIsNativeScreenshotResolution.ts b/resources/js/common/utils/getIsNativeScreenshotResolution.ts new file mode 100644 index 0000000000..66cd33f194 --- /dev/null +++ b/resources/js/common/utils/getIsNativeScreenshotResolution.ts @@ -0,0 +1,36 @@ +import { getIsSameScreenshotResolution } from './getIsSameScreenshotResolution'; +import { SCREENSHOT_SMPTE_RESOLUTIONS } from './screenshotSmpteResolutions'; + +/** + * Returns true only when the dimensions are a 1x native capture, never a 2x/3x upscale. + * + * Diverges from getIsValidScreenshotResolution() in two important ways: + * - An empty native list returns false (rather than "any size is valid"). This prevents + * every upload on a system with no resolution metadata from being treated as native. + * - 2x/3x multiples never match here, even when the system supports upscaling. + * + * SMPTE 601 sizes count as 1x when hasAnalogTvOutput is true, since they're real hardware + * captures (not upscales), and use exact equality (no tolerance), matching the validator. + */ +export function getIsNativeScreenshotResolution( + width: number, + height: number, + nativeResolutions: Array<{ width: number; height: number }>, + hasAnalogTvOutput?: boolean, +): boolean { + for (const resolution of nativeResolutions) { + if (getIsSameScreenshotResolution(width, height, resolution.width, resolution.height)) { + return true; + } + } + + if (hasAnalogTvOutput) { + for (const smpte of SCREENSHOT_SMPTE_RESOLUTIONS) { + if (width === smpte.width && height === smpte.height) { + return true; + } + } + } + + return false; +} diff --git a/resources/js/common/utils/getIsValidScreenshotResolution.test.ts b/resources/js/common/utils/getIsValidScreenshotResolution.test.ts index 062fde06d1..fbf3e56b56 100644 --- a/resources/js/common/utils/getIsValidScreenshotResolution.test.ts +++ b/resources/js/common/utils/getIsValidScreenshotResolution.test.ts @@ -90,4 +90,12 @@ describe('Util: getIsValidScreenshotResolution', () => { // ASSERT expect(result).toEqual(true); }); + + it('given a 1px deviation from an SMPTE 601 resolution with analog output enabled, returns false', () => { + // ACT + const result = getIsValidScreenshotResolution(720, 481, baseResolutions, true); + + // ASSERT + expect(result).toEqual(false); + }); }); diff --git a/resources/js/common/utils/getIsValidScreenshotResolution.ts b/resources/js/common/utils/getIsValidScreenshotResolution.ts index f4111efcd3..26707e9488 100644 --- a/resources/js/common/utils/getIsValidScreenshotResolution.ts +++ b/resources/js/common/utils/getIsValidScreenshotResolution.ts @@ -1,12 +1,5 @@ import { getIsSameScreenshotResolution } from './getIsSameScreenshotResolution'; - -const SMPTE_601_RESOLUTIONS = [ - { width: 704, height: 480 }, - { width: 720, height: 480 }, - { width: 720, height: 486 }, - { width: 704, height: 576 }, - { width: 720, height: 576 }, -]; +import { SCREENSHOT_SMPTE_RESOLUTIONS } from './screenshotSmpteResolutions'; export function getIsValidScreenshotResolution( width: number, @@ -35,7 +28,7 @@ export function getIsValidScreenshotResolution( // SMPTE 601 analog capture resolutions are an exact-match check. if (hasAnalogTvOutput) { - for (const smpte of SMPTE_601_RESOLUTIONS) { + for (const smpte of SCREENSHOT_SMPTE_RESOLUTIONS) { if (width === smpte.width && height === smpte.height) { return true; } diff --git a/resources/js/common/utils/screenshotSmpteResolutions.ts b/resources/js/common/utils/screenshotSmpteResolutions.ts new file mode 100644 index 0000000000..5aab1eb2a2 --- /dev/null +++ b/resources/js/common/utils/screenshotSmpteResolutions.ts @@ -0,0 +1,11 @@ +/** + * SMPTE 601 analog-capture resolutions, used by systems with `has_analog_tv_output`. + * Matched with no tolerance. These are well-defined standards. + */ +export const SCREENSHOT_SMPTE_RESOLUTIONS = [ + { width: 704, height: 480 }, + { width: 720, height: 480 }, + { width: 720, height: 486 }, + { width: 704, height: 576 }, + { width: 720, height: 576 }, +] as const; diff --git a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx index 13215317d9..1f7547540f 100644 --- a/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx +++ b/resources/js/features/games/components/GameScreenshotUploadDialog/GameScreenshotUploadDialog.test.tsx @@ -230,7 +230,10 @@ describe('Component: GameScreenshotUploadDialog', () => { render(, { pageProps: { game: createGame({ - system: createSystem({ screenshotResolutions: [{ width: 320, height: 240 }] }), + system: createSystem({ + screenshotResolutions: [{ width: 320, height: 240 }], + supportsUpscaledScreenshots: false, + }), }), screenshotUploadConsistency: { existingResolutions: [{ width: 256, height: 224 }], diff --git a/resources/js/features/games/components/UploadForm/ScreenshotDropZone.test.tsx b/resources/js/features/games/components/UploadForm/ScreenshotDropZone.test.tsx index 0ff5729716..d7c09c3b78 100644 --- a/resources/js/features/games/components/UploadForm/ScreenshotDropZone.test.tsx +++ b/resources/js/features/games/components/UploadForm/ScreenshotDropZone.test.tsx @@ -18,9 +18,9 @@ describe('Component: ScreenshotDropZone', () => { const { container } = render( , ); @@ -33,10 +33,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -44,58 +44,114 @@ describe('Component: ScreenshotDropZone', () => { expect(screen.getByText(/drop your screenshot here, or click to browse/i)).toBeVisible(); }); - it('given there is no preview and upscaled screenshots are not supported, shows only PNG and does not include multiples info', () => { + it('shows the upscale nudge only when upscaled screenshots are supported', () => { + // ARRANGE + const { rerender } = render( + , + ); + + // ASSERT + expect( + screen.getByText(/upscaled screenshots look sharper\. render at 2x or 3x/i), + ).toBeVisible(); + + // ACT + rerender( + , + ); + + // ASSERT + expect(screen.queryByText(/upscaled screenshots/i)).not.toBeInTheDocument(); + }); + + it('given a non-upscaling system with exactly one supported resolution, shows it', () => { // ARRANGE render( , ); // ASSERT - expect(screen.getByText(/PNG — max 4 MB/i)).toBeVisible(); - expect(screen.queryByText(/2x\/3x multiples/i)).not.toBeInTheDocument(); + expect(screen.getByText('Supported resolutions: 256x224')).toBeVisible(); }); - it('given there is no preview and upscaled screenshots are supported, shows multiple formats and includes multiples info', () => { + it('given a non-upscaling system with two or three supported resolutions, joins them with comma-separated dimensions', () => { // ARRANGE render( , ); // ASSERT - expect(screen.getByText(/PNG/)).toBeVisible(); - expect(screen.getByText(/JPEG/)).toBeVisible(); - expect(screen.getByText(/WebP/)).toBeVisible(); - expect(screen.getByText(/2x\/3x multiples/i)).toBeVisible(); + expect(screen.getByText('Supported resolutions: 256x224, 256x240')).toBeVisible(); }); - it('given there is no preview and formatted resolutions is empty, does not show the resolutions line', () => { + it('given a non-upscaling system with more than three supported resolutions, omits the supported resolutions line', () => { // ARRANGE render( , + ); + + // ASSERT + expect(screen.queryByText(/native resolution/i)).not.toBeInTheDocument(); + }); + + it('given an upscaling-capable system, does not show a supported resolutions line', () => { + // ARRANGE + render( + , ); // ASSERT - expect(screen.queryByText(/expected resolutions/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/supported resolutions/i)).not.toBeInTheDocument(); }); it('given there is a preview, shows the preview image and replacement prompt', () => { @@ -103,10 +159,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -120,11 +176,11 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -138,11 +194,11 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -156,10 +212,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -173,10 +229,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -191,9 +247,9 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -213,10 +269,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -236,10 +292,10 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -268,9 +324,9 @@ describe('Component: ScreenshotDropZone', () => { render( , ); @@ -288,9 +344,9 @@ describe('Component: ScreenshotDropZone', () => { render( , ); diff --git a/resources/js/features/games/components/UploadForm/ScreenshotDropZone.tsx b/resources/js/features/games/components/UploadForm/ScreenshotDropZone.tsx index 0ccd683d75..a5ddf3507f 100644 --- a/resources/js/features/games/components/UploadForm/ScreenshotDropZone.tsx +++ b/resources/js/features/games/components/UploadForm/ScreenshotDropZone.tsx @@ -4,21 +4,22 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { LuUpload } from 'react-icons/lu'; -import { usePageProps } from '@/common/hooks/usePageProps'; import { cn } from '@/common/utils/cn'; -import { getUserIntlLocale } from '@/common/utils/getUserIntlLocale'; import { ScreenshotPreviewMeta } from './ScreenshotPreviewMeta'; +const MAX_NATIVE_RESOLUTIONS_TO_SHOW = 3; + interface ScreenshotDropZoneProps { fileInputRef: RefObject; - formattedResolutions: string; isResolutionValid: boolean; previewUrl: string | null; + screenshotResolutions: Array<{ width: number; height: number }>; canonicalResolution?: string | null; hasConsistencyWarning?: boolean; hasPreview?: boolean; + is1xCapture?: boolean; onDrop?: (e: DragEvent) => void; onFileChange?: (file: File | undefined) => void; previewDimensions?: { width: number; height: number } | null; @@ -28,21 +29,19 @@ interface ScreenshotDropZoneProps { export const ScreenshotDropZone: FC = ({ canonicalResolution, fileInputRef, - formattedResolutions, hasConsistencyWarning, hasPreview, + is1xCapture, isResolutionValid, onDrop, onFileChange, previewDimensions, previewUrl, + screenshotResolutions, supportsUpscaledScreenshots, }) => { - const { auth } = usePageProps(); const { t } = useTranslation(); - const locale = getUserIntlLocale(auth?.user); - const [isDragOver, setIsDragOver] = useState(false); const { height: dropZoneHeight, ref: dropZoneContentRef } = useContentHeight(); @@ -101,7 +100,10 @@ export const ScreenshotDropZone: FC = ({ canonicalResolution={canonicalResolution} hasConsistencyWarning={hasConsistencyWarning} height={previewDimensions.height} + is1xCapture={is1xCapture} isResolutionValid={isResolutionValid} + screenshotResolutions={screenshotResolutions} + supportsUpscaledScreenshots={supportsUpscaledScreenshots} width={previewDimensions.width} /> ) : null} @@ -109,44 +111,59 @@ export const ScreenshotDropZone: FC = ({

{t('Click or drag to replace')}

) : ( -
- - -
-

- {t('Drop your screenshot here, or click to browse')} -

- -

- {supportsUpscaledScreenshots - ? new Intl.ListFormat(locale, { style: 'narrow', type: 'unit' }).format([ - 'PNG', - 'JPEG', - 'WebP', - ]) - : 'PNG'}{' '} - — max 4 MB -

-
- - {formattedResolutions ? ( -

- {supportsUpscaledScreenshots - ? t('Expected Resolutions: {{resolutions}} (or 2x/3x multiples)', { - resolutions: formattedResolutions, - }) - : t('Expected Resolutions: {{resolutions}}', { - resolutions: formattedResolutions, - })} -

- ) : null} -
+ )} ); }; +interface EmptyStateProps { + screenshotResolutions: Array<{ width: number; height: number }>; + supportsUpscaledScreenshots?: boolean; +} + +const EmptyState: FC = ({ + screenshotResolutions, + supportsUpscaledScreenshots, +}) => { + const { t } = useTranslation(); + + const formattedNatives = screenshotResolutions.map((r) => `${r.width}x${r.height}`).join(', '); + + const shouldShowNativeList = + !supportsUpscaledScreenshots && + screenshotResolutions.length > 0 && + screenshotResolutions.length <= MAX_NATIVE_RESOLUTIONS_TO_SHOW; + + return ( +
+ + +
+

+ {t('Drop your screenshot here, or click to browse')} +

+ + {supportsUpscaledScreenshots ? ( +

+ {t('Upscaled screenshots look sharper. Render at 2x or 3x in your emulator.')} +

+ ) : null} + + {shouldShowNativeList ? ( +

+ {t('Supported resolutions: {{resolutions}}', { resolutions: formattedNatives })} +

+ ) : null} +
+
+ ); +}; + /** * Track the scroll height of an element via ResizeObserver so we * can feed it into a motion `animate` for smooth height transitions. diff --git a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.test.tsx b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.test.tsx index ae57298d5b..2ed7f79ee1 100644 --- a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.test.tsx +++ b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.test.tsx @@ -1,4 +1,6 @@ -import { render, screen } from '@/test'; +import userEvent from '@testing-library/user-event'; + +import { render, screen, waitFor } from '@/test'; import { ScreenshotPreviewMeta } from './ScreenshotPreviewMeta'; @@ -39,6 +41,103 @@ describe('Component: ScreenshotPreviewMeta', () => { expect(screen.getByText(/invalid resolution/i)).toBeVisible(); }); + it('given the resolution is invalid, branches the explanation copy on supportsUpscaledScreenshots', () => { + // ARRANGE + const { rerender } = render( + , + ); + + // ASSERT + expect(screen.getByText(/ideally at 2x or 3x internal resolution/i)).toBeVisible(); + expect(screen.queryByText(/not a desktop capture/i)).not.toBeInTheDocument(); + + // ACT + rerender( + , + ); + + // ASSERT + expect(screen.getByText(/not a desktop capture/i)).toBeVisible(); + expect(screen.queryByText(/internal resolution/i)).not.toBeInTheDocument(); + }); + + it('given a 1x capture on an upscaling-capable system, shows the 1x nudge', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByText(/1x capture, render at 2x or 3x/i)).toBeVisible(); + }); + + it('given a 1x capture on a non-upscaling system, does not show the 1x nudge', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.queryByText(/1x capture/i)).not.toBeInTheDocument(); + }); + + it('given an upscaled capture (not 1x) on an upscaling-capable system, does not show the 1x nudge', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.queryByText(/1x capture/i)).not.toBeInTheDocument(); + }); + + it('given both 1x nudge and consistency warning conditions are true, only the 1x nudge renders', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByText(/1x capture/i)).toBeVisible(); + expect(screen.queryByText(/doesn't match existing screenshots/i)).not.toBeInTheDocument(); + }); + it('given the resolution is valid but inconsistent with canonical screenshots, shows an advisory warning message', () => { // ARRANGE render( @@ -70,4 +169,106 @@ describe('Component: ScreenshotPreviewMeta', () => { // ASSERT expect(screen.getByText(/doesn't match existing screenshots/i)).toBeVisible(); }); + + it('given the resolution is invalid, decorates the invalid label as a tooltip trigger only when accepted sizes are available', () => { + // ARRANGE + const { rerender } = render( + , + ); + + // ASSERT + const decoratedLabel = screen.getByText(/invalid resolution/i); + expect(decoratedLabel).toHaveClass('underline'); + expect(decoratedLabel).toHaveClass('decoration-dotted'); + + // ACT + rerender( + , + ); + + // ASSERT + expect(screen.getByText(/invalid resolution/i)).not.toHaveClass('underline'); + }); + + it('given the user hovers the invalid label on a non-upscaling system, the tooltip shows the native list verbatim', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.hover(screen.getByText(/invalid resolution/i)); + + // ASSERT + await waitFor(() => { + expect(screen.getAllByText('256x224, 256x240').length).toBeGreaterThan(0); + }); + }); + + it('given the user hovers the invalid label on an upscaling-capable system, the tooltip shows the native list plus the 2x/3x clause', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.hover(screen.getByText(/invalid resolution/i)); + + // ASSERT + await waitFor(() => { + expect(screen.getAllByText('320x240').length).toBeGreaterThan(0); + expect(screen.getAllByText(/or 2x or 3x of any of these/i).length).toBeGreaterThan(0); + }); + }); + + it('given the user hovers the invalid label, the tooltip sorts native resolutions by width and then height', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.hover(screen.getByText(/invalid resolution/i)); + + // ASSERT + await waitFor(() => { + expect(screen.getAllByText('256x224, 256x240, 320x224, 640x480').length).toBeGreaterThan(0); + }); + }); }); diff --git a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx index 3f324fe0e5..7ded5edc5e 100644 --- a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx +++ b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx @@ -2,6 +2,13 @@ import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { LuCircleCheckBig, LuCircleX, LuTriangleAlert } from 'react-icons/lu'; +import { + BaseTooltip, + BaseTooltipContent, + BaseTooltipPortal, + BaseTooltipTrigger, +} from '@/common/components/+vendor/BaseTooltip'; + interface ScreenshotPreviewMetaProps { height: number; isResolutionValid: boolean; @@ -9,14 +16,20 @@ interface ScreenshotPreviewMetaProps { canonicalResolution?: string | null; hasConsistencyWarning?: boolean; + is1xCapture?: boolean; + screenshotResolutions?: Array<{ width: number; height: number }>; + supportsUpscaledScreenshots?: boolean; } export const ScreenshotPreviewMeta: FC = ({ canonicalResolution, hasConsistencyWarning, height, + is1xCapture, isResolutionValid, + supportsUpscaledScreenshots, width, + screenshotResolutions = [], }) => { const { t } = useTranslation(); @@ -26,6 +39,15 @@ export const ScreenshotPreviewMeta: FC = ({ }) : t("Doesn't match existing screenshots"); + const invalidExplanation = supportsUpscaledScreenshots + ? t("Use your emulator's screenshot tool, ideally at 2x or 3x internal resolution.") + : t("Use your emulator's screenshot tool, not a desktop capture."); + + const showUpscaleNudge = isResolutionValid && supportsUpscaledScreenshots && is1xCapture; + const showConsistencyWarning = isResolutionValid && hasConsistencyWarning && !showUpscaleNudge; + + const showInvalidTooltip = !isResolutionValid && screenshotResolutions.length > 0; + return (
@@ -38,6 +60,24 @@ export const ScreenshotPreviewMeta: FC = ({ {t('Valid resolution')} + ) : showInvalidTooltip ? ( + + + + + {t('Invalid resolution')} + + + + + + + + + ) : ( @@ -46,7 +86,18 @@ export const ScreenshotPreviewMeta: FC = ({ )}
- {isResolutionValid && hasConsistencyWarning ? ( + {!isResolutionValid ? ( +

{invalidExplanation}

+ ) : null} + + {showUpscaleNudge ? ( +
+ + {t('1x capture, render at 2x or 3x for a sharper screenshot')} +
+ ) : null} + + {showConsistencyWarning ? (
{consistencyMessage} @@ -55,3 +106,30 @@ export const ScreenshotPreviewMeta: FC = ({
); }; + +interface AcceptedSizesTooltipProps { + screenshotResolutions: Array<{ width: number; height: number }>; + supportsUpscaledScreenshots?: boolean; +} + +const AcceptedSizesTooltip: FC = ({ + screenshotResolutions, + supportsUpscaledScreenshots, +}) => { + const { t } = useTranslation(); + + // Sort by width, then by height. + const sortedResolutions = [...screenshotResolutions].sort( + (a, b) => a.width - b.width || a.height - b.height, + ); + const nativeList = sortedResolutions.map((r) => `${r.width}x${r.height}`).join(', '); + + return ( +
+

{nativeList}

+ {supportsUpscaledScreenshots ? ( +

{t('or 2x or 3x of any of these')}

+ ) : null} +
+ ); +}; diff --git a/resources/js/features/games/components/UploadForm/UploadForm.test.tsx b/resources/js/features/games/components/UploadForm/UploadForm.test.tsx index 5a4748268d..27fd4bcaca 100644 --- a/resources/js/features/games/components/UploadForm/UploadForm.test.tsx +++ b/resources/js/features/games/components/UploadForm/UploadForm.test.tsx @@ -89,18 +89,36 @@ describe('Component: UploadForm', () => { expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test'); }); - it('given screenshot resolutions are provided, displays them in the drop zone', () => { + it('given a non-upscaling system, displays the supported resolutions line in the drop zone with the x sign', () => { // ARRANGE render( , + ); + + // ASSERT + expect(screen.getByText('Supported resolutions: 320x240')).toBeVisible(); + }); + + it('given an upscaling-capable system, displays the upscale nudge in the drop zone', () => { + // ARRANGE + render( + , ); // ASSERT - expect(screen.getByText(/expected resolutions: 320x240/i)).toBeVisible(); + expect( + screen.getByText(/upscaled screenshots look sharper\. render at 2x or 3x/i), + ).toBeVisible(); }); it('given the preview is valid and matches the existing canonical resolution, does not show a consistency warning', async () => { @@ -356,46 +374,7 @@ describe('Component: UploadForm', () => { }); }); - it('given the file has an invalid resolution, shows a validation error', async () => { - // ARRANGE - vi.stubGlobal( - 'Image', - class MockImage { - naturalWidth = 999; - naturalHeight = 888; - onload: (() => void) | null = null; - onerror: ((error: unknown) => void) | null = null; - - set src(_value: string) { - queueMicrotask(() => this.onload?.()); - } - }, - ); - - render( - , - ); - - const fileInput = screen.getByLabelText(/upload screenshot file/i) as HTMLInputElement; - await userEvent.upload(fileInput, createMockImageFile()); - await waitFor(() => { - expect(screen.getByRole('button', { name: /submit screenshot/i })).toBeEnabled(); - }); - - // ACT - await userEvent.click(screen.getByRole('button', { name: /submit screenshot/i })); - - // ASSERT - await waitFor(() => { - expect(screen.getByText(/999x888.*don't match/i)).toBeVisible(); - }); - }); - - it('given upscaled screenshots are supported and the resolution is invalid, includes multiples info in the error', async () => { + it('given the file has an invalid resolution, shows a short form-error pointing to the preview', async () => { // ARRANGE vi.stubGlobal( 'Image', @@ -416,7 +395,6 @@ describe('Component: UploadForm', () => { gameId={1} screenshotResolutions={[{ width: 320, height: 240 }]} selectedType="ingame" - supportsUpscaledScreenshots={true} />, ); @@ -431,7 +409,7 @@ describe('Component: UploadForm', () => { // ASSERT await waitFor(() => { - expect(screen.getByText(/2x\/3x multiples/i)).toBeVisible(); + expect(screen.getByText(/resolution doesn't match\. see the preview above/i)).toBeVisible(); }); }); diff --git a/resources/js/features/games/components/UploadForm/UploadForm.tsx b/resources/js/features/games/components/UploadForm/UploadForm.tsx index fecd657e98..0de5151db2 100644 --- a/resources/js/features/games/components/UploadForm/UploadForm.tsx +++ b/resources/js/features/games/components/UploadForm/UploadForm.tsx @@ -12,10 +12,9 @@ import { BaseFormMessage, } from '@/common/components/+vendor/BaseForm'; import { toastMessage } from '@/common/components/+vendor/BaseToaster'; -import { usePageProps } from '@/common/hooks/usePageProps'; +import { getIsNativeScreenshotResolution } from '@/common/utils/getIsNativeScreenshotResolution'; import { getIsSameScreenshotResolution } from '@/common/utils/getIsSameScreenshotResolution'; import { getIsValidScreenshotResolution } from '@/common/utils/getIsValidScreenshotResolution'; -import { getUserIntlLocale } from '@/common/utils/getUserIntlLocale'; import { ScreenshotDropZone } from './ScreenshotDropZone'; import { useGameScreenshotUploadForm } from './useGameScreenshotUploadForm'; @@ -43,11 +42,8 @@ export const UploadForm: FC = ({ selectedType, supportsUpscaledScreenshots, }) => { - const { auth } = usePageProps(); const { t } = useTranslation(); - const locale = getUserIntlLocale(auth?.user); - const { form, mutation, onSubmit } = useGameScreenshotUploadForm({ gameId, screenshotResolutions, @@ -156,12 +152,16 @@ export const UploadForm: FC = ({ ) ); - const formattedResolutions = - screenshotResolutions.length > 0 - ? new Intl.ListFormat(locale, { style: 'narrow', type: 'conjunction' }).format( - screenshotResolutions.map((r) => `${r.width}x${r.height}`), - ) - : ''; + const is1xCapture = !!( + previewDimensions && + isResolutionValid && + getIsNativeScreenshotResolution( + previewDimensions.width, + previewDimensions.height, + screenshotResolutions, + hasAnalogTvOutput, + ) + ); const handleFormSubmit = async (values: Parameters[0]) => { await onSubmit(values, (screenshot) => { @@ -189,14 +189,15 @@ export const UploadForm: FC = ({ diff --git a/resources/js/features/games/components/UploadForm/useGameScreenshotUploadForm.ts b/resources/js/features/games/components/UploadForm/useGameScreenshotUploadForm.ts index f03e5e61f0..ff416f825a 100644 --- a/resources/js/features/games/components/UploadForm/useGameScreenshotUploadForm.ts +++ b/resources/js/features/games/components/UploadForm/useGameScreenshotUploadForm.ts @@ -5,9 +5,7 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { toastMessage } from '@/common/components/+vendor/BaseToaster'; -import { usePageProps } from '@/common/hooks/usePageProps'; import { getIsValidScreenshotResolution } from '@/common/utils/getIsValidScreenshotResolution'; -import { getUserIntlLocale } from '@/common/utils/getUserIntlLocale'; import { useSubmitGameScreenshotMutation } from '../../hooks/mutations/useSubmitGameScreenshotMutation'; @@ -31,11 +29,8 @@ export function useGameScreenshotUploadForm({ screenshotResolutions, supportsUpscaledScreenshots, }: UseGameScreenshotUploadFormOptions) { - const { auth } = usePageProps(); const { t } = useTranslation(); - const locale = getUserIntlLocale(auth?.user); - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -62,22 +57,9 @@ export function useGameScreenshotUploadForm({ supportsUpscaledScreenshots, ) ) { - const formatted = new Intl.ListFormat(locale, { - style: 'narrow', - type: 'conjunction', - }).format(screenshotResolutions.map((r) => `${r.width}x${r.height}`)); - - const errorMessage = supportsUpscaledScreenshots - ? t( - "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}} (or 2x/3x multiples).", - { width, height, resolutions: formatted }, - ) - : t( - "This screenshot's dimensions ({{width}}x{{height}}) don't match the expected resolutions: {{resolutions}}.", - { width, height, resolutions: formatted }, - ); - - form.setError('imageData', { message: errorMessage }); + form.setError('imageData', { + message: t("Resolution doesn't match. See the preview above."), + }); return; } From 9da624c72475370c8f8035734ff61ad4f47afee6 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 21 May 2026 17:50:39 -0400 Subject: [PATCH 2/2] fix: address feedback --- .../games/components/UploadForm/ScreenshotPreviewMeta.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx index 7ded5edc5e..e9509f5b9f 100644 --- a/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx +++ b/resources/js/features/games/components/UploadForm/ScreenshotPreviewMeta.tsx @@ -51,10 +51,6 @@ export const ScreenshotPreviewMeta: FC = ({ return (
- - {width}x{height} - - {isResolutionValid ? ( @@ -84,6 +80,10 @@ export const ScreenshotPreviewMeta: FC = ({ {t('Invalid resolution')} )} + + + {width}x{height} +
{!isResolutionValid ? (