Added easy drop-down to add consoles and games to bio

This commit is contained in:
2026-06-07 05:43:06 +02:00
parent 1f195a16de
commit c8ba511ebb
44 changed files with 2111 additions and 82 deletions
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="132px" height="15.6px" viewBox="0 0 132 15.6" style="enable-background:new 0 0 132 15.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#8C8C8C;}
.st1{fill:#CE181E;}
.st2{fill:none;}
</style>
<g>
<g>
<path d="M128.7,13.5h0.5V15h0.3v-1.5h0.5v-0.3h-1.3V13.5z M131.9,13.2h-0.4l-0.2,0.7c-0.1,0.2-0.1,0.4-0.2,0.6h0
c0-0.2-0.1-0.4-0.2-0.6l-0.2-0.7h-0.4l-0.1,1.7h0.3l0-0.7c0-0.2,0-0.5,0-0.7h0c0,0.2,0.1,0.4,0.2,0.7l0.2,0.7h0.2l0.2-0.7
c0.1-0.2,0.2-0.4,0.2-0.7h0c0,0.2,0,0.5,0,0.7l0,0.7h0.3L131.9,13.2z"/>
<path class="st0" d="M68.7,8.3h-5.9c-0.8,0-1.5,0.7-1.5,1.5V14c0,0.8,0.7,1.5,1.5,1.5h5.9c0.8,0,1.5-0.7,1.5-1.5V9.8
C70.3,9,69.6,8.3,68.7,8.3 M69,14c0,0.2-0.1,0.3-0.3,0.3h-5.8c-0.2,0-0.3-0.1-0.3-0.3V9.8c0-0.2,0.1-0.3,0.3-0.3h5.8
c0.2,0,0.3,0.1,0.3,0.3V14z"/>
<path d="M23,1.4h3.2v6h1.4v-6h3.2V0.1H23V1.4z M20.1,5.4l-5.5-5.3h-1.1v7.3h1.3V2l5.5,5.4h1.1V0.1h-1.3V5.4z M10,7.4h1.4V0.1H10
V7.4z M6.6,5.4L1.2,0.1H0v7.3h1.3V2l5.5,5.4h1.1V0.1H6.6V5.4z M32.4,7.4h7.1V6.1h-5.8V4.3h4.5V3h-4.5V1.4h5.8V0.1h-7.1V7.4z
M48.1,5.4l-5.5-5.3h-1.2v7.3h1.3V2l5.5,5.4h1.1V0.1h-1.3V5.4z M121.6,6.4c-2.9-1-4.5-1.6-4.5-2.8c0-0.8,1.1-1.6,3.5-1.6
c2.3,0,3.8,0.5,5.5,0.9l0-2.4c-1.7-0.3-2.6-0.5-5.3-0.5c-5,0-8.3,1.5-8.3,4c0,2.4,2.7,3.5,6.5,4.9c2.8,1,3.8,1.6,3.8,2.6
c0,1.1-1,2-3.6,2c-2.3,0-5.2-0.4-6.7-1v2.6c2,0.3,3.7,0.5,6.3,0.5c6.2,0,9-1.8,9-4.2C127.8,8.9,125.6,7.8,121.6,6.4z M107.2,1.2
c-1.5-0.7-4.2-1.1-6.7-1.1h-9.3v15.5h9.3c2.4,0,5.2-0.4,6.7-1.1c3.6-1.6,4.7-4.2,4.7-6.6C112,5.4,110.8,2.8,107.2,1.2z M99.3,13.4
h-3V2.2h3c4.6,0,7.4,2,7.4,5.6C106.7,11.5,103.8,13.4,99.3,13.4z M56.9,0.1h-5.4v7.3h5.4c0.9,0,1.7-0.4,2.3-1.1
c0.5-0.6,0.8-1.5,0.8-2.6c0-1-0.3-1.9-0.8-2.6C58.6,0.5,57.9,0.1,56.9,0.1z M57,6.1h-4.1V1.4H57c1.3,0,1.8,1.3,1.8,2.4
C58.7,4.9,58.2,6.1,57,6.1z M68.7,0.1h-5.9c-0.8,0-1.5,0.7-1.5,1.5v4.2c0,0.8,0.7,1.5,1.5,1.5h5.9c0.8,0,1.5-0.7,1.5-1.5V1.7
C70.3,0.8,69.6,0.1,68.7,0.1z M69,5.9c0,0.2-0.1,0.3-0.3,0.3h-5.8c-0.2,0-0.3-0.1-0.3-0.3V1.7c0-0.2,0.1-0.3,0.3-0.3h5.8
c0.2,0,0.3,0.1,0.3,0.3V5.9z"/>
<path class="st1" d="M84.8,7.1c0,0,4.3-0.7,4.3-3.4c0-2.6-4.6-3.7-9.5-3.7c-4.4,0-7.3,0.5-7.3,0.5v2.4c2-0.5,3.9-0.9,6.5-0.9
c2.8,0,4.9,0.8,4.9,2c0,1.4-2.1,2.2-6.7,2.2H75v2.2h2c4.8,0,7.5,0.7,7.5,2.5c0,1.6-2.4,2.5-5.5,2.5c-2.7,0-5.1-0.6-7-1.1v2.6
c1,0.2,3.5,0.7,8.1,0.7c5.2,0,9.7-1.7,9.7-4.6C89.8,8.5,86.6,7.1,84.8,7.1"/>
</g>
<rect class="st2" width="127.8" height="15.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#E4202E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Atari</title><path d="M0 21.653s3.154-.355 5.612-2.384c2.339-1.93 3.185-3.592 3.77-5.476.584-1.885.671-6.419.671-7.764V2.346H8.598v1.365c-.024 2.041-.2 5.918-1.135 8.444C5.203 18.242 0 18.775 0 18.775zm24 0s-3.154-.355-5.61-2.384c-2.342-1.93-3.187-3.592-3.772-5.476-.583-1.885-.671-6.419-.671-7.764V2.346H15.4l.001 1.365c.024 2.041.202 5.918 1.138 8.444 2.258 6.087 7.46 6.62 7.46 6.62zM10.659 2.348h2.685v19.306H10.66Z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#1E2A4E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Commodore</title><path d="M11.202.798C5.016.798 0 5.814 0 12s5.016 11.202 11.202 11.202c1.094 0 2.153-.157 3.154-.45v-5.335a6.27 6.27 0 1 1 0-10.839v-5.33c-1-.293-2.057-.45-3.154-.45Zm3.375 6.343v4.304h5.27L24 7.14Zm-.037 5.377v4.304h9.423l-4.156-4.304z"/></svg>

After

Width:  |  Height:  |  Size: 355 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"
id="svg2" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0.9 -0.2 1126.5 197.4"
enable-background="new 0.9 -0.2 1126.5 197.4" xml:space="preserve">
<path fill="#393C9F" d="M215.7,100.6L232.1,64l7.2,36.6H215.7 M447.8,4.9l-44.5,103.5L383.6,4.9h-38.3l-54.5,152.5L265.3,4.9h-47.8
l-72.4,164.4l10.8-68.7H84l-5.7,35.4h30.2l-3.5,18.9c-17.2,7.1-46.4,4.4-58.9-18c-7.9-14.2-15.3-45.8,18-74.9
c27.2-23.7,74.2-28,101.3-15.6c0,0,3.4-19.9,6-38.3c-52.1-18-92.6-3-119.7,13.8C14.6,44.6-3.2,79.5,1.7,126.9
c5.5,54.9,76.6,89.6,136.9,54.6c0.7-0.3,1-0.6,1.6-0.7l-5.8,12.8h40.5l25.5-56.5h44.8l9.8,56.5h66l33.6-105.5l27.6,105.5H408
l44.3-116.8l17.5,117.5h38.7L494.4,4.9H447.8z"/>
<polyline fill="#393C9F" points="508.5,194.4 616.6,194.4 623.1,154 555.5,154 560.8,120.3 619.7,120.3 626.1,79.8 566.7,79.8
571.8,46.3 639.5,46.3 645.9,5.8 538.6,5.8 508.5,194.4 "/>
<polyline fill="#393C9F" points="1043.8,65.1 1019.6,3.6 975.2,3.6 1021,103.1 1006.8,192.2 1045.7,192.2 1057.5,113.4 1127.4,3.6
1082,3.6 1043.8,65.1 "/>
<path fill="#393C9F" d="M952.4,117.5c-10.4,31.2-35.6,49.4-56.1,40.1c-20.4-9.1-28.7-42-18.2-73.4c10.5-31.5,35.6-49.3,56-40.2
C954.6,53.2,962.9,86.1,952.4,117.5 M937.3,6.6c-43-9.9-87.7,24.3-99.8,76.4c-11.9,52.2,13.1,102.5,56,112.5
c43,9.8,87.6-24.4,99.8-76.5C1005.2,66.8,980.1,16.5,937.3,6.6z"/>
<path fill="#393C9F" d="M761.3,82c-21.3,0-26.8,0-26.8,0l5.9-35c0,0,0.8,0,25.2,0C793,47,787.4,82,761.3,82 M756.6,158.2
c-21.4,0-34.2,0-34.2,0l6-37.6c0,0,8.1,0,32.4,0C791.7,120.6,790.8,158.2,756.6,158.2z M779.3,7.1c-20.9-0.3-72.6,0-72.6,0h0.4
l-30,187.3c0,0,62.1,0,87.3,0c36,0,88.5-52.9,40.6-96.1C854,47.4,805.4,7.7,779.3,7.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 401.7 46.8" enable-background="new 0 0 401.7 46.8" xml:space="preserve">
<path fill="#1F00CC" d="M141.1,0.9c5.1,0,8.2,2.2,11.2,5.8c1.1-1,2.2-1.9,3.5-2.7c3.1-2,6.6-3,10.1-3l47,0l-3.1,4.9l59-0.1l5.4,9.3
l4.8-9.2l39.2,0l9.7,7.6V5.9h73.8v35.3h-68.1l-9.9-7.7v7.7l-55.2,0v0l-3.7-6.1c-3,3.4-8.4,6.1-17.3,6.1l-49,0
c-1.6,1.6-3.3,1.8-5.3,1.7l-0.5,3.2h-18.9l0.4-2.7c-0.2,0.1-0.4,0.3-0.6,0.4c-4.4,2.7-9.4,3.6-14.1,2.5c-3.6-0.8-6.8-2.8-9.2-5.4
c-3.2,3.1-7.5,5.2-11.6,5.2l-92.9,0.1l-1.5-8.5l-3.8,8.5H25c-4.9,0.8-9.8,0.1-14.2-2.1C4.8,40.9,0.8,35.3,0.2,29
c-1.1-10.5,3-18.9,11.6-24.2c8.3-5.1,17.1-6.2,26.2-3l1.7,0.6l0.6-1.4h17.4L59,8.8l2.6-7.9L141.1,0.9"/>
<path fill="#FFFFFF" d="M246.4,29.6h-6.2V17h8.8c4.7,0,7,1.9,7,6C256,28,253.5,29.6,246.4,29.6 M289.7,11.7h-7.2l-0.2,0.4L274.2,27
l-8.7-14.9l-0.2-0.3h-7.7l0.6,1l1.5,2.4c-2.4-2.3-6-3.5-10.7-3.5h-15.5v19.4l-11.5-19l-0.2-0.3h-5.9l-0.2,0.3l-13.1,22l-0.6,1h5.1v0
h11.4v-5.2h-6.7l6.7-12.1l9.5,16.9l0.2,0.3h7.6v0h11.7c10.1,0,15.3-4,15.3-11.8c0-1.4-0.2-2.6-0.5-3.7l9.2,15.2l0.2,0.3h4.5l0.2-0.3
l12.7-22L289.7,11.7"/>
<polyline fill="#FFFFFF" points="334.4,11.7 333.7,11.7 333.7,25.6 316.2,11.9 316,11.7 311.4,11.7 311.4,31.1 299.9,12.1
299.7,11.7 293.8,11.7 293.6,12.1 280.5,34 279.9,35.1 286.9,35.1 286.9,35.1 296.5,35.1 296.5,29.9 289.8,29.9 296.5,17.8
306,34.7 306.2,35.1 313.8,35.1 313.8,35.1 317.6,35.1 317.6,21 335.1,34.9 335.3,35.1 339.9,35.1 339.9,11.7 334.4,11.7 "/>
<path fill="#FFFFFF" d="M362.1,11.7h-13.7c-4.8,0-6.4,1.5-6.4,5.9v11.6c0,4.4,1.6,5.9,6.4,5.9h13.7c4.8,0,6.4-1.5,6.4-5.9v-3.3
l-0.7,0l-5.4,0l-0.7,0v3.6h-13.1V17.2h13.1v3.2h0.7l6.1,0v-2.8C368.5,13.1,366.9,11.7,362.1,11.7"/>
<polyline fill="#FFFFFF" points="395.2,29.6 377.4,29.6 377.4,25.5 388.1,25.5 388.1,20.4 377.4,20.4 377.4,17 395.6,17 395.6,11.7
370.7,11.7 370.7,35.1 395.9,35.1 395.9,29.6 395.2,29.6 "/>
<path fill="#FFFFFF" d="M43.8,23.6l2.9-6.5l1.3,6.5H43.8 M83.7,6.7L75.9,25L72.6,6.7h-6.8L57,33.7L52.5,6.7h-8.4l-12.8,29l1.9-12.1
H20.5l-1,6.2h5.3l-0.6,3.3C21.2,34.4,16,34,13.8,30c-1.4-2.5-2.7-8.1,3.2-13.2c4.8-4.2,13.1-4.9,17.9-2.7c0,0,0.6-3.5,1.1-6.8
c-9.2-3.2-16.4-0.5-21.2,2.4c-6.5,4-9.6,10.2-8.8,18.6c1,9.7,13.5,15.8,24.2,9.7c0.1-0.1,0.2-0.1,0.2-0.1l-1,2.3h7.2l4.5-10H49
l1.7,10h11.6l5.9-18.6l3.8,18.6h4.6l8.9-20.6l0.9,20.6h6.8L91.9,6.7H83.7"/>
<polyline fill="#FFFFFF" points="93.9,40.1 113,40.1 114.1,32.9 102.1,32.9 103.1,27 113.5,27 114.6,19.8 104.1,19.8 105,13.9
117,13.9 118.1,6.7 99.2,6.7 93.9,40.1 "/>
<polyline fill="#FFFFFF" points="187.1,17.6 182.9,6.7 175,6.7 183.1,24.3 180.6,40.1 187.5,40.1 189.6,26.1 201.9,6.7 193.9,6.7
187.1,17.6 "/>
<path fill="#FFFFFF" d="M171.1,26.6c-1.8,5.5-6.3,8.7-9.9,7.1c-3.6-1.6-5-7.4-3.2-13c1.8-5.5,6.3-8.7,9.9-7.1
C171.6,15.3,173,21.1,171.1,26.6 M168.5,7c-7.6-1.7-15.5,4.3-17.6,13.5c-2.1,9.2,2.3,18.1,9.9,19.9c7.6,1.8,15.5-4.3,17.6-13.5
C180.5,17.7,176,8.8,168.5,7"/>
<path fill="#FFFFFF" d="M138.1,20.1c-3.8,0-4.8,0-4.8,0l1.1-6.2c0,0,0.1,0,4.4,0C143.7,13.9,142.8,20.1,138.1,20.1 M137.3,33.6
c-3.8,0-6,0-6,0l1.1-6.6c0,0,1.4,0,5.7,0C143.5,26.9,143.4,33.6,137.3,33.6 M141.3,6.9c-3.7-0.1-12.8,0-12.8,0h0.1L123.3,40
c0,0,10.9,0,15.4,0c6.3,0,15.6-9.3,7.2-17C154.5,14,145.9,7,141.3,6.9"/>
<path fill="#FFFFFF" d="M193.3,36.9v-1.3h1.1c0.5,0,0.9,0.1,0.9,0.6c0,0.8-0.8,0.7-1.3,0.7H193.3 M194.6,37.3c0.7,0,1.2-0.3,1.2-1.1
c0-0.3-0.2-0.7-0.4-0.8c-0.3-0.2-0.6-0.2-0.9-0.2h-1.7v3.9h0.5v-1.7h0.8l1.1,1.7h0.6L194.6,37.3 M194.2,40.6c1.9,0,3.5-1.5,3.5-3.5
c0-1.9-1.5-3.4-3.5-3.4c-1.9,0-3.5,1.5-3.5,3.4C190.7,39.1,192.2,40.6,194.2,40.6 M194.2,40c-1.6,0-2.9-1.2-2.9-2.9
c0-1.6,1.3-2.9,2.9-2.9s2.9,1.3,2.9,2.9C197,38.8,195.8,40,194.2,40"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="577.28748"
height="235.66251"
id="svg2"
xml:space="preserve"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6" /><g
transform="matrix(1.25,0,0,-1.25,0,235.6625)"
id="g10"><g
transform="scale(0.1,0.1)"
id="g12"><path
d="m 1647.08,448.309 c 0,0 -75.98,-132.571 -242.8,-116.911 -195.86,20.082 -218.94,192.114 -218.94,192.114 0,0 -26.8,126.597 93.83,236.828 122.14,102.781 249.49,87.148 249.49,87.148 0,0 85.64,-8.199 129.61,-149.699 56.58,-180.238 -11.19,-249.48 -11.19,-249.48 z m 295.66,356.722 c -24.57,74.457 -89.38,145.215 -89.38,145.215 0,0 -97.53,126.624 -288.94,169.784 -163.85,34.28 -292.7,-37.97 -292.7,-37.97 0,0 -116.89,-46.91 -204.8,-143.728 C 926.918,803.551 886.684,606.93 886.684,606.93 c 0,0 -53.594,-171.289 31.289,-306.84 L 1010.32,188.391 C 1140.65,79.6406 1279.93,68.4883 1279.93,68.4883 c 0,0 112.46,-25.3281 243.53,8.9219 114.67,23.0898 198.11,79.7108 198.11,79.7108 0,0 189.9,104.981 242.03,319.469 l -20.86,328.441"
inkscape:connector-curvature="0"
id="path14"
style="fill:#4e4ba8;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="M 3266.88,409.559 C 3056.12,282.961 2923.56,421.5 2923.56,421.5 c 0,0 -58.83,38.711 -71.5,156.379 -8.93,136.293 126.6,207.031 126.6,207.031 0,0 71.52,43.949 160.88,37.25 89.39,-6.699 117.65,-0.738 189.9,-54.371 68.49,-61.82 64.06,-107.23 64.06,-107.23 0,0 24.55,-153.438 -126.62,-251 z m 356.7,434.929 c -130.27,212.982 -359.65,245.012 -359.65,245.012 0,0 -73.01,24.59 -229.39,3.75 -293.42,-55.86 -395.47,-281.52 -395.47,-281.52 0,0 -105.02,-165.332 -63.3,-365.66 l 128.1,-253.211 C 2798.44,104.25 2837.15,104.25 2843.13,99 c 125.12,-56.5898 270.33,-23.8086 270.33,-23.8086 0,0 183.21,17.8672 350.78,148.1996 134.05,98.3 192.17,242.769 192.17,242.769 l -32.83,378.328"
inkscape:connector-curvature="0"
id="path16"
style="fill:#daac06;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 3939.42,796.832 -14.18,194.371 c 0,0 136.27,2.988 192.89,-6.699 111.74,-17.129 105.72,-48.418 105.72,-48.418 0,0 -3.69,-39.453 -88.59,-78.207 -92.36,-44.66 -195.84,-61.047 -195.84,-61.047 z M 3656.41,466.148 c 0,0 4.43,-119.128 -14.18,-148.187 -25.33,-54.352 -21.6,-107.242 -21.6,-107.242 0,0 -17.13,-160.8596 169.08,-187.6799 128.09,0.7617 143.73,107.2699 145.94,152.6799 7.46,35.742 4.47,201.832 4.47,201.832 L 4106.2,242 c 0,0 120.68,-101.281 191.4,-160.1289 73.81,-58.0703 92.33,-68.5 92.33,-68.5 0,0 15.7,-18.60938 72.22,-11.91016 45.51,-0.781252 87.19,40.21096 87.19,40.21096 0,0 52.85,38.707 66.21,106.4881 16.41,80.43 -46.09,119.899 -46.09,119.899 0,0 -230.86,186.191 -387.27,314.3 104.96,57.321 155.63,96.813 155.63,96.813 0,0 146.01,96.797 177.26,245 43.25,163.108 -79.68,227.148 -79.68,227.148 0,0 -76.72,55.12 -141.55,84.18 -163.08,79.67 -314.98,63.3 -314.98,63.3 0,0 -215.99,14.88 -356.76,-158.65 -64.76,-84.14 -24.55,-194.377 -24.55,-194.377 0,0 8.03,-17.675 26.02,-101.285 36.25,-168.168 32.83,-378.34 32.83,-378.34"
inkscape:connector-curvature="0"
id="path18"
style="fill:#00938a;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 2575.77,446.051 c -62.76,13.789 -163.11,-11.903 -163.11,-11.903 0,0 -139.98,-51.378 -161.62,-50.636 -8.16,11.886 -26.04,240.547 -20.82,332.148 2.99,161.602 -10.43,293.43 -10.43,293.43 0,0 -6.7,155.65 -90.14,247.99 -98.3,99.04 -166.81,56.6 -206.27,32.78 -94.61,-84.91 -50.66,-188.4 -50.66,-188.4 l 49.16,-139.3 c 0,0 23.85,-59.57 20.86,-157.129 L 1963.6,476.59 c 0,-43.942 35.74,-339.59 35.74,-339.59 0,0 3.71,-96.0898 96.08,-113.1992 94.57,-15.62111 187.65,31.2695 227.14,49.8594 39.46,18.6406 91.62,35.0198 91.62,35.0198 0,0 43.17,9.672 86.35,13.422 101.31,9.687 137.8,27.539 137.8,27.539 0,0 39.23,5.898 65.54,43.218 26.31,37.313 58.19,212.25 -128.1,253.192"
inkscape:connector-curvature="0"
id="path20"
style="fill:#6eb122;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 917.973,300.129 c -66.524,19.883 -142.969,6.703 -142.969,6.703 0,0 -215.996,-20.844 -328.438,2.969 C 272.289,340.332 278.227,528 278.227,528 c 0,0 -5.938,124.379 99.082,292.68 129.57,207.79 272.558,245.03 272.558,245.03 0,0 35.762,13.38 69.278,-4.47 20.839,-120.642 137.753,-119.9 137.753,-119.9 0,0 168.342,-11.192 160.122,192.85 -19.36,167.6 -180.981,190.69 -248.754,206.31 -203.301,36.5 -335.118,-72.23 -335.118,-72.23 0,0 -136.289,-73.75 -299.375,-330.661 C 22.7773,778.238 9.39844,653.121 9.39844,653.121 9.39844,653.121 -4,623.309 1.19531,490.738 -1.75391,387.238 36.957,312.039 36.957,312.039 c 0,0 58.8477,-178.039 245.02,-251.0078 113.183,-46.9218 278.535,-46.1718 278.535,-46.1718 0,0 160.117,2.2226 282.988,8.9414 67.031,-5.211 99.062,29.789 99.062,29.789 0,0 75.198,49.1402 67.758,134.8012 0,0 -14.574,88.461 -92.347,111.738"
inkscape:connector-curvature="0"
id="path22"
style="fill:#b80a41;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 756.059,1629.89 -17.969,92.74 -41.465,-92.74 59.434,0 z m 651.051,-235.74 -98.2,0 -13.12,295.76 -127.68,-295.76 -65.49,0 -54.55,267.39 -85.136,-267.39 -166.973,0 -25.117,142.95 -113.496,0 -64.395,-142.95 -102.598,0 210.645,478.01 121.133,0 64.394,-386.35 125.513,386.35 97.11,0 48.02,-261.93 111.35,261.93 117.87,0 20.72,-478.01"
inkscape:connector-curvature="0"
id="path24"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 1688.7,1394.15 16.38,102.58 -171.36,0 13.59,85.14 149.02,0 16.39,102.58 -150.63,0 13.11,85.13 171.31,0 16.39,102.58 -271.74,0 -76.41,-478.01 273.95,0"
inkscape:connector-curvature="0"
id="path26"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 2964.52,1872.16 -114.61,0 -97.13,-156.05 -61.1,156.05 -112.42,0 115.69,-252.09 -35.65,-225.92 98.24,0 30.16,199.71 176.82,278.3"
inkscape:connector-curvature="0"
id="path28"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 2381.29,1485.17 c 51.96,-23.24 115.59,22.36 142.09,101.85 26.47,79.52 5.86,162.84 -46.09,186.08 -51.97,23.26 -115.55,-22.35 -142.07,-101.88 -26.5,-79.53 -5.86,-162.79 46.07,-186.05 z m -148.65,189.1 c 30.51,132.17 143.46,218.91 252.29,193.75 108.86,-25.16 172.38,-152.66 141.89,-284.82 -30.49,-132.15 -143.46,-218.89 -252.28,-193.74 -108.87,25.16 -172.39,152.68 -141.9,284.81"
inkscape:connector-curvature="0"
id="path30"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="M 280.824,1829.58 C 187.23,1771.67 142.211,1683.37 154.203,1563.31 168.129,1424.05 348.48,1336.3 501.273,1424.7 c 13.36,7.73 8.145,6.5 13.106,10.92 l 30.566,194.27 -182.285,0 -13.965,-89.49 76.172,0 -8.75,-48.04 c -43.633,-17.45 -117.851,-10.9 -149.492,45.85 -20.039,35.94 -38.613,116.16 45.859,189.91 68.711,60.02 187.696,70.92 256.465,39.27 0,0 8.731,50.2 15.254,97.15 -132.051,45.82 -234.648,7.62 -303.379,-34.96"
inkscape:connector-curvature="0"
id="path32"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 1912.39,1869.5 -75.8,-474.41 c 0,0 157.05,0 220.93,0 90.98,0 224.03,134.1 102.91,243.69 124.54,128.89 1.06,229.61 -65,230.72 -53.06,0.88 -184.1,0 -184.1,0 l 1.06,0 z m 54.16,-287.03 c 0,0 20.58,0 82.3,0 78.01,0 75.84,-95.31 -10.82,-95.31 -54.12,0 -86.64,0 -86.64,0 l 15.16,95.31 z m 30.31,186.29 c 0,0 2.17,0 63.93,0 69.31,0 55.25,-88.81 -10.8,-88.81 -54.18,0 -68.27,0 -68.27,0 l 15.14,88.81"
inkscape:connector-curvature="0"
id="path34"
style="fill:#2e3192;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 2846.06,1350.3 -18.54,0 15.53,66.04 -26.78,0 3.34,14.2 72.09,0 -3.34,-14.2 -26.77,0 -15.53,-66.04"
inkscape:connector-curvature="0"
id="path36"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none" /><path
d="m 2968.89,1350.3 -17.28,0 15.76,67.06 -0.24,0 -32.28,-67.06 -18.17,0 -0.43,67.06 -0.23,0 -15.76,-67.06 -17.29,0 18.85,80.24 27.01,0 1.21,-63.26 0.26,0 30.64,63.26 26.82,0 -18.87,-80.24"
inkscape:connector-curvature="0"
id="path38"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g></g></svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 660 B

+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
version="1.0"
width="750.54327"
height="115.8307"
id="svg101576">
<defs
id="defs101578" />
<g
transform="translate(-35.53469,-263.1837)"
id="layer1">
<g
transform="translate(7.088693,7.080902)"
id="g101650">
<path
d="M 487.38946,354.59939 C 487.38946,355.67865 486.50444,356.64997 485.41436,356.64997 L 447.61769,356.64997 C 446.51682,356.64997 445.6318,355.67865 445.6318,354.59939 L 445.6318,327.29336 C 445.6318,326.2141 446.51682,325.35065 447.61769,325.35065 L 485.41436,325.35065 C 486.50444,325.35065 487.38946,326.2141 487.38946,327.29336 L 487.38946,354.59939 z M 485.74894,317.1481 L 447.27232,317.1481 C 441.70319,317.1481 437.15939,321.6811 437.15939,327.29336 L 437.15939,354.70725 C 437.15939,360.21165 441.70319,364.8526 447.27232,364.8526 L 485.74894,364.8526 C 491.32886,364.8526 495.86187,360.21165 495.86187,354.70725 L 495.86187,327.29336 C 495.86187,321.6811 491.32886,317.1481 485.74894,317.1481"
style="fill:#929497;fill-rule:nonzero;stroke:none"
id="path100608" />
<path
d="M 44.363266,276.13513 C 44.363266,276.13513 44.363266,310.88819 44.363266,311.42782 C 43.899172,311.42782 36.009577,311.42782 35.534688,311.42782 C 35.534688,310.88819 35.534688,264.26295 35.534688,263.72332 C 36.031156,263.72332 42.852263,263.72332 43.078909,263.72332 L 78.835728,298.47639 C 78.835728,298.47639 78.835728,264.26295 78.835728,263.72332 C 79.321402,263.72332 85.90506,263.72332 85.90506,263.72332 C 85.90506,263.72332 87.157035,263.72332 87.545578,263.72332 C 87.545578,264.26295 87.545578,310.88819 87.545578,311.42782 C 87.070698,311.42782 80.886377,311.42782 80.648928,311.42782 L 44.363266,276.13513"
style="fill:#221f1f;fill-rule:nonzero;stroke:none"
id="path100618" />
<path
d="M 132.87569,276.13513 C 132.87569,276.13513 132.87569,310.88819 132.87569,311.42782 C 132.4008,311.42782 124.53278,311.42782 124.04711,311.42782 C 124.04711,310.88819 124.04711,264.26295 124.04711,263.72332 C 124.54358,263.72332 131.35389,263.72332 131.58053,263.72332 L 167.33735,298.47639 C 167.33735,298.47639 167.33735,264.26295 167.33735,263.72332 C 167.82304,263.72332 174.41748,263.72332 174.41748,263.72332 C 174.41748,263.72332 175.68025,263.72332 176.05801,263.72332 C 176.05801,264.26295 176.05801,310.88819 176.05801,311.42782 C 175.57232,311.42782 169.3988,311.42782 169.16135,311.42782 L 132.87569,276.13513"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100622" />
<path
d="M 316.32229,276.13513 C 316.32229,276.13513 316.32229,310.88819 316.32229,311.42782 C 315.83661,311.42782 307.97939,311.42782 307.49371,311.42782 C 307.49371,310.88819 307.49371,264.26295 307.49371,263.72332 C 307.99019,263.72332 314.81128,263.72332 315.02714,263.72332 L 350.78396,298.47639 C 350.78396,298.47639 350.78396,264.26295 350.78396,263.72332 C 351.26964,263.72332 357.86409,263.72332 357.86409,263.72332 C 357.86409,263.72332 359.12686,263.72332 359.50461,263.72332 C 359.50461,264.26295 359.50461,310.88819 359.50461,311.42782 C 359.01892,311.42782 352.8454,311.42782 352.61875,311.42782 L 316.32229,276.13513"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100626" />
<path
d="M 108.57011,263.72332 C 108.57011,263.72332 109.82209,263.72332 110.19984,263.72332 C 110.19984,264.26295 110.19984,310.88819 110.19984,311.42782 C 109.73574,311.42782 101.84615,311.42782 101.37126,311.42782 C 101.37126,310.88819 101.37126,264.26295 101.37126,263.72332 C 101.85694,263.72332 108.57011,263.72332 108.57011,263.72332"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100630" />
<path
d="M 236.18514,263.72332 C 236.18514,263.72332 237.41553,263.72332 237.80407,263.72332 C 237.80407,264.15501 237.80407,271.49418 237.80407,271.92587 C 237.28602,271.92587 216.52052,271.92587 216.52052,271.92587 C 216.52052,271.92587 216.52052,310.88819 216.52052,311.42782 C 216.04563,311.42782 207.82145,311.42782 207.34656,311.42782 C 207.34656,310.88819 207.34656,271.92587 207.34656,271.92587 C 207.34656,271.92587 186.58106,271.92587 186.063,271.92587 C 186.063,271.49418 186.063,264.15501 186.063,263.72332 C 186.59185,263.72332 236.18514,263.72332 236.18514,263.72332"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100634" />
<path
d="M 292.73985,263.72332 C 292.73985,263.72332 294.0134,263.72332 294.39115,263.72332 C 294.39115,264.15501 294.39115,271.49418 294.39115,271.92587 C 293.88389,271.92587 256.55131,271.92587 256.55131,271.92587 L 256.55131,282.61084 C 256.55131,282.61084 285.32513,282.61084 285.82161,282.61084 C 285.82161,283.15047 285.82161,290.27376 285.82161,290.81347 C 285.32513,290.81347 256.55131,290.81347 256.55131,290.81347 L 256.55131,303.22527 C 256.55131,303.22527 293.88389,303.22527 294.39115,303.22527 C 294.39115,303.65696 294.39115,310.88819 294.39115,311.42782 C 293.88389,311.42782 248.31635,311.42782 247.80908,311.42782 C 247.80908,310.88819 247.80908,264.26295 247.80908,263.72332 C 248.31635,263.72332 292.73985,263.72332 292.73985,263.72332"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100638" />
<path
d="M 408.59063,303.22527 L 381.47891,303.22527 L 381.47891,271.92587 L 408.59063,271.92587 C 417.07383,271.92587 420.08505,280.3443 420.08505,287.57553 C 420.08505,294.6989 417.07383,303.22527 408.59063,303.22527 z M 423.07468,270.73867 C 419.62096,266.09772 414.51592,263.72332 408.3424,263.72332 C 408.3424,263.72332 373.48139,263.72332 372.96333,263.72332 C 372.96333,264.26295 372.96333,310.88819 372.96333,311.42782 C 373.48139,311.42782 408.3424,311.42782 408.3424,311.42782 C 414.51592,311.42782 419.62096,308.94547 423.07468,304.41247 C 426.31254,300.20321 428.02861,294.37507 428.02861,287.57553 C 428.02861,280.77607 426.31254,274.94793 423.07468,270.73867"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100642" />
<path
d="M 487.38946,301.28256 C 487.38946,302.25387 486.50444,303.22527 485.41436,303.22527 L 447.61769,303.22527 C 446.51682,303.22527 445.6318,302.25387 445.6318,301.28256 L 445.6318,273.86858 C 445.6318,272.78933 446.51682,271.92587 447.61769,271.92587 L 485.41436,271.92587 C 486.50444,271.92587 487.38946,272.78933 487.38946,273.86858 L 487.38946,301.28256 z M 485.74894,263.72332 L 447.27232,263.72332 C 441.70319,263.72332 437.15939,268.25633 437.15939,273.86858 L 437.15939,301.28256 C 437.15939,306.89482 441.70319,311.42782 447.27232,311.42782 L 485.74894,311.42782 C 491.32886,311.42782 495.86187,306.89482 495.86187,301.28256 L 495.86187,273.86858 C 495.86187,268.25633 491.32886,263.72332 485.74894,263.72332"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100646" />
<path
d="M 646.02323,344.66994 C 655.17559,348.33948 674.50562,351.25359 689.59407,351.25359 C 706.10718,351.25359 712.93911,345.64125 712.93911,338.51796 C 712.93911,332.04225 706.59284,328.37262 688.36369,321.6811 C 663.98256,312.72296 646.09878,305.59967 646.09878,289.51824 C 646.09878,273.00521 667.61981,263.1837 700.41936,263.1837 C 718.03333,263.1837 724.07736,264.26295 735.25878,266.31361 L 735.34513,282.07121 C 724.35792,280.02056 714.62276,276.45887 699.50193,276.45887 C 683.29106,276.45887 676.38357,281.63944 676.38357,286.92796 C 676.38357,294.59096 687.02537,298.2605 705.68629,304.9521 C 731.66471,314.23399 746.11638,321.35727 746.11638,337.00693 C 746.11638,353.19631 728.01676,364.8526 687.19808,364.8526 C 670.43669,364.8526 658.87755,363.77334 646.02323,361.61474 L 646.02323,344.66994"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100656" />
<path
d="M 559.14054,277.10644 L 539.71335,277.10644 L 539.71335,350.71396 L 559.14054,350.71396 C 588.96126,350.71396 607.77326,337.87039 607.77326,314.0181 C 607.77326,290.1659 588.96126,277.10644 559.14054,277.10644 z M 611.28094,357.51342 C 601.67528,361.83062 583.50007,364.63671 567.61295,364.63671 L 506.89219,364.63671 L 506.89219,263.3995 L 567.61295,263.3995 C 583.50007,263.3995 601.67528,266.20567 611.30253,270.52278 C 634.69071,281.09981 642.31048,298.0447 642.31048,314.0181 C 642.31048,329.99159 634.75547,346.93639 611.28094,357.51342"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100660" />
<path
d="M 752.21438,349.52668 L 748.31815,349.52668 L 748.31815,348.12359 L 757.80513,348.12359 L 757.80513,349.52668 L 753.88732,349.52668 L 753.88732,360.85922 L 752.21438,360.85922 L 752.21438,349.52668"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100678" />
<path
d="M 769.96866,355.24697 C 769.87152,353.52005 769.76366,351.36154 769.76366,349.74256 L 769.72048,349.74256 C 769.26712,351.25359 768.74908,352.87248 768.10151,354.59939 L 765.84576,360.85922 L 764.59379,360.85922 L 762.50005,354.70725 C 761.89557,352.87248 761.39911,351.25359 761.04299,349.74256 L 760.99981,349.74256 C 760.96743,351.36154 760.87028,353.52005 760.76234,355.35482 L 760.41701,360.85922 L 758.84121,360.85922 L 759.73705,348.12359 L 761.84168,348.12359 L 764.02178,354.27557 C 764.55062,355.89454 764.9716,357.29754 765.31693,358.59277 L 765.34931,358.59277 C 765.69473,357.29754 766.148,355.89454 766.72001,354.27557 L 768.99735,348.12359 L 771.10189,348.12359 L 771.90058,360.85922 L 770.2709,360.85922 L 769.96866,355.24697"
style="fill:#221e1f;fill-rule:nonzero;stroke:none;stroke-width:0.05;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none"
id="path100682" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+221
View File
@@ -0,0 +1,221 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="139.14099"
height="51.375095"
version="1.1"
id="svg17"
sodipodi:docname="NES_logo.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs17" />
<sodipodi:namedview
id="namedview17"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.0626551"
inkscape:cx="69.570525"
inkscape:cy="25.695037"
inkscape:window-width="1280"
inkscape:window-height="730"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg17" />
<g
id="g48"
transform="matrix(0.3698585,0,0,0.3698585,-20.42467,-36.265977)">
<path
d="m 126.74968,98.053798 c -24.69067,-0.05733 -41.277343,17.695942 -41.277343,38.974612 0,21.27466 16.547883,38.82651 41.310543,38.83984 h 233.23047 c 24.76667,-0.0133 41.32032,-17.56518 41.32032,-38.83984 0,-21.28134 -16.58916,-39.033279 -41.28516,-38.974612 z m 233.18945,9.570312 c 19.60934,0.04 31.64063,13.14285 31.64063,29.35352 0,16.208 -11.96063,29.42027 -31.64063,29.34961 H 126.85906 c -19.68267,0.0707 -31.638676,-13.14161 -31.638676,-29.34961 0,-16.20934 12.033336,-29.31023 31.638676,-29.35157 z"
style="display:inline;fill:#ff0000"
id="path76" />
<path
d="m 126.85906,107.62611 c -19.60534,0.0413 -31.638676,13.14223 -31.638676,29.35157 0,16.208 11.956006,29.42027 31.638676,29.34961 h 233.08007 c 19.68,0.0707 31.64063,-13.14161 31.64063,-29.34961 0,-16.21067 -12.03125,-29.35368 -31.64063,-29.35352 z"
style="display:inline;fill:#ffffff"
id="path50"
sodipodi:nodetypes="csccssc" />
<path
d="m 161.60124,118.57138 v 7.48632 h 11.03907 v -7.48632 z"
style="display:inline;fill:#ff0000"
id="path51" />
<path
d="m 116.80437,118.58114 v 36.63281 h 11.34375 v -25.54492 l 15.86523,25.54492 h 11.3086 v -36.63281 h -11.32618 l 0.008,25.54297 -15.78711,-25.54297 z"
style="display:inline;fill:#ff0000"
id="path54" />
<path
d="m 324.1071,118.58114 v 13.47852 c -1.76133,-0.99334 -3.61623,-1.94316 -6.24023,-2.17383 -7.93333,-0.69467 -13.9961,6.32324 -13.9961,12.75391 0,8.47466 6.54325,11.61155 7.53125,12.07421 3.70934,1.72534 8.46369,1.73313 12.67969,-0.9082 v 1.40625 h 10.89844 v -36.63086 z m -4.23633,15.24414 c 1.71067,0 4.3086,0.96052 4.3086,4.72852 0,1.30114 0.002,4.35261 0.002,4.35351 0,0 0.006,2.75898 0.006,4.32032 0,3.764 -2.60431,4.75195 -4.32031,4.75195 -1.748,0 -4.31055,-0.98795 -4.31055,-4.75195 v -4.33204 -4.34179 c 0,-3.768 2.56112,-4.72852 4.31445,-4.72852 z"
style="display:inline;fill:#ff0000"
id="path55" />
<path
d="m 372.34148,118.87216 c -3.11867,0 -5.64649,2.51805 -5.64649,5.63672 0,3.10666 2.52782,5.63867 5.64649,5.63867 3.108,0 5.63672,-2.53201 5.63672,-5.63867 0,-3.11867 -2.52872,-5.63672 -5.63672,-5.63672 z m 0,1.07226 c 2.51733,0 4.55078,2.04446 4.55078,4.56446 0,2.516 -2.03345,4.55859 -4.55078,4.55859 -2.52134,0 -4.5586,-2.04259 -4.5586,-4.55859 0,-2.52 2.03726,-4.56446 4.5586,-4.56446 z"
style="display:inline;fill:#ff0000"
id="path57" />
<path
d="m 370.03093,121.3038 v 6.24609 h 1.51953 v -2.55078 h 0.74414 l 1.18946,2.55078 h 1.68945 l -1.39453,-2.78125 c 0.856,-0.22266 1.36523,-0.84139 1.36523,-1.66406 0,-1.196 -0.88797,-1.80078 -2.66797,-1.80078 z m 1.51953,0.96094 h 0.66602 c 0.91066,0 1.36523,0.28302 1.36523,0.92968 0,0.62667 -0.42177,0.88672 -1.28711,0.88672 h -0.74414 z"
style="display:inline;fill:#ff0000"
id="path59"
sodipodi:nodetypes="ccccccccssccssscc" />
<path
d="m 216.89226,122.35067 0.008,4.9336 h -5.99023 v 3.61523 h 5.98437 l -0.002,24.31445 h 11.02734 l -0.002,-24.31445 h 5.95898 v -3.62695 h -5.95898 v -4.92188 z"
style="display:inline;fill:#ff0000"
id="path61" />
<path
d="m 354.66179,129.19052 c -8.89067,0 -16.09766,6.1204 -16.09766,13.67773 0,7.55067 7.20699,13.67774 16.09766,13.67774 8.896,0 16.10156,-6.12707 16.10156,-13.67774 0,-7.55733 -7.20556,-13.67773 -16.10156,-13.67773 z m -0.0859,3.01367 c 2.19467,0 4.49805,1.59294 4.49805,5.46094 0,1.46933 -2.8e-4,4.19835 0.0117,5.17968 0,0.0627 -0.004,3.68525 -0.004,5.14258 0,3.88533 -2.29519,5.49414 -4.50586,5.49414 -2.208,0 -4.50781,-1.60881 -4.50781,-5.49414 v -5.24609 c 0,0 0.008,-3.60684 0.008,-5.07617 0,-3.868 2.304,-5.46094 4.5,-5.46094 z"
style="display:inline;fill:#ff0000"
id="path62" />
<path
d="m 248.64421,129.32528 c -8.95466,0 -16.21679,6.12183 -16.21679,13.67383 0,7.556 7.26213,13.67969 16.21679,13.67969 7.43867,0 13.70696,-4.24472 15.62696,-9.99805 l -10.98047,0.0117 c 0,0 0.008,0.10866 0.008,1.47266 0,4.46133 -2.92936,5.44726 -4.55469,5.44726 -1.624,0 -4.61328,-0.98593 -4.61328,-5.44726 0,-1.33334 0.0137,-5.02344 0.0137,-5.02344 0,0 20.75391,0.006 20.75391,-0.006 0,-7.55467 -7.30057,-13.81055 -16.25391,-13.81055 z m 0.0996,3.01367 c 1.43467,0.007 3.00967,0.72461 3.875,2.22461 0.7,1.22 0.73351,2.64597 0.71485,4.7793 h -9.19532 c -0.02,-2.13333 0.0258,-3.5593 0.72852,-4.7793 0.86666,-1.5 2.43962,-2.21794 3.87695,-2.22461 z"
style="display:inline;fill:#ff0000"
id="path63" />
<path
d="m 198.42546,129.72958 c -3.63733,0.10133 -6.66472,1.66049 -8.77539,3.67383 -0.012,-0.604 0,-2.5586 0,-2.5586 l -10.94336,0.006 0.01,24.35742 h 10.93359 c 0,0 -0.0117,-14.96204 -0.0117,-15.99804 0,-2.12534 2.23118,-4.48828 5.22852,-4.48828 2.99466,0 5.0332,2.36294 5.0332,4.48828 v 15.99804 h 10.94531 c 0,0 -0.004,-11.54128 0,-13.25195 0.0653,-9.64533 -8.29458,-12.34923 -12.41992,-12.22656 z"
style="display:inline;fill:#ff0000"
id="path64" />
<path
d="m 287.94499,129.72958 c -3.63733,0.10133 -6.67272,1.66049 -8.77539,3.67383 -0.012,-0.604 0,-2.5586 0,-2.5586 l -10.93359,0.006 -0.008,24.35742 h 10.9414 c 0,0 -0.0137,-14.96204 -0.0137,-15.99804 0,-2.12534 2.23252,-4.48828 5.22852,-4.48828 3.004,0 5.02929,2.36294 5.02929,4.48828 v 15.99804 h 10.94532 c 0,0 -0.005,-11.54128 0,-13.25195 0.068,-9.64533 -8.29007,-12.34923 -12.41407,-12.22656 z"
style="display:inline;fill:#ff0000"
id="path65" />
<path
d="m 161.62663,130.85067 v 24.36328 h 11.01368 v -24.36328 z"
style="display:inline;fill:#ff0000"
id="path66" />
</g>
<g
id="g76"
transform="matrix(0.41782174,0,0,0.41782174,-32.129259,-54.029466)">
<path
id="path9"
d="m 0,0 h -14.456 v -14.159 h 4.488 v 9.469 c 0,0.599 0.499,1.096 1.099,1.096 h 7.77 C -0.496,-3.594 0,-4.091 0,-4.69 v -9.469 H 4.488 V -4.69 C 4.488,-4.69 5.088,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,121.31373,207.0764)"
clip-path="none" />
<path
id="path11"
d="m 0,0 h -14.456 v -14.159 h 4.588 v 9.469 c 0,0.599 0.5,1.096 0.998,1.096 h 7.774 C -0.499,-3.594 0,-4.091 0,-4.69 v -9.469 H 4.489 V -4.69 C 4.489,-4.69 5.085,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,287.87653,207.0764)"
clip-path="none" />
<path
id="path13-3"
d="m 0,0 h -14.46 v -14.159 h 4.492 v 9.469 c 0,0.599 0.49,1.096 1.09,1.096 h 7.781 C -0.499,-3.594 0,-4.091 0,-4.69 v -9.469 H 4.487 V -4.69 C 4.487,-4.69 5.082,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,378.67693,207.0764)"
clip-path="none" />
<path
id="path15"
d="m 0,0 h 4.982 c 0.499,0 1.099,-0.499 1.099,-1.096 v -9.47 h 4.785 v 9.47 c 0,0.597 0.499,1.096 1.095,1.096 h 4.991 V 3.594 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,129.95907,211.86707)"
clip-path="none" />
<path
id="path17-6"
d="m 0,0 v -3.594 h 4.985 c 0.601,0 1.092,-0.497 1.092,-1.096 v -9.469 h 4.993 v 9.469 c 0,0.599 0.395,1.096 0.994,1.096 h 4.985 V 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,387.18027,207.0764)"
clip-path="none" />
<path
id="path19"
d="m 0,0 h 4.985 c 0.597,0 1.098,-0.499 1.098,-1.096 v -9.47 h 4.985 v 9.47 c 0,0.597 0.499,1.096 1.095,1.096 h 4.984 V 3.594 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,205.8644,211.86707)"
clip-path="none" />
<path
id="path21"
d="m 222.02,369.117 h 4.785 v 14.159 h -4.785 z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,-37.795333,718.11067)"
clip-path="none" />
<path
id="path23"
d="m 0,0 h -8.072 v 1.693 0.8 c 0,0.394 0.6,0.893 0.901,0.893 h 6.075 C -0.399,3.386 0,2.887 0,2.493 v -0.8 z m -0.098,6.978 h -7.873 c -5.09,0 -4.689,-4.69 -4.689,-4.69 V 1.693 -7.181 h 4.588 v 3.587 H 0 v -3.587 h 4.588 v 8.874 0.595 c 0,0 0.601,4.69 -4.686,4.69"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,247.99627,216.3804)"
clip-path="none" />
<path
id="path25"
d="m 0,0 c 0,-0.704 -0.3,-1.097 -0.897,-1.097 h -6.582 c -0.3,0 -0.701,0.393 -0.701,1.097 v 0.695 0.7 c 0,0.598 0.401,1.093 0.998,1.093 h 6.285 C -0.3,2.488 0,2.186 0,1.395 v -0.7 z m -0.102,5.88 h -12.76 V 0.695 -8.278 h 4.682 v 2.694 c 0,0.596 0.401,0.893 0.998,0.893 h 6.285 C -0.601,-4.691 0,-5.192 0,-5.584 v -2.694 h 4.486 v 2.197 c 0,0 0.397,1.991 -1.694,3.386 1.396,0.997 1.889,2.492 1.99,3.39 0.101,0.399 0.101,0.7 0.101,0.7 0,0 0,4.485 -4.985,4.485"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,197.3576,214.91693)"
clip-path="none" />
<path
id="path27"
d="m 0,0 c 0,0 -0.499,-4.687 4.683,-4.687 h 11.169 v 3.588 H 5.48 c -0.696,0 -1.292,0.6 -1.292,1.196 0,0.701 0.495,1.103 1.292,1.103 h 9.775 V 3.691 H 5.287 c -0.604,0 -1.099,0.496 -1.099,1.092 0,0.598 0.495,1.096 1.099,1.096 H 15.852 V 9.473 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,76.9144,219.706)"
clip-path="none" />
<path
id="path29"
d="m 0,0 c 0,0 -0.698,-4.687 4.488,-4.687 h 11.169 v 3.588 H 5.291 c -0.707,0 -1.304,0.6 -1.304,1.196 0,0.701 0.597,1.103 1.304,1.103 h 9.762 V 3.691 H 5.291 c -0.707,0 -1.304,0.496 -1.304,1.092 0,0.598 0.597,1.096 1.304,1.096 H 15.657 V 9.473 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,155.34533,219.706)"
clip-path="none" />
<path
id="path31"
d="m 0,0 c 0,0 -0.5,-4.687 4.586,-4.687 h 11.265 v 3.588 H 5.379 c -0.596,0 -1.196,0.6 -1.196,1.196 0,0.701 0.403,1.103 1.101,1.103 h 9.765 V 3.691 H 5.284 c -0.698,0 -1.101,0.496 -1.101,1.092 0,0.598 0.403,1.096 1.101,1.096 H 15.851 V 9.473 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,334.5408,219.706)"
clip-path="none" />
<path
id="path33"
d="m 0,0 h -20.042 v -14.157 h 4.19 v 9.475 c 0,0.593 0.498,1.096 1.099,1.096 h 3.387 c 0.602,0 1.093,-0.503 1.093,-1.096 v -9.475 h 4.692 v 9.475 c 0,0.593 0.392,1.096 1.095,1.096 h 3.591 C -0.498,-3.586 0,-4.089 0,-4.682 l 0.203,-9.475 h 4.489 v 9.475 C 4.692,-4.682 5.284,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,324.60853,207.106)"
clip-path="none" />
<path
id="path35"
d="m 0,0 h 5.085 c 0.396,0 0.896,-0.5 0.896,-1.099 v -9.47 h 4.986 l 0.198,9.47 C 11.165,-0.5 11.47,0 12.063,0 h 4.984 V 3.583 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,239.62253,238.17947)"
clip-path="none" />
<path
id="path37"
d="m 0,0 h -20.042 v -14.151 h 4.191 v 9.469 c 0,0.598 0.495,1.099 1.099,1.099 h 3.387 c 0.596,0 1.097,-0.501 1.097,-1.099 v -9.469 h 4.684 v 9.469 c 0,0.598 0.402,1.099 1.097,1.099 h 3.586 c 0.4,0 0.901,-0.501 0.901,-1.099 l 0.199,-9.469 h 4.484 v 9.469 C 4.683,-4.682 5.288,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,316.4612,233.40187)"
clip-path="none" />
<path
id="path39"
d="m 0,0 h -4.287 v -5.277 c 0,-0.605 -0.599,-1.099 -1.098,-1.099 h -6.381 c -0.593,0 -1.092,0.494 -1.092,1.099 V 0 h -4.495 v -5.182 c 0,0 -0.492,-4.783 4.694,-4.783 h 1.494 v -4.186 h 4.986 v 4.186 h 1.691 C 0.695,-9.965 0,-5.277 0,-5.277 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,212.24467,233.40187)"
clip-path="none" />
<path
id="path41"
d="m 0,0 c 0,0 -0.702,-4.683 4.488,-4.683 h 11.169 v 3.589 H 5.286 c -0.698,0 -1.294,0.493 -1.294,1.197 0,0.697 0.596,0.994 1.294,0.994 h 9.77 v 2.49 h -9.77 c -0.698,0 -1.294,0.604 -1.294,1.2 0,0.597 0.596,1.098 1.294,1.098 H 15.657 V 9.469 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,264.87827,246.02653)"
clip-path="none" />
<path
id="path43"
d="m 0,0 h -6.683 c -0.796,0 -1.298,0.301 -1.298,0.997 0,0.602 0.502,1.198 1.099,1.198 H 4.483 V 5.778 H -7.981 c -2.296,0 -4.289,-1.891 -4.289,-4.083 0,-3.092 1.993,-4.288 4.69,-4.288 h 6.674 c 0.698,0 1.4,-0.297 1.4,-0.994 0,-0.6 -0.494,-1.197 -1.103,-1.197 H -12.27 v -3.59 H 0.494 c 2.094,0 4.283,1.7 4.283,4.084 C 4.777,-1.397 2.483,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,231.3924,241.10627)"
clip-path="none" />
<path
id="path45"
d="m 0,0 h -6.484 c -0.79,0 -1.394,0.301 -1.394,0.997 0,0.602 0.604,1.198 1.197,1.198 H 4.683 v 3.583 h -12.46 c -2.495,0 -4.488,-1.891 -4.488,-4.083 0,-3.092 2.193,-4.288 4.884,-4.288 h 6.484 c 0.799,0 1.393,-0.297 1.393,-0.994 0,-0.6 -0.594,-1.197 -1.197,-1.197 h -11.564 v -3.59 H 0.496 c 2.296,0 4.387,1.7 4.387,4.084 C 4.883,-1.397 2.691,0 0,0"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,180.73973,241.10627)"
clip-path="none" />
<path
id="path47"
d="m 0,0 v -0.58 h -1.652 v -4.333 h -0.68 V -0.58 H -3.987 V 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,330.3048,233.40187)"
clip-path="none" />
<path
id="path49"
d="M 0,0 H 0.95 L 2.368,-4.157 3.769,0 H 4.717 V -4.913 H 4.081 v 2.901 c 0,0.099 0.003,0.266 0.009,0.5 0.002,0.232 0.002,0.478 0.002,0.746 L 2.691,-4.913 H 2.027 l -1.415,4.147 v -0.153 c 0,-0.121 0.002,-0.302 0.012,-0.555 C 0.633,-1.717 0.638,-1.9 0.638,-2.012 V -4.913 H 0 Z"
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none"
transform="matrix(1.3333333,0,0,-1.3333333,331.10053,233.40187)"
clip-path="none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="88" width="88" xmlns:v="https://vecta.io/nano"><path d="M0 12.402l35.687-4.86.016 34.423-35.67.203zm35.67 33.529l.028 34.453L.028 75.48.026 45.7zm4.326-39.025L87.314 0v41.527l-47.318.376zm47.329 39.349l-.011 41.34-47.318-6.678-.066-34.739z" fill="#00adef"/></svg>

