Compare commits

...

5 Commits

Author SHA1 Message Date
kawa 96ea99b953 feat: rich post editor + downloads section
Add a Markdown editor for post bodies with a formatting toolbar
(bold/italic/headings/lists/code/link/image) and a live Write/Preview
toggle. The body still submits as a plain field so saving works without
JS.

Add a per-post downloads section: upload files to the blog or attach
external links. Uploads POST to /admin/upload (admin-guarded, sanitized
names, 200MB cap), are stored under data/uploads, and served back via the
/uploads/[name] route. Downloads render on the post page with file vs link
icons and sizes, and are carried through export/import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:18:47 +02:00
kawa 88ed5d324a Added more icons 2026-06-07 14:40:13 +02:00
kawa c8ba511ebb Added easy drop-down to add consoles and games to bio 2026-06-07 05:43:06 +02:00
kawa 1f195a16de feat: add platform integrations + gamer bio system
Blog can now pull live gaming data and surface a curated gamer profile.

- DB: integrations config table (single JSON row, holds secrets, excluded
  from export), integration_cache table (lazy TTL cache), posts.links column
- Platform fetchers: Steam (official Web API), PSN (NPSSO->token->trophies),
  Xbox (OpenXBL). All degrade gracefully; cache keeps last-good payload on
  fetch failure so pages never blank
- Admin /integrations: profile/bio, social links, consoles, favorites,
  per-platform creds (blank keeps stored secret), cache TTL, live status table
  with refresh
- Public /bio page: avatar, bio, recent games, achievements, favorites,
  consoles. Gated behind hasIntegrations() so a vanilla blog stays clean
- About page bio teaser, Shell "now playing" tray widget + Bio nav link
- Per-post "shared on" social links (editable in PostForm, shown on post page)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:26:51 +02:00
kawa 2f373e683b docs: rebrand README + add Docker and bare-metal deploy
- Rewrite README with RetroBlog branding, config table, and two deploy
  paths (Docker Compose + bare-metal/systemd).
- Enable Next.js standalone output for slim runtime images.
- Add multi-stage Dockerfile (builds better-sqlite3 natively, runs as
  non-root, persists /app/data), docker-compose.yml, and .dockerignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:57:35 +02:00
