import { AutoFocus } from '@/mainview/components/AutoFocus'; import { OptionElement } from '@/mainview/components/ContextDialog'; import GameLookupElement from '@/mainview/components/game/GameLookup'; import { StickyHeaderUI } from '@/mainview/components/Header'; import LoadingScreen from '@/mainview/components/LoadingScreen'; import { Button } from '@/mainview/components/options/Button'; import { PathSettingsOptionBase } from '@/mainview/components/options/PathSettingsOption'; import { FloatingShortcuts } from '@/mainview/components/Shortcuts'; import { oneShot } from '@/mainview/scripts/audio/audio'; import { addManualGameMutation, allGamesInvalidateQuery, gameLookupDetails, platformLookupMatchQuery } from '@/mainview/scripts/queries/romm'; import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts'; import { HandleGoBack } from '@/mainview/scripts/utils'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; import { zodValidator } from '@tanstack/zod-adapter'; import { ArrowBigRightDash, Check, CirclePlus, CircleQuestionMark, CircleX, FileSearch, FolderOpen, HardDrive } from 'lucide-react'; import { basename } from 'pathe'; import { JSX, useState } from 'react'; import toast from 'react-hot-toast'; import { twMerge } from 'tailwind-merge'; import z from 'zod'; const StateSchema = z.object({ step: z.number().default(0), gameLocation: z.string().optional(), selectedGame: z.object({ source: z.string(), id: z.string() }).optional(), platformId: z.number().optional(), search: z.string().optional() }); export const Route = createFileRoute('/game/add')({ component: RouteComponent, validateSearch: zodValidator(StateSchema) }); function FileSelectionField (data: { location: string | undefined, setLocation: (location: string | undefined) => void; }) { const [localLocation, setLocalLocation] = useState(data.location); return ; } const TAG_REGEX = /\(([^)]+)\)|\[([^\]]+)\]/g; const EXTENSION_REGEX = /\.(([a-z]+\.)*\w+)$/g; const LEADING_ARTICLE_PATTERN = /^(a|an|the)\b/g; const COMMA_ARTICLE_PATTERN = /,\s(a|an|the)\b(?=\s*[^\w\s]|$)/g; const NON_WORD_SPACE_PATTERN = /[^\w\s]/g; const MULTIPLE_SPACE_PATTERN = /\s+/g; function BuildSearch (filePath: string) { const name = basename(filePath); const nameWithoutExt = name.replace(EXTENSION_REGEX, "").trim(); if (!nameWithoutExt) return undefined; const nameWithoutTags = nameWithoutExt.replaceAll(TAG_REGEX, "").trim(); if (TAG_REGEX.test(nameWithoutExt)) console.log("match"); if (!nameWithoutTags) return undefined; // Lower and replace underscores with spaces let finalSearch = nameWithoutTags.toLowerCase().replace("_", " "); // Remove articles (combined if possible) finalSearch = finalSearch.replaceAll(LEADING_ARTICLE_PATTERN, ''); finalSearch = finalSearch.replaceAll(COMMA_ARTICLE_PATTERN, ''); // Remove punctuation and normalize spaces in one step finalSearch = finalSearch.replaceAll(NON_WORD_SPACE_PATTERN, ''); finalSearch = finalSearch.replaceAll(MULTIPLE_SPACE_PATTERN, ''); return nameWithoutTags; } const typeIconMap: Record = { new: , existing: , unknown: }; function Overview (data: {}) { const navigate = useNavigate(); const router = useRouter(); const state = Route.useSearch(); const { data: game } = useQuery(gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id)); const { data: platform } = useQuery(platformLookupMatchQuery(state.selectedGame?.source, state.platformId)); const addGame = useMutation({ ...addManualGameMutation, onError (error, variables, onMutateResult, context) { toast.error(error.message); }, async onSuccess (data, variables, onMutateResult, context) { if (data.id === null) return; await context.client.invalidateQueries(allGamesInvalidateQuery); navigate({ to: '/game/$source/$id', params: { source: data.source, id: String(data.id) }, replace: true }); }, }); if (!game) return
Select A Game
; return
Preview
{!!game[0].coverUrl && }
{game[0].name}
{game[0].summary}
{platform?.details.name}
{!!platform?.match.coverUrl && }
{platform?.match.name}
{platform?.match.family_name}
{!!platform?.match.type && typeIconMap[platform?.match.type]}
{platform?.match.type}
{state.gameLocation}
Actions
; } function PlatformEntry (data: { id: string, displayName: string, platformSource: string, platformId: number; }) { const state = Route.useSearch(); const { data: match, isFetching: matchIsFetching } = useQuery({ ...platformLookupMatchQuery(data.platformSource, data.platformId), staleTime: 1000 * 60 * 60 }); const navigate = useNavigate(); const handleAction = () => { navigate({ to: '/game/add', search: { ...state, platformId: data.platformId, step: 3 }, replace: true }); oneShot('openGeneric'); }; return
{data.displayName}
{matchIsFetching ? : match && <> {match.match.coverUrl ? : }
{match.match.name} - {!!match.match.type && typeIconMap[match.match.type]} {match.match.type}
} } type={'primary'} />; } function PlatformSelection (data: {}) { const state = Route.useSearch(); const { data: game, isFetching } = useQuery({ ...gameLookupDetails(state.selectedGame?.source, state.selectedGame?.id), staleTime: 1000 * 60 * 60 }); if (isFetching) return ; if (!game) return
Select A Game
; return
    {game[0].platforms.map((p, i) => )}
; } function Lookup () { const state = Route.useSearch(); const [search, setSearch] = useState(state.search); const navigate = useNavigate(); const handleSetSelectedGame = (source: string, id: string) => { navigate({ to: '/game/add', search: { ...state, selectedGame: { source, id }, platformId: undefined, search, step: 2 }, replace: true }); oneShot('openGeneric'); }; return { handleSetSelectedGame(l.source, l.id); }} />; } const StepDetails = [{ label: "Select Location" }, { label: "Find Match" }, { label: "Select Platform" }, { label: "Confirm" }]; function Location () { const state = Route.useSearch(); const navigate = useNavigate(); const handleSetLocation = (location: string | undefined) => { if (!location) return; navigate({ to: '/game/add', search: { ...state, gameLocation: location, search: BuildSearch(location), selectedGame: undefined, platformId: undefined, step: 1 }, replace: true }); oneShot('openGeneric'); }; return
Select Game Rom
Select The Rom File from your local storage
; } function Details (data: {}) { const { ref, focusKey } = useFocusable({ focusKey: 'add-game-details-section' }); const state = Route.useSearch(); const step = state.step ?? 0; return
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && }
; } function getStepDetails (index: number, state: z.infer) { let completed = index < state.step; if (index === 0 && state.gameLocation) completed = true; if (index === 1 && state.selectedGame) completed = true; if (index === 2 && state.platformId) completed = true; if (index === 3 && state.gameLocation && state.selectedGame && state.platformId) completed = true; let canNavigate = index <= state.step; if (index === 1 && state.gameLocation) canNavigate = true; if (index === 2 && state.selectedGame) canNavigate = true; if (index === 3 && state.platformId) canNavigate = true; return { completed, canNavigate }; } function Step (data: { index: number; label: string; }) { const navigate = useNavigate(); const handleGoToStep = (step: number) => { navigate({ to: '/game/add', search: { ...state, step: step }, replace: true }); oneShot('openGeneric'); }; const state = Route.useSearch(); const step = state.step ?? 0; const { canNavigate, completed } = getStepDetails(data.index, state); const { ref } = useFocusable({ focusKey: `step-${data.index}`, focusable: canNavigate, onFocus: () => { if (step === data.index) return; navigate({ to: '/game/add', search: { ...state, step: data.index }, replace: true }); oneShot('openGeneric'); } }); return
  • { if (!canNavigate) return; handleGoToStep(data.index); }} className={twMerge("step not-aria-disabled:cursor-pointer", data.index <= step ? "step-primary" : "")}> {completed ? : } {data.label}
  • ; } function Steps () { const state = Route.useSearch(); const step = state.step ?? 0; const { ref, focusKey } = useFocusable({ focusKey: "steps", preferredChildFocusKey: `step-${step}`, saveLastFocusedChild: false }); return
      {StepDetails.map((s, i) => )}
    ; } function RouteComponent () { const navigate = useNavigate(); const state = Route.useSearch(); const step = state.step ?? 0; const router = useRouter(); const queryClient = useQueryClient(); const isAddingGame = queryClient.isMutating(addManualGameMutation) > 0; const { ref, focusKey, focusSelf } = useFocusable({ focusKey: 'add-game-page', preferredChildFocusKey: 'steps' }); const handleReturnStep = (e: Event) => { if (step <= 0) { HandleGoBack(router, e); } else { const newStep = step - 1; navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); } }; const handleStepNavigation = (newStep: number) => { if (step === newStep) return; const { canNavigate } = getStepDetails(newStep, state); if (!canNavigate) return; navigate({ to: '/game/add', search: { ...state, step: newStep }, replace: true }); oneShot('openGeneric'); }; useShortcuts(focusKey, () => [ { button: GamePadButtonCode.B, label: step === 0 ? "Cancel" : "Prev Step", action: handleReturnStep }, { button: GamePadButtonCode.Y, label: "Cancel", action: e => HandleGoBack(router, e) }, { button: GamePadButtonCode.L1, label: "Prev Step", action (e) { handleStepNavigation(Math.max(step - 1, 0)); }, }, { button: GamePadButtonCode.R1, label: "Next Step", action (e) { handleStepNavigation(Math.min(step + 1, 3)); }, } ], [step]); return
    {isAddingGame &&
    Adding Game
    }
    ; }