After

Width:  |  Height:  |  Size: 311 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#0070D1" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation</title><path d="M8.984 2.596v17.547l3.915 1.261V6.688c0-.69.304-1.151.794-.991.636.18.76.814.76 1.505v5.875c2.441 1.193 4.362-.002 4.362-3.152 0-3.237-1.126-4.675-4.438-5.827-1.307-.448-3.728-1.186-5.39-1.502zm4.656 16.241l6.296-2.275c.715-.258.826-.625.246-.818-.586-.192-1.637-.139-2.357.123l-4.205 1.5V14.98l.24-.085s1.201-.42 2.913-.615c1.696-.18 3.785.03 5.437.661 1.848.601 2.04 1.472 1.576 2.072-.465.6-1.622 1.036-1.622 1.036l-8.544 3.107V18.86zM1.807 18.6c-1.9-.545-2.214-1.668-1.352-2.32.801-.586 2.16-1.052 2.16-1.052l5.615-2.013v2.313L4.205 17c-.705.271-.825.632-.239.826.586.195 1.637.15 2.343-.12L8.247 17v2.074c-.12.03-.256.044-.39.073-1.939.331-3.996.196-6.038-.479z"/></svg>

After

Width:  |  Height:  |  Size: 796 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation 2</title><path d="M7.46 13.779v.292h4.142v-3.85h3.796V9.93h-4.115v3.85zm16.248-3.558v1.62h-7.195v2.23H24v-.292h-7.168v-1.646H24V9.929h-7.487v.292zm-16.513 0v1.62H0v2.23h.292v-1.938H7.46V9.929H0v.292Z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation 3</title><path d="M15.362 9.433h-3.148c-.97 0-1.446.6-1.446 1.38v2.365c0 .483-.228.83-.71.83H7.304a.035.035 0 00-.035.035v.47c0 .02.01.032.03.032h3.11c.97 0 1.45-.597 1.45-1.377v-2.363c0-.484.224-.832.71-.832h2.781c.02 0 .04-.014.04-.033v-.475c0-.02-.02-.035-.04-.035zm-9.266 0H.038c-.022 0-.038.017-.038.035v.477c0 .02.016.036.038.036h5.694c.48 0 .71.347.71.83s-.228.83-.71.83H1.228c-.7 0-1.227.586-1.227 1.365v1.513c0 .02.02.037.04.037h1.03c.02 0 .04-.016.04-.037v-1.513c0-.48.28-.82.68-.82H6.1c.97 0 1.444-.594 1.444-1.374 0-.778-.473-1.38-1.442-1.38zm17.453 2.498a.04.04 0 010-.056c.3-.25.45-.627.45-1.062 0-.778-.474-1.38-1.446-1.38h-6.057c-.02 0-.036.018-.036.038v.475c0 .02.02.04.04.04h5.7c.48 0 .715.35.715.83s-.23.83-.712.83h-5.7c-.02 0-.036.02-.036.04v.48c0 .02.016.033.037.033h5.7c.63.007.71.62.71.93v.06c0 .485-.23.833-.71.833h-5.7c-.02 0-.036.015-.036.034v.477c0 .02.015.037.036.037h6.05c.973 0 1.446-.645 1.446-1.38v-.057c0-.47-.15-.916-.45-1.19z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation 4</title><path d="M12.302 13.18v-2.387c0-.486.227-.834.712-.834h2.99c.017 0 .035-.018.035-.036v-.475c0-.004 0-.008-.003-.012h-3.66c-.792.1-1.18.653-1.18 1.357v2.386c0 .482-.233.831-.71.831H7.332c-.018 0-.036.012-.036.036v.475c0 .02.01.035.023.04h3.584c.933-.025 1.393-.62 1.393-1.385zM.024 14.564h1.05a.042.042 0 00.025-.04v-1.52c0-.487.275-.823.676-.823h4.323c.974 0 1.445-.6 1.445-1.384 0-.705-.386-1.257-1.18-1.357H.006c0 .003-.006.005-.006.01v.475c0 .024.013.036.037.036h5.697c.484 0 .712.35.712.833 0 .484-.227.836-.712.836H1.226c-.7 0-1.226.592-1.226 1.373v1.519c0 .02.01.036.028.04zm15.998-.55h5.738c.017 0 .03.012.03.024v.483c0 .024.017.036.035.036h1.035c.018 0 .036-.01.036-.036v-.475c0-.018.02-.036.04-.036h1.028c.024 0 .036-.018.036-.036v-.484c0-.018-.01-.036-.035-.036h-1.03c-.02 0-.037-.017-.037-.035V9.96c0-.283-.104-.463-.28-.523h-.3a1.153 1.153 0 00-.303.132l-6.18 3.815c-.24.15-.323.318-.263.445.048.104.185.182.454.182zm.895-.637l4.79-2.961c.03-.024.09-.018.09.048v2.961c0 .018-.016.036-.034.036h-4.817c-.04 0-.06-.012-.065-.024-.006-.024.005-.042.036-.06z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation 5</title><path d="M10.4499 14.56905a1.38287 1.38287 0 001.38287-1.38287v-2.37841a.83315.83315 0 01.83416-.83315h2.68403a.03732.03732 0 00.03631-.03732V9.4612a.03631.03631 0 00-.0363-.0363H12.1172a1.38287 1.38287 0 00-1.38388 1.38286v2.38043a.83416.83416 0 01-.83315.83415H7.25347a.03631.03631 0 00-.03631.03632v.47608a.03631.03631 0 00.03631.03631zm6.04488-3.21156V9.4612a.03631.03631 0 01.0363-.0363h7.30772a.03732.03732 0 01.03732.0363v.47609a.03833.03833 0 01-.03732.03732h-6.20929a.03631.03631 0 00-.0363.03631v1.2356a.3954.3954 0 00.3964.39741h4.62267a1.46457 1.46457 0 010 2.9251h-6.0812a.03631.03631 0 01-.0363-.0363v-.47407a.03631.03631 0 01.0363-.03632h5.53047a.91586.91586 0 10-.00706-1.8307h-4.72656a.83315.83315 0 01-.83315-.83416m-10.84608.28645a.83466.83466 0 000-1.66932H.03654a.03732.03732 0 01-.03632-.03732V9.4612a.03631.03631 0 01.03632-.0363h6.1528a1.38388 1.38388 0 010 2.76673H1.9328a.83315.83315 0 00-.83315.83416v1.51299a.03631.03631 0 01-.03631.0363H.03654a.03631.03631 0 01-.03632-.04034v-1.51298a1.38287 1.38287 0 011.38388-1.37783Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation Portable</title><path d="M0 9.93v.296h7.182v1.626H.001v2.217h.295v-1.921h7.182V9.93zm11.29 0v3.844H7.478v.296h4.124v-3.844h3.813V9.93zm5.233 0v.296h7.182v1.626h-7.182v2.217h.296v-1.921H24V9.93z"/></svg>