79 changed files with 7909 additions and 41 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
.next
.git
.gitignore
data
*.tsbuildinfo
next-env.d.ts
.env*
README.md
Dockerfile
.dockerignore
docker-compose.yml
npm-debug.log*
.pnpm-debug.log*
.DS_Store
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1
# ---- deps: install node_modules (native build tools for better-sqlite3) ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# ---- builder: compile the Next.js standalone server ----
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ---- runner: minimal runtime image ----
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
# Non-root user; owns the data volume so SQLite can write.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Standalone output bundles the traced node_modules (incl. the better-sqlite3
# native binary), so no install/rebuild is needed here.
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
VOLUME ["/app/data"]
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+146 -33
View File
@@ -1,50 +1,163 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
<div align="center">
## Getting Started
# 🕹️ RetroBlog
First, run the development server:
**A self-hosted blog engine with swappable OS / game-console skins.**
Write Markdown posts, flip between retro themes (Windows, PS1, PS2, PS3, Wii,
NDS, Dreamcast, JV2002…), and run the whole thing from a single SQLite file.
</div>
---
## Features
- **Markdown posts** — create / edit / delete with slugs, tags, excerpts, dates.
- **Swappable skins** — pick the default theme; optionally let visitors switch.
- **Admin panel** — single-password login, signed session cookie.
- **Import / export** — JSON backup of settings and posts.
- **Zero external services** — data lives in one SQLite file (`data/blog.db`).
Stack: Next.js 16 (App Router) · React 19 · better-sqlite3 · marked.
---
## Quick start (development)
```bash
npm run dev
# or
yarn dev
# or
pnpm install
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open <http://localhost:3000>. Admin panel at <http://localhost:3000/admin>
(default password `admin` in dev).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
---
## Configuration
All config is environment variables. Copy `.env.example` to `.env` and edit.
| Variable | Required | Default | Purpose |
| ---------------------- | -------- | ---------------- | --------------------------------------------------------- |
| `ADMIN_PASSWORD` | **prod** | `admin` | Password for `/admin`. **Set this in production.** |
| `ADMIN_SESSION_SECRET` | **prod** | `ADMIN_PASSWORD` | Signs the admin session cookie. Use a long random string. |
| `PORT` | no | `3000` | Port the server listens on. |
Generate a secret:
```bash
openssl rand -hex 32
```
**Data** is stored in `./data/blog.db` (created automatically). Back up that
directory to back up the whole site.
---
## Deploy — Docker (recommended)
Needs Docker + the Compose plugin.
```bash
# 1. Set production secrets
cp .env.example .env
$EDITOR .env # set ADMIN_PASSWORD and ADMIN_SESSION_SECRET
# 2. Build and run
docker compose up -d --build
```
Site is live on <http://localhost:3000>. The database persists in the
`retroblog-data` Docker volume across rebuilds.
Common operations:
```bash
docker compose logs -f # tail logs
docker compose down # stop (keeps the data volume)
docker compose up -d --build # update after pulling new code
```
**Back up the database:**
```bash
docker compose exec retroblog sh -c 'cat /app/data/blog.db' > backup-$(date +%F).db
```
> Put a TLS-terminating reverse proxy (Caddy, nginx, Traefik) in front for
> HTTPS in production. RetroBlog speaks plain HTTP on `PORT`.
---
## Deploy — bare metal
Needs Node.js 22+ and `pnpm` (`corepack enable`). A C toolchain
(`build-essential`, `python3`) is required once, to compile better-sqlite3.
```bash
# 1. Install deps and build
pnpm install --frozen-lockfile
pnpm build
# 2. Set production secrets
export ADMIN_PASSWORD='your-strong-password'
export ADMIN_SESSION_SECRET="$(openssl rand -hex 32)"
# 3. Start
pnpm start # serves on PORT (default 3000)
```
### Run it as a service (systemd)
`/etc/systemd/system/retroblog.service`:
```ini
[Unit]
Description=RetroBlog
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/retroblog
ExecStart=/usr/bin/pnpm start
Restart=on-failure
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=ADMIN_PASSWORD=your-strong-password
Environment=ADMIN_SESSION_SECRET=your-long-random-secret
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now retroblog
```
The SQLite database lives in `WorkingDirectory/data/blog.db` — make sure the
service `User` can write there, and back up that file.
---
## Admin panel
Visit [http://localhost:3000/admin](http://localhost:3000/admin) and sign in with
`ADMIN_PASSWORD` (see `.env.example`; defaults to `admin` in dev). From there you can:
Sign in at `/admin` with `ADMIN_PASSWORD`. From there:
- **Posts** — full create / edit / delete with Markdown bodies, slugs, tags, and dates.
- **Settings** — branding (title, subtitle, footer, version), the default theme for
- **Posts** — full create / edit / delete with Markdown bodies, slugs, tags, dates.
- **Settings** — branding (title, subtitle, footer, version), default theme for
new visitors, whether the public theme switcher is shown, and which skins it offers.
- **Import / export** — download a JSON backup of settings and/or posts, and import
one back (posts can replace or append).
- **Import / export** — download a JSON backup of settings and/or posts, and
import one back (posts can replace or append).
Auth is a single password kept in `ADMIN_PASSWORD`, with a signed session cookie
(`ADMIN_SESSION_SECRET`). All `/admin/*` routes are gated by middleware.
All `/admin/*` routes are gated by middleware behind the signed session cookie.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
---
## Learn More
## License
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
See repository.
+18
View File
@@ -0,0 +1,18 @@
services:
retroblog:
build: .
image: retroblog:latest
container_name: retroblog
restart: unless-stopped
ports:
- "3000:3000"
environment:
# CHANGE THESE before exposing to the internet.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-change-me-to-a-long-random-string}
volumes:
# Persist the SQLite database across container rebuilds.
- retroblog-data:/app/data
volumes:
retroblog-data:
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Emit a self-contained server in .next/standalone for slim Docker images
// and simple bare-metal deploys.
output: "standalone",
};
export default nextConfig;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 106 KiB

+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

+319
View File
@@ -0,0 +1,319 @@
<svg version="1.1" id="Layer_3" xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 420 150" style="enable-background:new 0 0 420 150" xml:space="preserve">
<style>
.st2{fill:url(#SVGID_00000056394673929355362660000002886737279489534605_)}.st3{fill:url(#SVGID_00000098183238961141449690000011575801526941837449_)}.st4{fill:url(#SVGID_00000029757559987817014000000002480583576565535900_)}.st5{fill:url(#SVGID_00000071538253802991888040000013321318579102368432_)}.st6{fill:url(#SVGID_00000103244760043842833170000013804250023603295395_)}.st7{fill:url(#SVGID_00000099663986924943033050000000117170716514306235_)}.st8{fill:url(#SVGID_00000183954985691024629220000010815158687216383628_)}.st9{fill:url(#SVGID_00000171678413241269041930000006557209051753740718_)}.st10{fill:url(#SVGID_00000167392999810461180850000018412093836479241088_)}.st11{fill:url(#SVGID_00000007412128000624129870000015819481678091446970_)}.st12{fill:url(#SVGID_00000075129211075532362760000011981815904846139796_)}
</style>
<path d="M386.15 9.25h-39.13c-4.17 0-8.29 1.01-11.98 2.95a8.98 8.98 0 0 0-6.64-2.95h-43.39c-4.33 0-8.56 1.08-12.33 3.14a8.93 8.93 0 0 0-6.81-3.14h-67.46a2 2 0 0 0-2 2V49.8h-14.34V18.2a8.97 8.97 0 0 0-8.96-8.96h-23.2a2 2 0 0 0-2 2v.77a25.54 25.54 0 0 0-11.63-2.77H97.15c-4.55 0-8.95 1.18-12.86 3.43a8.95 8.95 0 0 0-7.05-3.43H33.85A25.9 25.9 0 0 0 8 35.1v20.28c0 12.9 9.4 23.71 22.04 25.57 1.72 3.41 7.97 15.31 31.18 58.74a2 2 0 0 0 1.76 1.06h30.11c.74 0 1.41-.4 1.76-1.05l18.42-34.36v26.46a8.97 8.97 0 0 0 8.96 8.96h69.79c4.6 0 9.03-1.19 12.94-3.46a8.95 8.95 0 0 0 7.08 3.46h23.35a2 2 0 0 0 2-2v-1.05a25.61 25.61 0 0 0 12.18 3.05h39.13c4.58 0 9-1.19 12.93-3.47a8.94 8.94 0 0 0 7.08 3.47h23.73a2 2 0 0 0 2-2V100.2h21.26v31.6a8.97 8.97 0 0 0 8.96 8.96h23.73a2 2 0 0 0 2-2V94.62c0-4.75-1.31-9.38-3.73-13.39A25.88 25.88 0 0 0 412 55.38V35.1a25.88 25.88 0 0 0-25.85-25.85zm-298.86 72-9.26 16.04-9.26-16.04h18.52z" style="fill:#111111"/>
<path d="M386.15 11.25h-39.13c-4.6 0-8.89 1.31-12.54 3.57a6.96 6.96 0 0 0-6.08-3.57h-43.39c-4.76 0-9.21 1.4-12.93 3.82a6.96 6.96 0 0 0-6.21-3.82h-67.46V51.8h-18.34V18.2a6.98 6.98 0 0 0-6.96-6.96h-23.2v4.29c-4-2.8-8.75-4.29-13.63-4.29H97.15c-5.01 0-9.67 1.56-13.52 4.21a6.96 6.96 0 0 0-6.39-4.21H33.85A23.9 23.9 0 0 0 10 35.1v20.28c0 12.3 9.36 22.46 21.33 23.72.13.66 31.65 59.65 31.65 59.65h30.11l22.19-41.38v34.42a6.98 6.98 0 0 0 6.96 6.96h69.79a23.7 23.7 0 0 0 13.6-4.25 6.95 6.95 0 0 0 6.41 4.25h23.35v-4.69a23.75 23.75 0 0 0 14.18 4.69h39.13c5.05 0 9.73-1.58 13.59-4.26a6.97 6.97 0 0 0 6.42 4.26h23.73V98.2h25.26v33.6a6.98 6.98 0 0 0 6.96 6.96h23.73V94.62c0-5.7-1.98-11.08-5.62-15.38h3.38A23.88 23.88 0 0 0 410 55.39V35.1a23.88 23.88 0 0 0-23.85-23.85zM78.03 101.29 65.31 79.25h25.44l-12.72 22.04z"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path d="M79.98 114.65a2.21 2.21 0 0 1-3.89 0L56.25 77.72h-18.1l29.01 54.07h21.76l29.01-54.07H99.76l-19.78 36.93z" style="fill:url(#SVGID_1_)"/>
<linearGradient id="SVGID_00000170253157778316165400000000977080892050266772_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000170253157778316165400000000977080892050266772_)" d="M192.02 98h-29.09c-1.87 0-3.38-1.09-3.38-3.38s1.51-3.38 3.38-3.38h43.61V77.72h-46.86a16.9 16.9 0 0 0 0 33.8h29.09c1.87 0 3.38 1.81 3.38 3.38s-1.51 3.38-3.38 3.38h-45.08v13.52h48.34a16.9 16.9 0 1 0-.01-33.8z"/>
<linearGradient id="SVGID_00000178165894598832384160000002628037304573611187_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.75" x2="400.05" y2="106.75" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000178165894598832384160000002628037304573611187_)" d="M77.24 58.76H40.69a6.98 6.98 0 0 1-6.96-6.96V38.68a6.98 6.98 0 0 1 6.96-6.96h36.55V18.21H33.85c-9.29 0-16.9 7.6-16.9 16.9v20.28c0 9.29 7.6 16.9 16.9 16.9h43.39V58.76z"/>
<linearGradient id="SVGID_00000159460745954203877830000002128961389207735468_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.76" x2="400.05" y2="106.76" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000159460745954203877830000002128961389207735468_)" d="M173.24 72.28h29.21V58.76h-22.37a6.98 6.98 0 0 1-6.96-6.96V18.2h-16.77v37.17a16.89 16.89 0 0 0 16.87 16.91h.02z"/>
<linearGradient id="SVGID_00000047771124936840639570000010413644445567804593_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.75" x2="400.05" y2="106.75" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000047771124936840639570000010413644445567804593_)" d="M265.87 72.28V58.76h-39.85a3.81 3.81 0 0 1-3.89-3.73v-.16a3.82 3.82 0 0 1 3.75-3.89h36.97V39.5h-36.83c-2.15 0-3.89-1.83-3.89-3.89s1.74-3.89 3.89-3.89h39.85V18.21h-60.5v37.17a16.9 16.9 0 0 0 16.9 16.9h43.6z"/>
<linearGradient id="SVGID_00000170967180119102120980000014870985591373894035_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.76" x2="400.05" y2="106.76" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000170967180119102120980000014870985591373894035_)" d="M97.15 72.28h39.13c9.29 0 16.9-7.6 16.9-16.9V35.1c0-9.29-7.6-16.9-16.9-16.9H97.15c-9.29 0-16.9 7.6-16.9 16.9v20.28a16.96 16.96 0 0 0 16.9 16.9zm-.12-33.6a6.98 6.98 0 0 1 6.96-6.96h25.46a6.98 6.98 0 0 1 6.96 6.96V51.8a6.98 6.98 0 0 1-6.96 6.96h-25.46a6.98 6.98 0 0 1-6.96-6.96V38.68z"/>
<linearGradient id="SVGID_00000165957492473322812780000008461313583871852717_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.75" x2="400.05" y2="106.75" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000165957492473322812780000008461313583871852717_)" d="M285.01 72.28h43.39V58.76h-36.55a6.98 6.98 0 0 1-6.96-6.96V38.68a6.98 6.98 0 0 1 6.96-6.96h36.55V18.21h-43.39c-9.29 0-16.9 7.6-16.9 16.9v20.28a16.95 16.95 0 0 0 16.9 16.89z"/>
<linearGradient id="SVGID_00000003800336205836736790000017127685192883055240_" gradientUnits="userSpaceOnUse" x1="16.77" y1="106.75" x2="400.05" y2="106.75" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000003800336205836736790000017127685192883055240_)" d="M386.15 18.21h-39.13c-9.29 0-16.9 7.6-16.9 16.9v20.28c0 9.29 7.6 16.9 16.9 16.9h39.13c9.29 0 16.9-7.6 16.9-16.9V35.1a16.95 16.95 0 0 0-16.9-16.89zm.12 33.59a6.98 6.98 0 0 1-6.96 6.96h-25.46a6.98 6.98 0 0 1-6.96-6.96V38.68a6.98 6.98 0 0 1 6.96-6.96h25.46a6.98 6.98 0 0 1 6.96 6.96V51.8z"/>
<linearGradient id="SVGID_00000144298862349047524770000004973050712855015614_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000144298862349047524770000004973050712855015614_)" d="M288.7 77.72h-39.13c-9.29 0-16.9 7.6-16.9 16.9v20.28c0 9.29 7.6 16.9 16.9 16.9h39.13c9.29 0 16.9-7.6 16.9-16.9V94.62a16.96 16.96 0 0 0-16.9-16.9zm.12 33.6a6.98 6.98 0 0 1-6.96 6.96H256.4a6.98 6.98 0 0 1-6.96-6.96V98.2a6.98 6.98 0 0 1 6.96-6.96h25.46a6.98 6.98 0 0 1 6.96 6.96v13.12z"/>
<linearGradient id="SVGID_00000074414624697793150500000018212030733622346121_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000074414624697793150500000018212030733622346121_)" d="M122.23 131.79H139V77.72h-16.77v54.07z"/>
<linearGradient id="SVGID_00000034091522569499096210000017492494641054368921_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000034091522569499096210000017492494641054368921_)" d="M212.04 131.79h16.77V77.72h-16.77v54.07z"/>
<linearGradient id="SVGID_00000046316400393776809320000017029069420188131515_" gradientUnits="userSpaceOnUse" x1="16.77" y1="47.24" x2="400.05" y2="47.24" gradientTransform="matrix(1 0 0 -1 0 152)">
<stop offset="0" style="stop-color:#ff809b"/>
<stop offset=".05" style="stop-color:#fe829e"/>
<stop offset=".07" style="stop-color:#eb7581"/>
<stop offset=".1" style="stop-color:#fe5b38"/>
<stop offset=".14" style="stop-color:#f35c3d"/>
<stop offset=".3" style="stop-color:#f56236"/>
<stop offset=".33" style="stop-color:#eb7549"/>
<stop offset=".36" style="stop-color:#ea8540"/>
<stop offset=".37" style="stop-color:#e99740"/>
<stop offset=".47" style="stop-color:#e99940"/>
<stop offset=".49" style="stop-color:#e8a641"/>
<stop offset=".52" style="stop-color:#ede16b"/>
<stop offset=".57" style="stop-color:#efe562"/>
<stop offset=".61" style="stop-color:#efe464"/>
<stop offset=".65" style="stop-color:#a8c662"/>
<stop offset=".69" style="stop-color:#a7c860"/>
<stop offset=".72" style="stop-color:#a8c961"/>
<stop offset=".75" style="stop-color:#a6c75f"/>
<stop offset=".76" style="stop-color:#94c077"/>
<stop offset=".76" style="stop-color:#92c182"/>
<stop offset=".91" style="stop-color:#91c59d"/>
<stop offset=".93" style="stop-color:#7ec1bd"/>
<stop offset="1" style="stop-color:#63bce9"/>
</linearGradient>
<path style="fill:url(#SVGID_00000046316400393776809320000017029069420188131515_)" d="M364.53 77.72h-55.82v54.07h16.77V98.2a6.98 6.98 0 0 1 6.96-6.96h25.26a6.98 6.98 0 0 1 6.96 6.96v33.6h16.77V94.62a16.9 16.9 0 0 0-16.9-16.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

+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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 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: 7.1 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

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<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 3839 1557.6" style="enable-background:new 0 0 3839 1557.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#FFFFFA;}
.st2{fill:#FF0000;}
</style>
<g>
<rect x="0" width="3839" height="1557.6"/>
<g>
<g>
<g>
<polygon class="st1" points="2722.2,513.4 2436.3,513.4 2436.3,231.8 2758.7,231.8 "/>
</g>
<g>
<rect x="1579.3" y="1044.2" class="st1" width="570.3" height="281.5"/>
</g>
<g>
<rect x="1902.4" y="638" class="st1" width="247.1" height="281.5"/>
</g>
<g>
<path class="st1" d="M1902.4,919.6c-458.5,0-458.5-687.7,0-687.7c0,93.9,0,187.7,0,281.5c-83.1,0-83.1,124.7,0,124.7
C1902.4,731.9,1902.4,825.7,1902.4,919.6z"/>
</g>
<g>
<path class="st1" d="M2149.5,638c458.5,0,458.5,687.7,0,687.7c0-93.9,0-187.7,0-281.5c83.1,0,83.1-124.7,0-124.7
C2149.5,825.7,2149.5,731.9,2149.5,638z"/>
</g>
<g>
<polygon class="st1" points="3535.8,1325.8 2758.7,231.8 2513.6,372.9 3190.5,1325.8 "/>
</g>
<g>
<polygon class="st1" points="2389.5,1325.8 3166.5,231.8 3511.8,231.8 2734.8,1325.8 "/>
</g>
<g>
<rect x="1902.4" y="231.8" class="st1" width="583.4" height="281.5"/>
</g>
<g>
<polygon class="st1" points="595,1325.8 729.4,833.1 863.8,1325.8 1117.7,1325.8 1252.2,833.1 1386.5,1325.8 1678.3,1325.8
1379.9,231.8 1124.3,231.8 990.7,721.5 857.2,231.8 601.6,231.8 303.2,1325.8 "/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 627.66 581.96">
<title id="title207">Nintendo 64 (1996)</title>
<metadata id="metadata235">image/svg+xmlNintendo 64 (1996)</metadata>
<g class="layer">
<title>Layer 1</title>
<path d="m191.28,483.04l-67.23,-145.37l4.3,173.23l62.93,-27.86zm270.79,-92.55l4.06,-168.31l66.68,-20.26l0,280.8l-66.68,28.18l0,0l-97.98,-120.63l0,161.95l-70.75,29.74l-2.87,-292.6l73.62,-23.58l93.92,124.71zm-113.36,-131.16l14.43,-10.62l0,-111.59l-71.91,19.69l-48.37,102.83l54.85,-15.9l51,15.59z" fill="#021daa" id="path183" stroke-width="3.13px"/>
<path d="m231.66,136.81l-36.32,65.11l31.31,63.86l15.51,-4.9l60.49,-107.76l-70.99,-16.31zm-5.01,128.97l-2.92,134.93l-92.53,-174.39l-69.53,-24.4l0,280.81l66.68,28.17l0,-149.33l98.3,190.65l70.75,29.74l0,-293.99l-70.75,-22.19zm172.8,-63.86l-50.52,57.16l19.22,6.7l97.98,140.25l0,-186.38l-66.68,-17.73z" fill="#07942d" id="path185" stroke-width="3.13px"/>
<path d="m368.15,452.67l20.03,28.18l77.95,30.05l-97.98,-128.04l0,69.81zm-244.68,-234.88l71.87,-15.87l31.31,63.86l0,146.82l-103.18,-194.81z" fill="#ff2920" id="path193" stroke-width="3.13px"/>
<path d="m297.4,111.45l-65.74,25.36l65.74,25.67l65.74,-25.36l0,0l-65.74,-25.67zm-168.73,64.8l66.67,25.67l-66.67,25.67l-67,-25.67l67,-25.67zm337.46,0l66.68,25.67l-66.68,25.67l-66.68,-25.67l66.68,-25.67zm-239.48,89.53l71.06,-26.92l70.44,26.92l-70.75,26.92l-70.75,-26.92z" fill="#ffbd01" id="path201" stroke-width="3.13px"/>
<path d="m496.5,18.79c-22.54,0 -33.5,14.71 -33.5,36c0,21.28 11.27,36 33.5,36c22.54,0 33.49,-14.72 33.49,-36c0,-21.29 -11.27,-36 -33.49,-36zm-496.5,1.88l0,68.24l20.66,0l0,-39.45l0.32,0l13.77,39.45l30.68,0l0,-68.24l-20.66,0l0,39.13l-0.31,0l-13.78,-39.13l-30.68,0zm77.95,0l0,68.24l25.67,0l0,-68.24l-25.67,0zm37.88,0l0,68.24l20.66,0l0,-39.45l0.31,0l13.78,39.45l30.68,0l0,-68.24l-20.66,0l0,39.13l-0.32,0l-13.77,-39.13l-30.68,0zm72.94,0l0,18.47l16.28,0l0,49.77l25.67,0l0,-49.77l16.28,0l0,-18.47l-58.23,0zm66.05,0l0,68.24l51.34,0l0,-17.84l-26.29,0l0,-7.52l23.48,0l0,-17.53l-23.48,0l0,-7.2l25.67,0l0,-18.15l-50.72,0zm60.42,0l0,68.24l20.66,0l0,-39.45l0.32,0l13.77,39.45l30.68,0l0,-68.24l-20.66,0l0,39.13l-0.32,0l-13.77,-39.13l-30.68,0zm76.7,0l0,68.24l31.93,0c19.72,0 32.56,-12.84 32.56,-33.5c0,-20.03 -8.14,-34.74 -34.13,-34.74l-30.36,0zm104.56,15.02c4.07,0 6.88,2.51 6.88,18.79c0,16.9 -2.81,18.78 -6.88,18.78c-4.07,0 -6.89,-2.19 -6.89,-18.78c0,-16.6 2.82,-18.79 6.89,-18.79zm-79.52,0.32l2.51,0c4.07,0 6.88,1.25 8.45,4.06c1.56,2.82 1.88,8.14 1.88,14.72c0,6.57 -0.32,11.89 -1.88,14.71c-1.57,2.82 -4.38,4.07 -8.45,4.07l-2.51,0l0,-0.31l0,-37.25z" fill="#021daa" id="path203" stroke-width="3.13px"/>
<path d="m570.69,0c-8.77,0.32 -15.96,2.82 -21.6,8.14c-5.95,5.33 -9.39,14.09 -9.39,24.11c0,12.52 6.57,24.1 21.91,24.1c11.58,0 20.04,-8.76 20.04,-20.03c0,-11.59 -7.83,-17.85 -16.28,-17.85c-4.38,0 -7.52,0.94 -10.33,3.45l-0.31,0c1.56,-5.32 5.63,-9.71 16.59,-10.33l4.38,0l0,-11.59l-5.01,0zm33.18,0.94l-18.15,32.56l0,9.71l22.85,0l0,12.52l13.46,0l0,-12.21l5.63,0l0,-11.27l-5.63,0l0,-31.31l-18.16,0zm4.7,10.65l0.31,0c0,3.75 -0.31,7.51 -0.31,10.95l0,9.4l-9.39,0l0,-0.32l4.38,-9.08c1.88,-3.75 3.44,-7.2 5.01,-10.95zm-47.9,17.53c4.7,0 6.58,4.38 6.58,8.14c0,4.69 -2.19,8.13 -5.64,8.13c-4.69,0 -6.89,-4.69 -7.2,-9.07c0,-1.57 0,-2.51 0.32,-3.13c0.93,-2.19 3.12,-4.07 5.94,-4.07z" fill="#ff2920" id="path223" stroke-width="3.13px"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+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

+124
View File
@@ -0,0 +1,124 @@
<?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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="323.388547527"
height="309.225545446"
viewBox="0 0 485.43286 464.17334"
id="svg4978"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="Neo Geo.svg">
<defs
id="defs4980" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-514.97784"
inkscape:cy="989.30971"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1001"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1" />
<metadata
id="metadata4983">
<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></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-588.18893)">
<circle
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-opacity:1"
id="path5565"
cx="243.57146"
cy="818.07648"
r="216.42857" />
<path
style="fill:#ffcc00;fill-rule:evenodd;stroke:#ffcc00;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 83.301656,823.20494 29.799504,-75.5089 74.49875,58.84139 -4.79823,-139.90613 -7.3236,10.85914 -2.27285,-9.84899 -6.56599,9.59645 -3.03046,-20.96066 -10.10152,80.55966 -53.538094,-40.15356 -13.13198,13.63706 z"
id="path5537"
inkscape:connector-curvature="0" />
<path
style="fill:#ffcc00;fill-rule:evenodd;stroke:#ffcc00;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 315.13166,670.41937 -30.80965,46.97209 21.97082,50.50763 40.65864,52.2754 33.33503,11.61675 38.63834,-50.76017 -8.83884,-41.66879 -39.14341,-55.05331 z"
id="path5545"
inkscape:connector-curvature="0" />
<path
style="fill:#ffcc00;fill-rule:evenodd;stroke:#ffcc00;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 203.25727,820.67956 75.76144,-57.07362 -14.39467,2.02031 8.58629,-7.32361 -11.61675,2.52538 15.40483,-11.36421 -51.77032,9.34391 -1.76777,-17.42513 69.95307,-51.26525 -72.22591,5.30331 64.9023,-42.67895 -22.47589,2.52538 18.94036,-15.15228 -10.60661,-0.50508 15.40483,-11.11168 -88.64089,29.29442 z"
id="path5539"
inkscape:connector-curvature="0" />
<path
style="fill:#37abc8;fill-rule:evenodd;stroke:#37abc8;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 185.83214,899.97654 62.37692,83.08504 -6.81853,-15.65736 9.84899,7.82868 -4.79823,-15.40482 11.11168,2.77792 -37.37564,-71.97337 67.42768,2.0203 -35.86042,-60.60915 55.55839,18.68782 -9.84898,-10.85914 16.41497,4.29315 -13.13198,-16.92006 7.32361,0.75762 -46.46702,-37.88072 z"
id="path5541"
inkscape:connector-curvature="0" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 344.17355,786.08184 9.84899,3.28299 16.92005,0 8.08122,-5.55584 6.31346,-8.83883 6.06091,-20.70813 2.77792,17.17259 -2.52538,11.11168 -8.08122,9.34391 -9.34391,5.55584 -17.42513,-1.01015 z"
id="path5543"
inkscape:connector-curvature="0" />
<path
style="fill:#37abc8;fill-rule:evenodd;stroke:#37abc8;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 281.79663,959.82808 20.96067,20.20305 39.90102,-10.10153 61.11423,-57.57869 2.27284,-70.71068 -71.97336,1.51523 -52.02286,67.68022 z"
id="path5547"
inkscape:connector-curvature="0" />
<path
style="fill:#37abc8;fill-rule:evenodd;stroke:#37abc8;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 172.19508,815.12372 c -7.46547,-4.98526 -20.42851,-12.59066 -33.58757,-10.35406 -61.351744,10.42774 -57.238984,85.85412 -29.29442,127.27922 15.49316,22.96711 61.14073,52.78671 86.87311,58.84139 l -20.96066,-121.47085 -40.65864,13.13198 11.11168,0.25254 -14.14214,11.61676 14.39467,0 -12.87944,10.35406 24.24366,0 8.33376,33.33504 -0.25254,-0.50508 c -12.87635,-3.65561 -27.02066,-15.53029 -35.62508,-24.21789 -25.0083,-25.25005 -27.51627,-73.97164 15.16949,-76.03975 19.65982,5.34601 34.81979,19.90489 51.0127,27.77919 z"
id="path5551"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssccccccccccsccc" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 324.47558,926.74558 9.59645,3.53553 11.61675,-5.05076 10.10153,19.1929 -22.72844,9.59645 -10.10152,-0.50508 -4.54569,-7.57614 -12.62691,1.51523 -5.55583,-17.67767 5.05076,-10.10153 13.13198,-5.05076 z"
id="path5555"
inkscape:connector-curvature="0" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 343.75,885.39792 10.89286,-2.14286 4.64286,-11.78571 -4.46429,-9.46429 -14.10714,2.5 -5.53572,11.78571 z"
id="path5557"
inkscape:connector-curvature="0" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 316.78572,738.79077 6.78571,4.28572 7.5,-4.82143 -2.14286,-6.25 -6.78571,-1.78571 -5,3.57142 z"
id="path5559"
inkscape:connector-curvature="0" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 345.35715,718.79077 5.71428,-8.57142 6.96429,0 5.71428,9.28571 -3.92857,7.67857 -9.10714,-0.17857 z"
id="path5561"
inkscape:connector-curvature="0" />
<path
style="fill:#000000;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 370,891.29078 5.53572,-2.67858 7.67857,1.78572 -0.17857,6.78571 -4.64286,5.89286 -7.85714,-4.46429 z"
id="path5563"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+91
View File
@@ -0,0 +1,91 @@
<?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:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="600"
height="200"
id="svg4058"
inkscape:version="0.48.1 "
sodipodi:docname="ouya logo.svg">
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1024"
inkscape:window-height="715"
id="namedview13"
showgrid="false"
inkscape:zoom="1"
inkscape:cx="293.5"
inkscape:cy="66"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg4058" />
<defs
id="defs4060" />
<metadata
id="metadata4063">
<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="matrix(0.70921753,0,0,0.70921753,16.312988,-575.43195)"
id="layer1">
<g
transform="translate(-32.826774,1321.6644)"
id="g4050">
<path
d="m 227.85715,514.50507 a 81.428575,81.428575 0 1 1 -162.85715,0 81.428575,81.428575 0 1 1 162.85715,0 z"
transform="matrix(1.0008772,0,0,1.0008772,21.2686,-884.25862)"
id="path2999"
style="color:#000000;fill:none;stroke:#000000;stroke-width:11.98948383;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
inkscape:connector-curvature="0" />
<path
d="m 423.60916,-446.45023 0.49999,87 c 0.22695,39.48771 -32.01164,71.64801 -71.5,71.64801 -39.48836,0 -71.49999,-32.01164 -71.5,-71.5 l 0,-87.14801"
id="path2999-6"
style="color:#000000;fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-miterlimit:4;stroke-opacity:1;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
inkscape:connector-curvature="0" />
<path
d="m 603.45555,-446.45023 0.5,37.05346 c 0.53281,39.48477 -32.01164,71.648 -71.5,71.648 -39.48836,0 -71.5,-32.01163 -71.5,-71.5 l 0,-37.20146"
id="path2999-6-5"
style="color:#000000;fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-miterlimit:4;stroke-opacity:1;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
inkscape:connector-curvature="0" />
<path
d="m 532.45878,-292.98077 0,-41.55194"
id="path3998"
style="fill:#ff0000;stroke:#000000;stroke-width:12;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" />
<path
d="m 636.82793,-292.45023 -0.5,-81.9747 c -0.24085,-39.48763 32.01164,-71.648 71.5,-71.648 39.48836,0 71.5,32.01163 71.5,71.49999 l 0,82.12271"
id="path2999-6-43"
style="color:#000000;fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-miterlimit:4;stroke-opacity:1;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
inkscape:connector-curvature="0" />
<path
d="m 637.16323,-345.75021 140.57043,0"
id="path3998-9"
style="fill:#ff0000;stroke:#000000;stroke-width:12;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 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

+109
View File
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<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"
viewBox="0 0 639.94666 915.91998"
height="915.91998"
width="639.94666"
xml:space="preserve"
version="1.1"
id="svg2"><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"><clipPath
id="clipPath18"
clipPathUnits="userSpaceOnUse"><path
id="path20"
d="m 0,0 4799.6,0 0,6869 L 0,6869 0,0 Z" /></clipPath></defs><g
transform="matrix(1.3333333,0,0,-1.3333333,0,915.92)"
id="g10"><g
transform="scale(0.1)"
id="g12"><g
id="g14"><g
clip-path="url(#clipPath18)"
id="g16"><path
id="path22"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 100,100 4599.6,0 0,6669.4 -4599.6,0 0,-6669.4 z" /><path
id="path24"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 360,6389.4 0,-179.9 210,0 0,-2639.8 179.902,0 0,2639.8 210,0 0,179.9 -599.902,0" /><path
id="path26"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 929.902,629.898 -240,0 0,2429.802 240,0 0,-569.89 185.598,0 -0.2,614.89 c 0,91.1 -73.9,165 -165.003,165 l -275.395,0 c -91,0 -164.902,-73.9 -164.902,-165 l 0,-2519.802 c 0,-91.097 73.902,-165 164.902,-165 l 440.898,0.204 -0.2,1589.708 -305.698,0 0,-180 120,0 0,-1199.912" /><path
id="path28"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3869.7,6179.5 239.9,0 0,-2399.8 -239.9,0 0,2399.8 z m -180,45 0.5,-2490.3 c 0,-91 73.9,-165 165,-165 l 271.8,0 c 91.11,0 168.61,74 168.61,165 L 4295,6224.5 c 0,91 -73.9,164.9 -165,164.9 l -275.3,0 c -91.1,0 -165,-73.9 -165,-164.9" /><path
id="path30"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1139.9,6389.4 3.1,-1902.9 c 0,-91.1 73.9,-165 165,-165 l 269.1,0 c 91.1,0 164,73.9 164,165 l 4.1,1902.9 -185.3,0 0,-1859.8 -240,0 0,1859.8 -180,0" /><path
id="path32"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2999.7,5309.5 240,0 0,-779.9 -240,0 0,779.9 z m 0,870 240,0 0,-660 -240,0 0,660 z m -179.9,-1859.9 437.3,-2.7 c 91.09,0 168,81.6 168,172.7 l 0,849.9 c 0,22.1 -34.41,58.3 -42.31,77.6 7.9,19.3 42.31,55.3 42.31,77.4 l 0,730 c 0,91 -73.9,164.9 -165,164.9 l -440.3,0 0,-2069.8" /><path
id="path34"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2129.8,6179.5 240,0 0,-660 -240,0 0,660 z m 0,-870.4 90,0.4 210,-989.9 210,0 -210,989.9 c 85.5,6.4 155.89,77.7 155.89,164.6 l -0.49,750.4 c 0,91 -74,164.9 -165,164.9 l -470.4,0 0,-2069.8 180,0 0,989.5" /><path
id="path36"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2639.8,2279.81 0,-659.91 -264.6,0 0,659.91 264.6,0 z M 2825.2,420 l -0.1,1904.81 c 0,91.09 -73.9,165 -164.9,165 l -305.4,0 c -91.1,0 -165,-73.91 -165,-165 l 0,-1904.81 185.4,0 0,989.9 264.6,0 0,-989.9 185.4,0" /><path
id="path38"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3539.7,2489.5 -315,0.31 c -91,0 -165,-73.91 -165,-165 l 0,-1904.81 185.4,0 0,989.9 264.6,0 0,210 -264.6,0 0,659.91 294.6,0 0,209.69" /><path
id="path40"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1499.9,2279.81 239.9,0 0,-659.91 -239.9,0 0,659.91 z m 0,-870.41 90,0.5 209.9,-989.9 210,0 -210,989.9 c 85.6,6.4 156,77.7 156,164.5 l -0.6,750.41 c 0,91.09 -73.9,165 -165,165 l -470.3,0 0,-2069.81 180,0 0,989.4" /><path
id="path42"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3689.7,1323.8 0,-903.8 180,0 0,903.8 c 0.3,21.3 3.09,125.7 30,176.1 13.8,25.9 59.6,104.7 104.9,181.91 45.4,-77.21 91.2,-156.01 105,-181.91 26.9,-50.4 29.7,-154.8 30,-176.1 l 0,-903.8 180,0 0,903.8 c -0.21,26.89 -2.6,184.7 -30,236.1 -25.5,47.8 -137.71,231 -170.8,284.91 33.09,53.89 145.3,237.19 170.8,285 27.4,51.39 29.79,209.09 30,236.09 l 0,903.8 -180,0 0,-903.8 c -0.3,-21.3 -3.1,-125.7 -30,-176.09 -13.8,-26.01 -59.6,-104.71 -105,-181.91 -45.3,77.2 -91.1,155.9 -104.9,181.91 -26.91,50.39 -29.7,154.79 -30,176.09 l 0,903.8 -180,0 0,-903.8 c 0.2,-27 2.6,-184.7 30,-236.09 25.5,-47.81 137.7,-231.11 170.8,-285 -33.1,-53.91 -145.3,-237.11 -170.8,-284.91 -27.4,-51.4 -29.8,-209.21 -30,-236.1" /><path
id="path44"
style="fill:#f7ed19;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 180,6659.4 4419.6,0 0,-6479.4 -4419.6,0 0,6479.4 z M 0,6869.4 0,0 l 4799.6,0 0,6869.4 -4799.6,0" /><path
id="path46"
style="fill:#f19c1b;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1299.9,3376 c 0,341.5 277.1,618.6 618.6,618.6 l 900.4,0 c 341.5,0 618.6,-277.1 618.6,-618.6 0,-341.5 -277.1,-618.6 -618.6,-618.6 l -900.4,0 c -341.5,0 -618.6,277.1 -618.6,618.6 z m -100,-1.3 c 0,-389.09 315.8,-704.89 704.9,-704.89 l 930,0 c 389.1,0 704.9,315.8 704.9,704.89 0,389.1 -315.8,704.9 -704.9,704.9 l -930,0 c -389.1,0 -704.9,-315.8 -704.9,-704.9" /><path
id="path48"
style="fill:#f19c1b;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1679.9,3044.7 0,-90 329.9,0 0,90 -120,0 0,750 -209.9,0 0,-90 119.9,0 0,-660 -119.9,0" /><path
id="path50"
style="fill:#f19c1b;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2249.8,3434.7 0,238.6 c 0,30.4 0.9,31.4 31.4,31.4 l 627.2,0 c 30.4,0 31.3,-1 31.3,-31.4 l 0,-88.6 90,0 0,63 c 0,81.1 -65.8,147 -146.91,147 l -576.09,0 c -81.1,0 -146.9,-65.9 -146.9,-147 l 0,-546 c 0,-81.09 65.8,-147 146.9,-147 l 576.09,0 c 81.11,0 146.91,65.91 146.91,147 l 0,216.09 c 0,81.11 -35.8,116.91 -116.91,116.91 l -662.99,0 z m 689.9,-328.6 0,-31.4 -689.9,0 0,238.6 c 0,30.5 0.9,31.4 31.4,31.4 l 627.2,0 c 30.4,0 31.3,-0.9 31.3,-31.4 l 0,-207.2" /><path
id="path52"
style="fill:none;stroke:#f9f8e7;stroke-width:29.99699974;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 1299.9,3371 c 0,341.5 277.1,618.7 618.6,618.7 l 900.4,0 c 341.5,0 618.6,-277.2 618.6,-618.7 0,-341.5 -277.1,-618.6 -618.6,-618.6 l -900.4,0 c -341.5,0 -618.6,277.1 -618.6,618.6 z" /><path
id="path54"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 500,6299.4 0,-89.9 159.902,0 0,-2639.8 90,0 0,2639.8 210,0 0,89.9 -459.902,0" /><path
id="path56"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1019.9,530 -329.998,0 0,2529.7 329.998,0 0,-569.89 95.6,0 -0.2,514.89 c 0,91.1 -73.9,165 -165.003,165 l -185.395,0 c -91,0 -165,-73.9 -165,-165 l 0,-2419.802 c 0,-91.097 74,-165 165,-165 l 350.898,0.204 -0.2,1499.708 -235.698,0 0,-90 139.998,0 0,-1299.81" /><path
id="path58"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3869.7,6179.5 339.9,0 0,-2499.8 -339.9,0 0,2499.8 z m -90,-45 0.5,-2400.3 c 0,-91 73.9,-165 165,-165 l 181.8,0 c 91.11,0 168.61,74 168.61,165 L 4295,6134.5 c 0,91 -73.9,164.9 -165,164.9 l -185.3,0 c -91.1,0 -165,-73.9 -165,-164.9" /><path
id="path60"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1229.9,6299.4 3.1,-1812.9 c 0,-91.1 3.9,-165 95,-165 l 249.1,0 c 91.1,0 164,73.9 164,165 l 4.1,1812.9 -95.3,0 0,-1869.8 -330,0 0,1869.8 -90,0" /><path
id="path62"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2999.7,5309.5 330,0 0,-879.9 -330,0 0,879.9 z m 0,870 330,0 0,-760 -330,0 0,760 z m -90,-1859.9 347.4,-2.7 c 91.09,0 168,81.6 168,172.7 l 0,849.9 c 0,22.1 -34.41,58.3 -42.31,77.6 7.9,19.3 42.31,55.3 42.31,77.4 l 0,640 c 0,91 -73.9,164.9 -165,164.9 l -350.4,0 0,-1979.8" /><path
id="path64"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2129.8,6179.5 340,0 0,-760 -340,0 0,760 z m 0,-870.4 190,0.4 210,-989.9 110,0 -210,989.9 c 85.5,6.4 155.89,77.7 155.89,164.6 l -0.49,660.4 c 0,91 -74,164.9 -165,164.9 l -380.4,0 0,-1979.8 90,0 0,989.5" /><path
id="path66"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2729.8,2279.81 0,-759.91 -354.6,0 0,759.91 354.6,0 z M 2825.2,420 l -0.1,1794.81 c 0,91.09 -73.9,165 -164.9,165 l -215.4,0 c -91.1,0 -165,-73.91 -165,-165 l 0,-1794.81 95.4,0 0,989.9 354.6,0 0,-989.9 95.4,0" /><path
id="path68"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3539.7,2379.5 -225,0.31 c -91,0 -165,-73.91 -165,-165 l 0,-1794.81 95.4,0 0,989.9 264.6,0 0,110 -264.6,0 0,759.91 294.6,0 0,99.69" /><path
id="path70"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3789.7,1323.8 0,-903.8 80,0 0,903.8 c 0.3,21.3 3.09,125.7 30,176.1 13.8,25.9 109.6,174.7 154.9,251.91 45.4,-77.21 141.2,-226.01 155,-251.91 26.9,-50.4 29.7,-154.8 30,-176.1 l 0,-903.8 80,0 0,903.8 c -0.21,26.89 -2.6,184.7 -30,236.1 -25.5,47.8 -137.71,231 -170.8,284.91 33.09,53.89 145.3,237.19 170.8,285 27.4,51.39 29.79,209.09 30,236.09 l 0,803.8 -80,0 0,-803.8 c -0.3,-21.3 -3.1,-125.7 -30,-176.09 -13.8,-26.01 -109.6,-184.71 -155,-261.91 -45.3,77.2 -141.1,235.9 -154.9,261.91 -26.91,50.39 -29.7,154.79 -30,176.09 l 0,803.8 -80,0 0,-803.8 c 0.2,-27 2.6,-184.7 30,-236.09 25.5,-47.81 137.7,-231.11 170.8,-285 -33.1,-53.91 -145.3,-237.11 -170.8,-284.91 -27.4,-51.4 -29.8,-209.21 -30,-236.1" /><path
id="path72"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 100,6769.4 0,-6669.4 4599.6,0 0,6669.4 -4599.6,0 z m 80,-110 4419.6,0 0,-6479.4 -4419.6,0 0,6479.4" /><path
id="path74"
style="fill:#f8fae6;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 1499.9,2279.81 339.9,0 0,-759.91 -339.9,0 0,759.91 z m 0,-870.41 190,0.5 209.9,-989.9 110,0 -210,989.9 c 85.6,6.4 156,77.7 156,164.5 l -0.6,640.41 c 0,91.09 -73.9,165 -165,165 l -380.3,0 0,-1959.81 90,0 0,989.4" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 11 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
version="1.1"
width="345.02859"
height="89.038597"
id="svg2"
sodipodi:docname="Virtualboy_logo.svg"
inkscape:version="1.1.2 (b8e25be833, 2022-02-05)"
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">
<sodipodi:namedview
id="namedview39"
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
showgrid="false"
inkscape:zoom="2.7593163"
inkscape:cx="177.03661"
inkscape:cy="44.395055"
inkscape:window-width="1128"
inkscape:window-height="682"
inkscape:window-x="-6"
inkscape:window-y="-6"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<defs
id="defs6" />
<path
d="M 0,0.01011 18.565281,89.02825 37.035351,0.01011 H 27.038666 L 18.565281,50.04116 9.99669,0.01011 Z"
id="path2952"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 41.129241,0.01011 h 9.99669 v 89.01814 h -9.99669 z"
id="path3726"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="M 60.069121,5e-5 V 89.0313 h 10 v -46 l 13.90625,46 h 9.21875 l -11.125,-42.09375 c 6.18089,-3.7085 8,-8.5625 8,-8.5625 V 10.0938 C 85.975231,1.001575 78.912871,5e-5 78.912871,5e-5 Z m 10,9.0625 h 10 v 30.9375 h -10 z"
id="path3728"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 93.207231,0.01011 h 29.894859 v 9.04463 h -9.99669 v 79.97351 h -9.99669 V 9.05474 h -9.901479 z"
id="path3733"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 178.41287,5e-5 -19.3125,88.9375 v 0.0937 h 9.90625 l 2.09375,-12 h 13.96875 l 2,12 h 10 L 178.41287,0 Z m 0,38.375 4.5625,30.65625 h -9.875 z"
id="path3735"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 201.07627,0.01011 h 9.99669 v 79.97352 h 14.94743 v 9.04462 h -24.94412 z"
id="path2882"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 307.99325,0.01011 h 9.13983 l 9.33024,38.36825 8.47339,-38.36825 h 10.09189 l -13.99536,52.36362 v 36.65452 h -9.99669 V 52.37373 Z"
id="path3660"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 290.05385,0.01011 c -11.36341,0 -15.01598,10.08369 -15.01598,10.08369 0.0952,0.142818 0,66.93962 0,68.84375 1.50738,10.82145 15.01598,10.0907 15.01598,10.0907 v 0 c 14.98799,0 14.98402,-10.0907 14.98402,-10.0907 V 10.0938 C 301.16269,-0.489651 290.05385,0.01011 290.05385,0.01011 Z m -5.01598,10.08369 h 10 v 69.875 h -10 z"
id="path3662"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="m 127.00556,0.01011 h 10.0919 v 78.92625 h 10.09189 V 0.01011 h 9.90149 v 78.92625 c 0,0 -3.23703,10.09189 -14.56661,10.09189 -11.32958,0 -15.51867,-10.09189 -15.51867,-10.09189 z"
id="path3667"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
<path
d="M 237.06912,5e-5 V 89.0313 h 20.09375 c 9.32664,-2.42777 9.90625,-9.0625 9.90625,-9.0625 0,0 -0.0147,-23.7805 -0.0147,-29.92764 -0.7003,-3.8802 -3.89155,-4.00986 -3.89155,-4.00986 0,0 -0.0119,-1.08369 0,-1.9375 3.52628,-0.26929 3.90625,-4.09375 3.90625,-4.09375 0,0 -0.0147,-23.756642 0,-29.90625 C 265.54515,2.831 257.16287,5e-5 257.16287,5e-5 Z m 10.03125,10.15625 h 10.0625 v 29.84375 h -10.0625 z m -0.0312,38.875 h 10.09375 v 30.9375 h -10.09375 z"
id="path3669"
style="fill:#fe0016;fill-opacity:1;stroke:none" />
</svg>

After

Width:  |  Height:  |  Size: 3.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

+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="400" height="258" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="m261.85 58.54c30.919 2.0083 63.248 28.088 72.232 58.266-17.968-60.357-82.638-112.54-144.46-116.54-61.815-4.0142-109.46 38.84-79.413 102.05 11.099 23.333 28.052 44.348 51.6 53.488-5.3404 1.7339-11.41 2.5332-18.136 2.0896-30.907-1.9845-63.248-28.088-72.232-58.266 17.969 60.369 82.65 112.54 144.47 116.56 61.827 4.0023 106.68-43.201 79.401-102.05-10.812-23.369-28.04-44.36-51.588-53.488 5.3404-1.7339 11.41-2.5433 18.136-2.1031zm4.0262 52.974c6.5948 18.088 4.2532 34.288-4.7549 45.005 4.1098-5.9259 4.9819-14.313 1.6008-23.631-6.547-17.969-26.391-32.52-44.372-32.52-17.945 0-27.216 14.552-20.669 32.52 6.5232 17.956 26.391 32.508 44.348 32.508 5.2209 0 9.6772-1.2294 13.249-3.393-6.8219 5.0653-15.83 7.9805-26.594 7.9805-32.293 0-68.015-26.176-79.759-58.469-11.756-32.305 4.8983-58.493 37.191-58.493 32.305 0 68.003 26.188 79.759 58.493" fill="#ce001d"/>
<path d="m161.83 237.83 7.6582-9.0321h-11.302l-2.8193 4.54-3.9546 3.7273h-12.019l-9.9878 6.1528-2.7479 10.263c-0.60931 2.2588 1.3858 4.0859 4.4443 4.0859h19.462l8.2914 0.2034 4.3846-16.595c0.37096-1.3614-0.21325-2.5806-1.4098-3.3324zm-13.214 16.57-4.1935 0.1013h-5.5315c-0.75267 0-1.2544-0.4776-1.1112-1.016l2.7002-10.263 4.9939-3.0717h5.5913c0.74102 0 1.2544 0.4301 1.0989 1.016l-3.5483 13.237m205.84 3.1393-6.8458-0.036 4.6594-17.383h-4.1695c-3.7992 0-7.5865 2.3063-8.3271 5.113l-1.1112 4.0859c-0.70428 2.8448 1.7922 5.1494 5.5913 5.1494h3.2617l-4.074 3.0716h-6.9174c-6.1289 0-10.191-3.6917-8.9843-8.2199l1.1471-4.0859c1.2662-4.516 7.2041-8.1838 13.321-8.1838h11.087l11.23-0.4166-5.5435 20.74-4.3248 0.1694m-78.827-26.756h-15.352c-1.5172 0-3.178 1.375-3.6439 3.119l-0.44174 1.6121c-0.44175 1.7441 0.39425 3.119 1.9354 3.119h12.533c3.8111 0 6.0214 3.5245 4.8147 7.909l-0.87185 3.1056c-1.1948 4.3728-5.3404 7.8972-9.1515 7.8972h-42.329l5.4718-4.7192h31.23c1.5294 0 3.142-1.4461 3.62-3.1766l0.4301-1.5647c0.47758-1.744-0.39426-3.1664-1.9236-3.1664h-12.544c-3.8112 0-5.9019-3.5245-4.767-7.8373l0.87185-3.1766c1.183-4.3128 5.2807-7.8373 9.1516-7.8373h15.209l11.756-0.71773-5.9974 5.4362m-69.365 26.748h-6.9532l3.835-14.325 9.9878-6.1647h16.666c4.5877 0 7.5386 2.7601 6.6307 6.1647l-0.0269 0.072-4.9342 3.0716h-6.9652l0.84855-3.1529c0.50178-1.7306-1.0752-3.0954-3.3453-3.0954h-2.7956l-4.994 3.0954-3.7394 14.158-4.2174 0.1693m-88.755 0.01h-6.965l3.835-14.325c0.46594-1.7306-1.0634-3.0953-3.3331-3.0953h-6.9652l-3.2976 12.508-5.4838 4.9105h-6.9652l5.6152-20.956 10.908 0.4677h13.966c4.5877 0 7.5505 2.7601 6.6307 6.1647l-3.7275 14.158-4.2174 0.1694m274.38 0h-6.9772l3.847-14.325c0.46594-1.7305-1.0634-3.0954-3.3332-3.0954h-6.9771l-3.2854 12.508-5.4957 4.9106h-6.9532l5.6033-20.956 10.92 0.4677h13.966c4.5996 0 7.5506 2.7601 6.6426 6.1645l-3.7394 14.158-4.2174 0.1693m-216.82-4.088 0.97973-3.7276h13.871l9.9878-6.1528 0.59766-2.4383c0.6093-2.2589-1.3858-4.0859-4.4444-4.0859h-19.414l-9.9878 6.1646-0.94388 3.5244c-0.0358 0.1013-0.0448 0.2034-0.0717 0.3047l-1.7085 6.4037c-0.60931 2.2453 1.3857 4.0859 4.4443 4.0859h18.877l4.9939-3.0716h-16.081c-0.74102 0-1.2544-0.4677-1.0993-1.016zm2.6761-10.239 4.9939-3.0953h7.3714c0.72848 0 1.2544 0.4301 1.099 1.0159l-0.66934 2.4384-4.982 3.0716h-8.7334l0.91978-3.4289m-101.6 14.323h-19.474c-3.0584 0-5.0417-1.8389-4.4444-4.0859l2.7479-10.239 9.9878-6.1647h19.414c3.0584 0 5.0417 1.8289 4.4444 4.086l-2.7002 10.25zm-1.147-6.1531 2.736-10.25c0.16577-0.586-0.34676-1.016-1.0993-1.016h-5.5913l-4.9819 3.0954-2.7002 10.239c-0.14247 0.5484 0.35842 1.0159 1.0994 1.0159h5.5315l5.0059-3.0817m239.03 6.151h-37.538l4.6833-17.527 4.8266-2.9734 6.8338-0.4534-4.8028 17.98h2.6402l4.8267-2.9633 3.1062-11.589 4.8147-2.9735 6.8457-0.4533-4.0262 15.017-1.9236 2.9633h3.7753l4.8625-2.9633 3.0584-11.588 4.8267-2.9735 6.8457-0.4543-4.0382 15.018-9.6174 5.938m-275.14 0h-38.876l6.3081-28.279 5.0059-3.7036 7.0608-0.47758-6.4276 28.769h2.7479l4.994-3.7036 4.6713-20.884 4.994-3.7036 7.0727-0.47758-5.6033 25.065-1.9832 3.7035h3.9068l5.0416-3.7035 4.6116-20.884 4.9939-3.7036 7.0728-0.47759-5.6033 25.065-9.9878 7.3954"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+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

+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.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"
width="446px" height="138px" viewBox="0 0 446 138" enable-background="new 0 0 446 138" xml:space="preserve">
<polygon fill="#00A7E1" points="338.29,0 308.726,0 406.816,138 436.392,138 "/>
<polygon fill="#3D9B00" points="308.726,0 279.151,0 377.241,138 406.816,138 "/>
<polygon fill="#FFC610" points="279.151,0 249.575,0 347.665,138 377.241,138 "/>
<polygon fill="#FF1F1F" points="249.575,0 220,0 318.101,138 347.665,138 "/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M78.56,29.22v40.45h12.2V29.22H78.56z M322.069,29.22v40.45h12.201V29.22H322.069z
M235.09,16v53.68h12.2V16H235.09z M93.979,29.22v40.45h12.2V38.88h42.94v30.79h12.189V29.22H93.979z M404.819,29.22h-67.33v40.45
h12.19V38.88h55.14V29.22z M8,54.28h55.04v5.75H8v9.641h67.22V44.63H20.1v-5.75h55.219v-9.66H8V54.28z M164.53,29.22v40.45h67.33
v-9.641h-55.131V38.88h55.131v-9.66H164.53z M262.71,60.029v-5.75h43.48v5.75H262.71z M250.5,29.22v9.66h55.68v5.75H250.5v25.04
h68.351V29.22H250.5z M78.56,16v9.65h12.2V16H78.56z M322.06,16v9.65h12.2V16H322.06z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#00A7E1" d="M8,115.939v5.84h28.42v-6.219H17.87l18.07-21.61V88.1H9.42v6.23
h16.66L8,115.939z M49.49,104.15l-11.82,17.67h8.36l7.51-11.67l7.36,11.67h8.83l-11.75-17.61L68.79,88.1h-8.12L53.83,98.87
L47.22,88.1h-8.59L49.49,104.15z M81.44,110.6l-7.18,0.01c-0.09,8.311,6.9,11.99,14.35,11.99c9.16,0,14.02-4.629,14.02-10.82
c0-7.639-7.55-9.199-10.01-9.819c-8.45-2.17-10.05-2.5-10.05-5.101c0-2.83,2.73-3.83,5.1-3.83c3.53,0,6.41,1.041,6.65,5.101h7.17
c0-7.79-6.47-10.851-13.5-10.851c-6.08,0-12.6,3.301-12.6,10.201c0,6.319,5.06,8.27,10.06,9.58c4.95,1.319,10.01,1.93,10.01,5.569
c0,3.44-3.97,4.2-6.57,4.2C84.93,116.83,81.44,115.09,81.44,110.6z M123.01,109.66c0,3.96-1.4,7.74-5.8,7.75
c-4.35,0-5.86-3.79-5.86-7.75s1.42-7.881,5.8-7.881C121.45,101.779,123.01,105.79,123.01,109.66z M104.9,97.4v33h6.69v-11.621h0.09
c1.61,2.41,4.4,3.681,7.32,3.681c7.13,0,10.72-6.08,10.72-12.601c0-6.939-3.45-13.119-11.09-13.119c-3.02,0-5.66,1.17-7.27,3.77
h-0.09V97.4H104.9z M148.75,106.971h-10.91c0.09-1.881,1.32-5.191,5.57-5.191C146.67,101.779,148.13,103.57,148.75,106.971z
M137.84,111.21h17.62c0.46-7.51-3.54-14.489-11.86-14.489c-7.41,0-12.46,5.569-12.46,12.889c0,7.551,4.76,12.841,12.46,12.841
c5.53,0,9.54-2.45,11.431-8.21h-5.9c-0.43,1.51-2.6,3.16-5.29,3.16C140.11,117.4,138.03,115.471,137.84,111.21z M174.61,105.99
l6.569,0.01c-0.42-6.221-5.71-9.25-11.47-9.25c-7.939,0-12.471,5.609-12.471,13.21c0,7.33,4.961,12.521,12.32,12.521
c6.42,0,10.91-3.551,11.75-10.021h-6.47c-0.42,2.97-2.22,4.96-5.33,4.96c-4.2,0-5.569-4.199-5.569-7.689
c0-3.59,1.409-7.94,5.709-7.94C172.43,101.79,174.18,103.26,174.61,105.99z M192.159,97.4V90.09h-6.709v7.32h-4.061v4.49h4.061
v14.39c0,4.86,3.6,5.81,7.709,5.81c1.311,0,2.771-0.05,3.91-0.24v-5.239c-0.699,0.14-1.369,0.19-2.069,0.19
c-2.271,0-2.841-0.57-2.841-2.84V101.88h4.91V97.4H192.159z M198.87,97.4l-0.01,24.41h6.709V110.8c0-4.3,1.69-7.829,6.601-7.829
c0.81,0,1.8,0.09,2.409,0.229v-6.229c-0.42-0.141-0.989-0.23-1.459-0.23c-3.26,0-6.561,2.119-7.791,5.189h-0.09V97.4H198.87z
M238.7,121.79V97.4h-6.711v12.789c0,4.951-1.549,6.98-5.189,6.98c-3.109,0-4.29-1.97-4.29-5.99V97.4h-6.71v15
c0,6.039,1.8,10.05,8.93,10.05c2.82,0,5.76-1.33,7.45-4.06h0.149v3.399H238.7z M242.56,97.4v24.379h6.711V107.62
c0-4.761,3.209-5.61,4.529-5.61c4.25,0,4.061,3.26,4.061,6.09v13.68h6.709v-13.59c0-3.069,0.721-6.18,4.49-6.18
c3.83,0,4.11,2.69,4.11,5.95v13.86h6.7v-16.34c0-6.33-3.74-8.74-8.93-8.74c-3.4,0-5.94,1.939-7.361,4.06
c-1.329-2.92-4.059-4.06-7.129-4.06c-3.16,0-5.711,1.46-7.461,3.96h-0.09v-3.3H242.56z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+18
View File
@@ -1,9 +1,14 @@
import Link from "next/link";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import { THEMES } from "@/themes/registry";
import { getIntegrationConfig, hasIntegrations } from "@/lib/integrations";
export default async function AboutPage() {
const theme = await getTheme();
const showBio = hasIntegrations();
const cfg = showBio ? getIntegrationConfig() : null;
return (
<Shell theme={theme} title="About">
<article className="rb-article">
@@ -30,6 +35,19 @@ export default async function AboutPage() {
file and registering it no markup changes required.
</p>
</div>
{showBio && cfg && (
<div className="rb-about-bio rb-prose">
<h2>About the webmaster</h2>
<p>
{cfg.displayName ? <strong>{cfg.displayName}</strong> : "The webmaster"}{" "}
keeps a living gamer profile here recently played titles,
achievements, the console shelf, and all-time favorites.{" "}
<Link href="/bio">See the full bio </Link>
</p>
<SocialLinks links={cfg.social} />
</div>
)}
</article>
</Shell>
);
@@ -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." });
}
}
+453
View File
@@ -0,0 +1,453 @@
import type { ReactNode } from "react";
import { getIntegrationConfig, getProfile } from "@/lib/integrations";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import type { SocialLink } from "@/lib/integrations/types";
import {
saveIntegrationsAction,
refreshIntegrationsAction,
testIntegrationAction,
} 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 {
return links
.map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | "))
.join("\n");
}
function fmtAge(iso: string): string {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "—";
const mins = Math.round((Date.now() - t) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
return hrs < 24 ? `${hrs}h ago` : `${Math.round(hrs / 24)}d ago`;
}
type Banners = {
saved?: string;
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;
}
export default async function IntegrationsPage({
searchParams,
}: {
searchParams: Promise<Banners>;
}) {
const banners = await searchParams;
const cfg = getIntegrationConfig();
// Loads (cached) platform data so we can show freshness + any fetch errors.
const profile = await getProfile();
const networkIds = SOCIAL_NETWORKS.map((n) => n.id).join(", ");
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Integrations</h1>
<Banner {...banners} />
<p className="rb-admin-muted">
Build your gamer bio: a profile, social links, console list, favorite
games, and live data pulled from gaming platforms. Everything here powers
the public <a href="/bio" target="_blank">bio page</a>.
</p>
{/* ---- platform status ---- */}
{profile.platforms.length > 0 && (
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Platform status</h2>
<table className="rb-admin-table">
<thead>
<tr>
<th>Platform</th>
<th>Games</th>
<th>Achievements</th>
<th>Fetched</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{profile.platforms.map((p) => (
<tr key={p.platform}>
<td>{p.platform.toUpperCase()}</td>
<td>{p.games.length}</td>
<td>{p.achievements.length}</td>
<td>{fmtAge(p.fetchedAt)}</td>
<td>
{p.error ? (
<span className="rb-admin-error" style={{ margin: 0 }}>
{p.error}
</span>
) : (
"OK"
)}
</td>
</tr>
))}
</tbody>
</table>
<form action={refreshIntegrationsAction} className="rb-form-actions">
<button className="rb-btn" type="submit">
Refresh now
</button>
</form>
</div>
)}
<form className="rb-admin-card" action={saveIntegrationsAction}>
{/* ---- profile ---- */}
<h2 className="rb-admin-h2">Profile</h2>
<div className="rb-field-row">
<label className="rb-field">
<span>Display name</span>
<input name="displayName" defaultValue={cfg.displayName} />
</label>
<label className="rb-field">
<span>Avatar image URL</span>
<input name="avatarUrl" defaultValue={cfg.avatarUrl} placeholder="https://…" />
</label>
</div>
<label className="rb-field">
<span>
Bio <em className="rb-admin-muted">(Markdown)</em>
</span>
<textarea name="bio" rows={4} className="rb-mono" defaultValue={cfg.bio} />
</label>
{/* ---- social links ---- */}
<h2 className="rb-admin-h2">Social links</h2>
<label className="rb-field">
<span>
One per line: <code>network|url|label</code>
<em className="rb-admin-muted"> networks: {networkIds}</em>
</span>
<textarea
name="social"
rows={4}
className="rb-mono"
placeholder={"github|https://github.com/me\nmastodon|https://mastodon.social/@me"}
defaultValue={socialToText(cfg.social)}
/>
</label>
{/* ---- consoles + favorites ---- */}
<h2 className="rb-admin-h2">Collection</h2>
<div className="rb-field">
<span>
Consoles owned{" "}
<em className="rb-admin-muted"> search and click to add</em>
</span>
<ConsolePicker name="consoles" initial={cfg.consoles} />
</div>
<div className="rb-field">
<span>
Favorite games{" "}
<em className="rb-admin-muted"> search (Steam catalog) and click to add</em>
</span>
<GamePicker name="favorites" initial={cfg.favorites} />
</div>
{/* ---- platforms ---- */}
<h2 className="rb-admin-h2">Steam</h2>
<label className="rb-check">
<input type="checkbox" name="steamEnabled" defaultChecked={cfg.steam.enabled} />
<span>Enable Steam integration</span>
</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">
<label className="rb-field">
<span>
API key{" "}
<em className="rb-admin-muted">
({cfg.steam.apiKey ? "stored — blank keeps it" : "required for game data"})
</em>
</span>
<input
name="steamApiKey"
type="password"
autoComplete="off"
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 className="rb-field">
<span>SteamID (64-bit)</span>
<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>
</div>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="steam" />
</div>
<h2 className="rb-admin-h2">PlayStation (PSN)</h2>
<label className="rb-check">
<input type="checkbox" name="psnEnabled" defaultChecked={cfg.psn.enabled} />
<span>Enable PSN integration</span>
</label>
<label className="rb-field">
<span>
NPSSO token{" "}
<em className="rb-admin-muted">
({cfg.psn.npsso ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
name="psnNpsso"
type="password"
autoComplete="off"
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>
<div className="rb-form-actions rb-form-actions-sub">
<TestButton platform="psn" />
</div>
<h2 className="rb-admin-h2">Xbox</h2>
<label className="rb-check">
<input type="checkbox" name="xboxEnabled" defaultChecked={cfg.xbox.enabled} />
<span>Enable Xbox integration</span>
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>
OpenXBL API key{" "}
<em className="rb-admin-muted">
({cfg.xbox.apiKey ? "stored — blank keeps it" : "required"})
</em>
</span>
<input
name="xboxApiKey"
type="password"
autoComplete="off"
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 className="rb-field">
<span>
XUID <em className="rb-admin-muted">(optional)</em>
</span>
<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>
</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 ---- */}
<h2 className="rb-admin-h2">Caching</h2>
<label className="rb-field">
<span>
Refresh interval <em className="rb-admin-muted">(minutes)</em>
</span>
<input
name="cacheTtlMinutes"
type="number"
min={1}
defaultValue={cfg.cacheTtlMinutes}
/>
</label>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
Save integrations
</button>
</div>
</form>
</div>
);
}
@@ -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 });
}
+1
View File
@@ -21,6 +21,7 @@ export default async function PanelLayout({
<nav className="rb-admin-nav">
<Link href="/admin">Dashboard</Link>
<Link href="/admin/posts">Posts</Link>
<Link href="/admin/integrations">Integrations</Link>
<Link href="/admin/settings">Settings</Link>
<Link href="/" target="_blank">
View site
@@ -0,0 +1,161 @@
"use client";
import { useRef, useState } from "react";
import type { Download } from "@/lib/posts";
// Manages a post's download section: a mix of uploaded files and external links.
// Uploads POST to /admin/upload and come back as a /uploads/<name> URL; external
// links are entered by hand. The whole list is serialized to a hidden input
// (name="downloads") as JSON, mirroring the console/game pickers' pattern.
function formatSize(bytes?: number): string {
if (!bytes && bytes !== 0) return "";
const units = ["B", "KB", "MB", "GB"];
let n = bytes;
let u = 0;
while (n >= 1024 && u < units.length - 1) {
n /= 1024;
u++;
}
return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;
}
export default function DownloadEditor({
name,
initial,
}: {
name: string;
initial: Download[];
}) {
const [items, setItems] = useState<Download[]>(initial);
const [linkLabel, setLinkLabel] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
function setLabel(idx: number, label: string) {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, label } : it)));
}
function remove(idx: number) {
setItems((prev) => prev.filter((_, i) => i !== idx));
}
function addLink() {
const url = linkUrl.trim();
if (!url) return;
setItems((prev) => [
...prev,
{ label: linkLabel.trim() || url, url, kind: "link" },
]);
setLinkLabel("");
setLinkUrl("");
}
async function onFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setError(null);
setUploading(true);
try {
for (const file of Array.from(files)) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/admin/upload", { method: "POST", body: fd });
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error || `upload failed (${res.status})`);
}
const j = (await res.json()) as { url: string; filename: string; size: number };
setItems((prev) => [
...prev,
{ label: j.filename, url: j.url, kind: "file", size: j.size, filename: j.filename },
]);
}
} catch (e) {
setError(e instanceof Error ? e.message : "upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
return (
<div className="rb-dl">
<input type="hidden" name={name} value={JSON.stringify(items)} />
{items.length > 0 && (
<ul className="rb-dl-list">
{items.map((it, i) => (
<li key={`${it.url}-${i}`} className="rb-dl-row">
<span className="rb-dl-kind" aria-hidden>
{it.kind === "file" ? "📦" : "🔗"}
</span>
<input
className="rb-dl-label"
type="text"
value={it.label}
placeholder="Download label"
onChange={(e) => setLabel(i, e.target.value)}
/>
<a className="rb-dl-url" href={it.url} target="_blank" rel="noreferrer" title={it.url}>
{it.kind === "file" ? it.filename ?? it.url : it.url}
</a>
{it.size != null && <span className="rb-dl-size">{formatSize(it.size)}</span>}
<button
type="button"
className="rb-dl-remove"
aria-label={`Remove ${it.label}`}
onClick={() => remove(i)}
>
</button>
</li>
))}
</ul>
)}
<div className="rb-dl-add">
<div className="rb-dl-add-row">
<input
type="text"
className="rb-dl-add-label"
value={linkLabel}
placeholder="Label (optional)"
onChange={(e) => setLinkLabel(e.target.value)}
/>
<input
type="url"
className="rb-dl-add-url"
value={linkUrl}
placeholder="https://example.com/file.zip"
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addLink();
}
}}
/>
<button type="button" className="rb-btn rb-btn-inline" onClick={addLink}>
Add link
</button>
</div>
<div className="rb-dl-add-row">
<input
ref={fileRef}
type="file"
multiple
className="rb-dl-file"
disabled={uploading}
onChange={(e) => onFiles(e.target.files)}
/>
{uploading && <span className="rb-dl-status">Uploading</span>}
</div>
{error && <p className="rb-admin-error rb-dl-error">{error}</p>}
</div>
</div>
);
}
@@ -0,0 +1,170 @@
"use client";
import { useRef, useState } from "react";
import { marked } from "marked";
// Richer Markdown editor for the post body. A formatting toolbar wraps/inserts
// syntax around the textarea selection, and a Write/Preview toggle renders the
// same `marked` output the server uses. The textarea keeps name="body" so the
// surrounding server-action form submits it exactly as before — no JS required
// to save, the toolbar is pure enhancement.
type Wrap = { before: string; after: string; placeholder: string };
type ToolButton =
| { kind: "wrap"; label: string; title: string; wrap: Wrap }
| { kind: "prefix"; label: string; title: string; prefix: string }
| { kind: "link" }
| { kind: "image" };
const TOOLS: ToolButton[] = [
{ kind: "wrap", label: "B", title: "Bold", wrap: { before: "**", after: "**", placeholder: "bold text" } },
{ kind: "wrap", label: "I", title: "Italic", wrap: { before: "_", after: "_", placeholder: "italic text" } },
{ kind: "wrap", label: "S", title: "Strikethrough", wrap: { before: "~~", after: "~~", placeholder: "struck" } },
{ kind: "wrap", label: "</>", title: "Inline code", wrap: { before: "`", after: "`", placeholder: "code" } },
{ kind: "prefix", label: "H1", title: "Heading 1", prefix: "# " },
{ kind: "prefix", label: "H2", title: "Heading 2", prefix: "## " },
{ kind: "prefix", label: "H3", title: "Heading 3", prefix: "### " },
{ kind: "prefix", label: "“ ”", title: "Quote", prefix: "> " },
{ kind: "prefix", label: "• List", title: "Bulleted list", prefix: "- " },
{ kind: "prefix", label: "1. List", title: "Numbered list", prefix: "1. " },
{ kind: "wrap", label: "{ }", title: "Code block", wrap: { before: "```\n", after: "\n```", placeholder: "code block" } },
{ kind: "link" },
{ kind: "image" },
];
export default function MarkdownEditor({
name,
defaultValue = "",
rows = 18,
}: {
name: string;
defaultValue?: string;
rows?: number;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState(defaultValue);
const [tab, setTab] = useState<"write" | "preview">("write");
// Replace the current selection and restore focus + a sensible caret/selection
// so chained formatting feels native.
function apply(transform: (sel: string) => { text: string; selStart: number; selEnd: number }) {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = value.slice(start, end);
const { text, selStart, selEnd } = transform(selected);
const next = value.slice(0, start) + text + value.slice(end);
setValue(next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + selStart, start + selEnd);
});
}
function wrap(w: Wrap) {
apply((sel) => {
const inner = sel || w.placeholder;
return {
text: w.before + inner + w.after,
selStart: w.before.length,
selEnd: w.before.length + inner.length,
};
});
}
function prefix(p: string) {
apply((sel) => {
const lines = (sel || "").split("\n");
const text = lines.map((l) => p + l).join("\n");
return { text, selStart: 0, selEnd: text.length };
});
}
function link() {
const url = window.prompt("Link URL", "https://");
if (!url) return;
apply((sel) => {
const label = sel || "link text";
const text = `[${label}](${url})`;
return { text, selStart: 1, selEnd: 1 + label.length };
});
}
function image() {
const url = window.prompt("Image URL", "https://");
if (!url) return;
apply((sel) => {
const alt = sel || "alt text";
const text = `![${alt}](${url})`;
return { text, selStart: 2, selEnd: 2 + alt.length };
});
}
function onTool(t: ToolButton) {
if (t.kind === "wrap") wrap(t.wrap);
else if (t.kind === "prefix") prefix(t.prefix);
else if (t.kind === "link") link();
else image();
}
const previewHtml = marked.parse(value || "*Nothing to preview yet.*", { async: false }) as string;
return (
<div className="rb-mde">
<div className="rb-mde-bar">
<div className="rb-mde-tools">
{TOOLS.map((t, i) => (
<button
key={i}
type="button"
className="rb-mde-tool"
title={t.kind === "link" ? "Link" : t.kind === "image" ? "Image" : t.title}
onClick={() => onTool(t)}
disabled={tab === "preview"}
>
{t.kind === "link" ? "🔗" : t.kind === "image" ? "🖼" : t.label}
</button>
))}
</div>
<div className="rb-mde-tabs">
<button
type="button"
className={`rb-mde-tab ${tab === "write" ? "is-active" : ""}`}
onClick={() => setTab("write")}
>
Write
</button>
<button
type="button"
className={`rb-mde-tab ${tab === "preview" ? "is-active" : ""}`}
onClick={() => setTab("preview")}
>
Preview
</button>
</div>
</div>
{tab === "write" ? (
<textarea
ref={ref}
name={name}
className="rb-mono rb-mde-textarea"
rows={rows}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<>
<div
className="rb-prose rb-mde-preview"
dangerouslySetInnerHTML={{ __html: previewHtml }}
/>
{/* Keep the value submittable even while previewing. */}
<input type="hidden" name={name} value={value} />
</>
)}
</div>
);
}
+30 -4
View File
@@ -1,6 +1,10 @@
import Link from "next/link";
import type { Post } from "@/lib/posts";
import { linksToText } from "@/lib/posts";
import { SOCIAL_NETWORKS } from "@/lib/integrations/social";
import { savePostAction } from "../../actions";
import MarkdownEditor from "./MarkdownEditor";
import DownloadEditor from "./DownloadEditor";
// Server-rendered create/edit form. Both modes post to the same action; the
// hidden `id` (present only when editing) decides insert vs update.
@@ -68,15 +72,37 @@ export default function PostForm({
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
</label>
<label className="rb-field">
<div className="rb-field">
<span>
Body <em className="rb-admin-muted">(Markdown)</em>
</span>
<MarkdownEditor name="body" defaultValue={post?.body ?? ""} />
</div>
<div className="rb-field">
<span>
Downloads{" "}
<em className="rb-admin-muted">
(upload a file to the blog, or add an external link)
</em>
</span>
<DownloadEditor name="downloads" initial={post?.downloads ?? []} />
</div>
<label className="rb-field">
<span>
Shared on{" "}
<em className="rb-admin-muted">
(one per line: network|url|label networks:{" "}
{SOCIAL_NETWORKS.map((n) => n.id).join(", ")})
</em>
</span>
<textarea
name="body"
rows={16}
name="links"
rows={3}
className="rb-mono"
defaultValue={post?.body ?? ""}
placeholder={"mastodon|https://mastodon.social/@me/123|Discuss\nx|https://x.com/me/status/123"}
defaultValue={post ? linksToText(post.links) : ""}
/>
</label>
+143
View File
@@ -21,6 +21,20 @@ import {
normalizeSettings,
type Settings,
} from "@/lib/settings";
import {
getIntegrationConfig,
saveIntegrationConfig,
refreshPlatforms,
testPlatform,
} from "@/lib/integrations";
import { isSocialNetworkId, type SocialNetworkId } from "@/lib/integrations/social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
PlatformId,
SocialLink,
} from "@/lib/integrations/types";
import { THEMES, isThemeId, type ThemeId } from "@/themes/registry";
function s(formData: FormData, key: string): string {
@@ -70,6 +84,8 @@ function postInputFromForm(formData: FormData): PostInput {
body: s(formData, "body"),
author: s(formData, "author"),
tags: s(formData, "tags"),
links: s(formData, "links"),
downloads: s(formData, "downloads"),
createdAt: s(formData, "createdAt"),
};
}
@@ -165,6 +181,11 @@ export async function importAction(formData: FormData) {
: typeof p.tags === "string"
? p.tags
: "",
downloads: Array.isArray(p.downloads)
? JSON.stringify(p.downloads)
: typeof p.downloads === "string"
? p.downloads
: undefined,
createdAt:
typeof p.createdAt === "string"
? p.createdAt
@@ -183,3 +204,125 @@ export async function importAction(formData: FormData) {
revalidateSite();
redirect("/admin/settings?import=ok");
}
/* ---------------------------- integrations --------------------------- */
// Each line: `network|url|label?`. Unknown networks / blank urls are dropped.
function parseSocialLines(raw: string): SocialLink[] {
const out: SocialLink[] = [];
for (const line of raw.split("\n")) {
const [network, url, ...rest] = line.split("|").map((p) => p.trim());
if (!isSocialNetworkId(network) || !url) continue;
const label = rest.join("|").trim();
out.push({ network: network as SocialNetworkId, url, label: label || undefined });
}
return out;
}
// The console / game pickers post their selection as a JSON array (one hidden
// input each). Parse defensively — a malformed payload yields an empty list
// rather than throwing. config.normalize* re-validates field shapes after this.
function parseJsonArray(raw: string): Record<string, unknown>[] {
if (!raw.trim()) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
} catch {
return [];
}
}
function parseConsolesJson(raw: string): ConsoleItem[] {
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);
}
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"),
bio: s(formData, "bio"),
avatarUrl: s(formData, "avatarUrl"),
social: parseSocialLines(s(formData, "social")),
consoles: parseConsolesJson(s(formData, "consoles")),
favorites: parseFavoritesJson(s(formData, "favorites")),
steam: {
enabled: formData.get("steamEnabled") === "on",
apiKey: s(formData, "steamApiKey").trim() || current.steam.apiKey,
steamId: s(formData, "steamId").trim(),
},
psn: {
enabled: formData.get("psnEnabled") === "on",
npsso: s(formData, "psnNpsso").trim() || current.psn.npsso,
},
xbox: {
enabled: formData.get("xboxEnabled") === "on",
apiKey: s(formData, "xboxApiKey").trim() || current.xbox.apiKey,
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,
};
}
export async function saveIntegrationsAction(formData: FormData) {
const current = getIntegrationConfig();
saveIntegrationConfig(configFromForm(formData, current));
// Config changed (creds/toggles) — drop cached payloads so they refetch.
refreshPlatforms();
revalidateSite();
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() {
refreshPlatforms();
revalidateSite();
redirect("/admin/integrations?refreshed=1");
}
+429
View File
@@ -169,6 +169,7 @@
/* ---- banners ---- */
.rb-admin-ok,
.rb-admin-warn,
.rb-admin-error {
border-radius: var(--a-radius);
padding: 10px 14px;
@@ -181,6 +182,11 @@
color: var(--a-ok-text);
}
.rb-admin-warn {
background: color-mix(in srgb, #f5a623 22%, transparent);
color: #b8740f;
}
.rb-admin-error {
background: var(--a-err-bg);
color: var(--a-err-text);
@@ -390,3 +396,426 @@
.rb-login-card .rb-admin-h1 {
margin-bottom: 6px;
}
/* integrations: platform status table */
.rb-admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
margin: 8px 0;
}
.rb-admin-table th,
.rb-admin-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid #e2e2e2;
vertical-align: top;
}
.rb-admin-table th {
font-weight: 600;
color: #555;
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);
}
/* ---- markdown editor (post body) ---- */
.rb-mde {
border: 1px solid var(--a-border);
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.rb-mde-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
padding: 6px 8px;
background: #f6f8fa;
border-bottom: 1px solid var(--a-border);
}
.rb-mde-tools {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.rb-mde-tool {
font: inherit;
font-size: 13px;
min-width: 30px;
padding: 4px 7px;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
cursor: pointer;
}
.rb-mde-tool:hover:not(:disabled) {
background: #eef1f6;
}
.rb-mde-tool:disabled {
opacity: 0.4;
cursor: default;
}
.rb-mde-tabs {
display: flex;
gap: 2px;
}
.rb-mde-tab {
font: inherit;
font-size: 13px;
padding: 4px 12px;
color: var(--a-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
cursor: pointer;
}
.rb-mde-tab.is-active {
color: var(--a-text);
background: #fff;
border-color: var(--a-border);
font-weight: 600;
}
.rb-mde-textarea {
display: block;
width: 100%;
border: 0 !important;
border-radius: 0 !important;
resize: vertical;
padding: 12px !important;
}
.rb-mde-textarea:focus {
outline: 2px solid var(--a-primary);
outline-offset: -2px;
}
.rb-mde-preview {
padding: 14px 16px;
min-height: 200px;
max-height: 520px;
overflow: auto;
}
/* ---- download editor ---- */
.rb-dl-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.rb-dl-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--a-border);
border-radius: 6px;
background: #fbfcfe;
}
.rb-dl-kind {
font-size: 16px;
flex: none;
}
.rb-dl-label {
flex: 1 1 40%;
min-width: 0;
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 5px;
padding: 5px 8px;
}
.rb-dl-label:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-dl-url {
flex: 1 1 30%;
min-width: 0;
font-size: 12px;
color: var(--a-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-dl-size {
flex: none;
font-size: 12px;
color: var(--a-muted);
white-space: nowrap;
}
.rb-dl-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-dl-remove:hover {
background: var(--a-err-bg);
color: var(--a-danger);
}
.rb-dl-add {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px dashed var(--a-border);
border-radius: 6px;
background: #f6f8fa;
}
.rb-dl-add-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.rb-dl-add-label {
flex: 1 1 160px;
}
.rb-dl-add-url {
flex: 2 1 240px;
}
.rb-dl-add-label,
.rb-dl-add-url {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 8px 10px;
min-width: 0;
}
.rb-dl-add-label:focus,
.rb-dl-add-url:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
}
.rb-dl-file {
font: inherit;
font-size: 13px;
flex: 1 1 auto;
}
.rb-dl-status {
color: var(--a-muted);
font-size: 13px;
}
.rb-dl-error {
margin: 0;
}
+1
View File
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
body: p.body,
author: p.author,
tags: p.tags,
downloads: p.downloads,
createdAt: p.createdAt,
}));
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { isAdmin } from "@/lib/auth";
import { makeStoredName, uploadsDir } from "@/lib/uploads";
// POST /admin/upload — multipart with a single `file` field. Saves the file to
// data/uploads and returns its public URL + metadata for the download editor.
// Guarded by the /admin middleware, plus a check here for defence in depth.
// Cap uploads so a single request can't exhaust the disk. 200 MB suits ROMs /
// archives while staying sane for a self-hosted blog.
const MAX_BYTES = 200 * 1024 * 1024;
export async function POST(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: "invalid form data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return NextResponse.json({ error: "no file" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "file too large" }, { status: 413 });
}
const stored = makeStoredName(file.name || "file");
const dest = path.join(uploadsDir(), stored);
const buf = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(dest, buf);
return NextResponse.json({
url: `/uploads/${stored}`,
filename: file.name || stored,
size: file.size,
});
}
+197
View File
@@ -0,0 +1,197 @@
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import { getProfile, hasIntegrations } from "@/lib/integrations";
import { isIconUrl } from "@/lib/integrations/consoles";
import type { Achievement, Game } from "@/lib/integrations/types";
function platformLabel(p: string): string {
const labels: Record<string, string> = {
psn: "PlayStation",
xbox: "Xbox",
retro: "RetroAchievements",
steam: "Steam",
};
return labels[p] ?? "Steam";
}
function fmtPlaytime(mins?: number): string | null {
if (!mins || mins < 1) return null;
const h = Math.round(mins / 60);
return h >= 1 ? `${h}h played` : `${mins}m played`;
}
function GameCard({ game }: { game: Game }) {
const body = (
<>
{game.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-game-art" src={game.image} alt="" loading="lazy" />
)}
<span className="rb-game-info">
<span className="rb-game-name">{game.name}</span>
<span className="rb-game-sub">
<span className="rb-badge">{platformLabel(game.platform)}</span>
{fmtPlaytime(game.playtimeMinutes) && (
<span className="rb-game-time">{fmtPlaytime(game.playtimeMinutes)}</span>
)}
</span>
</span>
</>
);
return game.url ? (
<a className="rb-game-card" href={game.url} target="_blank" rel="noreferrer">
{body}
</a>
) : (
<div className="rb-game-card">{body}</div>
);
}
function AchievementRow({ a }: { a: Achievement }) {
return (
<li className="rb-ach">
{a.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-ach-icon" src={a.icon} alt="" loading="lazy" />
) : (
<span className="rb-ach-icon rb-ach-icon-empty" aria-hidden>
🏆
</span>
)}
<span className="rb-ach-body">
<span className="rb-ach-name">{a.name}</span>
<span className="rb-ach-meta">
{a.game} · {platformLabel(a.platform)}
{a.rarity ? ` · ${a.rarity}` : ""}
</span>
{a.description && <span className="rb-ach-desc">{a.description}</span>}
</span>
</li>
);
}
export default async function BioPage() {
if (!hasIntegrations()) notFound();
const theme = await getTheme();
const profile = await getProfile();
const name = profile.displayName || "The Webmaster";
return (
<Shell theme={theme} title="Bio">
<div className="rb-bio">
{/* ---- header ---- */}
<header className="rb-bio-header">
{profile.avatarUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img className="rb-bio-avatar" src={profile.avatarUrl} alt={name} />
)}
<div className="rb-bio-head-text">
<h1 className="rb-article-title">{name}</h1>
{profile.bioHtml && (
<div
className="rb-prose"
dangerouslySetInnerHTML={{ __html: profile.bioHtml }}
/>
)}
<SocialLinks links={profile.social} className="rb-bio-social" />
</div>
</header>
{/* ---- recent games ---- */}
{profile.games.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Recently played</h2>
<div className="rb-game-grid">
{profile.games.slice(0, 12).map((g, i) => (
<GameCard key={`${g.platform}-${g.name}-${i}`} game={g} />
))}
</div>
</section>
)}
{/* ---- achievements ---- */}
{profile.achievements.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Recent achievements</h2>
<ul className="rb-ach-list">
{profile.achievements.slice(0, 12).map((a, i) => (
<AchievementRow key={`${a.platform}-${a.name}-${i}`} a={a} />
))}
</ul>
</section>
)}
<div className="rb-bio-cols">
{/* ---- favorites ---- */}
{profile.favorites.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Favorite games</h2>
<ul className="rb-fav-list">
{profile.favorites.map((f, i) => {
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>
{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>
);
})}
</ul>
</section>
)}
{/* ---- consoles ---- */}
{profile.consoles.length > 0 && (
<section className="rb-bio-section">
<h2 className="rb-bio-h2">Consoles</h2>
<ul className="rb-console-list">
{profile.consoles.map((c, i) => (
<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>
{c.note && <span className="rb-console-note">{c.note}</span>}
</li>
))}
</ul>
</section>
)}
</div>
{/* ---- platform fetch errors (subtle) ---- */}
{profile.platforms.some((p) => p.error) && (
<p className="rb-bio-note">
Some platforms couldn&apos;t be reached right now; showing the last
known data.
</p>
)}
</div>
</Shell>
);
}
+343
View File
@@ -193,3 +193,346 @@ img {
:root {
color-scheme: light;
}
/* ---------------------------------------------------------------------------
Integrations: bio page, social links, game/achievement cards, tray widget.
Theme-agnostic base styling — themes may override these rb- classes, but
these defaults keep everything legible under any skin.
--------------------------------------------------------------------------- */
/* social links row */
.rb-social {
list-style: none;
margin: 10px 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rb-social-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid currentColor;
border-radius: 4px;
text-decoration: none;
font-size: 0.9em;
line-height: 1.4;
}
.rb-social-link:hover {
filter: brightness(0.95);
}
.rb-social-icon {
font-size: 1.1em;
}
/* per-post downloads section */
.rb-downloads {
margin-top: 26px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
.rb-downloads-title {
margin: 0 0 10px;
font-size: 1.1em;
}
.rb-downloads-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.rb-download-link {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border: 1px solid currentColor;
border-radius: 6px;
text-decoration: none;
}
.rb-download-link:hover {
filter: brightness(0.96);
}
.rb-download-icon {
font-size: 1.2em;
flex: none;
}
.rb-download-label {
flex: 1;
font-weight: bold;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-download-size {
flex: none;
opacity: 0.7;
font-size: 0.85em;
}
/* per-post share footer */
.rb-article-share {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
.rb-article-share-label {
font-weight: bold;
opacity: 0.8;
}
.rb-article-share .rb-social {
margin: 0;
}
/* bio page layout */
.rb-bio {
display: flex;
flex-direction: column;
gap: 26px;
}
.rb-bio-header {
display: flex;
gap: 18px;
align-items: flex-start;
flex-wrap: wrap;
}
.rb-bio-avatar {
width: 96px;
height: 96px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.2);
flex: 0 0 auto;
}
.rb-bio-head-text {
flex: 1 1 260px;
min-width: 0;
}
.rb-bio-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.rb-bio-h2 {
margin: 0;
font-size: 1.15em;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
padding-bottom: 4px;
}
.rb-bio-cols {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 26px;
}
.rb-bio-note {
font-size: 0.85em;
opacity: 0.7;
font-style: italic;
}
/* game cards */
.rb-game-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.rb-game-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
overflow: hidden;
text-decoration: none;
background: rgba(255, 255, 255, 0.35);
}
.rb-game-card:hover {
filter: brightness(1.03);
}
.rb-game-art {
width: 100%;
aspect-ratio: 460 / 215;
object-fit: cover;
display: block;
}
.rb-game-info {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
}
.rb-game-name {
font-weight: bold;
line-height: 1.2;
}
.rb-game-sub {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8em;
opacity: 0.85;
}
.rb-badge {
display: inline-block;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 3px;
font-size: 0.9em;
}
/* achievements */
.rb-ach-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rb-ach {
display: flex;
gap: 10px;
align-items: flex-start;
}
.rb-ach-icon {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex: 0 0 auto;
}
.rb-ach-icon-empty {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.rb-ach-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rb-ach-name {
font-weight: bold;
}
.rb-ach-meta {
font-size: 0.8em;
opacity: 0.75;
}
.rb-ach-desc {
font-size: 0.85em;
opacity: 0.9;
}
/* favorites + consoles lists */
.rb-fav-list,
.rb-console-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.rb-fav,
.rb-console {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-left: 3px solid currentColor;
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: 22px;
width: auto;
max-width: 110px;
object-fit: contain;
flex: none;
/* Light plate so dark/colored brand logos stay legible on dark themes. */
background: #fff;
padding: 3px 5px;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.rb-fav-name,
.rb-console-name {
font-weight: bold;
}
.rb-fav-note,
.rb-console-note {
font-size: 0.85em;
opacity: 0.8;
}
.rb-about-bio {
margin-top: 22px;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
}
/* now-playing tray widget */
.rb-nowplaying {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
font-size: 0.8em;
padding: 2px 8px;
max-width: 220px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.rb-nowplaying-label {
overflow: hidden;
text-overflow: ellipsis;
}
.rb-nowplaying-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #36c436;
box-shadow: 0 0 4px #36c436;
flex: 0 0 auto;
animation: rb-pulse 2s ease-in-out infinite;
}
@keyframes rb-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+34
View File
@@ -1,12 +1,14 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import SocialLinks from "@/components/SocialLinks";
import { getTheme } from "@/themes/server";
import {
getAllPosts,
getPostBySlug,
renderMarkdown,
formatDate,
formatSize,
} from "@/lib/posts";
export function generateStaticParams() {
@@ -47,6 +49,38 @@ export default async function PostPage({
className="rb-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
{post.downloads.length > 0 && (
<section className="rb-downloads">
<h2 className="rb-downloads-title">Downloads</h2>
<ul className="rb-downloads-list">
{post.downloads.map((d, i) => (
<li key={`${d.url}-${i}`} className="rb-download">
<a
className="rb-download-link"
href={d.url}
{...(d.kind === "file"
? { download: d.filename ?? "" }
: { target: "_blank", rel: "noreferrer" })}
>
<span className="rb-download-icon" aria-hidden>
{d.kind === "file" ? "📦" : "🔗"}
</span>
<span className="rb-download-label">{d.label}</span>
{d.size != null && (
<span className="rb-download-size">{formatSize(d.size)}</span>
)}
</a>
</li>
))}
</ul>
</section>
)}
{post.links.length > 0 && (
<footer className="rb-article-share">
<span className="rb-article-share-label">Shared on:</span>
<SocialLinks links={post.links} />
</footer>
)}
</article>
</Shell>
);
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import { isSafeStoredName, mimeForName, uploadsDir } from "@/lib/uploads";
// GET /uploads/[name] — streams an uploaded attachment back to visitors. Public
// by design (downloads are meant to be downloaded); the name is validated so a
// crafted path can't escape the uploads directory.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
if (!isSafeStoredName(name)) {
return NextResponse.json({ error: "bad name" }, { status: 400 });
}
const file = path.join(uploadsDir(), name);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
return NextResponse.json({ error: "not found" }, { status: 404 });
}
const data = fs.readFileSync(file);
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": mimeForName(name),
"Content-Length": String(data.length),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+19
View File
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode } from "react";
import { THEMES, type ThemeId } from "@/themes/registry";
import { getSettings } from "@/lib/settings";
import { isAdmin } from "@/lib/auth";
import { getNowPlaying, hasIntegrations } from "@/lib/integrations";
import ThemeSwitcher from "./ThemeSwitcher";
import Clock from "./Clock";
@@ -20,6 +21,11 @@ export default async function Shell({
const settings = getSettings();
const admin = await isAdmin();
// Gamer bio surfaces: nav link + a "now playing" tray widget. Both gate on the
// integrations being configured so a vanilla blog stays clean.
const bioEnabled = hasIntegrations();
const nowPlaying = bioEnabled ? await getNowPlaying() : null;
// Admins always get the switcher with every skin; the public only sees it when
// enabled, and only the allowed skins.
const showSwitcher = admin || settings.publicThemeToggle;
@@ -79,6 +85,11 @@ export default async function Shell({
<Link className="rb-menu-link" href="/about">
About
</Link>
{bioEnabled && (
<Link className="rb-menu-link" href="/bio">
Bio
</Link>
)}
{admin && (
<Link className="rb-menu-link" href="/admin">
Admin
@@ -112,6 +123,14 @@ export default async function Shell({
</button>
</div>
<div className="rb-tray">
{nowPlaying && (
<Link className="rb-nowplaying" href="/bio" title="Recently played">
<span className="rb-nowplaying-dot" aria-hidden />
<span className="rb-nowplaying-label">
Playing: <strong>{nowPlaying.name}</strong>
</span>
</Link>
)}
<Clock />
</div>
</div>
+36
View File
@@ -0,0 +1,36 @@
import { socialNetwork } from "@/lib/integrations/social";
import type { SocialLink } from "@/lib/integrations/types";
// Theme-agnostic row of social links. Reused for the curated profile links and
// for per-post "shared on" links. Styling lives in the `rb-social*` classes.
export default function SocialLinks({
links,
className = "",
}: {
links: SocialLink[];
className?: string;
}) {
if (links.length === 0) return null;
return (
<ul className={`rb-social ${className}`.trim()}>
{links.map((l, i) => {
const net = socialNetwork(l.network);
return (
<li key={`${l.network}-${i}`}>
<a
className="rb-social-link"
href={l.url}
target="_blank"
rel="me noreferrer"
>
<span className="rb-social-icon" aria-hidden>
{net.icon}
</span>
<span className="rb-social-name">{l.label ?? net.name}</span>
</a>
</li>
);
})}
</ul>
);
}
+29
View File
@@ -41,7 +41,36 @@ function migrate(db: Database.Database) {
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
);
-- Single-row blob holding the integrations config JSON (platform creds,
-- curated bio/social/consoles/favorites). Holds secrets — never exported.
CREATE TABLE IF NOT EXISTS integrations (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
);
-- Lazy TTL cache of data fetched from external platform APIs. One row per
-- (platform) payload; refreshed on demand when stale.
CREATE TABLE IF NOT EXISTS integration_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
error TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Per-post social-share links (JSON array). Added via ALTER so existing
// databases pick it up; guarded because SQLite has no ADD COLUMN IF NOT EXISTS.
const cols = (
db.prepare("PRAGMA table_info(posts)").all() as { name: string }[]
).map((c) => c.name);
if (!cols.includes("links")) {
db.exec("ALTER TABLE posts ADD COLUMN links TEXT NOT NULL DEFAULT '[]'");
}
// Per-post download section (JSON array of {label,url,kind,size?,filename?}).
if (!cols.includes("downloads")) {
db.exec("ALTER TABLE posts ADD COLUMN downloads TEXT NOT NULL DEFAULT '[]'");
}
}
function seed(db: Database.Database) {
+89
View File
@@ -0,0 +1,89 @@
import "server-only";
import { getDb } from "../db";
// Lazy TTL cache for external platform fetches. Each platform stores one row.
// On a fresh hit we return the cached payload; when stale we run the fetcher,
// persist the result, and — crucially — fall back to the last good payload if
// the fetcher fails, so a transient API outage never blanks the bio page.
type CacheRow = {
data: string;
error: string | null;
fetched_at: string;
};
function read(key: string): CacheRow | undefined {
return getDb()
.prepare("SELECT data, error, fetched_at FROM integration_cache WHERE key = ?")
.get(key) as CacheRow | undefined;
}
function write(key: string, data: string, error: string | null): void {
getDb()
.prepare(
`INSERT INTO integration_cache (key, data, error, fetched_at)
VALUES (@key, @data, @error, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = @data, error = @error,
fetched_at = datetime('now')`
)
.run({ key, data, error });
}
function ageMinutes(iso: string): number {
const t = new Date(iso.replace(" ", "T") + "Z").getTime();
if (Number.isNaN(t)) return Infinity;
return (Date.now() - t) / 60000;
}
export type Cached<T> = { data: T; fetchedAt: string; error?: string };
/**
* Return cached `key` data if younger than `ttlMinutes`, otherwise refetch.
* The fetcher itself should never throw (catch internally) but we guard anyway.
*/
export async function cached<T>(
key: string,
ttlMinutes: number,
fallback: T,
fetcher: () => Promise<T>
): Promise<Cached<T>> {
const row = read(key);
if (row && ageMinutes(row.fetched_at) < ttlMinutes) {
try {
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: row.error ?? undefined };
} catch {
/* fall through to refetch on corrupt cache */
}
}
try {
const fresh = await fetcher();
write(key, JSON.stringify(fresh), null);
return { data: fresh, fetchedAt: new Date().toISOString() };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Keep the previous good payload visible; just surface the error.
if (row) {
try {
write(key, row.data, msg);
return { data: JSON.parse(row.data) as T, fetchedAt: row.fetched_at, error: msg };
} catch {
/* corrupt previous payload — fall through */
}
}
write(key, JSON.stringify(fallback), msg);
return { data: fallback, fetchedAt: new Date().toISOString(), error: msg };
}
}
/** Force the next read of these keys to refetch by deleting their rows. */
export function clearCache(keys?: string[]): void {
const db = getDb();
if (!keys || keys.length === 0) {
db.prepare("DELETE FROM integration_cache").run();
return;
}
const stmt = db.prepare("DELETE FROM integration_cache WHERE key = ?");
const tx = db.transaction((ks: string[]) => ks.forEach((k) => stmt.run(k)));
tx(keys);
}
+142
View File
@@ -0,0 +1,142 @@
import "server-only";
import { getDb } from "../db";
import { isSocialNetworkId, type SocialNetworkId } from "./social";
import type {
ConsoleItem,
FavoriteGame,
IntegrationConfig,
SocialLink,
} from "./types";
// Integrations config persisted as a single JSON row, mirroring the settings
// module. Holds platform credentials (secrets) alongside curated profile
// content, so it is deliberately NOT part of the export/import surface.
export const DEFAULT_CONFIG: IntegrationConfig = {
displayName: "",
bio: "",
avatarUrl: "",
social: [],
consoles: [],
favorites: [],
steam: { enabled: false, apiKey: "", steamId: "" },
psn: { enabled: false, npsso: "" },
xbox: { enabled: false, apiKey: "", xuid: "" },
retro: { enabled: false, username: "", apiKey: "" },
cacheTtlMinutes: 360,
};
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function bool(v: unknown, fallback = false): boolean {
return typeof v === "boolean" ? v : fallback;
}
function normSocial(input: unknown): SocialLink[] {
if (!Array.isArray(input)) return [];
const out: SocialLink[] = [];
for (const raw of input) {
const o = (raw ?? {}) as Record<string, unknown>;
const network = o.network;
const url = str(o.url).trim();
if (!isSocialNetworkId(network) || !url) continue;
out.push({
network: network as SocialNetworkId,
url,
label: str(o.label).trim() || undefined,
});
}
return out;
}
function normConsoles(input: unknown): ConsoleItem[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
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);
}
function normFavorites(input: unknown): FavoriteGame[] {
if (!Array.isArray(input)) return [];
return input
.map((raw) => {
const o = (raw ?? {}) as Record<string, unknown>;
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);
}
export function normalizeConfig(input: unknown): IntegrationConfig {
const o = (input ?? {}) as Record<string, unknown>;
const steam = (o.steam ?? {}) as Record<string, unknown>;
const psn = (o.psn ?? {}) 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);
return {
displayName: str(o.displayName, DEFAULT_CONFIG.displayName),
bio: str(o.bio, DEFAULT_CONFIG.bio),
avatarUrl: str(o.avatarUrl, DEFAULT_CONFIG.avatarUrl).trim(),
social: normSocial(o.social),
consoles: normConsoles(o.consoles),
favorites: normFavorites(o.favorites),
steam: {
enabled: bool(steam.enabled),
apiKey: str(steam.apiKey).trim(),
steamId: str(steam.steamId).trim(),
},
psn: {
enabled: bool(psn.enabled),
npsso: str(psn.npsso).trim(),
},
xbox: {
enabled: bool(xbox.enabled),
apiKey: str(xbox.apiKey).trim(),
xuid: str(xbox.xuid).trim(),
},
retro: {
enabled: bool(retro.enabled),
username: str(retro.username).trim(),
apiKey: str(retro.apiKey).trim(),
},
cacheTtlMinutes:
Number.isFinite(ttl) && ttl >= 1 ? Math.floor(ttl) : DEFAULT_CONFIG.cacheTtlMinutes,
};
}
export function getIntegrationConfig(): IntegrationConfig {
const row = getDb()
.prepare("SELECT data FROM integrations WHERE id = 1")
.get() as { data: string } | undefined;
if (!row) return DEFAULT_CONFIG;
try {
return normalizeConfig(JSON.parse(row.data));
} catch {
return DEFAULT_CONFIG;
}
}
export function saveIntegrationConfig(next: IntegrationConfig): IntegrationConfig {
const clean = normalizeConfig(next);
getDb()
.prepare(
`INSERT INTO integrations (id, data) VALUES (1, @data)
ON CONFLICT(id) DO UPDATE SET data = @data`
)
.run({ data: JSON.stringify(clean) });
return clean;
}
+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, icon: "/consoles/n64.svg" },
{ id: "gamecube", name: "GameCube", maker: "Nintendo", year: 2001, icon: "/consoles/gamecube.svg" },
{ 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, icon: "/consoles/virtualboy.svg" },
// --- 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, icon: "/consoles/neogeo.svg" },
{ id: "ngpc", name: "Neo Geo Pocket Color", maker: "SNK", year: 1999, icon: "/consoles/ngpc.png" },
{ id: "turbografx16", name: "TurboGrafx-16 / PC Engine", maker: "NEC", year: 1987, icon: "/consoles/turbografx16.svg" },
{ id: "3do", name: "3DO", maker: "Panasonic", year: 1993, icon: "/consoles/3do.svg" },
{ id: "colecovision", name: "ColecoVision", maker: "Coleco", year: 1982, icon: "/consoles/colecovision.svg" },
{ id: "intellivision", name: "Intellivision", maker: "Mattel", year: 1979, icon: "/consoles/intellivision.svg" },
{ id: "wonderswan", name: "WonderSwan", maker: "Bandai", year: 1999, icon: "/consoles/wonderswan.svg" },
{ id: "steamdeck", name: "Steam Deck", maker: "Valve", year: 2022, icon: "/consoles/steamdeck.svg" },
{ id: "ouya", name: "Ouya", maker: "Ouya", year: 2013, icon: "/consoles/ouya.svg" },
// --- Computers ---
{ id: "pc", name: "PC", maker: "Microsoft Windows", icon: "/consoles/pc.svg" },
{ id: "mac", name: "Mac", maker: "Apple", icon: "/consoles/mac.svg" },
{ id: "linux", name: "Linux", maker: "GNU/Linux", icon: "/consoles/linux.svg" },
{ 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, icon: "/consoles/msx.svg" },
{ id: "zxspectrum", name: "ZX Spectrum", maker: "Sinclair", year: 1982, icon: "/consoles/zxspectrum.svg" },
{ 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);
}
+164
View File
@@ -0,0 +1,164 @@
import "server-only";
import { marked } from "marked";
import { cached, clearCache, type Cached } from "./cache";
import { getIntegrationConfig } from "./config";
import { fetchSteam } from "./steam";
import { fetchPsn } from "./psn";
import { fetchXbox } from "./xbox";
import { fetchRetro } from "./retro";
import type {
Game,
IntegrationConfig,
PlatformData,
PlatformId,
Profile,
} from "./types";
export { getIntegrationConfig, saveIntegrationConfig, DEFAULT_CONFIG } from "./config";
export type { IntegrationConfig } from "./types";
const CACHE_KEYS: Record<PlatformId, string> = {
steam: "platform:steam",
psn: "platform:psn",
xbox: "platform:xbox",
retro: "platform:retro",
};
function emptyPlatform(platform: PlatformId): PlatformData {
return { platform, games: [], achievements: [], fetchedAt: new Date().toISOString() };
}
/** Fetch one platform through the TTL cache. */
async function loadPlatform(
platform: PlatformId,
cfg: IntegrationConfig
): Promise<PlatformData> {
const ttl = cfg.cacheTtlMinutes;
const key = CACHE_KEYS[platform];
const fallback = emptyPlatform(platform);
let result: Cached<PlatformData>;
switch (platform) {
case "steam":
result = await cached(key, ttl, fallback, () => fetchSteam(cfg.steam));
break;
case "psn":
result = await cached(key, ttl, fallback, () => fetchPsn(cfg.psn));
break;
case "xbox":
result = await cached(key, ttl, fallback, () => fetchXbox(cfg.xbox));
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.
return result.error && !result.data.error
? { ...result.data, error: result.error, fetchedAt: result.fetchedAt }
: { ...result.data, fetchedAt: result.fetchedAt };
}
function enabledPlatforms(cfg: IntegrationConfig): PlatformId[] {
const out: PlatformId[] = [];
if (cfg.steam.enabled) out.push("steam");
if (cfg.psn.enabled) out.push("psn");
if (cfg.xbox.enabled) out.push("xbox");
if (cfg.retro.enabled) out.push("retro");
return out;
}
function gameTime(g: Game): number {
return g.lastPlayed ? new Date(g.lastPlayed).getTime() : 0;
}
/** Assemble the full public profile: curated content + cached platform data. */
export async function getProfile(): Promise<Profile> {
const cfg = getIntegrationConfig();
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms
.flatMap((p) => p.games)
.sort((a, b) => gameTime(b) - gameTime(a));
const achievements = platforms
.flatMap((p) => p.achievements)
.sort(
(a, b) =>
new Date(b.unlockedAt ?? 0).getTime() - new Date(a.unlockedAt ?? 0).getTime()
);
return {
displayName: cfg.displayName,
bioHtml: cfg.bio ? (marked.parse(cfg.bio, { async: false }) as string) : "",
avatarUrl: cfg.avatarUrl,
social: cfg.social,
consoles: cfg.consoles,
favorites: cfg.favorites,
games,
achievements,
platforms,
};
}
/** Latest game across all enabled platforms — for the persistent shell widget. */
export async function getNowPlaying(): Promise<Game | null> {
const cfg = getIntegrationConfig();
if (enabledPlatforms(cfg).length === 0) return null;
const platforms = await Promise.all(
enabledPlatforms(cfg).map((p) => loadPlatform(p, cfg))
);
const games = platforms.flatMap((p) => p.games);
if (games.length === 0) return null;
// Prefer most-recently-played; platforms without timestamps fall to list order.
const timed = games.filter((g) => g.lastPlayed);
if (timed.length) return timed.sort((a, b) => gameTime(b) - gameTime(a))[0];
return games[0];
}
/** True if any platform integration is switched on (gates the public bio link). */
export function hasIntegrations(): boolean {
const cfg = getIntegrationConfig();
return (
enabledPlatforms(cfg).length > 0 ||
cfg.social.length > 0 ||
cfg.consoles.length > 0 ||
cfg.favorites.length > 0 ||
cfg.bio.trim().length > 0
);
}
/** Drop cached platform payloads so the next read refetches. */
export function refreshPlatforms(): void {
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(),
};
}
}
+124
View File
@@ -0,0 +1,124 @@
import "server-only";
import type { Achievement, Game, PlatformData, PsnConfig } from "./types";
// PlayStation Network has no official public API. We replicate the well-known
// NPSSO -> authorization-code -> access-token exchange (the same flow the
// `psn-api` library performs) using raw fetch, then read the user's trophy
// titles. This is ToS-gray and brittle by nature; every step degrades to a
// thrown Error which the cache layer turns into a surfaced status, never a crash.
const AUTH_BASE = "https://ca.account.sony.com/api/authz/v3/oauth";
const API_BASE = "https://m.np.playstation.com/api";
const CLIENT_ID = "09515159-7237-4370-9b40-3806e67c0891";
const REDIRECT = "com.scee.psxandroid.scecompcall://redirect";
// Public basic-auth pair used by the PSN mobile client (client_id:client_secret).
const BASIC = "MDk1MTUxNTktNzIzNy00MzcwLTliNDAtMzgwNmU2N2MwODkxOnVjUGprYTV0bnRCMktxc1A=";
const TIMEOUT = 8000;
/** NPSSO cookie -> short-lived authorization code. */
async function exchangeCode(npsso: string): Promise<string> {
const params = new URLSearchParams({
access_type: "offline",
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
response_type: "code",
scope: "psn:mobile.v2.core psn:clientapp",
});
const res = await fetch(`${AUTH_BASE}/authorize?${params}`, {
method: "GET",
redirect: "manual",
headers: { Cookie: `npsso=${npsso}` },
signal: AbortSignal.timeout(TIMEOUT),
});
const location = res.headers.get("location") ?? "";
const code = new URL(location, "https://ca.account.sony.com").searchParams.get("code");
if (!code) throw new Error("PSN: could not obtain auth code (NPSSO expired or invalid).");
return code;
}
/** Authorization code -> access token. */
async function exchangeToken(code: string): Promise<string> {
const res = await fetch(`${AUTH_BASE}/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BASIC}`,
},
body: new URLSearchParams({
code,
redirect_uri: REDIRECT,
grant_type: "authorization_code",
token_format: "jwt",
}),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`PSN token exchange failed (${res.status}).`);
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("PSN: no access token returned.");
return json.access_token;
}
type TrophyTitle = {
npCommunicationId: string;
trophyTitleName: string;
trophyTitleIconUrl?: string;
trophyTitlePlatform?: string;
lastUpdatedDateTime?: string;
earnedTrophies?: { bronze: number; silver: number; gold: number; platinum: number };
};
async function trophyTitles(token: string): Promise<TrophyTitle[]> {
const res = await fetch(
`${API_BASE}/trophy/v1/users/me/trophyTitles?limit=16`,
{
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT),
}
);
if (!res.ok) throw new Error(`PSN trophyTitles failed (${res.status}).`);
const json = (await res.json()) as { trophyTitles?: TrophyTitle[] };
return json.trophyTitles ?? [];
}
export async function fetchPsn(cfg: PsnConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "psn",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.npsso) return { ...base, error: "PSN NPSSO token is required." };
const token = await exchangeToken(await exchangeCode(cfg.npsso));
const titles = await trophyTitles(token);
const sorted = [...titles].sort(
(a, b) =>
new Date(b.lastUpdatedDateTime ?? 0).getTime() -
new Date(a.lastUpdatedDateTime ?? 0).getTime()
);
const games: Game[] = sorted.map((t) => ({
platform: "psn",
name: t.trophyTitleName,
image: t.trophyTitleIconUrl,
lastPlayed: t.lastUpdatedDateTime,
}));
// Surface platinum trophies as headline achievements (the API does not return
// individual trophy unlocks without a per-title call; platinums are the prize).
const achievements: Achievement[] = sorted
.filter((t) => (t.earnedTrophies?.platinum ?? 0) > 0)
.slice(0, 8)
.map((t) => ({
platform: "psn" as const,
game: t.trophyTitleName,
name: "Platinum Trophy",
icon: t.trophyTitleIconUrl,
unlockedAt: t.lastUpdatedDateTime,
rarity: "platinum",
}));
return { ...base, games, achievements };
}
+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 };
}
+59
View File
@@ -0,0 +1,59 @@
// Registry of known social networks. Used to render icons + labels for curated
// profile links and per-post share links. Icons are emoji to avoid shipping any
// image assets — they reskin fine across every theme.
export type SocialNetworkId =
| "x"
| "mastodon"
| "bluesky"
| "github"
| "youtube"
| "twitch"
| "instagram"
| "tiktok"
| "linkedin"
| "discord"
| "reddit"
| "steam"
| "psn"
| "xbox"
| "retroachievements"
| "rss"
| "website";
export type SocialNetwork = {
id: SocialNetworkId;
name: string;
/** Emoji glyph used as a lightweight, theme-agnostic icon. */
icon: string;
};
export const SOCIAL_NETWORKS: SocialNetwork[] = [
{ id: "x", name: "X / Twitter", icon: "𝕏" },
{ id: "mastodon", name: "Mastodon", icon: "🐘" },
{ id: "bluesky", name: "Bluesky", icon: "🦋" },
{ id: "github", name: "GitHub", icon: "🐙" },
{ id: "youtube", name: "YouTube", icon: "▶️" },
{ id: "twitch", name: "Twitch", icon: "🎮" },
{ id: "instagram", name: "Instagram", icon: "📷" },
{ id: "tiktok", name: "TikTok", icon: "🎵" },
{ id: "linkedin", name: "LinkedIn", icon: "💼" },
{ id: "discord", name: "Discord", icon: "💬" },
{ id: "reddit", name: "Reddit", icon: "👽" },
{ id: "steam", name: "Steam", icon: "🕹️" },
{ id: "psn", name: "PlayStation", icon: "🎯" },
{ id: "xbox", name: "Xbox", icon: "🟢" },
{ id: "retroachievements", name: "RetroAchievements", icon: "🏆" },
{ id: "rss", name: "RSS", icon: "📡" },
{ id: "website", name: "Website", icon: "🌐" },
];
const BY_ID = new Map(SOCIAL_NETWORKS.map((n) => [n.id, n]));
export function isSocialNetworkId(v: unknown): v is SocialNetworkId {
return typeof v === "string" && BY_ID.has(v as SocialNetworkId);
}
export function socialNetwork(id: SocialNetworkId): SocialNetwork {
return BY_ID.get(id) ?? { id: "website", name: "Website", icon: "🌐" };
}
+157
View File
@@ -0,0 +1,157 @@
import "server-only";
import type { Achievement, Game, PlatformData, SteamConfig } from "./types";
// Steam Web API. The only platform here with a sane, official, key-based API.
// Docs: https://developer.valvesoftware.com/wiki/Steam_Web_API
//
// 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
// the request count bounded.
const API = "https://api.steampowered.com";
const TIMEOUT = 8000;
// Thrown when the Steam account's "Game details" privacy hides achievement data.
class PrivacyError extends Error {}
function header(appid: number): string {
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
}
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });
if (!res.ok) throw new Error(`Steam API ${res.status} ${res.statusText}`);
return res.json();
}
type RecentGame = {
appid: number;
name: string;
playtime_forever?: number;
playtime_2weeks?: number;
};
async function recentGames(key: string, steamId: string): Promise<RecentGame[]> {
const url = `${API}/IPlayerService/GetRecentlyPlayedGames/v1/?key=${key}&steamid=${steamId}&count=12&format=json`;
const json = (await getJson(url)) as { response?: { games?: RecentGame[] } };
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 };
async function achievementsFor(
key: string,
steamId: string,
appid: number,
gameName: string
): Promise<Achievement[]> {
// Player progress.
const progUrl = `${API}/ISteamUserStats/GetPlayerAchievements/v1/?key=${key}&steamid=${steamId}&appid=${appid}&l=en`;
let progress: { apiname: string; achieved: number; unlocktime: number }[] = [];
try {
const json = (await getJson(progUrl)) as {
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 ?? [];
} catch (e) {
if (e instanceof PrivacyError) throw e;
return []; // many games expose no achievements; treat as none
}
// Names + icons come from the game schema.
const schemaUrl = `${API}/ISteamUserStats/GetSchemaForGame/v2/?key=${key}&appid=${appid}&l=en`;
const schema = new Map<string, SchemaAch>();
try {
const json = (await getJson(schemaUrl)) as {
game?: { availableGameStats?: { achievements?: SchemaAch[] } };
};
for (const a of json.game?.availableGameStats?.achievements ?? []) {
schema.set(a.name, a);
}
} catch {
/* names just stay as api ids */
}
return progress
.filter((a) => a.achieved === 1)
.sort((a, b) => b.unlocktime - a.unlocktime)
.slice(0, 8)
.map((a) => {
const meta = schema.get(a.apiname);
return {
platform: "steam" as const,
game: gameName,
name: meta?.displayName ?? a.apiname,
description: meta?.description,
icon: meta?.icon,
unlockedAt: a.unlocktime ? new Date(a.unlocktime * 1000).toISOString() : undefined,
};
});
}
export async function fetchSteam(cfg: SteamConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "steam",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey || !cfg.steamId) {
return { ...base, error: "Steam API key and SteamID are required." };
}
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) => ({
platform: "steam",
name: g.name,
image: header(g.appid),
url: `https://store.steampowered.com/app/${g.appid}`,
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 notice: string | undefined;
for (const g of recent.slice(0, 5)) {
try {
achievements = await achievementsFor(cfg.apiKey, cfg.steamId, g.appid, g.name);
} catch (e) {
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, notice };
}
+137
View File
@@ -0,0 +1,137 @@
// Shared types for the integrations system. No server-only imports here so the
// shapes can be referenced from client components and serialized freely.
import type { SocialNetworkId } from "./social";
/** A curated link to a profile on an external network. */
export type SocialLink = {
network: SocialNetworkId;
url: string;
/** Optional override label; falls back to the network's display name. */
label?: string;
};
/** The platforms we can auto-fetch gameplay data from. */
export type PlatformId = "steam" | "psn" | "xbox" | "retro";
/** A game surfaced from a platform's "recently played" / activity feed. */
export type Game = {
platform: PlatformId;
name: string;
/** Cover / header / icon image URL, if the platform gives us one. */
image?: string;
/** Link to the game/store/profile page. */
url?: string;
/** ISO timestamp of the last play session, if known. */
lastPlayed?: string;
/** Total playtime in minutes, if known. */
playtimeMinutes?: number;
};
/** A single unlocked (or notable) achievement / trophy. */
export type Achievement = {
platform: PlatformId;
/** Game the achievement belongs to. */
game: string;
name: string;
description?: string;
icon?: string;
/** ISO timestamp of unlock, if known. */
unlockedAt?: string;
/** PSN trophy type or rarity flavor, if known. */
rarity?: string;
};
/** A console the author owns / has owned (curated by hand). */
export type ConsoleItem = {
/** Registry id when picked from the known-consoles list; absent if free-form. */
id?: string;
name: string;
/** Emoji glyph or image URL, denormalized from the registry for rendering. */
icon?: string;
note?: string;
};
/** An all-time favorite game (curated by hand). */
export type FavoriteGame = {
name: string;
/** Cover / capsule art URL, when added from the game search. */
image?: string;
/** Store / info page link. */
url?: string;
note?: string;
};
/** Per-platform credential + toggle config. */
export type SteamConfig = {
enabled: boolean;
apiKey: string;
/** 64-bit SteamID. */
steamId: string;
};
export type PsnConfig = {
enabled: boolean;
/** NPSSO token extracted from a logged-in PSN web session. */
npsso: string;
};
export type XboxConfig = {
enabled: boolean;
/** OpenXBL (xbl.io) API key. */
apiKey: string;
/** Optional XUID; OpenXBL infers the authed account if blank. */
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. */
export type IntegrationConfig = {
displayName: string;
/** Markdown bio shown at the top of the profile. */
bio: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
steam: SteamConfig;
psn: PsnConfig;
xbox: XboxConfig;
retro: RetroConfig;
/** Cache freshness window for platform fetches, in minutes. */
cacheTtlMinutes: number;
};
/** Result of fetching one platform — always returned, never throws. */
export type PlatformData = {
platform: PlatformId;
games: Game[];
achievements: Achievement[];
/** Human-readable error if the last fetch failed (data may be stale/empty). */
error?: string;
/** Non-fatal hint (e.g. games loaded but achievements are privacy-blocked). */
notice?: string;
/** ISO timestamp of when this payload was fetched. */
fetchedAt: string;
};
/** The fully-assembled profile rendered on the public bio page. */
export type Profile = {
displayName: string;
bioHtml: string;
avatarUrl: string;
social: SocialLink[];
consoles: ConsoleItem[];
favorites: FavoriteGame[];
games: Game[];
achievements: Achievement[];
/** Per-platform fetch status (for showing errors / freshness in admin). */
platforms: PlatformData[];
};
+93
View File
@@ -0,0 +1,93 @@
import "server-only";
import type { Achievement, Game, PlatformData, XboxConfig } from "./types";
// Xbox Live has no official public consumer API. We use OpenXBL (xbl.io), a
// popular third-party proxy: the user signs in there and pastes their API key.
// Docs: https://xbl.io/console
//
// Endpoints used:
// GET /api/v2/player/titleHistory -> recently played titles
// GET /api/v2/achievements -> recent achievement unlocks for the account
const API = "https://xbl.io/api/v2";
const TIMEOUT = 8000;
function headers(apiKey: string): HeadersInit {
return { "X-Authorization": apiKey, Accept: "application/json" };
}
async function getJson(path: string, apiKey: string): Promise<unknown> {
const res = await fetch(`${API}${path}`, {
headers: headers(apiKey),
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`OpenXBL ${res.status} ${res.statusText}`);
return res.json();
}
type TitleEntry = {
name?: string;
displayImage?: string;
titleHistory?: { lastTimePlayed?: string };
achievement?: { currentAchievements?: number };
};
type AchievementEntry = {
name?: string;
titleAssociations?: { name?: string }[];
description?: string;
unlockedDescription?: string;
progressState?: string;
progression?: { timeUnlocked?: string };
mediaAssets?: { name?: string; type?: string; url?: string }[];
rarity?: { currentPercentage?: number };
};
export async function fetchXbox(cfg: XboxConfig): Promise<PlatformData> {
const base: PlatformData = {
platform: "xbox",
games: [],
achievements: [],
fetchedAt: new Date().toISOString(),
};
if (!cfg.enabled) return base;
if (!cfg.apiKey) return { ...base, error: "Xbox (OpenXBL) API key is required." };
// Recently played titles.
const history = (await getJson("/player/titleHistory", cfg.apiKey)) as {
titles?: TitleEntry[];
};
const games: Game[] = (history.titles ?? []).slice(0, 12).map((t) => ({
platform: "xbox",
name: t.name ?? "Unknown title",
image: t.displayImage,
lastPlayed: t.titleHistory?.lastTimePlayed,
}));
// Recent achievement unlocks.
let achievements: Achievement[] = [];
try {
const ach = (await getJson("/achievements", cfg.apiKey)) as {
achievements?: AchievementEntry[];
};
achievements = (ach.achievements ?? [])
.filter((a) => a.progressState === "Achieved")
.slice(0, 8)
.map((a) => ({
platform: "xbox" as const,
game: a.titleAssociations?.[0]?.name ?? "Xbox",
name: a.name ?? "Achievement",
description: a.description ?? a.unlockedDescription,
icon: a.mediaAssets?.find((m) => m.type === "Icon")?.url,
unlockedAt: a.progression?.timeUnlocked,
rarity:
typeof a.rarity?.currentPercentage === "number"
? `${a.rarity.currentPercentage}%`
: undefined,
}));
} catch {
/* achievements are optional; keep the games */
}
return { ...base, games, achievements };
}
+126 -3
View File
@@ -1,6 +1,19 @@
import "server-only";
import { marked } from "marked";
import { getDb } from "./db";
import { isSocialNetworkId } from "./integrations/social";
import type { SocialLink } from "./integrations/types";
/** A downloadable attached to a post: an uploaded file or an external link. */
export type Download = {
label: string;
url: string;
kind: "file" | "link";
/** Byte size of an uploaded file, when known. */
size?: number;
/** Original filename of an uploaded file, for the download attribute. */
filename?: string;
};
export type Post = {
id: number;
@@ -10,6 +23,10 @@ export type Post = {
body: string;
author: string;
tags: string[];
/** Links to where this post was shared on social networks. */
links: SocialLink[];
/** Files / external links offered for download on the post. */
downloads: Download[];
createdAt: string;
};
@@ -21,9 +38,65 @@ type Row = {
body: string;
author: string;
tags: string;
links: string | null;
downloads: string | null;
created_at: string;
};
// Parse the per-post links JSON, discarding anything malformed so a bad row can
// never break rendering.
function parseLinks(raw: string | null): SocialLink[] {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.map((o): SocialLink | null => {
const network = (o as Record<string, unknown>)?.network;
const url = (o as Record<string, unknown>)?.url;
if (!isSocialNetworkId(network) || typeof url !== "string" || !url.trim()) {
return null;
}
const label = (o as Record<string, unknown>)?.label;
return {
network,
url: url.trim(),
label: typeof label === "string" && label.trim() ? label.trim() : undefined,
};
})
.filter((l): l is SocialLink => l !== null);
} catch {
return [];
}
}
// Parse the per-post downloads JSON, discarding malformed entries so a bad row
// can never break rendering.
function parseDownloads(raw: string | null): Download[] {
if (!raw) return [];
try {
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
return arr
.map((o): Download | null => {
const rec = o as Record<string, unknown>;
const label = typeof rec.label === "string" ? rec.label.trim() : "";
const url = typeof rec.url === "string" ? rec.url.trim() : "";
if (!url) return null;
const kind = rec.kind === "file" ? "file" : "link";
const size = typeof rec.size === "number" && rec.size >= 0 ? rec.size : undefined;
const filename =
typeof rec.filename === "string" && rec.filename.trim()
? rec.filename.trim()
: undefined;
return { label: label || filename || url, url, kind, size, filename };
})
.filter((d): d is Download => d !== null);
} catch {
return [];
}
}
function toPost(r: Row): Post {
return {
id: r.id,
@@ -33,6 +106,8 @@ function toPost(r: Row): Post {
body: r.body,
author: r.author,
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
links: parseLinks(r.links),
downloads: parseDownloads(r.downloads),
createdAt: r.created_at,
};
}
@@ -65,9 +140,41 @@ export type PostInput = {
body?: string;
author?: string;
tags?: string; // comma-separated
/** Social links, one per line as `network|url` or `network|url|label`. */
links?: string;
/** Downloads as a JSON array string (from the download editor). */
downloads?: string;
createdAt?: string;
};
// Turn the admin textarea (one `network|url|label` line each) into the JSON
// string stored in the posts.links column. Unknown networks / blank urls drop.
function serializeLinks(input: string | undefined): string {
if (!input) return "[]";
const out: SocialLink[] = [];
for (const line of input.split("\n")) {
const [network, url, ...rest] = line.split("|").map((s) => s.trim());
if (!isSocialNetworkId(network) || !url) continue;
const label = rest.join("|").trim();
out.push({ network, url, label: label || undefined });
}
return JSON.stringify(out);
}
// Re-validate the editor's downloads JSON before storing it. Reuses
// parseDownloads so stored data matches what rendering will accept.
function serializeDownloads(input: string | undefined): string {
if (!input) return "[]";
return JSON.stringify(parseDownloads(input));
}
/** Render a post's links back to the editable `network|url|label` text form. */
export function linksToText(links: SocialLink[]): string {
return links
.map((l) => [l.network, l.url, l.label].filter(Boolean).join(" | "))
.join("\n");
}
// Turn a title (or supplied slug) into a URL-safe, unique slug. When an id is
// given, that row is allowed to keep its own slug (used during edits).
export function slugify(input: string): string {
@@ -96,8 +203,8 @@ export function createPost(input: PostInput): Post {
const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
const info = getDb()
.prepare(
`INSERT INTO posts (slug, title, excerpt, body, author, tags, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @created_at)`
`INSERT INTO posts (slug, title, excerpt, body, author, tags, links, downloads, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @downloads, @created_at)`
)
.run({
slug,
@@ -106,6 +213,8 @@ export function createPost(input: PostInput): Post {
body: input.body ?? "",
author: input.author?.trim() || "webmaster",
tags: normalizeTags(input.tags),
links: serializeLinks(input.links),
downloads: serializeDownloads(input.downloads),
created_at: input.createdAt?.trim() || nowStamp(),
});
return getPostById(Number(info.lastInsertRowid))!;
@@ -123,7 +232,7 @@ export function updatePost(id: number, input: PostInput): Post | null {
`UPDATE posts
SET slug = @slug, title = @title, excerpt = @excerpt,
body = @body, author = @author, tags = @tags,
created_at = @created_at
links = @links, downloads = @downloads, created_at = @created_at
WHERE id = @id`
)
.run({
@@ -134,6 +243,8 @@ export function updatePost(id: number, input: PostInput): Post | null {
body: input.body ?? "",
author: input.author?.trim() || "webmaster",
tags: normalizeTags(input.tags),
links: serializeLinks(input.links),
downloads: serializeDownloads(input.downloads),
created_at: input.createdAt?.trim() || existing.createdAt,
});
return getPostById(id);
@@ -171,6 +282,18 @@ export function renderMarkdown(md: string): string {
return marked.parse(md, { async: false }) as string;
}
/** Human-readable byte size, e.g. 1536 -> "1.5 KB". */
export function formatSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let n = bytes;
let u = 0;
while (n >= 1024 && u < units.length - 1) {
n /= 1024;
u++;
}
return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;
}
export function formatDate(iso: string): string {
const d = new Date(iso.replace(" ", "T"));
if (isNaN(d.getTime())) return iso;
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import path from "node:path";
import fs from "node:fs";
// Uploaded post attachments live alongside the SQLite db under data/, so a
// single persisted volume covers both content and files. They are served back
// to visitors through the /uploads/[name] route (not Next's static public dir).
export function uploadsDir(): string {
const dir = path.join(process.cwd(), "data", "uploads");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
// A stored filename is the safe, unique name we wrote to disk. Reject anything
// with path separators or traversal so the serve route can't escape the dir.
export function isSafeStoredName(name: string): boolean {
return /^[A-Za-z0-9._-]+$/.test(name) && !name.includes("..");
}
// Collapse an arbitrary client filename to a safe slug while keeping a sane
// extension, then prefix a short random token so uploads never clash.
export function makeStoredName(original: string): string {
const ext = path.extname(original).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 12);
const base =
path
.basename(original, path.extname(original))
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "file";
const token = Math.random().toString(36).slice(2, 8) + Date.now().toString(36);
return `${token}-${base}${ext}`;
}
const MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".zip": "application/zip",
".7z": "application/x-7z-compressed",
".rar": "application/vnd.rar",
".gz": "application/gzip",
".tar": "application/x-tar",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".json": "application/json",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".iso": "application/x-iso9660-image",
".nes": "application/octet-stream",
".sfc": "application/octet-stream",
".smc": "application/octet-stream",
".gb": "application/octet-stream",
".gba": "application/octet-stream",
};
export function mimeForName(name: string): string {
return MIME_BY_EXT[path.extname(name).toLowerCase()] ?? "application/octet-stream";
}