>, direction: -1 | 1) {
37 | setDescriptors(descriptors => {
38 | if (descriptors.length > 1) {
39 | const items = descriptors.slice().sort((a, b) => a.zIndex - b.zIndex);
40 | if (direction < 0) {
41 | items.push(items.shift()!);
42 | } else {
43 | items.unshift(items.pop()!);
44 | }
45 | return items.map((descriptor, index) => ({...descriptor, zIndex: index + 1}));
46 | }
47 | return descriptors;
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Game Library (frontend)
2 |
3 | This frontend is using [CRA](https://create-react-app.dev/), [React](https://reactjs.org/) and [MUI](https://mui.com/).
4 |
5 | ## Links
6 |
7 | - [Backend](https://github.com/jbdemonte/game-library-backend)
8 | - [Online demo](https://jb.demonte.fr/demos/game-library/)
9 | - [dev.to article](https://dev.to/jbdemonte/create-a-window-manager-with-react-3mak)
10 |
11 | ## Screenshot
12 |
13 | 
14 |
15 | ## Development
16 |
17 | Copy `.env.example` to `.env` and modify it.
18 |
19 | Install the dependencies using `yarn` (or `npm`):
20 |
21 | ```shell
22 | yarn
23 | ```
24 |
25 | Start the development server:
26 |
27 | ```shell
28 | yarn start
29 | ```
30 |
31 | ## Production
32 |
33 | Build the project:
34 |
35 | ```shell
36 | yarn build
37 | ```
38 |
39 | It produces the `build` folder which contains the static files.
40 |
41 | The [backend](https://github.com/jbdemonte/game-library-backend) provides a docker build which automatically includes this frontend.
42 |
43 |
44 | ## Credits
45 |
46 | - Consoles Icons from [OpenEmu](https://openemu.org/).
47 | - Medias from [Screenscraper](https://www.screenscraper.fr/).
48 |
49 | ## Licence
50 |
51 |
52 | Game Library (frontend) by Jean-Baptiste Demonte is licensed under CC BY-NC-SA 4.0
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/hooks/use-on-resize.ts:
--------------------------------------------------------------------------------
1 | import React, { MouseEventHandler, useMemo, useState, useEffect } from 'react';
2 | import { useEffectOnUpdate } from './use-effect-on-update';
3 |
4 |
5 | type Props = {
6 | onCursorChange: (cursor: string) => void;
7 | onMouseDown?: (e: React.MouseEvent) => void;
8 | onResize: (offset: { top: number, left: number, bottom: number, right: number }) => void;
9 | onChange: (resizing: boolean) => void;
10 | resizable?: boolean;
11 | }
12 |
13 | export const useOnResize = ({ onCursorChange, onResize, onChange, resizable, onMouseDown: onMouseDownParent }: Props) => {
14 | const [, setCursor] = useState('auto');
15 | const [{ top, left, bottom, right, resizing }, setResizing] = useState({ top: false, left: false, bottom: false, right: false, resizing: false });
16 |
17 | useEffectOnUpdate(() => {
18 | onChange(resizing)
19 | }, [resizing]);
20 |
21 | useEffect(() => {
22 | function onMouseMove(e: MouseEvent) {
23 | if (resizing && resizable) {
24 | onResize({
25 | top: top ? e.movementY : 0,
26 | left: left ? e.movementX : 0,
27 | bottom: bottom ? e.movementY : 0,
28 | right: right ? e.movementX : 0,
29 | });
30 | e.preventDefault();
31 | }
32 | }
33 | function onMouseUp() {
34 | setResizing({ top: false, left: false, bottom: false, right: false, resizing: false });
35 | }
36 | window.addEventListener('mousemove', onMouseMove);
37 | window.addEventListener('mouseup', onMouseUp);
38 |
39 | return () => {
40 | window.removeEventListener('mousemove', onMouseMove);
41 | window.removeEventListener('mouseup', onMouseUp);
42 | };
43 | }, [resizing, top, left, bottom, right, onResize, resizable]);
44 |
45 | return useMemo(() => {
46 | const onMouseMove: MouseEventHandler = (e: React.MouseEvent) => {
47 | const { top, left, bottom, right } = getActiveSides(e);
48 | const cursor = getCursor(top, right, bottom, left);
49 | setCursor(current => {
50 | if (current !== cursor) {
51 | onCursorChange(cursor);
52 | }
53 | return cursor;
54 | });
55 |
56 | };
57 |
58 | const onMouseDown: MouseEventHandler = (e: React.MouseEvent) => {
59 | const { top, left, bottom, right } = getActiveSides(e);
60 | if (top || left || bottom || right) {
61 | setResizing({ top, left, bottom, right, resizing: true });
62 | }
63 | if (onMouseDownParent) {
64 | onMouseDownParent(e);
65 | }
66 | };
67 |
68 | return resizable && !resizing ? { onMouseMove, onMouseDown } : {};
69 | }, [onCursorChange, onMouseDownParent, resizable, resizing]);
70 | }
71 |
72 | function getActiveSides(e: React.MouseEvent) {
73 | const size = 8;
74 | const element = e.currentTarget;
75 | const rect = element.getBoundingClientRect();
76 | const x = Math.floor(e.clientX - rect.left);
77 | const y = Math.floor(e.clientY - rect.top);
78 | const width = element.offsetWidth;
79 | const height = element.offsetHeight;
80 |
81 | const left = x <= size;
82 | const right = x >= width - size;
83 | const top = y <= size;
84 | const bottom = y >= height - size;
85 | return {
86 | top, right, bottom, left
87 | };
88 | }
89 |
90 |
91 | function getCursor(top: boolean, right: boolean, bottom: boolean, left: boolean): string {
92 | const cursors = [
93 | ['nwse-resize', 'ns-resize', 'nesw-resize'],
94 | ['ew-resize', 'auto', 'ew-resize'],
95 | ['nesw-resize', 'ns-resize', 'nwse-resize'],
96 | ];
97 | const x = left ? 0 : right ? 2 : 1;
98 | const y = top ? 0 : bottom ? 2 : 1;
99 | return cursors[y][x];
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/SystemWindow.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useMemo, useState } from 'react';
2 | import { Box, Grid } from '@mui/material';
3 | import { ScrapedGame, systemService } from '../services/system.service';
4 | import { Win } from './Win/Win';
5 | import { Loading } from './Loading';
6 | import { Game } from './Game';
7 | import { ToastContext } from '../contexts/toast.context';
8 | import { IRom } from '../interfaces/rom.interface';
9 | import { Rom } from './Rom';
10 | import { gameWindowDataEquals } from './GameWindow';
11 | import { WinManagerContext, WinPayload } from '../contexts/win-manager.context';
12 | import { formatFileSize } from '../tools/file';
13 | import { WinContext } from '../contexts/win.context';
14 |
15 | export type SystemWindowData = {
16 | systemId: string;
17 | }
18 |
19 | export const isSystemWindowData = (data: any): data is SystemWindowData => {
20 | return data.hasOwnProperty('systemId');
21 | }
22 |
23 | export function systemWindowDataEquals(payloadA: WinPayload, payloadB: WinPayload) {
24 | return isSystemWindowData(payloadA) && isSystemWindowData(payloadB) && payloadA.systemId === payloadB.systemId;
25 | }
26 |
27 | function sumRomSizes(roms: IRom[]): number {
28 | return roms.reduce((sum, rom) => sum + rom.archive.size, 0);
29 | }
30 |
31 | export const SystemWindow = ({ systemId }: SystemWindowData) => {
32 | const system = systemService.get(systemId);
33 | const [content, setContent] = useState<{ scraped: ScrapedGame[], roms: IRom[] }>();
34 | const { showError } = useContext(ToastContext);
35 | const { openNewWindow } = useContext(WinManagerContext);
36 | const { searched } = useContext(WinContext);
37 |
38 | useEffect(() => {
39 | systemService
40 | .getSystemContent(systemId)
41 | .then(content => {
42 |
43 | setContent({
44 | scraped: content.scraped.sort((a, b) => a.game.name.toLowerCase() < b.game.name.toLowerCase() ? -1 : 1),
45 | roms: content.roms.sort((a, b) => a.archive.name.toLowerCase() < b.archive.name.toLowerCase() ? -1 : 1),
46 | })
47 | })
48 | .catch(showError)
49 | }, [systemId, showError]);
50 |
51 | const footer: [string, string, string] = useMemo(() => {
52 | if (content) {
53 | const romCount = content.scraped.reduce((sum, scraped) => sum + scraped.roms.length, 0) + content.roms.length;
54 | const totalSize = content.scraped.reduce((sum, scraped) => sum + sumRomSizes(scraped.roms), 0) + sumRomSizes(content.roms);
55 |
56 | const gameCountLabel = content.scraped.length > 1 ? `${content.scraped.length} scraped games` : (content.scraped.length ? '1 scraped game' : '');
57 | const unknownGameLabel = content.roms.length > 1 ? `${content.roms.length} unknown games` : (content.roms.length ? '1 unknown game' : '');
58 |
59 | return [
60 | `${gameCountLabel}${gameCountLabel && unknownGameLabel ? ', ' : ''}${unknownGameLabel}`,
61 | '',
62 | `Total: ${romCount > 1 ? `${romCount} roms, ` : romCount === 1 ? '1 rom, ' : ''}${formatFileSize(totalSize)}`
63 | ]
64 |
65 | }
66 | return ['', 'loading', ''];
67 |
68 | }, [content]);
69 |
70 | const filterRoms = (roms: IRom[]) => {
71 | return searched ? roms.filter(rom => rom.archive.name.toLowerCase().includes(searched)) : roms;
72 | }
73 |
74 | const filterScraped = (scrapedGames: ScrapedGame[]) => {
75 | return searched ? scrapedGames.filter(scrapedGame => scrapedGame.game.name.includes(searched) || filterRoms(scrapedGame.roms).length) : scrapedGames;
76 | }
77 |
78 | return (
79 |
80 | { content ? (
81 |
82 |
83 | { filterScraped(content.scraped).map((gameData) => openNewWindow({ gameData }, { equals: gameWindowDataEquals })} />)}
84 | { filterRoms(content.roms).map((rom) => )}
85 |
86 |
87 | ) : }
88 |
89 | )
90 | };
91 |
--------------------------------------------------------------------------------
/src/components/GameWindow.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from 'react';
2 | import { Box, Typography } from '@mui/material';
3 | import PersonIcon from '@mui/icons-material/Person';
4 | import PeopleIcon from '@mui/icons-material/People';
5 | import HelpIcon from '@mui/icons-material/Help';
6 | import StarRateIcon from '@mui/icons-material/StarRate';
7 | import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
8 | import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
9 | import CancelIcon from '@mui/icons-material/Cancel';
10 | import { ScrapedGame } from '../services/system.service';
11 | import { Win } from './Win/Win';
12 | import { getDefaultMedia, getVideoMedia } from '../tools/media';
13 | import { IGame } from '../interfaces/game.interface';
14 | import { Video } from './Video';
15 | import { RomTable } from './RomTable';
16 | import { Gallery } from './MediaGallery';
17 | import { WinPayload } from '../contexts/win-manager.context';
18 |
19 | export type GameWindowData = {
20 | gameData: ScrapedGame;
21 | }
22 |
23 | export const isGameWindowData = (data: any): data is GameWindowData => {
24 | return data.hasOwnProperty('gameData');
25 | }
26 |
27 | export function gameWindowDataEquals(payloadA: WinPayload, payloadB: WinPayload) {
28 | return isGameWindowData(payloadA) && isGameWindowData(payloadB) && payloadA.gameData.game.id === payloadB.gameData.game.id;
29 | }
30 |
31 | const DetailsBox = ({ game, playVideo }: { game: IGame, playVideo?: () => void}) => (
32 |
33 |
34 |
35 | { game.players > 1 && }
36 | { game.players === 1 && }
37 | { game.players > 1 ? `${game.players} players` : `1 player` }
38 |
39 |
40 | { game.genres[0] || '' }
41 |
42 |
43 | { game.grade ?? '?' } / 20
44 |
45 |
46 | { game.date ?? '?'}
47 |
48 | { playVideo && (
49 |
50 | Video
51 |
52 | )}
53 |
54 |
55 | );
56 |
57 | export const GameWindow = ({ gameData: { game, roms } }: GameWindowData) => {
58 | const [showVideo, setShowVideo] = useState(false);
59 | const [media, setMedia] = useState(() => getDefaultMedia(game.medias))
60 | const video = getVideoMedia(game.medias);
61 |
62 | const icon = useMemo(() => getDefaultMedia(game.medias), [game]);
63 |
64 | return (
65 |
66 |
67 |
68 |
69 | {
70 | showVideo && video ?
71 |
72 |
73 | setShowVideo(false)} />
74 |
75 | :
76 |
81 | }
82 | setShowVideo(true) : undefined } />
83 |
84 |
85 | { setMedia(media); setShowVideo(false); }}/>
86 |
87 |
88 | { game.name }
89 |
90 |
91 |
92 | { game.synopsis }
93 |
94 |
95 |
96 |
97 |
98 |
99 | )
100 | };
101 |
--------------------------------------------------------------------------------
/src/components/Win/WinManager.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
2 | import { Box } from '@mui/material';
3 | import { WinContext, WinContextType } from '../../contexts/win.context';
4 | import { guid } from '../../tools/guid';
5 | import { WinManagerContext, WinOptions, WinPayload } from '../../contexts/win-manager.context';
6 | import { IDescriptor } from './interfaces/descriptor.interface';
7 | import { getMaxZIndex, focusedDescriptor } from './tools/descriptors';
8 | import { removeOneAndSetDescriptors, rotateAndSetDescriptors, updateStateAndSetDescriptors } from './tools/set-descriptors';
9 |
10 | type Props = {
11 | render: (payload: WinPayload) => ReactElement;
12 | }
13 |
14 | export const WinManager: FC = ({ render, children }) => {
15 | const [descriptors, setDescriptors] = useState([]);
16 |
17 | useEffect(() => {
18 | function keydown(event: KeyboardEvent) {
19 | if (event.defaultPrevented) {
20 | return; // Do nothing if the event was already processed
21 | }
22 |
23 | // command + F or ctrl + F
24 | if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
25 | updateStateAndSetDescriptors(setDescriptors, { searching: true }, '', (descriptor) => descriptor.options.search && !descriptor.state.searching );
26 | event.preventDefault();
27 | }
28 |
29 | if (event.key === 'Escape') {
30 | const updated = updateStateAndSetDescriptors(setDescriptors, { searching: false, searched: '' }, '', (descriptor) => descriptor.options.search && descriptor.state.searching );
31 | if (!updated) {
32 | removeOneAndSetDescriptors(setDescriptors);
33 | }
34 | event.preventDefault();
35 | }
36 |
37 | if (event.key === 'Tab') {
38 | rotateAndSetDescriptors(setDescriptors, event.shiftKey || event.metaKey ? -1 : 1);
39 | event.preventDefault();
40 | }
41 | }
42 |
43 | window.addEventListener('keydown', keydown);
44 | return () => {
45 | window.removeEventListener('keydown', keydown);
46 | };
47 | }, []);
48 |
49 | const winManagerContextValue = useMemo(() => ({
50 | openNewWindow: (payload: WinPayload, { equals, ...options }: WinOptions = {}) => {
51 | setDescriptors(descriptors => {
52 | const existing = equals ? descriptors.find(descriptor => equals(payload, descriptor.payload)) : undefined;
53 | if (existing) {
54 | return focusedDescriptor(descriptors, existing.id);
55 | }
56 | // add a new descriptor
57 | return descriptors.concat([{
58 | id: guid(),
59 | zIndex: 1 + getMaxZIndex(descriptors),
60 | payload,
61 | options,
62 | state: {},
63 | }]);
64 | })
65 | }
66 | }), []);
67 |
68 | const windowHandlers: Omit = useMemo(() => ({
69 | close: (id: string) => {
70 | removeOneAndSetDescriptors(setDescriptors, id);
71 | },
72 | focus: (id: string) => {
73 | setDescriptors(descriptors => focusedDescriptor(descriptors, id));
74 | },
75 | setFooter: (id: string, left: string = '', center: string = '', right: string = '') => {
76 | updateStateAndSetDescriptors(setDescriptors, { footer: (left || center || right) ? [left, center, right] : undefined }, id);
77 | },
78 | setSearched: (id: string, searched: string = '') => {
79 | updateStateAndSetDescriptors(setDescriptors, { searched }, id);
80 | },
81 | }), []);
82 |
83 | return (
84 |
85 | { children }
86 |
87 | { descriptors.map(descriptor => ) }
88 |
89 |
90 | );
91 | }
92 |
93 | type GenericWinProps = {
94 | descriptor: IDescriptor;
95 | close: (id: string) => void;
96 | focus: (id: string) => void;
97 | setFooter: (id: string, left?: string, center?: string, right?: string) => void;
98 | setSearched: (id: string, searched: string) => void;
99 | render: (payload: WinPayload) => ReactElement;
100 | }
101 |
102 | const GenericWin = ({ descriptor, close, focus, setFooter, setSearched, render }: GenericWinProps) => {
103 | const contextValue: WinContextType = useMemo(() => ({
104 | searching: descriptor.state.searching,
105 | searched: descriptor.state.searched,
106 | zIndex: descriptor.zIndex,
107 | close: close.bind(null, descriptor.id),
108 | focus: focus.bind(null, descriptor.id),
109 | footer: descriptor.state.footer ? [...descriptor.state.footer] : undefined,
110 | setFooter: setFooter.bind(null, descriptor.id),
111 | setSearched: setSearched.bind(null, descriptor.id)
112 | }), [descriptor, close, focus, setFooter, setSearched]);
113 | return (
114 |
115 | { render(descriptor.payload) }
116 |
117 | );
118 | };
119 |
--------------------------------------------------------------------------------
/src/components/Win/Win.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, FC, useCallback, useContext, useEffect, useState } from 'react';
2 | import { Box, styled } from '@mui/material';
3 | import { useOnDrag } from '../../hooks/use-on-drag';
4 | import { useOnResize } from '../../hooks/use-on-resize';
5 | import { Header } from './components/Header';
6 | import { Footer } from './components/Footer';
7 | import { Content } from './components/Content';
8 | import { WinContext } from '../../contexts/win.context';
9 | import { Search } from './components/Search';
10 | import { minMax } from './tools/min-max';
11 |
12 | type Props = {
13 | title: string;
14 | img?: string;
15 | footer?: [string, string, string];
16 | }
17 |
18 | const StyledBox = styled(Box)`
19 | position: absolute;
20 | pointer-events: auto;
21 | border-radius: 10px;
22 | border: 1px solid #747676;
23 | background: #353738;
24 | display: flex;
25 | flex-direction: column;
26 | `;
27 |
28 | function randomOffset(size: number, percent: number) {
29 | const sign = Math.sign(Math.random() - 0.5);
30 | return sign * Math.floor((size * percent / 100) * Math.random());
31 | }
32 |
33 | const WinMinSize = 250;
34 |
35 | export const Win: FC = ({ img, title, footer: defaultFooter, children }) => {
36 | const { footer, zIndex, close, focus, searching, setSearched } = useContext(WinContext);
37 | const [resizing, setResizing] = useState(false);
38 | const [{ top, left, width, height, cursor, fullscreen }, setProperties] = useState(() => {
39 | const height = window.innerHeight / 2;
40 | const width = window.innerWidth / 2.5;
41 | const randX = randomOffset(window.innerWidth - width, 20);
42 | const randY = randomOffset(window.innerHeight - height, 20);
43 | return {
44 | width,
45 | height,
46 | top: Math.floor((window.innerHeight - height) / 2) + randY,
47 | left: Math.floor((window.innerWidth - width) / 2) + randX,
48 | cursor: 'auto',
49 | fullscreen: false
50 | }});
51 |
52 | const onCursorChange = useCallback((cursor: string) => setProperties(properties => ({ ...properties, cursor })), []);
53 |
54 | const onResize = useCallback((offset: { top: number, left: number, bottom: number, right: number }) => {
55 | setProperties(properties => {
56 | let top = properties.top + offset.top;
57 | let left = properties.left + offset.left;
58 | let height = properties.height + offset.bottom - offset.top;
59 | let width = properties.width + offset.right - offset.left;
60 |
61 | // keep top side in the window
62 | if (top < 0) {
63 | height += top;
64 | top = 0;
65 | }
66 |
67 | // keep left side in the window
68 | if (left < 0) {
69 | width += left;
70 | left = 0;
71 | }
72 |
73 | // respect min height
74 | if (height < WinMinSize) {
75 | if (offset.top) {
76 | top -= WinMinSize - height;
77 | }
78 | height = WinMinSize;
79 | }
80 |
81 | // respect min width
82 | if (width < WinMinSize) {
83 | if (offset.left) {
84 | left -= WinMinSize - width;
85 | }
86 | width = WinMinSize;
87 | }
88 |
89 | // keep left side in the window
90 | if (left + width > window.innerWidth) {
91 | width = window.innerWidth - left;
92 | }
93 |
94 | // keep bottom side in the window
95 | if (top + height > window.innerHeight) {
96 | height = window.innerHeight - top;
97 | }
98 |
99 | const updated = {...properties, top, left, width, height };
100 |
101 | return arePropertiesEquals(properties, updated) ? properties : updated;
102 | });
103 | }, []);
104 |
105 | const toggleFullScreen = useCallback(() => {
106 | setProperties(properties => ({ ...properties, fullscreen: !properties.fullscreen }));
107 | }, []);
108 |
109 | const onMouseDown = useCallback(() => focus(), [focus]);
110 |
111 | const onDragMove = useCallback((e: MouseEvent) => {
112 | setProperties(properties => ({
113 | ...properties,
114 | top: minMax(0, properties.top + e.movementY, window.innerHeight - properties.height),
115 | left: minMax(0, properties.left + e.movementX, window.innerWidth - properties.width),
116 | }))
117 | }, []);
118 |
119 | const onChange = useCallback((e: ChangeEvent) => {
120 | setSearched(e.target.value.toLowerCase().trim());
121 | }, [setSearched]);
122 |
123 | // Resize the box when the window size change (reduce it size, and move its position)
124 | useEffect(() => {
125 | const onWindowResize = () => {
126 | setProperties(properties => {
127 | let { top, left, width, height } = properties;
128 | if (left + width > window.innerWidth) {
129 | width = window.innerWidth - left;
130 | if (width < WinMinSize) {
131 | left = Math.max(0, left - (WinMinSize - width));
132 | width = WinMinSize;
133 | }
134 | }
135 | if (top + height > window.innerHeight) {
136 | height = window.innerHeight - top;
137 | if (height < WinMinSize) {
138 | top = Math.max(0, top - (WinMinSize - height));
139 | height = WinMinSize;
140 | }
141 | }
142 | const updated = {...properties, top, left, width, height };
143 | return arePropertiesEquals(properties, updated) ? properties : updated;
144 | });
145 | };
146 |
147 | window.addEventListener('resize', onWindowResize);
148 | return () => window.removeEventListener('resize', onWindowResize);
149 | }, []);
150 |
151 | return (
152 |
162 |
171 |
172 | {children}
173 | { searching && }
174 |
175 |
176 |
177 | );
178 | };
179 |
180 | function arePropertiesEquals>(source: T, target: T): boolean {
181 | const keys = Object.keys(source);
182 | return Object.keys(target).length === keys.length && !keys.some(key => source[key] !== target[key]);
183 | }
184 |
--------------------------------------------------------------------------------
/src/data/systems.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": "amiga",
4 | "name": "Amiga",
5 | "picture": "Amiga.png",
6 | "section": "computers"
7 | },
8 | {
9 | "id": "cpc",
10 | "name": "Amstrad CPC",
11 | "picture": "Amstrad CPC.png",
12 | "section": "computers"
13 | },
14 | {
15 | "id": "apple2",
16 | "name": "Apple 2",
17 | "picture": "Apple 2.png",
18 | "section": "computers"
19 | },
20 | {
21 | "id": "atari2600",
22 | "name": "Atari 2600",
23 | "picture": "Atari 2600.png",
24 | "icon": "atari2600.png",
25 | "section": "consoles"
26 | },
27 | {
28 | "id": "atari5200",
29 | "name": "Atari 5200",
30 | "picture": "Atari 5200.png",
31 | "icon": "atari5200.png",
32 | "section": "consoles"
33 | },
34 | {
35 | "id": "atari7800",
36 | "name": "Atari 7800",
37 | "picture": "Atari 7800.png",
38 | "icon": "atari7800.png",
39 | "section": "consoles"
40 | },
41 | {
42 | "id": "atari800",
43 | "name": "Atari 8-bit Series",
44 | "picture": "Atari 800.png",
45 | "section": "computers"
46 | },
47 | {
48 | "id": "atarijaguar",
49 | "name": "Atari Jaguar",
50 | "picture": "Atari Jaguar.png",
51 | "section": "consoles"
52 | },
53 | {
54 | "id": "lynx",
55 | "name": "Atari Lynx",
56 | "picture": "Atari Lynx.png",
57 | "icon": "lynx.png",
58 | "section": "handhelds"
59 | },
60 | {
61 | "id": "atarist",
62 | "name": "Atari ST",
63 | "picture": "Atari ST.png",
64 | "section": "computers"
65 | },
66 | {
67 | "id": "coleco",
68 | "name": "Colecovision",
69 | "picture": "Colecovision.png",
70 | "icon": "colecovision.png",
71 | "section": "consoles"
72 | },
73 | {
74 | "id": "c64",
75 | "name": "Commodore 64",
76 | "picture": "Commodore 64.png",
77 | "icon": "c64.png",
78 | "section": "computers"
79 | },
80 | {
81 | "id": "pc",
82 | "name": "DOSBox",
83 | "picture": "DOSBox.png",
84 | "section": "others"
85 | },
86 | {
87 | "id": "daphne",
88 | "name": "Daphne",
89 | "picture": "LaserActive.png",
90 | "section": "others"
91 | },
92 | {
93 | "id": "dragon32",
94 | "name": "Dragon 32",
95 | "picture": "Dragon 32.png",
96 | "section": "computers"
97 | },
98 | {
99 | "id": "dreamcast",
100 | "name": "Dreamcast",
101 | "picture": "Dreamcast.png",
102 | "section": "consoles"
103 | },
104 | {
105 | "id": "fba",
106 | "name": "FBA",
107 | "picture": "Final Burn Alpha.png",
108 | "pictures": [
109 | "Final Burn Alpha.png",
110 | "Neo Geo MVS.png"
111 | ],
112 | "section": "arcades"
113 | },
114 | {
115 | "id": "gw",
116 | "name": "Game & Watch",
117 | "picture": "Game & Watch.png",
118 | "section": "handhelds"
119 | },
120 | {
121 | "id": "gb",
122 | "name": "Game Boy",
123 | "picture": "Game Boy.png",
124 | "pictures": [
125 | "Game Boy Pocket.png",
126 | "Game Boy.png"
127 | ],
128 | "icon": "gameboy.png",
129 | "section": "handhelds"
130 | },
131 | {
132 | "id": "gba",
133 | "name": "Game Boy Advance",
134 | "picture": "Game Boy Advance.png",
135 | "icon": "gba.png",
136 | "section": "handhelds"
137 | },
138 | {
139 | "id": "gbc",
140 | "name": "Game Boy Color",
141 | "picture": "Game Boy Color.png",
142 | "section": "handhelds"
143 | },
144 | {
145 | "id": "gamegear",
146 | "name": "Game Gear",
147 | "picture": "Game Gear.png",
148 | "icon": "gamegear.png",
149 | "section": "handhelds"
150 | },
151 | {
152 | "id": "gc",
153 | "name": "GameCube",
154 | "picture": "GameCube.png",
155 | "icon": "gamecube.png",
156 | "section": "consoles"
157 | },
158 | {
159 | "id": "intellivision",
160 | "name": "Intellivision",
161 | "picture": "Intellivision.png",
162 | "icon": "intellivision.png",
163 | "section": "consoles"
164 | },
165 | {
166 | "id": "lutro",
167 | "name": "LUA games",
168 | "picture": "Lua.png",
169 | "section": "others"
170 | },
171 | {
172 | "id": "mame-advmame",
173 | "name": "MAME AdvanceMAME",
174 | "picture": "AdvanceMAME.png",
175 | "section": "arcades"
176 | },
177 | {
178 | "id": "mame-libretro",
179 | "name": "MAME Libretro",
180 | "picture": "MAME.png",
181 | "section": "arcades"
182 | },
183 | {
184 | "id": "mame-mame4all",
185 | "name": "MAME MAME4all",
186 | "picture": "MAME.png",
187 | "section": "arcades"
188 | },
189 | {
190 | "id": "msx",
191 | "name": "MSX",
192 | "picture": "Sony HitBit (MSX 1).png",
193 | "pictures": [
194 | "Sony HitBit (MSX 1).png",
195 | "Philips VG 8235 (MSX 2).png"
196 | ],
197 | "section": "computers"
198 | },
199 | {
200 | "id": "macintosh",
201 | "name": "Macintosh",
202 | "picture": "Macintosh.png",
203 | "section": "computers"
204 | },
205 | {
206 | "id": "mame",
207 | "name": "Mame",
208 | "picture": "MAME.png",
209 | "section": "arcades"
210 | },
211 | {
212 | "id": "megadrive",
213 | "name": "Megadrive",
214 | "alias": "Genesis",
215 | "picture": "Megadrive.png",
216 | "pictures": [
217 | "Genesis.png",
218 | "Megadrive.png"
219 | ],
220 | "icon": "megadrive.png",
221 | "icons": ["megadrive.png", "genesis.png"],
222 | "section": "consoles"
223 | },
224 | {
225 | "id": "neogeo",
226 | "name": "Neo Geo",
227 | "picture": "Neo Geo.png",
228 | "section": "consoles"
229 | },
230 | {
231 | "id": "ngpc",
232 | "name": "Neo Geo Pocket Color",
233 | "picture": "Neo Geo Pocket Color.png",
234 | "section": "handhelds"
235 | },
236 | {
237 | "id": "ngp",
238 | "name": "NeoGeo Pocket",
239 | "picture": "Neo Geo Pocket.png",
240 | "icon": "neogeopocket.png",
241 | "section": "handhelds"
242 | },
243 | {
244 | "id": "n64",
245 | "name": "Nintendo 64",
246 | "picture": "Nintendo 64.png",
247 | "icon": "n64.png",
248 | "section": "consoles"
249 | },
250 | {
251 | "id": "nds",
252 | "name": "Nintendo DS",
253 | "picture": "Nintendo DS.png",
254 | "icon": "nds.png",
255 | "section": "handhelds"
256 | },
257 | {
258 | "id": "nes",
259 | "name": "Nintendo Entertainment System",
260 | "picture": "Nintendo Entertainment System.png",
261 | "icon": "nes.png",
262 | "section": "consoles"
263 | },
264 | {
265 | "id": "fds",
266 | "name": "Nintendo Family Computer Disk System",
267 | "picture": "Nintendo Family Computer.png",
268 | "pictures": [
269 | "Nintendo Family Computer.png",
270 | "Nintendo Family Computer - Famicom Disk System.png"
271 | ],
272 | "icon": "famicom.png",
273 | "section": "consoles"
274 | },
275 | {
276 | "id": "virtualboy",
277 | "name": "Nintendo Virtual Boy",
278 | "picture": "Nintendo Virtual-Boy.png",
279 | "icon": "vb.png",
280 | "section": "others"
281 | },
282 | {
283 | "id": "o2em",
284 | "name": "Odyssey 2",
285 | "picture": "Odyssey 2.png",
286 | "icon": "odyssey2.png",
287 | "section": "consoles"
288 | },
289 | {
290 | "id": "oric",
291 | "name": "Oric 1 / Atmos",
292 | "picture": "Oric Atmos.png",
293 | "section": "computers"
294 | },
295 | {
296 | "id": "pcfx",
297 | "name": "PC-FX",
298 | "section": "consoles",
299 | "picture": "PC-FX.png",
300 | "icon": "pcfx.png"
301 | },
302 | {
303 | "id": "pce",
304 | "name": "PC Engine / TurboGrafx 16",
305 | "picture": "PC Engine.png",
306 | "pictures": [
307 | "PC Engine.png",
308 | "TurboGrafx 16.png"
309 | ],
310 | "icon": "pcengine.png",
311 | "icons": ["pcengine.png", "tg16.png"],
312 | "section": "consoles"
313 | },
314 | {
315 | "id": "pcecd",
316 | "name": "PC Engine CD",
317 | "picture": "PC Engine Super CDRom2.png",
318 | "icon": "pcenginecd.png",
319 | "icons": ["pcenginecd.png", "tgcd.png"],
320 | "section": "consoles"
321 | },
322 | {
323 | "id": "psp",
324 | "name": "PSP",
325 | "picture": "PlayStation Portable.png",
326 | "icon": "psp.png",
327 | "section": "handhelds"
328 | },
329 | {
330 | "id": "3do",
331 | "name": "Panasonic 3do",
332 | "picture": "Panasonic 3do.png",
333 | "icon": "3do.png",
334 | "section": "consoles"
335 | },
336 | {
337 | "id": "psx",
338 | "name": "PlayStation",
339 | "picture": "PlayStation.png",
340 | "icon": "psx.png",
341 | "section": "consoles"
342 | },
343 | {
344 | "id": "ps2",
345 | "name": "PlayStation 2",
346 | "picture": "PlayStation 2.png",
347 | "section": "consoles"
348 | },
349 | {
350 | "id": "saturn",
351 | "name": "Sega Saturn",
352 | "picture": "Sega Saturn.png",
353 | "icon": "saturn.png",
354 | "section": "consoles"
355 | },
356 | {
357 | "id": "samcoupe",
358 | "name": "SAM Coupé",
359 | "picture": "SAM Coupé.png",
360 | "section": "computers"
361 | },
362 | {
363 | "id": "scummvm",
364 | "name": "ScummVM",
365 | "picture": "ScummVM.png",
366 | "section": "others"
367 | },
368 | {
369 | "id": "sega32x",
370 | "name": "Sega 32x",
371 | "picture": "Sega Genesis Model2 32X.png",
372 | "icon": "32x_eu.png",
373 | "icons": ["32x_eu.png", "32x_jp.png", "32x_na.png"],
374 | "section": "consoles"
375 | },
376 | {
377 | "id": "segacd",
378 | "name": "Sega CD",
379 | "picture": "Sega CD.png",
380 | "icon": "segacd.png",
381 | "section": "consoles"
382 | },
383 | {
384 | "id": "sms",
385 | "name": "Sega Master System",
386 | "picture": "Sega Master System 2.png",
387 | "pictures": [
388 | "Sega Master System.png",
389 | "Sega Master System 2.png",
390 | "Sega Mark III.png"
391 | ],
392 | "icon": "sms.png",
393 | "section": "consoles"
394 | },
395 | {
396 | "id": "sg1000",
397 | "name": "Sega SG-1000",
398 | "picture": "Sega SG-1000.png",
399 | "icon": "sg1000.png",
400 | "section": "consoles"
401 | },
402 | {
403 | "id": "zxspectrum",
404 | "name": "Sinclair ZX Spectrum",
405 | "picture": "Sinclair ZX Spectrum.png",
406 | "section": "computers"
407 | },
408 | {
409 | "id": "zx81",
410 | "name": "Sinclair ZX81",
411 | "picture": "Sinclair ZX81.png",
412 | "section": "computers"
413 | },
414 | {
415 | "id": "snes",
416 | "name": "Super Nintendo Entertainment System",
417 | "picture": "Super Nintendo Entertainment System.png",
418 | "icon": "snes_eujap.png",
419 | "icons": ["snes_eujap.png", "snes_usa.png"],
420 | "section": "consoles"
421 | },
422 | {
423 | "id": "supergrafx",
424 | "name": "SuperGrafX",
425 | "picture": "SuperGrafX.png",
426 | "icon": "supergrafx.png",
427 | "section": "consoles"
428 | },
429 | {
430 | "id": "ti99",
431 | "name": "TI-99/4A",
432 | "picture": "TI-99:4A.png",
433 | "section": "computers"
434 | },
435 | {
436 | "id": "trs-80",
437 | "name": "TRS-80",
438 | "picture": "TRS-80 Model 3.png",
439 | "section": "computers"
440 | },
441 | {
442 | "id": "coco",
443 | "name": "Tandy Color Computer",
444 | "picture": "Tandy Color Computer.png",
445 | "section": "computers"
446 | },
447 | {
448 | "id": "vectrex",
449 | "name": "Vectrex",
450 | "picture": "Vectrex.png",
451 | "icon": "vectrex.png",
452 | "section": "consoles"
453 | },
454 | {
455 | "id": "videopac",
456 | "name": "Videopac",
457 | "picture": "Philips Videopac G7400.png",
458 | "section": "consoles"
459 | },
460 | {
461 | "id": "wii",
462 | "name": "Wii",
463 | "picture": "Wii.png",
464 | "icon": "wii.png",
465 | "section": "handhelds"
466 | },
467 | {
468 | "id": "wswan",
469 | "name": "Wonder Swan",
470 | "picture": "Bandai Wonder Swan.png",
471 | "icon": "wonderswan.png",
472 | "section": "handhelds"
473 | },
474 | {
475 | "id": "wswanc",
476 | "name": "Wonder Swan Color",
477 | "picture": "Bandai Wonder Swan Color.png",
478 | "section": "handhelds"
479 | },
480 | {
481 | "id": "zmachine",
482 | "name": "Zmachine (Infocom)",
483 | "picture": "Infocom.png",
484 | "section": "others"
485 | }
486 | ]
487 |
--------------------------------------------------------------------------------