After

Width:  |  Height:  |  Size: 307 B

+1
View File
@@ -0,0 +1 @@
<svg fill="#003791" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PlayStation Vita</title><path d="M3.176 12.216H1.274c-.26 0-.453.198-.453.538v1.235H0v-1.19c0-.668.42-1.119 1.014-1.119h1.924c.471-.018.584-.252.584-.592 0-.26-.13-.616-.584-.58H0v-.49h3.176c.832 0 1.16.481 1.16 1.07 0 .669-.328 1.128-1.16 1.128zm3.488-1.122v1.813c0 .663-.299 1.076-1.126 1.076H3.761v-.49h1.55c.318 0 .507-.258.507-.586v-1.813c0-.578.28-1.077 1.102-1.077h1.765v.49H7.158c-.412-.017-.494.32-.494.587zm4.84 2.904c-.331-.018-.47-.27-.532-.377-.063-.107-1.92-3.609-1.92-3.609h.924l1.538 2.855c.08.16.262.2.36.012l1.53-2.867h.577s-1.798 3.404-1.87 3.52c-.071.117-.276.484-.607.466zm3.005-3.972h.84v3.96h-.84zm3.77.46l.003 3.477h-.826V10.49l-1.57-.002v-.483L19.859 10l-.002.489zm3.235-.481c-.314.005-.51.354-.579.467-.071.116-1.873 3.527-1.873 3.527h.578l.44-.84h2.541l.454.84H24s-1.86-3.508-1.923-3.616c-.062-.107-.201-.36-.533-.378h-.03zm-.18.996c.078-.005.155.047.2.138l.825 1.525h-2.004l.818-1.538c.043-.082.102-.12.162-.125Z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#0089CF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Sega</title><path d="M21.229 4.14l-.006 3.33h-10.6c-.219 0-.397.181-.397.399 0 .221.18.399.397.399l2.76-.016c4.346 0 7.868 3.525 7.868 7.869 0 4.348-3.522 7.869-7.869 7.869L2.748 24l.005-3.375h10.635c2.487 0 4.504-2.016 4.504-4.504 0-2.49-2.017-4.506-4.506-4.506l-2.771-.03c-2.06 0-3.727-1.666-3.727-3.72 0-2.061 1.666-3.726 3.723-3.726h10.618zM2.763 19.843l-.004-3.331h10.609c.21 0 .383-.175.383-.387 0-.213-.173-.385-.384-.385h-2.744c-4.345 0-7.867-3.525-7.867-7.871S6.278 0 10.623 0l10.6.003.006 3.35-10.604.003c-2.49 0-4.5 2.019-4.5 4.507 0 2.489 2.024 4.504 4.515 4.504l2.775.03c2.055 0 3.72 1.668 3.72 3.724 0 2.055-1.665 3.719-3.72 3.719H2.765l-.002.003z"/></svg>

After

Width:  |  Height:  |  Size: 763 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#1A9FFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Steam Deck</title><path d="M8.999 0v4.309c4.242 0 7.694 3.45 7.694 7.691s-3.452 7.691-7.694 7.691V24c6.617 0 12-5.383 12-12s-5.383-12-12-12Zm0 6.011c-3.313 0-6 2.687-5.998 6a5.999 5.999 0 1 0 5.998-6z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="900.000000pt" height="900.000000pt" viewBox="0 0 900.000000 900.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.13, written by Peter Selinger 2001-2015
</metadata>
<g transform="translate(0.000000,900.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2965 8314 c-481 -86 -868 -442 -990 -910 -44 -169 -47 -268 -42
-1579 3 -1204 4 -1232 24 -1325 111 -501 467 -858 973 -976 66 -15 150 -18
691 -21 560 -4 618 -3 633 12 15 15 16 208 16 2396 0 1622 -3 2386 -10 2400
-10 18 -27 19 -613 18 -476 -1 -619 -4 -682 -15z m905 -2400 l0 -2026 -407 5
c-375 4 -415 6 -490 25 -322 83 -561 331 -628 654 -22 101 -22 2589 -1 2688
60 281 255 514 518 619 132 53 193 59 621 60 l387 1 0 -2026z"/>
<path d="M3051 7329 c-63 -12 -159 -60 -210 -105 -105 -91 -157 -220 -149
-372 4 -79 9 -100 41 -164 47 -97 118 -168 215 -216 67 -33 84 -37 171 -40 79
-3 107 0 160 18 217 73 348 284 311 500 -43 257 -287 429 -539 379z"/>
<path d="M4757 8323 c-4 -3 -7 -1087 -7 -2409 0 -2181 1 -2402 16 -2408 27
-10 803 -6 899 4 406 46 764 293 959 660 25 47 58 126 75 175 63 188 61 138
61 1575 0 1147 -2 1318 -16 1391 -99 521 -496 914 -1018 1004 -70 12 -178 15
-526 15 -240 0 -440 -3 -443 -7z m1068 -2178 c156 -41 284 -160 336 -312 33
-94 32 -232 -1 -318 -61 -158 -181 -269 -335 -310 -250 -65 -516 86 -589 334
-22 76 -21 204 4 282 75 245 335 389 585 324z"/>
<path d="M7493 2776 c-155 -51 -247 -200 -221 -362 16 -104 76 -186 168 -233
125 -62 258 -46 358 44 75 68 107 139 107 240 0 95 -28 166 -91 229 -79 79
-221 115 -321 82z m177 -146 c60 -31 93 -81 98 -149 6 -89 -32 -153 -111 -186
-134 -56 -286 73 -248 212 16 62 43 97 93 122 54 26 117 27 168 1z"/>
<path d="M790 2465 l0 -306 63 3 62 3 5 193 5 193 145 -195 145 -196 60 2 60
3 0 300 0 300 -62 3 -63 3 0 -197 0 -198 -147 197 -148 197 -62 0 -63 0 0
-305z"/>
<path d="M1790 2465 l0 -305 65 0 65 0 0 305 0 305 -65 0 -65 0 0 -305z"/>
<path d="M2375 2757 c-3 -7 -4 -143 -3 -302 l3 -290 63 -3 62 -3 0 202 0 203
150 -202 150 -202 60 0 60 0 0 305 0 305 -65 0 -65 0 -2 -192 -3 -192 -144
192 -143 192 -59 0 c-39 0 -61 -4 -64 -13z"/>
<path d="M3347 2763 c-4 -3 -7 -33 -7 -65 l0 -58 100 0 100 0 0 -240 0 -240
65 0 65 0 0 240 0 240 95 0 95 0 0 65 0 65 -253 0 c-140 0 -257 -3 -260 -7z"/>
<path d="M4300 2465 l0 -305 235 0 235 0 0 60 0 60 -175 0 -175 0 0 70 0 70
160 0 160 0 0 55 0 55 -160 0 -160 0 0 60 0 60 173 2 172 3 3 58 3 57 -236 0
-235 0 0 -305z"/>
<path d="M5265 2757 c-3 -7 -4 -143 -3 -302 l3 -290 63 -3 62 -3 0 203 0 202
150 -202 150 -202 60 0 60 0 0 305 0 305 -65 0 -65 0 -2 -192 -3 -192 -144
192 -143 192 -59 0 c-39 0 -61 -4 -64 -13z"/>
<path d="M6320 2466 l0 -306 133 0 c154 0 227 15 293 61 195 134 165 421 -54
522 -41 19 -69 22 -209 25 l-163 4 0 -306z m327 155 c84 -39 120 -146 78 -235
-32 -66 -97 -96 -211 -96 l-74 0 0 175 0 175 83 0 c59 0 94 -6 124 -19z"/>
<path d="M6018 1889 c-139 -18 -263 -83 -365 -192 -223 -240 -216 -605 17
-837 86 -87 164 -132 276 -160 184 -47 370 -10 522 105 59 44 136 130 130 145
-2 4 -43 41 -92 84 l-88 77 -27 -35 c-129 -169 -390 -187 -541 -36 -22 22 -52
63 -67 92 -25 48 -28 63 -28 153 0 86 3 107 25 150 74 153 257 236 416 190 74
-21 144 -65 188 -118 l34 -41 44 36 c24 19 66 56 93 80 l50 45 -39 48 c-130
158 -340 240 -548 214z"/>
<path d="M1072 1850 c-238 -63 -361 -222 -308 -402 38 -133 164 -198 499 -258
225 -40 291 -73 291 -144 0 -49 -29 -85 -92 -114 -54 -25 -66 -27 -202 -27
-161 0 -224 13 -343 67 -37 17 -69 29 -71 27 -12 -15 -107 -192 -104 -194 10
-11 170 -69 236 -87 110 -29 345 -36 465 -14 224 42 335 145 345 318 8 143
-32 217 -155 281 -88 46 -164 68 -358 107 -169 33 -217 49 -252 82 -31 29 -31
81 1 113 72 72 330 75 530 5 33 -11 63 -17 67 -13 7 8 79 177 79 187 0 9 -153
54 -239 70 -116 22 -296 20 -389 -4z"/>
<path d="M1924 1843 c3 -10 73 -259 156 -553 83 -294 153 -543 156 -552 5 -16
21 -18 144 -18 l139 0 11 38 c99 358 199 694 204 682 3 -8 50 -172 104 -365
l98 -350 140 -3 140 -3 159 558 c87 307 160 564 163 571 3 9 -25 12 -122 12
l-125 0 -14 -47 c-8 -27 -56 -207 -107 -400 -52 -194 -96 -353 -100 -353 -3 0
-50 152 -104 338 -54 185 -107 365 -118 400 l-19 63 -102 -3 -102 -3 -117
-400 c-64 -220 -117 -402 -118 -405 -1 -3 -46 159 -99 360 -54 201 -104 384
-110 408 l-12 42 -125 0 c-115 0 -125 -1 -120 -17z"/>
<path d="M3820 1290 l0 -570 130 0 130 0 -2 568 -3 567 -127 3 -128 3 0 -571z"/>
<path d="M4350 1735 l0 -125 180 0 180 0 0 -445 0 -445 125 0 125 0 0 445 0
445 178 2 177 3 0 120 0 120 -482 3 -483 2 0 -125z"/>
<path d="M6860 1290 l0 -570 120 0 120 0 0 225 0 225 265 0 265 0 0 -225 0
-225 120 0 120 0 -2 568 -3 567 -117 3 -118 3 0 -226 0 -225 -265 0 -265 0 0
225 0 225 -120 0 -120 0 0 -570z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="79.718mm" height="34.673mm" version="1.1" viewBox="0 0 79.718 34.673" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g transform="translate(-75.415 -84.916)">
<path d="m152.17 116.28h0.73448l0.73731 2.4476 0.74894-2.4476h0.74084v3.0593h-0.48578v-2.528l-0.76094 2.528h-0.49953l-0.74683-2.528v2.528h-0.46849zm-2.7616 0h2.395v0.4512h-0.94051v2.6081h-0.51117v-2.6081h-0.94333zm-34.39-29.468-6.1207 23.996s-4.6796-18.012-5.4409-20.567c-0.76164-2.559-2.3283-3.6802-4.5505-3.6802-2.2228 0-3.7924 1.1211-4.5536 3.6802-0.75812 2.5548-5.4391 20.567-5.4391 20.567l-6.1246-23.996h-7.3748s7.0831 25.602 8.0458 28.568c0.74895 2.3142 2.5231 4.2062 5.153 4.2062 3.0071 0 4.4132-2.1922 5.0645-4.2062 0.64417-2.0024 5.2289-18.912 5.2289-18.912s4.5847 16.91 5.2275 18.912c0.65053 2.014 2.057 4.2062 5.0631 4.2062 2.6321 0 4.403-1.8919 5.1566-4.2062 0.96062-2.9651 8.0388-28.568 8.0388-28.568zm25.286 32.527h6.9818v-22.583h-6.9818zm-0.67669-30.479c0 2.1766 1.8362 3.9434 4.0908 3.9434 2.346 0 4.1836-1.7304 4.1836-3.9434 0-2.2137-1.8376-3.9479-4.1836-3.9479-2.2546 0-4.0908 1.7692-4.0908 3.9479m-13.51 30.479h6.9797v-22.583h-6.9797zm-0.67871-30.479c0 2.1766 1.8327 3.9434 4.0887 3.9434 2.3446 0 4.1857-1.7304 4.1857-3.9434 0-2.2137-1.8412-3.9479-4.1857-3.9479-2.256 0-4.0887 1.7692-4.0887 3.9479" fill="#a1a0a4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 94.2 26.6">
<path d="M89.8 0h1.8v.3h-.7v2h-.4v-2h-.7zm2.1 0h.6l.5 1.9.6-1.9h.6v2.3h-.4V.4l-.6 1.9h-.4L92.2.4v1.9h-.4V0z" fill="#8c8c8c"/>
<path d="M0 0h88.3v26.6H0z" fill="none"/>
<path d="M41.4 0c1.8 0 3.2 1.3 3.2 3s-1.4 3-3.2 3c-1.7 0-3.1-1.4-3.1-3s1.4-3 3.1-3m-2.6 9.1v17.3h5.3V9.1zM49.2 3c0 1.7 1.4 3 3.1 3 1.8 0 3.2-1.3 3.2-3s-1.4-3-3.2-3c-1.7 0-3.1 1.4-3.1 3m.5 6.1v17.3H55V9.1zM30.3 1.5l-4.7 18.4S22 6.1 21.4 4.1s-1.8-2.8-3.5-2.8-2.9.9-3.5 2.8c-.6 2-4.2 15.8-4.2 15.8L5.6 1.5H0s5.4 19.6 6.2 21.9c.6 1.8 1.9 3.2 3.9 3.2 2.3 0 3.4-1.7 3.9-3.2s4-14.5 4-14.5 3.5 13 4 14.5 1.6 3.2 3.9 3.2c2 0 3.4-1.5 3.9-3.2.8-2.3 6.2-21.9 6.2-21.9z" fill="#8c8c8c"/>
<path d="M75 13.2c2 0 3.5-1.6 3.5-4V0h-7v9.2c0 2.5 1.5 4 3.5 4M84.8 0h-3v9.3c0 4.5-2.6 6.9-6.8 6.9s-6.8-2.4-6.8-6.9V0h-3c-2.2 0-3.5 1.3-3.5 3.5v11.4c0 3.3 1.7 5 5 5h16.5c3.3 0 5-1.7 5-5V3.5C88.3 1.3 87 0 84.8 0" fill="#0096c8"/>
</svg>

After

Width:  |  Height:  |  Size: 952 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="713.6" height="568" xml:space="preserve"><path d="M178.8 564H146l-56.4-78-56.8 78H0l72.8-100.4L6 371.2h32.8l50.8 70 50.8-70h32.8L106 463.6 178.8 564zm170.4-54.8c0 16.8-5.6 30.4-16.8 40s-27.2 14.8-48 14.8h-90v-86h-43.2l18-24.8h25.2v-82h86.4c19.6 0 34.4 4.8 44.8 14s15.6 21.6 15.6 36.4c0 18.4-8.4 32.8-25.2 42 10.8 4 18.8 10.4 24.4 18 6 7.6 8.8 17.2 8.8 27.6zm-125.6-55.6h55.2c11.2 0 19.6-2.4 25.2-7.6 5.6-4.8 8.4-12.4 8.4-22.4 0-8.4-2.8-15.2-8.8-20-5.6-4.8-14-7.2-24.8-7.2h-55.2v57.2zm96 54.8c0-10-3.2-18-9.2-22.8-6-5.2-15.2-7.6-27.2-7.6h-59.6v60.4h59.2c11.6 0 20.8-2.8 27.2-7.6 6.4-5.2 9.6-12.8 9.6-22.4zm228-40.8c0 14.8-2 28.8-6.4 40.8-4.4 12-10.8 22.8-19.2 32-8.8 9.2-18.8 16.4-30 20.8-11.2 4.8-24 6.8-38.4 6.8-14 0-27.2-2.4-38.4-6.8-11.2-4.8-21.2-11.6-30-20.8s-15.2-20-19.6-32-6.4-26-6.4-40.8 2-28.8 6.4-40.8 10.8-23.2 19.6-32.4c8.4-9.2 18.4-16 30-20.8 11.2-4.4 24.4-6.8 38.4-6.8s26.8 2.4 38.4 6.8c11.2 4.4 21.6 11.6 30 20.8s15.2 20.4 19.2 32.4 6.4 25.6 6.4 40.8zm-158 0c0 22.8 6 40.8 17.6 54 11.6 13.2 26.8 20 46.4 20 19.2 0 34.8-6.8 46.4-20s17.2-31.2 17.2-54-5.6-41.2-17.2-54.4-27.2-20-46.4-20-34.8 6.8-46.4 20c-11.6 13.6-17.6 31.6-17.6 54.4zm250.8-4l67.2-92.4h-32.8l-50.8 70-50.8-70h-32.8l67.2 92.4L534.8 564h32.8l56.4-78 56.8 78h32.8l-73.2-100.4zM356.8 128.8s.4 0 0 0c48.4 36.8 130.4 127.2 105.6 152.8-28.4 24.8-65.2 39.6-105.6 39.6s-77.6-14.8-105.6-39.6c-25.2-25.6 57.2-116 105.2-152.4 0-.4.4-.4.4-.4zm83.6-105.2C416 8.8 389.2 0 356.8 0s-59.2 8.8-83.6 23.6c-.4 0-.4.4-.4.8s.4.4.8.4c31.2-6.8 78.4 20 82.8 22.8h.8c4.4-2.8 51.6-29.6 82.8-22.8.4 0 .8 0 .8-.4s0-.8-.4-.8zM244.4 46c-.4 0-.4.4-.8.4-29.2 29.2-47.2 69.6-47.2 114 0 36.4 12.4 70.4 32.8 97.2 0 .4.4.4.8.4s.4-.4 0-.8c-12.4-38 50.4-129.6 82.8-168l.4-.4c0-.4 0-.4-.4-.4-49.2-48.8-65.6-43.6-68.4-42.4zm156.4 42l-.4.4s0 .4.4.4c32.4 38.4 94.8 130 82.8 168v.8c.4 0 .8 0 .8-.4 20.4-26.8 32.8-60.8 32.8-97.2 0-44.4-18-84.8-47.6-114-.4-.4-.4-.4-.8-.4-2.4-.8-18.8-6-68 42.4z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="88" height="88"><title>Xbox Logo</title><path fill="#107c10" d="M39.73 86.91c-6.628-.635-13.338-3.015-19.102-6.776-4.83-3.15-5.92-4.447-5.92-7.032 0-5.193 5.71-14.29 15.48-24.658 5.547-5.89 13.275-12.79 14.11-12.604 1.626.363 14.616 13.034 19.48 19 7.69 9.43 11.224 17.154 9.428 20.597-1.365 2.617-9.837 7.733-16.06 9.698-5.13 1.62-11.867 2.306-17.416 1.775zM8.184 67.703c-4.014-6.158-6.042-12.22-7.02-20.988-.324-2.895-.21-4.55.733-10.494 1.173-7.4 5.39-15.97 10.46-21.24 2.158-2.24 2.35-2.3 4.982-1.41 3.19 1.08 6.6 3.436 11.89 8.22l3.09 2.794-1.69 2.07c-7.828 9.61-16.09 23.24-19.2 31.67-1.69 4.58-2.37 9.18-1.64 11.095.49 1.294.04.812-1.61-1.714zm70.453 1.047c.397-1.936-.105-5.49-1.28-9.076-2.545-7.765-11.054-22.21-18.867-32.032l-2.46-3.092 2.662-2.443c3.474-3.19 5.886-5.1 8.49-6.723 2.053-1.28 4.988-2.413 6.25-2.413.777 0 3.516 2.85 5.726 5.95 3.424 4.8 5.942 10.63 7.218 16.69.825 3.92.894 12.3.133 16.21-.63 3.208-1.95 7.366-3.23 10.187-.97 2.113-3.36 6.218-4.41 7.554-.54.687-.54.686-.24-.796zM40.44 11.505C36.834 9.675 31.272 7.71 28.2 7.18c-1.076-.185-2.913-.29-4.08-.23-2.536.128-2.423-.004 1.643-1.925 3.38-1.597 6.2-2.536 10.03-3.34C40.098.78 48.193.77 52.43 1.663c4.575.965 9.964 2.97 13 4.84l.904.554-2.07-.104C60.148 6.745 54.15 8.408 47.71 11.54c-1.942.946-3.63 1.7-3.754 1.68-.123-.024-1.706-.795-3.52-1.715z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,153 @@
"use client";
import { useMemo, useState } from "react";
import {
searchConsoles,
isIconUrl,
type ConsoleDef,
} from "@/lib/integrations/consoles";
import type { ConsoleItem } from "@/lib/integrations/types";
// Searchable console picker. Filters the bundled registry locally (instant, no
// network), shows clickable suggestions, and keeps the chosen list as chips with
// an optional per-console note. The whole selection serializes to a single
// hidden input (JSON) so the surrounding server-action form picks it up as
// `name` with zero extra wiring.
function Glyph({ icon }: { icon?: string }) {
if (!icon) return <span className="rb-pick-glyph" aria-hidden>🎮</span>;
if (isIconUrl(icon))
// eslint-disable-next-line @next/next/no-img-element
return <img className="rb-pick-glyph-img" src={icon} alt="" />;
return (
<span className="rb-pick-glyph" aria-hidden>
{icon}
</span>
);
}
export default function ConsolePicker({
name,
initial,
}: {
name: string;
initial: ConsoleItem[];
}) {
const [items, setItems] = useState<ConsoleItem[]>(initial);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const chosenIds = useMemo(
() => new Set(items.map((i) => i.id).filter(Boolean) as string[]),
[items]
);
const suggestions = useMemo(
() => searchConsoles(query).filter((c) => !chosenIds.has(c.id)),
[query, chosenIds]
);
function add(def: ConsoleDef) {
setItems((prev) => [...prev, { id: def.id, name: def.name, icon: def.icon }]);
setQuery("");
setOpen(false);
}
function addFreeform() {
const label = query.trim();
if (!label) return;
setItems((prev) => [...prev, { name: label }]);
setQuery("");
setOpen(false);
}
function remove(idx: number) {
setItems((prev) => prev.filter((_, i) => i !== idx));
}
function setNote(idx: number, note: string) {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, note } : it)));
}
return (
<div className="rb-picker">
<input type="hidden" name={name} value={JSON.stringify(items)} />
<div className="rb-pick-search">
<input
type="text"
value={query}
placeholder="Search consoles — e.g. Dreamcast, Sega, PS2…"
autoComplete="off"
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (suggestions[0]) add(suggestions[0]);
else addFreeform();
}
}}
/>
{open && (suggestions.length > 0 || query.trim()) && (
<ul className="rb-pick-suggest">
{suggestions.map((c) => (
<li key={c.id}>
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(c)}>
<Glyph icon={c.icon} />
<span className="rb-pick-suggest-name">{c.name}</span>
<span className="rb-pick-suggest-meta">
{c.maker}
{c.year ? ` · ${c.year}` : ""}
</span>
</button>
</li>
))}
{query.trim() && (
<li>
<button
type="button"
className="rb-pick-freeform"
onMouseDown={(e) => e.preventDefault()}
onClick={addFreeform}
>
Add {query.trim()} as custom
</button>
</li>
)}
</ul>
)}
</div>
{items.length > 0 && (
<ul className="rb-pick-list">
{items.map((it, i) => (
<li key={`${it.id ?? it.name}-${i}`} className="rb-pick-chip">
<Glyph icon={it.icon} />
<span className="rb-pick-chip-name">{it.name}</span>
<input
className="rb-pick-chip-note"
type="text"
value={it.note ?? ""}
placeholder="note (optional)"
onChange={(e) => setNote(i, e.target.value)}
/>
<button
type="button"
className="rb-pick-remove"
aria-label={`Remove ${it.name}`}
onClick={() => remove(i)}
>
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useRef, useState } from "react";
import type { FavoriteGame } from "@/lib/integrations/types";
// Searchable favorite-games picker. Mirrors the console picker UX, but results
// come from the server (Steam store search via /games/search) since the catalog
// is far too large to bundle. Typing debounces a fetch; clicking a result adds
// it as a chip with its cover art. Free-text entry is always available for
// console/retro titles the search doesn't surface.
type Result = { name: string; image?: string; url?: string };
function Cover({ image }: { image?: string }) {
if (!image) return <span className="rb-pick-cover rb-pick-cover-empty" aria-hidden>🎲</span>;
// eslint-disable-next-line @next/next/no-img-element
return <img className="rb-pick-cover" src={image} alt="" loading="lazy" />;
}
export default function GamePicker({
name,
initial,
}: {
name: string;
initial: FavoriteGame[];
}) {
const [items, setItems] = useState<FavoriteGame[]>(initial);
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Search is event-driven (no effect): each keystroke reschedules a debounced
// fetch against the admin-only Steam search route, cancelling any in-flight
// request so only the latest query's results land.
function onQueryChange(next: string) {
setQuery(next);
setOpen(true);
if (timerRef.current) clearTimeout(timerRef.current);
const q = next.trim();
if (q.length < 2) {
abortRef.current?.abort();
setResults([]);
setLoading(false);
return;
}
setLoading(true);
timerRef.current = setTimeout(async () => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const res = await fetch(
`/admin/integrations/games/search?q=${encodeURIComponent(q)}`,
{ signal: ctrl.signal }
);
const json = (await res.json()) as { games?: Result[] };
setResults(json.games ?? []);
} catch {
// aborted or network error — leave prior results, drop the spinner
} finally {
setLoading(false);
}
}, 300);
}
function add(g: Result) {
setItems((prev) => [...prev, { name: g.name, image: g.image, url: g.url }]);
setQuery("");
setResults([]);
setOpen(false);
}
function addFreeform() {
const label = query.trim();
if (!label) return;
setItems((prev) => [...prev, { name: label }]);
setQuery("");
setResults([]);
setOpen(false);
}
function remove(idx: number) {
setItems((prev) => prev.filter((_, i) => i !== idx));
}
function setNote(idx: number, note: string) {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, note } : it)));
}
return (
<div className="rb-picker">
<input type="hidden" name={name} value={JSON.stringify(items)} />
<div className="rb-pick-search">
<input
type="text"
value={query}
placeholder="Search games — e.g. Chrono Trigger, Hollow Knight…"
autoComplete="off"
onChange={(e) => onQueryChange(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (results[0]) add(results[0]);
else addFreeform();
}
}}
/>
{open && query.trim().length >= 2 && (
<ul className="rb-pick-suggest">
{loading && results.length === 0 && (
<li className="rb-pick-status">Searching</li>
)}
{results.map((g, i) => (
<li key={`${g.url ?? g.name}-${i}`}>
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => add(g)}>
<Cover image={g.image} />
<span className="rb-pick-suggest-name">{g.name}</span>
</button>
</li>
))}
<li>
<button
type="button"
className="rb-pick-freeform"
onMouseDown={(e) => e.preventDefault()}
onClick={addFreeform}
>
Add {query.trim()} as custom
</button>
</li>
</ul>
)}
</div>
{items.length > 0 && (
<ul className="rb-pick-list">
{items.map((it, i) => (
<li key={`${it.url ?? it.name}-${i}`} className="rb-pick-chip">
<Cover image={it.image} />
<span className="rb-pick-chip-name">{it.name}</span>
<input
className="rb-pick-chip-note"
type="text"
value={it.note ?? ""}
placeholder="note (optional)"
onChange={(e) => setNote(i, e.target.value)}
/>
<button
type="button"
className="rb-pick-remove"
aria-label={`Remove ${it.name}`}
onClick={() => remove(i)}
>
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
// Game search for the admin "favorite games" picker. Backed by Steam's public
// store-search endpoint: keyless, returns capsule cover art, and its catalog is
// broad enough to cover PC plus the countless console/retro titles that have
// since been re-released on Steam. The picker also allows free-text entry for
// anything the search can't find, so console-only games are never blocked.
//
// Admin-gated (it lives under the panel layout, but route handlers don't inherit
// that guard, so we check here too) to avoid turning the blog into an open proxy.
const STORE_SEARCH = "https://store.steampowered.com/api/storesearch/";
const TIMEOUT = 6000;
type StoreItem = { id: number; name: string; tiny_image?: string };
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const q = (new URL(req.url).searchParams.get("q") ?? "").trim();
if (q.length < 2) return NextResponse.json({ games: [] });
const url = `${STORE_SEARCH}?term=${encodeURIComponent(q)}&cc=us&l=en`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) {
return NextResponse.json({ games: [], error: `Steam ${res.status}` });
}
const json = (await res.json()) as { items?: StoreItem[] };
const games = (json.items ?? []).slice(0, 10).map((it) => ({
name: it.name,
image: it.tiny_image,
url: `https://store.steampowered.com/app/${it.id}`,
}));
return NextResponse.json({ games });
} catch {
return NextResponse.json({ games: [], error: "Search timed out." });
}
}
+228 -38
View File
@@ -1,23 +1,46 @@
import type { ReactNode } from "react";
import { getIntegrationConfig, getProfile } from "@/lib/integrations"; import { getIntegrationConfig, getProfile } from "@/lib/integrations";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social"; import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import type { import type { SocialLink } from "@/lib/integrations/types";
ConsoleItem,
FavoriteGame,
SocialLink,
} from "@/lib/integrations/types";
import { import {
saveIntegrationsAction, saveIntegrationsAction,
refreshIntegrationsAction, refreshIntegrationsAction,
testIntegrationAction,
} from "../../actions"; } from "../../actions";
import ConsolePicker from "./ConsolePicker";
import GamePicker from "./GamePicker";
// Expandable "how do I get this?" help, shown under credential fields. Uses a
// native <details> so it works with zero JS and stays readable on mobile.
function CredHelp({ title, children }: { title: string; children: ReactNode }) {
return (
<details className="rb-cred-help">
<summary> {title}</summary>
<div className="rb-cred-help-body">{children}</div>
</details>
);
}
// A "Test connection" submit button. Submits the whole form to the test action
// (so it validates the live, unsaved field values) tagged with which platform.
function TestButton({ platform }: { platform: string }) {
return (
<button
type="submit"
className="rb-btn rb-btn-ghost"
formAction={testIntegrationAction.bind(null, platform)}
formNoValidate
>
Test connection
</button>
);
}
function socialToText(links: SocialLink[]): string { function socialToText(links: SocialLink[]): string {
return links return links
.map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | ")) .map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | "))
.join("\n"); .join("\n");
} }
function namedToText(items: (ConsoleItem | FavoriteGame)[]): string {
return items.map((i) => [i.name, i.note].filter(Boolean).join(" | ")).join("\n");
}
function fmtAge(iso: string): string { function fmtAge(iso: string): string {
const t = new Date(iso).getTime(); const t = new Date(iso).getTime();
@@ -29,18 +52,51 @@ function fmtAge(iso: string): string {
return hrs < 24 ? `${hrs}h ago` : `${Math.round(hrs / 24)}d ago`; return hrs < 24 ? `${hrs}h ago` : `${Math.round(hrs / 24)}d ago`;
} }
function Banner({ saved, refreshed }: { saved?: string; refreshed?: string }) { type Banners = {
if (saved) return <p className="rb-admin-ok">Integrations saved. Caches cleared.</p>; saved?: string;
if (refreshed) return <p className="rb-admin-ok">Platform caches cleared data refetches on next view.</p>; refreshed?: string;
steam?: string;
steamErr?: string;
test?: string;
testErr?: string;
testGames?: string;
testNotice?: string;
};
function Banner(p: Banners) {
if (p.saved) return <p className="rb-admin-ok">Integrations saved. Caches cleared.</p>;
if (p.refreshed)
return <p className="rb-admin-ok">Platform caches cleared data refetches on next view.</p>;
if (p.steam)
return (
<p className="rb-admin-ok">
Steam linked SteamID <code>{p.steam}</code> saved. Add your API key below to pull
game data.
</p>
);
if (p.steamErr) return <p className="rb-admin-error">{p.steamErr}</p>;
if (p.test && p.testErr)
return (
<p className="rb-admin-error">
{p.test.toUpperCase()} test failed: {p.testErr}
</p>
);
if (p.test)
return (
<p className={p.testNotice ? "rb-admin-warn" : "rb-admin-ok"}>
{p.test.toUpperCase()} connection OK fetched {p.testGames ?? 0} game(s).
{p.testNotice ? ` ${p.testNotice}` : ""}
</p>
);
return null; return null;
} }
export default async function IntegrationsPage({ export default async function IntegrationsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ saved?: string; refreshed?: string }>; searchParams: Promise<Banners>;
}) { }) {
const { saved, refreshed } = await searchParams; const banners = await searchParams;
const cfg = getIntegrationConfig(); const cfg = getIntegrationConfig();
// Loads (cached) platform data so we can show freshness + any fetch errors. // Loads (cached) platform data so we can show freshness + any fetch errors.
const profile = await getProfile(); const profile = await getProfile();
@@ -49,7 +105,7 @@ export default async function IntegrationsPage({
return ( return (
<div className="rb-admin-page"> <div className="rb-admin-page">
<h1 className="rb-admin-h1">Integrations</h1> <h1 className="rb-admin-h1">Integrations</h1>
<Banner saved={saved} refreshed={refreshed} /> <Banner {...banners} />
<p className="rb-admin-muted"> <p className="rb-admin-muted">
Build your gamer bio: a profile, social links, console list, favorite Build your gamer bio: a profile, social links, console list, favorite
games, and live data pulled from gaming platforms. Everything here powers games, and live data pulled from gaming platforms. Everything here powers
@@ -136,31 +192,19 @@ export default async function IntegrationsPage({
{/* ---- consoles + favorites ---- */} {/* ---- consoles + favorites ---- */}
<h2 className="rb-admin-h2">Collection</h2> <h2 className="rb-admin-h2">Collection</h2>
<div className="rb-field-row"> <div className="rb-field">
<label className="rb-field">
<span> <span>
Consoles owned <em className="rb-admin-muted">(name|note per line)</em> Consoles owned{" "}
<em className="rb-admin-muted"> search and click to add</em>
</span> </span>
<textarea <ConsolePicker name="consoles" initial={cfg.consoles} />
name="consoles" </div>
rows={5} <div className="rb-field">
className="rb-mono"
placeholder={"PlayStation 2|Phat, launch unit\nDreamcast|with VMU"}
defaultValue={namedToText(cfg.consoles)}
/>
</label>
<label className="rb-field">
<span> <span>
Favorite games <em className="rb-admin-muted">(name|note per line)</em> Favorite games{" "}
<em className="rb-admin-muted"> search (Steam catalog) and click to add</em>
</span> </span>
<textarea <GamePicker name="favorites" initial={cfg.favorites} />
name="favorites"
rows={5}
className="rb-mono"
placeholder={"Shadow of the Colossus|GOAT\nChrono Trigger"}
defaultValue={namedToText(cfg.favorites)}
/>
</label>
</div> </div>
{/* ---- platforms ---- */} {/* ---- platforms ---- */}
@@ -169,12 +213,20 @@ export default async function IntegrationsPage({
<input type="checkbox" name="steamEnabled" defaultChecked={cfg.steam.enabled} /> <input type="checkbox" name="steamEnabled" defaultChecked={cfg.steam.enabled} />
<span>Enable Steam integration</span> <span>Enable Steam integration</span>
</label> </label>
<p className="rb-admin-muted">
One-click:{" "}
<a className="rb-btn rb-btn-ghost rb-btn-inline" href="/admin/integrations/steam/link">
Sign in with Steam
</a>{" "}
to fill your SteamID automatically{cfg.steam.steamId ? ` (current: ${cfg.steam.steamId})` : ""}.
You still add an API key below for game data.
</p>
<div className="rb-field-row"> <div className="rb-field-row">
<label className="rb-field"> <label className="rb-field">
<span> <span>
API key{" "} API key{" "}
<em className="rb-admin-muted"> <em className="rb-admin-muted">
({cfg.steam.apiKey ? "stored — blank keeps it" : "from steamcommunity.com/dev"}) ({cfg.steam.apiKey ? "stored — blank keeps it" : "required for game data"})
</em> </em>
</span> </span>
<input <input
@@ -183,12 +235,41 @@ export default async function IntegrationsPage({
autoComplete="off" autoComplete="off"
placeholder={cfg.steam.apiKey ? "•••••••• stored" : ""} placeholder={cfg.steam.apiKey ? "•••••••• stored" : ""}
/> />
<CredHelp title="How to get a Steam API key">
<ol>
<li>
Go to{" "}
<a href="https://steamcommunity.com/dev/apikey" target="_blank" rel="noreferrer">
steamcommunity.com/dev/apikey
</a>{" "}
(sign in if asked).
</li>
<li>Enter any domain name (e.g. <code>localhost</code>) and agree to the terms.</li>
<li>Copy the generated key and paste it here.</li>
</ol>
<p>Your Steam profile must be set to <strong>Public</strong> for game data to load.</p>
</CredHelp>
</label> </label>
<label className="rb-field"> <label className="rb-field">
<span>SteamID (64-bit)</span> <span>SteamID (64-bit)</span>
<input name="steamId" defaultValue={cfg.steam.steamId} placeholder="7656119…" /> <input name="steamId" defaultValue={cfg.steam.steamId} placeholder="7656119…" />
<CredHelp title="How to find your SteamID">
<ol>
<li>Use the Sign in with Steam button above easiest, fills it for you.</li>
<li>
Or open{" "}
<a href="https://steamid.io" target="_blank" rel="noreferrer">
steamid.io
</a>
, paste your profile URL, and copy the <code>steamID64</code> value.
</li>
</ol>
</CredHelp>
</label> </label>
</div> </div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="steam" />
</div>
<h2 className="rb-admin-h2">PlayStation (PSN)</h2> <h2 className="rb-admin-h2">PlayStation (PSN)</h2>
<label className="rb-check"> <label className="rb-check">
@@ -199,7 +280,7 @@ export default async function IntegrationsPage({
<span> <span>
NPSSO token{" "} NPSSO token{" "}
<em className="rb-admin-muted"> <em className="rb-admin-muted">
({cfg.psn.npsso ? "stored — blank keeps it" : "from ca.account.sony.com/api/v1/ssocookie"}) ({cfg.psn.npsso ? "stored — blank keeps it" : "required"})
</em> </em>
</span> </span>
<input <input
@@ -208,7 +289,37 @@ export default async function IntegrationsPage({
autoComplete="off" autoComplete="off"
placeholder={cfg.psn.npsso ? "•••••••• stored" : "64-char token"} placeholder={cfg.psn.npsso ? "•••••••• stored" : "64-char token"}
/> />
<CredHelp title="How to get your NPSSO token">
<ol>
<li>
Sign in at{" "}
<a href="https://www.playstation.com" target="_blank" rel="noreferrer">
playstation.com
</a>{" "}
in your browser.
</li>
<li>
In the same browser, open{" "}
<a
href="https://ca.account.sony.com/api/v1/ssocookie"
target="_blank"
rel="noreferrer"
>
ca.account.sony.com/api/v1/ssocookie
</a>
.
</li>
<li>
Copy the 64-character value of <code>npsso</code> from the JSON shown and paste it
here.
</li>
</ol>
<p>The token expires periodically re-paste a fresh one if PSN starts erroring.</p>
</CredHelp>
</label> </label>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="psn" />
</div>
<h2 className="rb-admin-h2">Xbox</h2> <h2 className="rb-admin-h2">Xbox</h2>
<label className="rb-check"> <label className="rb-check">
@@ -220,7 +331,7 @@ export default async function IntegrationsPage({
<span> <span>
OpenXBL API key{" "} OpenXBL API key{" "}
<em className="rb-admin-muted"> <em className="rb-admin-muted">
({cfg.xbox.apiKey ? "stored — blank keeps it" : "from xbl.io"}) ({cfg.xbox.apiKey ? "stored — blank keeps it" : "required"})
</em> </em>
</span> </span>
<input <input
@@ -229,14 +340,93 @@ export default async function IntegrationsPage({
autoComplete="off" autoComplete="off"
placeholder={cfg.xbox.apiKey ? "•••••••• stored" : ""} placeholder={cfg.xbox.apiKey ? "•••••••• stored" : ""}
/> />
<CredHelp title="How to get an OpenXBL API key">
<ol>
<li>
Go to{" "}
<a href="https://xbl.io" target="_blank" rel="noreferrer">
xbl.io
</a>{" "}
and sign in with your Microsoft / Xbox account.
</li>
<li>Open the console / API keys page and generate a key.</li>
<li>Copy the key and paste it here.</li>
</ol>
<p>Xbox has no official public API; OpenXBL is a third-party proxy.</p>
</CredHelp>
</label> </label>
<label className="rb-field"> <label className="rb-field">
<span> <span>
XUID <em className="rb-admin-muted">(optional)</em> XUID <em className="rb-admin-muted">(optional)</em>
</span> </span>
<input name="xboxXuid" defaultValue={cfg.xbox.xuid} /> <input name="xboxXuid" defaultValue={cfg.xbox.xuid} />
<CredHelp title="Do I need a XUID?">
<p>
No leave blank and OpenXBL uses the account tied to your API key. Only set this to
read a <em>different</em> profile.
</p>
</CredHelp>
</label> </label>
</div> </div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="xbox" />
</div>
<h2 className="rb-admin-h2">RetroAchievements</h2>
<label className="rb-check">
<input type="checkbox" name="retroEnabled" defaultChecked={cfg.retro.enabled} />
<span>Enable RetroAchievements integration</span>
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Username</span>
<input
name="retroUsername"
defaultValue={cfg.retro.username}
placeholder="your RA username"
/>
</label>
<label className="rb-field">
<span>
Web API key{" "}
<em className="rb-admin-muted">
({cfg.retro.apiKey ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
name="retroApiKey"
type="password"
autoComplete="off"
placeholder={cfg.retro.apiKey ? "•••••••• stored" : ""}
/>
<CredHelp title="How to get a RetroAchievements API key">
<ol>
<li>
Sign in at{" "}
<a href="https://retroachievements.org" target="_blank" rel="noreferrer">
retroachievements.org
</a>
.
</li>
<li>
Open{" "}
<a
href="https://retroachievements.org/settings"
target="_blank"
rel="noreferrer"
>
Settings Keys
</a>{" "}
and copy your <strong>Web API Key</strong>.
</li>
<li>Paste it here along with your username above.</li>
</ol>
</CredHelp>
</label>
</div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="retro" />
</div>
{/* ---- cache ---- */} {/* ---- cache ---- */}
<h2 className="rb-admin-h2">Caching</h2> <h2 className="rb-admin-h2">Caching</h2>
@@ -0,0 +1,27 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
// Step 1 of Steam's one-click sign-in (OpenID 2.0). We bounce the admin to
// Steam, which authenticates them and redirects back to /return with their
// identity. No API key or secret is needed for this exchange — that's what makes
// it one-click: the admin just confirms on Steam and their SteamID flows back.
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const origin = new URL(req.url).origin;
const params = new URLSearchParams({
"openid.ns": "http://specs.openid.net/auth/2.0",
"openid.mode": "checkid_setup",
"openid.return_to": `${origin}/admin/integrations/steam/return`,
"openid.realm": origin,
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
});
return NextResponse.redirect(`${STEAM_OPENID}?${params}`);
}
@@ -0,0 +1,53 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
import { getIntegrationConfig, saveIntegrationConfig } from "@/lib/integrations";
// Step 2 of Steam OpenID sign-in. Steam redirects back here with its assertion.
// We MUST verify it by echoing the params back to Steam with mode=check_auth;
// trusting the query string blindly would let anyone forge a SteamID. On success
// we extract the 64-bit id from the claimed_id URL and persist it. The Steam API
// key (needed to actually read game data) is still entered separately.
const STEAM_OPENID = "https://steamcommunity.com/openid/login";
const CLAIMED_RE = /^https:\/\/steamcommunity\.com\/openid\/id\/(\d{17})$/;
function back(req: NextRequest, qs: Record<string, string>): NextResponse {
const url = new URL("/admin/integrations", req.url);
for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, v);
return NextResponse.redirect(url);
}
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.redirect(new URL("/admin/login", req.url));
}
const incoming = new URL(req.url).searchParams;
const claimed = incoming.get("openid.claimed_id") ?? "";
const match = CLAIMED_RE.exec(claimed);
if (!match) return back(req, { steamErr: "Steam sign-in returned no identity." });
// Re-send every openid.* param back to Steam with mode=check_authentication.
const verify = new URLSearchParams();
for (const [k, v] of incoming) verify.set(k, v);
verify.set("openid.mode", "check_authentication");
let valid = false;
try {
const res = await fetch(STEAM_OPENID, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: verify,
signal: AbortSignal.timeout(8000),
});
valid = (await res.text()).includes("is_valid:true");
} catch {
return back(req, { steamErr: "Could not reach Steam to verify sign-in." });
}
if (!valid) return back(req, { steamErr: "Steam sign-in could not be verified." });
const steamId = match[1];
const cfg = getIntegrationConfig();
saveIntegrationConfig({ ...cfg, steam: { ...cfg.steam, enabled: true, steamId } });
return back(req, { steam: steamId });
}
+73 -16
View File
@@ -25,12 +25,14 @@ import {
getIntegrationConfig, getIntegrationConfig,
saveIntegrationConfig, saveIntegrationConfig,
refreshPlatforms, refreshPlatforms,
testPlatform,
} from "@/lib/integrations"; } from "@/lib/integrations";
import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social"; import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social";
import type { import type {
ConsoleItem, ConsoleItem,
FavoriteGame, FavoriteGame,
IntegrationConfig, IntegrationConfig,
PlatformId,
SocialLink, SocialLink,
} from "@/lib/integrations/types"; } from "@/lib/integrations/types";
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry"; import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
@@ -211,31 +213,57 @@ function parseSocialLines(raw: string): SocialLink[] {
return out; return out;
} }
// Each line: `name|note?`. // The console / game pickers post their selection as a JSON array (one hidden
function parseNamedLines(raw: string): { name: string; note?: string }[] { // input each). Parse defensively — a malformed payload yields an empty list
const out: { name: string; note?: string }[] = []; // rather than throwing. config.normalize* re-validates field shapes after this.
for (const line of raw.split("\n")) { function parseJsonArray(raw: string): Record<string, unknown>[] {
const [name, ...rest] = line.split("|").map((p) => p.trim()); if (!raw.trim()) return [];
if (!name) continue; try {
const note = rest.join("|").trim(); const parsed = JSON.parse(raw);
out.push({ name, note: note || undefined }); return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
} catch {
return [];
} }
return out;
} }
export async function saveIntegrationsAction(formData: FormData) { function parseConsolesJson(raw: string): ConsoleItem[] {
const current = getIntegrationConfig(); return parseJsonArray(raw)
.map((o) => ({
id: typeof o.id === "string" ? o.id : undefined,
name: typeof o.name === "string" ? o.name.trim() : "",
icon: typeof o.icon === "string" ? o.icon : undefined,
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
}))
.filter((c) => c.name);
}
const next: IntegrationConfig = { function parseFavoritesJson(raw: string): FavoriteGame[] {
return parseJsonArray(raw)
.map((o) => ({
name: typeof o.name === "string" ? o.name.trim() : "",
image: typeof o.image === "string" ? o.image : undefined,
url: typeof o.url === "string" ? o.url : undefined,
note: typeof o.note === "string" ? o.note.trim() || undefined : undefined,
}))
.filter((f) => f.name);
}
// Build a full IntegrationConfig from the admin form. Blank credential fields
// fall back to the stored secret so saving never wipes a key the user left
// masked. Shared by save + test so both see identical (live) values.
function configFromForm(
formData: FormData,
current: IntegrationConfig
): IntegrationConfig {
return {
displayName: s(formData, "displayName"), displayName: s(formData, "displayName"),
bio: s(formData, "bio"), bio: s(formData, "bio"),
avatarUrl: s(formData, "avatarUrl"), avatarUrl: s(formData, "avatarUrl"),
social: parseSocialLines(s(formData, "social")), social: parseSocialLines(s(formData, "social")),
consoles: parseNamedLines(s(formData, "consoles")) as ConsoleItem[], consoles: parseConsolesJson(s(formData, "consoles")),
favorites: parseNamedLines(s(formData, "favorites")) as FavoriteGame[], favorites: parseFavoritesJson(s(formData, "favorites")),
steam: { steam: {
enabled: formData.get("steamEnabled") === "on", enabled: formData.get("steamEnabled") === "on",
// Blank credential field = keep the stored one (so secrets aren't wiped on save).
apiKey: s(formData, "steamApiKey").trim() || current.steam.apiKey, apiKey: s(formData, "steamApiKey").trim() || current.steam.apiKey,
steamId: s(formData, "steamId").trim(), steamId: s(formData, "steamId").trim(),
}, },
@@ -248,16 +276,45 @@ export async function saveIntegrationsAction(formData: FormData) {
apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey, apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey,
xuid: s(formData, "xboxXuid").trim(), xuid: s(formData, "xboxXuid").trim(),
}, },
retro: {
enabled: formData.get("retroEnabled") === "on",
username: s(formData, "retroUsername").trim(),
apiKey: s(formData, "retroApiKey").trim() || current.retro.apiKey,
},
cacheTtlMinutes: Number(s(formData, "cacheTtlMinutes")) || current.cacheTtlMinutes, cacheTtlMinutes: Number(s(formData, "cacheTtlMinutes")) || current.cacheTtlMinutes,
}; };
}
saveIntegrationConfig(next); export async function saveIntegrationsAction(formData: FormData) {
const current = getIntegrationConfig();
saveIntegrationConfig(configFromForm(formData, current));
// Config changed (creds/toggles) — drop cached payloads so they refetch. // Config changed (creds/toggles) — drop cached payloads so they refetch.
refreshPlatforms(); refreshPlatforms();
revalidateSite(); revalidateSite();
redirect("/admin/integrations?saved=1"); redirect("/admin/integrations?saved=1");
} }
const PLATFORM_IDS: PlatformId[] = ["steam", "psn", "xbox", "retro"];
function isPlatformId(v: string): v is PlatformId {
return (PLATFORM_IDS as string[]).includes(v);
}
// "Test connection": runs the chosen platform's fetcher against the live form
// values (not the saved config) so the admin can validate creds before saving.
export async function testIntegrationAction(platform: string, formData: FormData) {
if (!isPlatformId(platform)) redirect("/admin/integrations");
const cfg = configFromForm(formData, getIntegrationConfig());
const result = await testPlatform(platform, cfg);
const qs = new URLSearchParams({ test: platform });
if (result.error) qs.set("testErr", result.error);
else {
qs.set("testGames", String(result.games.length));
if (result.notice) qs.set("testNotice", result.notice);
}
redirect(`/admin/integrations?${qs}`);
}
export async function refreshIntegrationsAction() { export async function refreshIntegrationsAction() {
refreshPlatforms(); refreshPlatforms();
revalidateSite(); revalidateSite();
+215
View File
@@ -169,6 +169,7 @@
/* ---- banners ---- */ /* ---- banners ---- */
.rb-admin-ok, .rb-admin-ok,
.rb-admin-warn,
.rb-admin-error { .rb-admin-error {
border-radius: var(--a-radius); border-radius: var(--a-radius);
padding: 10px 14px; padding: 10px 14px;
@@ -181,6 +182,11 @@
color: var(--a-ok-text); color: var(--a-ok-text);
} }
.rb-admin-warn {
background: color-mix(in srgb, #f5a623 22%, transparent);
color: #b8740f;
}
.rb-admin-error { .rb-admin-error {
background: var(--a-err-bg); background: var(--a-err-bg);
color: var(--a-err-text); color: var(--a-err-text);
@@ -410,3 +416,212 @@
color: #555; color: #555;
border-bottom: 2px solid #d0d0d0; border-bottom: 2px solid #d0d0d0;
} }
/* integrations: per-credential expandable help (tooltips) */
.rb-cred-help {
margin-top: 6px;
font-size: 13px;
}
.rb-cred-help > summary {
cursor: pointer;
color: var(--a-muted);
user-select: none;
list-style: none;
}
.rb-cred-help > summary:hover {
color: var(--a-text);
text-decoration: underline;
}
.rb-cred-help[open] > summary {
color: var(--a-text);
font-weight: 500;
}
.rb-cred-help-body {
margin-top: 6px;
padding: 10px 12px;
background: #f6f8fa;
border: 1px solid var(--a-border);
border-radius: 6px;
color: var(--a-text);
}
.rb-cred-help-body ol {
margin: 0;
padding-left: 18px;
}
.rb-cred-help-body li {
margin: 2px 0;
}
.rb-cred-help-body p {
margin: 8px 0 0;
color: var(--a-muted);
}
/* integrations: per-platform action row (e.g. Test connection) */
.rb-form-actions-sub {
margin-top: 10px;
margin-bottom: 6px;
}
.rb-btn-inline {
padding: 4px 10px;
font-size: 13px;
border-color: var(--a-border);
background: #fff;
}
/* integrations: console / game pickers */
.rb-picker {
margin-bottom: 16px;
}
.rb-pick-search {
position: relative;
}
.rb-pick-search > input {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 11px;
width: 100%;
}
.rb-pick-search > input:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
border-color: var(--a-primary);
}
.rb-pick-suggest {
position: absolute;
z-index: 20;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
background: var(--a-surface);
border: 1px solid var(--a-border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 320px;
overflow: auto;
}
.rb-pick-suggest > li > button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
font: inherit;
color: var(--a-text);
background: transparent;
border: 0;
border-radius: 4px;
padding: 7px 9px;
cursor: pointer;
}
.rb-pick-suggest > li > button:hover {
background: #eef1f6;
}
.rb-pick-status {
padding: 8px 9px;
color: var(--a-muted);
}
.rb-pick-suggest-name {
font-weight: 500;
flex: 1;
}
.rb-pick-suggest-meta {
color: var(--a-muted);
font-size: 12px;
white-space: nowrap;
}
.rb-pick-freeform {
color: var(--a-primary) !important;
font-weight: 500;
}
.rb-pick-glyph,
.rb-pick-glyph-img,
.rb-pick-cover {
flex: none;
}
.rb-pick-glyph {
font-size: 18px;
width: 28px;
text-align: center;
}
.rb-pick-glyph-img {
height: 20px;
width: auto;
max-width: 90px;
object-fit: contain;
}
.rb-pick-cover {
width: 46px;
height: 22px;
object-fit: cover;
border: 1px solid var(--a-border);
}
.rb-pick-cover-empty {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 22px;
background: #eef1f6;
border: 1px solid var(--a-border);
font-size: 13px;
}
.rb-pick-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.rb-pick-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid var(--a-border);
border-radius: 6px;
background: #fbfcfe;
}
.rb-pick-chip-name {
font-weight: 500;
min-width: 0;
flex: none;
max-width: 38%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-pick-chip-note {
flex: 1;
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
padding: 5px 8px;
}
.rb-pick-chip-note:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-pick-remove {
flex: none;
font: inherit;
line-height: 1;
color: var(--a-muted);
background: transparent;
border: 0;
border-radius: 4px;
padding: 4px 7px;
cursor: pointer;
}
.rb-pick-remove:hover {
background: var(--a-err-bg);
color: var(--a-danger);
}
+39 -4
View File
@@ -3,10 +3,17 @@ import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks"; import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server"; import { getTheme } from "@/themes/server";
import { getProfile, hasIntegrations } from "@/lib/integrations"; import { getProfile, hasIntegrations } from "@/lib/integrations";
import { isIconUrl } from "@/lib/integrations/consoles";
import type { Achievement, Game } from "@/lib/integrations/types"; import type { Achievement, Game } from "@/lib/integrations/types";
function platformLabel(p: string): string { function platformLabel(p: string): string {
return p === "psn" ? "PlayStation" : p === "xbox" ? "Xbox" : "Steam"; const labels: Record<string, string> = {
psn: "PlayStation",
xbox: "Xbox",
retro: "RetroAchievements",
steam: "Steam",
};
return labels[p] ?? "Steam";
} }
function fmtPlaytime(mins?: number): string | null { function fmtPlaytime(mins?: number): string | null {
@@ -123,12 +130,31 @@ export default async function BioPage() {
<section className="rb-bio-section"> <section className="rb-bio-section">
<h2 className="rb-bio-h2">Favorite games</h2> <h2 className="rb-bio-h2">Favorite games</h2>
<ul className="rb-fav-list"> <ul className="rb-fav-list">
{profile.favorites.map((f, i) => ( {profile.favorites.map((f, i) => {
<li key={i} className="rb-fav"> const body = (
<>
{f.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-fav-cover" src={f.image} alt="" loading="lazy" />
)}
<span className="rb-fav-text">
<span className="rb-fav-name">{f.name}</span> <span className="rb-fav-name">{f.name}</span>
{f.note && <span className="rb-fav-note">{f.note}</span>} {f.note && <span className="rb-fav-note">{f.note}</span>}
</span>
</>
);
return (
<li key={i} className="rb-fav">
{f.url ? (
<a href={f.url} target="_blank" rel="noreferrer" className="rb-fav-link">
{body}
</a>
) : (
body
)}
</li> </li>
))} );
})}
</ul> </ul>
</section> </section>
)} )}
@@ -140,6 +166,15 @@ export default async function BioPage() {
<ul className="rb-console-list"> <ul className="rb-console-list">
{profile.consoles.map((c, i) => ( {profile.consoles.map((c, i) => (
<li key={i} className="rb-console"> <li key={i} className="rb-console">
{c.icon &&
(isIconUrl(c.icon) ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-console-icon-img" src={c.icon} alt="" loading="lazy" />
) : (
<span className="rb-console-icon" aria-hidden>
{c.icon}
</span>
))}
<span className="rb-console-name">{c.name}</span> <span className="rb-console-name">{c.name}</span>
{c.note && <span className="rb-console-note">{c.note}</span>} {c.note && <span className="rb-console-note">{c.note}</span>}
</li> </li>
+34 -2
View File
@@ -399,12 +399,44 @@ img {
.rb-fav, .rb-fav,
.rb-console { .rb-console {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 2px; gap: 10px;
padding: 6px 10px; padding: 6px 10px;
border-left: 3px solid currentColor; border-left: 3px solid currentColor;
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
} }
.rb-fav-link {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
width: 100%;
}
.rb-fav-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.rb-fav-cover {
width: 56px;
height: 26px;
object-fit: cover;
flex: none;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.rb-console-icon {
font-size: 1.2em;
line-height: 1;
flex: none;
}
.rb-console-icon-img {
height: 20px;
width: auto;
max-width: 96px;
object-fit: contain;
flex: none;
}
.rb-fav-name, .rb-fav-name,
.rb-console-name { .rb-console-name {
font-weight: bold; font-weight: bold;
+19 -2
View File
@@ -22,6 +22,7 @@ export const DEFAULT_CONFIG: IntegrationConfig = {
steam: { enabled: false, apiKey: "", steamId: "" }, steam: { enabled: false, apiKey: "", steamId: "" },
psn: { enabled: false, npsso: "" }, psn: { enabled: false, npsso: "" },
xbox: { enabled: false, apiKey: "", xuid: "" }, xbox: { enabled: false, apiKey: "", xuid: "" },
retro: { enabled: false, username: "", apiKey: "" },
cacheTtlMinutes: 360, cacheTtlMinutes: 360,
}; };
@@ -54,7 +55,12 @@ function normConsoles(input: unknown): ConsoleItem[] {
return input return input
.map((raw) => { .map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>; const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined }; return {
id: str(o.id).trim() || undefined,
name: str(o.name).trim(),
icon: str(o.icon).trim() || undefined,
note: str(o.note).trim() || undefined,
};
}) })
.filter((c) => c.name); .filter((c) => c.name);
} }
@@ -64,7 +70,12 @@ function normFavorites(input: unknown): FavoriteGame[] {
return input return input
.map((raw) => { .map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>; const o = (raw ?? {}) as Record<string, unknown>;
return { name: str(o.name).trim(), note: str(o.note).trim() || undefined }; return {
name: str(o.name).trim(),
image: str(o.image).trim() || undefined,
url: str(o.url).trim() || undefined,
note: str(o.note).trim() || undefined,
};
}) })
.filter((f) => f.name); .filter((f) => f.name);
} }
@@ -74,6 +85,7 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
const steam = (o.steam ?? {}) as Record<string, unknown>; const steam = (o.steam ?? {}) as Record<string, unknown>;
const psn = (o.psn ?? {}) as Record<string, unknown>; const psn = (o.psn ?? {}) as Record<string, unknown>;
const xbox = (o.xbox ?? {}) as Record<string, unknown>; const xbox = (o.xbox ?? {}) as Record<string, unknown>;
const retro = (o.retro ?? {}) as Record<string, unknown>;
const ttl = Number(o.cacheTtlMinutes); const ttl = Number(o.cacheTtlMinutes);
return { return {
displayName: str(o.displayName, DEFAULT_CONFIG.displayName), displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
@@ -96,6 +108,11 @@ export function normalizeConfig(input: unknown): IntegrationConfig {
apiKey: str(xbox.apiKey).trim(), apiKey: str(xbox.apiKey).trim(),
xuid: str(xbox.xuid).trim(), xuid: str(xbox.xuid).trim(),
}, },
retro: {
enabled: bool(retro.enabled),
username: str(retro.username).trim(),
apiKey: str(retro.apiKey).trim(),
},
cacheTtlMinutes: cacheTtlMinutes:
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes, Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
}; };
+134
View File
@@ -0,0 +1,134 @@
// Registry of known gaming consoles / platforms. Powers the admin "console
// picker": a searchable list the author clicks to build their owned-hardware
// shelf. Kept free of any server-only imports so the picker (a client
// component) can bundle and filter it locally for instant suggestions — same
// philosophy as ./social.
//
// `icon`, when present, is a path to a self-hosted brand logo SVG under
// /public/consoles (sourced from Wikimedia Commons / Simple Icons). Consoles
// without a logo omit `icon` and render as plain text. Where a model has no
// logo of its own, it falls back to its maker's mark (e.g. all Sega models use
// the Sega logo). Renderers <img> any icon that looks like a path/URL.
export type ConsoleDef = {
/** Stable slug, persisted on the saved item. */
id: string;
name: string;
/** Maker — shown as a muted hint + used to weight search. */
maker: string;
/** Release year, for disambiguation in suggestions. */
year?: number;
/** Logo path (/consoles/*.svg) or URL. Absent → text-only. */
icon?: string;
};
// Hand-curated, roughly chronological within each maker. Not exhaustive, but
// covers the consoles a retro-leaning author is likely to list.
export const CONSOLES: ConsoleDef[] = [
// --- Nintendo ---
{ id: "nes", name: "NES", maker: "Nintendo", year: 1983, icon: "/consoles/nes.svg" },
{ id: "fds", name: "Famicom Disk System", maker: "Nintendo", year: 1986 },
{ id: "snes", name: "Super Nintendo (SNES)", maker: "Nintendo", year: 1990, icon: "/consoles/snes.svg" },
{ id: "n64", name: "Nintendo 64", maker: "Nintendo", year: 1996 },
{ id: "gamecube", name: "GameCube", maker: "Nintendo", year: 2001 },
{ id: "wii", name: "Wii", maker: "Nintendo", year: 2006, icon: "/consoles/wii.svg" },
{ id: "wiiu", name: "Wii U", maker: "Nintendo", year: 2012, icon: "/consoles/wiiu.svg" },
{ id: "switch", name: "Nintendo Switch", maker: "Nintendo", year: 2017, icon: "/consoles/switch.svg" },
{ id: "gameboy", name: "Game Boy", maker: "Nintendo", year: 1989, icon: "/consoles/gameboy.svg" },
{ id: "gbc", name: "Game Boy Color", maker: "Nintendo", year: 1998, icon: "/consoles/gbc.svg" },
{ id: "gba", name: "Game Boy Advance", maker: "Nintendo", year: 2001, icon: "/consoles/gba.svg" },
{ id: "nds", name: "Nintendo DS", maker: "Nintendo", year: 2004, icon: "/consoles/nds.svg" },
{ id: "3ds", name: "Nintendo 3DS", maker: "Nintendo", year: 2011, icon: "/consoles/3ds.svg" },
{ id: "virtualboy", name: "Virtual Boy", maker: "Nintendo", year: 1995 },
// --- Sega (models without their own logo fall back to the Sega mark) ---
{ id: "sg1000", name: "SG-1000", maker: "Sega", year: 1983, icon: "/consoles/sega.svg" },
{ id: "mastersystem", name: "Master System", maker: "Sega", year: 1985, icon: "/consoles/sega.svg" },
{ id: "genesis", name: "Genesis / Mega Drive", maker: "Sega", year: 1988, icon: "/consoles/sega.svg" },
{ id: "segacd", name: "Sega CD", maker: "Sega", year: 1991, icon: "/consoles/sega.svg" },
{ id: "saturn", name: "Sega Saturn", maker: "Sega", year: 1994, icon: "/consoles/sega.svg" },
{ id: "dreamcast", name: "Dreamcast", maker: "Sega", year: 1998, icon: "/consoles/dreamcast.svg" },
{ id: "gamegear", name: "Game Gear", maker: "Sega", year: 1990, icon: "/consoles/sega.svg" },
// --- Sony ---
{ id: "ps1", name: "PlayStation", maker: "Sony", year: 1994, icon: "/consoles/ps1.svg" },
{ id: "ps2", name: "PlayStation 2", maker: "Sony", year: 2000, icon: "/consoles/ps2.svg" },
{ id: "ps3", name: "PlayStation 3", maker: "Sony", year: 2006, icon: "/consoles/ps3.svg" },
{ id: "ps4", name: "PlayStation 4", maker: "Sony", year: 2013, icon: "/consoles/ps4.svg" },
{ id: "ps5", name: "PlayStation 5", maker: "Sony", year: 2020, icon: "/consoles/ps5.svg" },
{ id: "psp", name: "PSP", maker: "Sony", year: 2004, icon: "/consoles/psp.svg" },
{ id: "psvita", name: "PS Vita", maker: "Sony", year: 2011, icon: "/consoles/psvita.svg" },
// --- Microsoft (360 / Series fall back to the Xbox mark) ---
{ id: "xbox", name: "Xbox", maker: "Microsoft", year: 2001, icon: "/consoles/xbox.svg" },
{ id: "xbox360", name: "Xbox 360", maker: "Microsoft", year: 2005, icon: "/consoles/xbox.svg" },
{ id: "xboxone", name: "Xbox One", maker: "Microsoft", year: 2013, icon: "/consoles/xboxone.svg" },
{ id: "xboxseries", name: "Xbox Series X|S", maker: "Microsoft", year: 2020, icon: "/consoles/xbox.svg" },
// --- Atari (all fall back to the Atari mark) ---
{ id: "atari2600", name: "Atari 2600", maker: "Atari", year: 1977, icon: "/consoles/atari2600.svg" },
{ id: "atari5200", name: "Atari 5200", maker: "Atari", year: 1982, icon: "/consoles/atari2600.svg" },
{ id: "atari7800", name: "Atari 7800", maker: "Atari", year: 1986, icon: "/consoles/atari2600.svg" },
{ id: "lynx", name: "Atari Lynx", maker: "Atari", year: 1989, icon: "/consoles/atari2600.svg" },
{ id: "jaguar", name: "Atari Jaguar", maker: "Atari", year: 1993, icon: "/consoles/atari2600.svg" },
// --- Other consoles / handhelds ---
{ id: "neogeo", name: "Neo Geo (AES)", maker: "SNK", year: 1990 },
{ id: "ngpc", name: "Neo Geo Pocket Color", maker: "SNK", year: 1999 },
{ id: "turbografx16", name: "TurboGrafx-16 / PC Engine", maker: "NEC", year: 1987 },
{ id: "3do", name: "3DO", maker: "Panasonic", year: 1993 },
{ id: "colecovision", name: "ColecoVision", maker: "Coleco", year: 1982 },
{ id: "intellivision", name: "Intellivision", maker: "Mattel", year: 1979 },
{ id: "wonderswan", name: "WonderSwan", maker: "Bandai", year: 1999 },
{ id: "steamdeck", name: "Steam Deck", maker: "Valve", year: 2022, icon: "/consoles/steamdeck.svg" },
{ id: "ouya", name: "Ouya", maker: "Ouya", year: 2013 },
// --- Computers ---
{ id: "pc", name: "PC", maker: "Microsoft Windows" },
{ id: "mac", name: "Mac", maker: "Apple", icon: "/consoles/mac.svg" },
{ id: "linux", name: "Linux", maker: "GNU/Linux" },
{ id: "c64", name: "Commodore 64", maker: "Commodore", year: 1982, icon: "/consoles/commodore.svg" },
{ id: "amiga", name: "Amiga", maker: "Commodore", year: 1985, icon: "/consoles/commodore.svg" },
{ id: "msx", name: "MSX", maker: "Microsoft / ASCII", year: 1983 },
{ id: "zxspectrum", name: "ZX Spectrum", maker: "Sinclair", year: 1982 },
{ id: "dos", name: "MS-DOS", maker: "Microsoft", year: 1981 },
{ id: "arcade", name: "Arcade", maker: "Various" },
];
const BY_ID = new Map(CONSOLES.map((c) => [c.id, c]));
export function isConsoleId(v: unknown): v is string {
return typeof v === "string" && BY_ID.has(v);
}
export function consoleById(id: string): ConsoleDef | undefined {
return BY_ID.get(id);
}
/** True when an icon string should render as an <img> rather than text. */
export function isIconUrl(icon: string): boolean {
return /^(https?:\/\/|\/)/.test(icon);
}
/**
* Filter the registry for the picker. Matches name + maker, ranks prefix hits
* first, and caps results so the suggestion list stays tight.
*/
export function searchConsoles(query: string, limit = 8): ConsoleDef[] {
const q = query.trim().toLowerCase();
if (!q) return CONSOLES.slice(0, limit);
const scored: { c: ConsoleDef; score: number }[] = [];
for (const c of CONSOLES) {
const name = c.name.toLowerCase();
const maker = c.maker.toLowerCase();
let score = -1;
if (name.startsWith(q)) score = 3;
else if (name.includes(q)) score = 2;
else if (maker.includes(q)) score = 1;
if (score >= 0) scored.push({ c, score });
}
return scored
.sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name))
.slice(0, limit)
.map((s) => s.c);
}
+37
View File
@@ -5,6 +5,7 @@ import { getIntegrationConfig } from "./config";
import { fetchSteam } from "./steam"; import { fetchSteam } from "./steam";
import { fetchPsn } from "./psn"; import { fetchPsn } from "./psn";
import { fetchXbox } from "./xbox"; import { fetchXbox } from "./xbox";
import { fetchRetro } from "./retro";
import type { import type {
Game, Game,
IntegrationConfig, IntegrationConfig,
@@ -20,6 +21,7 @@ const CACHE_KEYS: Record<PlatformId, string> = {
steam: "platform:steam", steam: "platform:steam",
psn: "platform:psn", psn: "platform:psn",
xbox: "platform:xbox", xbox: "platform:xbox",
retro: "platform:retro",
}; };
function emptyPlatform(platform: PlatformId): PlatformData { function emptyPlatform(platform: PlatformId): PlatformData {
@@ -45,6 +47,9 @@ async function loadPlatform(
case "xbox": case "xbox":
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox)); result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
break; break;
case "retro":
result = await cached(key, ttl, fallback, () => fetchRetro(cfg.retro));
break;
} }
// Surface a cache-layer error onto the payload if the fetch's own error is unset. // Surface a cache-layer error onto the payload if the fetch's own error is unset.
return result.error && !result.data.error return result.error && !result.data.error
@@ -57,6 +62,7 @@ function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
if (cfg.steam.enabled) out.push("steam"); if (cfg.steam.enabled) out.push("steam");
if (cfg.psn.enabled) out.push("psn"); if (cfg.psn.enabled) out.push("psn");
if (cfg.xbox.enabled) out.push("xbox"); if (cfg.xbox.enabled) out.push("xbox");
if (cfg.retro.enabled) out.push("retro");
return out; return out;
} }
@@ -125,3 +131,34 @@ export function hasIntegrations(): boolean {
export function refreshPlatforms(): void { export function refreshPlatforms(): void {
clearCache(Object.values(CACHE_KEYS)); clearCache(Object.values(CACHE_KEYS));
} }
/**
* Run one platform's fetcher directly (no cache) against an arbitrary config —
* powers the admin "Test connection" buttons, which validate live form values
* before anything is saved. Returns the payload (with `.error` set on failure).
*/
export async function testPlatform(
platform: PlatformId,
cfg: IntegrationConfig
): Promise<PlatformData> {
try {
switch (platform) {
case "steam":
return await fetchSteam({ ...cfg.steam, enabled: true });
case "psn":
return await fetchPsn({ ...cfg.psn, enabled: true });
case "xbox":
return await fetchXbox({ ...cfg.xbox, enabled: true });
case "retro":
return await fetchRetro({ ...cfg.retro, enabled: true });
}
} catch (e) {
return {
platform,
games: [],
achievements: [],
error: e instanceof Error ? e.message : String(e),
fetchedAt: new Date().toISOString(),
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import "server-only";
import type { Achievement, Game, PlatformData, RetroConfig } from "./types";
// RetroAchievements has a clean, official, key-based Web API. We pull the user's
// recently-played games and their recent achievement unlocks. Auth is two query
// params on every call: z = username, y = web API key.
// Docs: https://api-docs.retroachievements.org
const API = "https://retroachievements.org/API";
const MEDIA = "https://media.retroachievements.org";
const TIMEOUT = 8000;
function media(path?: string): string | undefined {
if (!path) return undefined;
return path.startsWith("http") ? path : `${MEDIA}${path}`;
}
async function getJson(path: string, cfg: RetroConfig): Promise<unknown> {
const auth = `z=${encodeURIComponent(cfg.username)}&y=${encodeURIComponent(cfg.apiKey)}`;
const url = `${API}/${path}${path.includes("?") ? "&" : "?"}${auth}`;
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) throw new Error(`RetroAchievements ${res.status} ${res.statusText}`);
return res.json();
}
type RecentGame = {
GameID: number;
Title: string;
ImageIcon?: string;
ConsoleName?: string;
LastPlayed?: string;
NumAchieved?: number;
};
type RecentAch = {
AchievementID: number;
Title: string;
Description?: string;
BadgeName?: string;
GameTitle?: string;
ConsoleName?: string;
Date?: string;
};
export async function fetchRetro(cfg: RetroConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "retro",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.username || !cfg.apiKey) {
return { ...base, error: "RetroAchievements username and API key are required." };
}
const recent = (await getJson(
`API_GetUserRecentlyPlayedGames.php?u=${encodeURIComponent(cfg.username)}&c=12`,
cfg
)) as RecentGame[];
const games: Game[] = (recent ?? []).map((g) => ({
platform: "retro",
name: g.ConsoleName ? `${g.Title} (${g.ConsoleName})` : g.Title,
image: media(g.ImageIcon),
url: `https://retroachievements.org/game/${g.GameID}`,
lastPlayed: g.LastPlayed ? g.LastPlayed.replace(" ", "T") + "Z" : undefined,
}));
// Recent achievement unlocks across the last ~2 weeks (m = minutes back).
let achievements: Achievement[] = [];
try {
const ach = (await getJson(
`API_GetUserRecentAchievements.php?u=${encodeURIComponent(cfg.username)}&m=20160`,
cfg
)) as RecentAch[];
achievements = (ach ?? []).slice(0, 8).map((a) => ({
platform: "retro" as const,
game: a.GameTitle ?? "RetroAchievements",
name: a.Title,
description: a.Description,
icon: a.BadgeName ? `${MEDIA}/Badge/${a.BadgeName}.png` : undefined,
unlockedAt: a.Date ? a.Date.replace(" ", "T") + "Z" : undefined,
}));
} catch {
/* achievements optional; keep the games */
}
return { ...base, games, achievements };
}
+2
View File
@@ -17,6 +17,7 @@ export type SocialNetworkId =
| "steam" | "steam"
| "psn" | "psn"
| "xbox" | "xbox"
| "retroachievements"
| "rss" | "rss"
| "website"; | "website";
@@ -42,6 +43,7 @@ export const SOCIAL_NETWORKS: SocialNetwork[] = [
{ id: "steam", name: "Steam", icon: "🕹️" }, { id: "steam", name: "Steam", icon: "🕹️" },
{ id: "psn", name: "PlayStation", icon: "🎯" }, { id: "psn", name: "PlayStation", icon: "🎯" },
{ id: "xbox", name: "Xbox", icon: "🟢" }, { id: "xbox", name: "Xbox", icon: "🟢" },
{ id: "retroachievements", name: "RetroAchievements", icon: "🏆" },
{ id: "rss", name: "RSS", icon: "📡" }, { id: "rss", name: "RSS", icon: "📡" },
{ id: "website", name: "Website", icon: "🌐" }, { id: "website", name: "Website", icon: "🌐" },
]; ];
+49 -12
View File
@@ -4,13 +4,18 @@ import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
// Steam Web API. The only platform here with a sane, official, key-based API. // Steam Web API. The only platform here with a sane, official, key-based API.
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API // Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
// //
// We pull the recently-played games, then fetch unlocked achievements (with // We pull the recently-played games (last 2 weeks). If the account hasn't been
// played recently that list is empty, so we fall back to the full owned-games
// library sorted by last-played. We then fetch unlocked achievements (with
// human names from the game schema) for the single most-recent title to keep // human names from the game schema) for the single most-recent title to keep
// the request count bounded. // the request count bounded.
const API = "https://api.steampowered.com"; const API = "https://api.steampowered.com";
const TIMEOUT = 8000; const TIMEOUT = 8000;
// Thrown when the Steam account's "Game details" privacy hides achievement data.
class PrivacyError extends Error {}
function header(appid: number): string { function header(appid: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
} }
@@ -34,6 +39,20 @@ async function recentGames(key: string, steamId: string): Promise<RecentGame[]>
return json.response?.games ?? []; return json.response?.games ?? [];
} }
type OwnedGame = RecentGame & { rtime_last_played?: number };
// Fallback when nothing was played in the last 2 weeks: the whole library,
// sorted by most-recently-played, trimmed to the same count as the recent list.
async function ownedGames(key: string, steamId: string): Promise<RecentGame[]> {
const url = `${API}/IPlayerService/GetOwnedGames/v1/?key=${key}&steamid=${steamId}&include_appinfo=1&include_played_free_games=1&format=json`;
const json = (await getJson(url)) as { response?: { games?: OwnedGame[] } };
const games = json.response?.games ?? [];
return games
.slice()
.sort((a, b) => (b.rtime_last_played ?? 0) - (a.rtime_last_played ?? 0))
.slice(0, 12);
}
type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string }; type SchemaAch = { name: string; displayName?: string; description?: string; icon?: string };
async function achievementsFor( async function achievementsFor(
@@ -47,10 +66,16 @@ async function achievementsFor(
let progress: { apiname: string; achieved: number; unlocktime: number }[] = []; let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
try { try {
const json = (await getJson(progUrl)) as { const json = (await getJson(progUrl)) as {
playerstats?: { achievements?: typeof progress }; playerstats?: { achievements?: typeof progress; success?: boolean; error?: string };
}; };
// success=false with a privacy error means the account's "Game details"
// visibility is not Public — distinct from a game that has no achievements.
if (json.playerstats?.success === false && /not public/i.test(json.playerstats.error ?? "")) {
throw new PrivacyError();
}
progress = json.playerstats?.achievements ?? []; progress = json.playerstats?.achievements ?? [];
} catch { } catch (e) {
if (e instanceof PrivacyError) throw e;
return []; // many games expose no achievements; treat as none return []; // many games expose no achievements; treat as none
} }
@@ -97,7 +122,10 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
return { ...base, error: "Steam API key and SteamID are required." }; return { ...base, error: "Steam API key and SteamID are required." };
} }
const recent = await recentGames(cfg.apiKey, cfg.steamId); let recent = await recentGames(cfg.apiKey, cfg.steamId);
if (recent.length === 0) {
recent = await ownedGames(cfg.apiKey, cfg.steamId);
}
const games: Game[] = recent.map((g) => ({ const games: Game[] = recent.map((g) => ({
platform: "steam", platform: "steam",
name: g.name, name: g.name,
@@ -106,15 +134,24 @@ export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
playtimeMinutes: g.playtime_forever, playtimeMinutes: g.playtime_forever,
})); }));
// Probe the most-recent games in order until one yields unlocked
// achievements — many titles (e.g. older or MP games) expose none. Bounded so
// the request count stays small.
let achievements: Achievement[] = []; let achievements: Achievement[] = [];
if (recent[0]) { let notice: string | undefined;
achievements = await achievementsFor( for (const g of recent.slice(0, 5)) {
cfg.apiKey, try {
cfg.steamId, achievements = await achievementsFor(cfg.apiKey, cfg.steamId, g.appid, g.name);
recent[0].appid, } catch (e) {
recent[0].name if (e instanceof PrivacyError) {
); notice =
'Games loaded, but achievements are hidden by Steam privacy. Set Steam → Profile → Edit Profile → Privacy → "Game details" to Public.';
break;
}
throw e;
}
if (achievements.length > 0) break;
} }
return { ...base, games, achievements }; return { ...base, games, achievements, notice };
} }
+20 -1
View File
@@ -12,7 +12,7 @@ export type SocialLink = {
}; };
/** The platforms we can auto-fetch gameplay data from. */ /** The platforms we can auto-fetch gameplay data from. */
export type PlatformId = "steam" | "psn" | "xbox"; export type PlatformId = "steam" | "psn" | "xbox" | "retro";
/** A game surfaced from a platform's "recently played" / activity feed. */ /** A game surfaced from a platform's "recently played" / activity feed. */
export type Game = { export type Game = {
@@ -44,13 +44,21 @@ export type Achievement = {
/** A console the author owns / has owned (curated by hand). */ /** A console the author owns / has owned (curated by hand). */
export type ConsoleItem = { export type ConsoleItem = {
/** Registry id when picked from the known-consoles list; absent if free-form. */
id?: string;
name: string; name: string;
/** Emoji glyph or image URL, denormalized from the registry for rendering. */
icon?: string;
note?: string; note?: string;
}; };
/** An all-time favorite game (curated by hand). */ /** An all-time favorite game (curated by hand). */
export type FavoriteGame = { export type FavoriteGame = {
name: string; name: string;
/** Cover / capsule art URL, when added from the game search. */
image?: string;
/** Store / info page link. */
url?: string;
note?: string; note?: string;
}; };
@@ -76,6 +84,14 @@ export type XboxConfig = {
xuid: string; xuid: string;
}; };
export type RetroConfig = {
enabled: boolean;
/** RetroAchievements username (also used as the API caller). */
username: string;
/** Web API key from retroachievements.org/settings. */
apiKey: string;
};
/** Everything the admin curates + the platform credentials. Holds secrets. */ /** Everything the admin curates + the platform credentials. Holds secrets. */
export type IntegrationConfig = { export type IntegrationConfig = {
displayName: string; displayName: string;
@@ -88,6 +104,7 @@ export type IntegrationConfig = {
steam: SteamConfig; steam: SteamConfig;
psn: PsnConfig; psn: PsnConfig;
xbox: XboxConfig; xbox: XboxConfig;
retro: RetroConfig;
/** Cache freshness window for platform fetches, in minutes. */ /** Cache freshness window for platform fetches, in minutes. */
cacheTtlMinutes: number; cacheTtlMinutes: number;
}; };
@@ -99,6 +116,8 @@ export type PlatformData = {
achievements: Achievement[]; achievements: Achievement[];
/** Human-readable error if the last fetch failed (data may be stale/empty). */ /** Human-readable error if the last fetch failed (data may be stale/empty). */
error?: string; error?: string;
/** Non-fatal hint (e.g. games loaded but achievements are privacy-blocked). */
notice?: string;
/** ISO timestamp of when this payload was fetched. */ /** ISO timestamp of when this payload was fetched. */
fetchedAt: string; fetchedAt: string;
}; };