Compare commits

...

9 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
kawa 307e0b48da Refine PS1/PS2/PS3/NDS themes and add decorative background field
Rework console theme stylesheets and add opt-in .rb-bg cube field
(used by PS2 BIOS bobbing-cube background) wired through Shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:53:20 +02:00
kawa b465233f71 Refined themes 2026-06-07 01:49:30 +02:00
kawa b0664dd562 Merge remote-tracking branch 'origin/main' 2026-06-07 01:16:59 +02:00
kawa 91244a5a2b feat: add 6 more skins (PS1, PS3, Wii, NDS, Dreamcast, JV2002)
Brings the catalog to nine. Each is a single scoped [data-theme="..."]
CSS file plus a registry entry — no markup changes.

- ps1: charcoal BIOS, gray panels, uppercase letterspacing
- ps3: animated XMB sky gradient + drifting wave, black glass
- wii: white channels, rounded pills, soft blue glow
- nds: silver shell with content framed as the touch screen
- dreamcast: cream BIOS, conic-gradient orange swirl, lowercase blue
- jv2002: dense boxy portal, Verdana 11px, red masthead, blue nav tabs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 01:15:25 +02:00
101 changed files with 10484 additions and 470 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"]
+147 -20
View File
@@ -1,36 +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.
---
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.
## Configuration
## Learn More
All config is environment variables. Copy `.env.example` to `.env` and edit.
To learn more about Next.js, take a look at the following resources:
| 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. |
- [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.
Generate a secret:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
```bash
openssl rand -hex 32
```
## Deploy on Vercel
**Data** is stored in `./data/blog.db` (created automatically). Back up that
directory to back up the whole site.
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.
## 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
Sign in at `/admin` with `ADMIN_PASSWORD`. From there:
- **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).
All `/admin/*` routes are gated by middleware behind the signed session cookie.
---
## License
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 });
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { requireAdmin } from "@/lib/auth";
import { logoutAction } from "../actions";
// Guarded chrome for every authenticated admin page. Login lives outside this
// group so it never inherits the nav or the auth gate.
export default async function PanelLayout({
children,
}: {
children: ReactNode;
}) {
await requireAdmin();
return (
<div className="rb-admin-shell">
<header className="rb-admin-bar">
<Link href="/admin" className="rb-admin-brand">
RetroBlog Admin
</Link>
<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
</Link>
</nav>
<form action={logoutAction} className="rb-admin-logout">
<button className="rb-btn rb-btn-ghost" type="submit">
Sign out
</button>
</form>
</header>
<main className="rb-admin-main">{children}</main>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
import { getSettings } from "@/lib/settings";
import { themeMeta } from "@/themes/registry";
export default async function DashboardPage() {
const posts = getAllPosts();
const settings = getSettings();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Dashboard</h1>
<div className="rb-admin-grid">
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Posts</h2>
<p className="rb-admin-stat">{posts.length}</p>
<Link className="rb-btn" href="/admin/posts">
Manage posts
</Link>
<Link className="rb-btn rb-btn-primary" href="/admin/posts/new">
New post
</Link>
</div>
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Theme</h2>
<p className="rb-admin-stat-sm">
Default: {themeMeta(settings.defaultTheme).name}
</p>
<p className="rb-admin-muted">
Public switcher:{" "}
{settings.publicThemeToggle ? "visible" : "hidden"} ·{" "}
{settings.allowedThemes.length} skins allowed
</p>
<Link className="rb-btn" href="/admin/settings">
Edit settings
</Link>
</div>
<div className="rb-admin-card">
<h2 className="rb-admin-h2">Backup</h2>
<p className="rb-admin-muted">Export or import settings and posts.</p>
<a className="rb-btn" href="/admin/export">
Export everything
</a>
<Link className="rb-btn" href="/admin/settings#data">
Import / export
</Link>
</div>
</div>
</div>
);
}
@@ -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>
);
}
+119
View File
@@ -0,0 +1,119 @@
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.
export default function PostForm({
post,
titleError,
}: {
post?: Post;
titleError?: boolean;
}) {
const editing = !!post;
return (
<form className="rb-admin-card rb-post-form" action={savePostAction}>
{editing && <input type="hidden" name="id" value={post.id} />}
{titleError && <p className="rb-admin-error">Title is required.</p>}
<label className="rb-field">
<span>Title</span>
<input name="title" defaultValue={post?.title ?? ""} required />
</label>
<label className="rb-field">
<span>
Slug <em className="rb-admin-muted">(blank = auto from title)</em>
</span>
<input
name="slug"
defaultValue={post?.slug ?? ""}
placeholder="my-post-title"
/>
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Author</span>
<input name="author" defaultValue={post?.author ?? "webmaster"} />
</label>
<label className="rb-field">
<span>
Date <em className="rb-admin-muted">(YYYY-MM-DD HH:MM:SS)</em>
</span>
<input
name="createdAt"
defaultValue={post?.createdAt ?? ""}
placeholder="2002-03-14 09:21:00"
/>
</label>
</div>
<label className="rb-field">
<span>
Tags <em className="rb-admin-muted">(comma-separated)</em>
</span>
<input
name="tags"
defaultValue={post?.tags.join(", ") ?? ""}
placeholder="design, opinion"
/>
</label>
<label className="rb-field">
<span>Excerpt</span>
<textarea name="excerpt" rows={2} defaultValue={post?.excerpt ?? ""} />
</label>
<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="links"
rows={3}
className="rb-mono"
placeholder={"mastodon|https://mastodon.social/@me/123|Discuss\nx|https://x.com/me/status/123"}
defaultValue={post ? linksToText(post.links) : ""}
/>
</label>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
{editing ? "Save changes" : "Create post"}
</button>
<Link className="rb-btn rb-btn-ghost" href="/admin/posts">
Cancel
</Link>
</div>
</form>
);
}
@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { getPostById } from "@/lib/posts";
import PostForm from "../../PostForm";
export default async function EditPostPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ error?: string }>;
}) {
const { id } = await params;
const { error } = await searchParams;
const post = getPostById(Number(id));
if (!post) notFound();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Edit post</h1>
<PostForm post={post} titleError={error === "title"} />
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import PostForm from "../PostForm";
export default async function NewPostPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">New post</h1>
<PostForm titleError={error === "title"} />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Link from "next/link";
import { getAllPosts, formatDate } from "@/lib/posts";
import { deletePostAction } from "../../actions";
export default async function AdminPostsPage() {
const posts = getAllPosts();
return (
<div className="rb-admin-page">
<div className="rb-admin-page-head">
<h1 className="rb-admin-h1">Posts</h1>
<Link className="rb-btn rb-btn-primary" href="/admin/posts/new">
New post
</Link>
</div>
{posts.length === 0 ? (
<p className="rb-admin-muted">No posts yet. Create your first one.</p>
) : (
<table className="rb-admin-table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Date</th>
<th className="rb-col-actions">Actions</th>
</tr>
</thead>
<tbody>
{posts.map((p) => (
<tr key={p.id}>
<td>{p.title}</td>
<td className="rb-admin-mono">{p.slug}</td>
<td>{formatDate(p.createdAt)}</td>
<td className="rb-col-actions">
<Link className="rb-btn rb-btn-sm" href={`/admin/posts/${p.id}/edit`}>
Edit
</Link>
<a
className="rb-btn rb-btn-sm"
href={`/posts/${p.slug}`}
target="_blank"
>
View
</a>
<form action={deletePostAction} className="rb-inline-form">
<input type="hidden" name="id" value={p.id} />
<button
className="rb-btn rb-btn-sm rb-btn-danger"
type="submit"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { getSettings } from "@/lib/settings";
import { THEMES } from "@/themes/registry";
import { saveSettingsAction, importAction } from "../../actions";
function StatusBanner({
saved,
imp,
}: {
saved?: string;
imp?: string;
}) {
if (saved) return <p className="rb-admin-ok">Settings saved.</p>;
if (imp === "ok") return <p className="rb-admin-ok">Import complete.</p>;
if (imp === "empty")
return <p className="rb-admin-error">Nothing to import no file or text.</p>;
if (imp === "invalid")
return <p className="rb-admin-error">Import failed invalid JSON.</p>;
return null;
}
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ saved?: string; import?: string }>;
}) {
const { saved, import: imp } = await searchParams;
const settings = getSettings();
return (
<div className="rb-admin-page">
<h1 className="rb-admin-h1">Settings</h1>
<StatusBanner saved={saved} imp={imp} />
{/* ---- branding + theme ---- */}
<form className="rb-admin-card" action={saveSettingsAction}>
<h2 className="rb-admin-h2">Branding</h2>
<label className="rb-field">
<span>Site title</span>
<input name="title" defaultValue={settings.title} />
</label>
<label className="rb-field">
<span>Subtitle / tagline</span>
<input name="subtitle" defaultValue={settings.subtitle} />
</label>
<div className="rb-field-row">
<label className="rb-field">
<span>Status bar / footer text</span>
<input name="footer" defaultValue={settings.footer} />
</label>
<label className="rb-field">
<span>Version label</span>
<input name="version" defaultValue={settings.version} />
</label>
</div>
<h2 className="rb-admin-h2">Theme</h2>
<label className="rb-field">
<span>Default theme for new visitors</span>
<select name="defaultTheme" defaultValue={settings.defaultTheme}>
{THEMES.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.era})
</option>
))}
</select>
</label>
<label className="rb-check">
<input
type="checkbox"
name="publicThemeToggle"
defaultChecked={settings.publicThemeToggle}
/>
<span>
Show the theme switcher to public visitors
<em className="rb-admin-muted"> (you always see it as admin)</em>
</span>
</label>
<fieldset className="rb-fieldset">
<legend>Skins available in the public switcher</legend>
<div className="rb-check-grid">
{THEMES.map((t) => (
<label key={t.id} className="rb-check">
<input
type="checkbox"
name="allowedThemes"
value={t.id}
defaultChecked={settings.allowedThemes.includes(t.id)}
/>
<span>
{t.name} <em className="rb-admin-muted">({t.era})</em>
</span>
</label>
))}
</div>
</fieldset>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
Save settings
</button>
</div>
</form>
{/* ---- export / import ---- */}
<div id="data" className="rb-admin-card">
<h2 className="rb-admin-h2">Export</h2>
<p className="rb-admin-muted">
Download a JSON backup. Pick what to include.
</p>
<div className="rb-btn-group">
<a className="rb-btn" href="/admin/export?settings=1">
Settings only
</a>
<a className="rb-btn" href="/admin/export?posts=1">
Posts only
</a>
<a className="rb-btn rb-btn-primary" href="/admin/export?settings=1&posts=1">
Settings + posts
</a>
</div>
</div>
<form
className="rb-admin-card"
action={importAction}
encType="multipart/form-data"
>
<h2 className="rb-admin-h2">Import</h2>
<p className="rb-admin-muted">
Upload a previously exported JSON file (or paste it below), then choose
what to apply.
</p>
<label className="rb-field">
<span>JSON file</span>
<input type="file" name="file" accept="application/json,.json" />
</label>
<label className="rb-field">
<span>or paste JSON</span>
<textarea name="pasted" rows={5} className="rb-mono" />
</label>
<fieldset className="rb-fieldset">
<legend>What to import</legend>
<label className="rb-check">
<input type="checkbox" name="what" value="settings" defaultChecked />
<span>Settings</span>
</label>
<label className="rb-check">
<input type="checkbox" name="what" value="posts" />
<span>Posts</span>
</label>
</fieldset>
<fieldset className="rb-fieldset">
<legend>Posts import mode</legend>
<label className="rb-check">
<input type="radio" name="postMode" value="replace" defaultChecked />
<span>
Replace all <em className="rb-admin-muted">(wipes existing posts)</em>
</span>
</label>
<label className="rb-check">
<input type="radio" name="postMode" value="append" />
<span>Append to existing</span>
</label>
</fieldset>
<div className="rb-form-actions">
<button className="rb-btn rb-btn-primary" type="submit">
Import
</button>
</div>
</form>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { checkPassword } from "@/lib/auth";
import {
ADMIN_COOKIE,
createSessionToken,
SESSION_MAX_AGE,
} from "@/lib/session";
import {
createPost,
updatePost,
deletePost,
replaceAllPosts,
type PostInput,
} from "@/lib/posts";
import {
saveSettings,
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 {
const v = formData.get(key);
return typeof v === "string" ? v : "";
}
// Refresh the whole site after a content/settings change. The layout-level
// revalidation covers branding + theme that live in the shared shell.
function revalidateSite() {
revalidatePath("/", "layout");
}
/* ------------------------------- auth -------------------------------- */
export async function loginAction(formData: FormData) {
const password = s(formData, "password");
const next = s(formData, "next");
if (!checkPassword(password)) {
redirect(
`/admin/login?error=1${next ? `&next=${encodeURIComponent(next)}` : ""}`
);
}
const token = await createSessionToken();
(await cookies()).set(ADMIN_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_MAX_AGE,
});
redirect(next && next.startsWith("/admin") ? next : "/admin");
}
export async function logoutAction() {
(await cookies()).delete(ADMIN_COOKIE);
redirect("/admin/login");
}
/* ------------------------------- posts ------------------------------- */
function postInputFromForm(formData: FormData): PostInput {
return {
slug: s(formData, "slug"),
title: s(formData, "title"),
excerpt: s(formData, "excerpt"),
body: s(formData, "body"),
author: s(formData, "author"),
tags: s(formData, "tags"),
links: s(formData, "links"),
downloads: s(formData, "downloads"),
createdAt: s(formData, "createdAt"),
};
}
export async function savePostAction(formData: FormData) {
const input = postInputFromForm(formData);
const idRaw = s(formData, "id");
if (!input.title.trim()) {
redirect(idRaw ? `/admin/posts/${idRaw}/edit?error=title` : "/admin/posts/new?error=title");
}
if (idRaw) {
updatePost(Number(idRaw), input);
} else {
createPost(input);
}
revalidateSite();
redirect("/admin/posts");
}
export async function deletePostAction(formData: FormData) {
const id = Number(s(formData, "id"));
if (Number.isFinite(id)) deletePost(id);
revalidateSite();
redirect("/admin/posts");
}
/* ------------------------------ settings ----------------------------- */
export async function saveSettingsAction(formData: FormData) {
const allowed = formData
.getAll("allowedThemes")
.map(String)
.filter(isThemeId) as ThemeId[];
const defaultTheme = s(formData, "defaultTheme");
const next: Settings = normalizeSettings({
title: s(formData, "title"),
subtitle: s(formData, "subtitle"),
footer: s(formData, "footer"),
version: s(formData, "version"),
defaultTheme: isThemeId(defaultTheme) ? defaultTheme : undefined,
publicThemeToggle: formData.get("publicThemeToggle") === "on",
allowedThemes: allowed.length ? allowed : THEMES.map((t) => t.id),
});
saveSettings(next);
revalidateSite();
redirect("/admin/settings?saved=1");
}
/* ------------------------------- import ------------------------------ */
export async function importAction(formData: FormData) {
const file = formData.get("file");
const pasted = s(formData, "pasted");
const wants = formData.getAll("what").map(String);
const wantSettings = wants.includes("settings");
const wantPosts = wants.includes("posts");
const postMode = s(formData, "postMode") || "replace";
let raw = pasted;
if (file && file instanceof File && file.size > 0) {
raw = await file.text();
}
if (!raw.trim()) redirect("/admin/settings?import=empty");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
redirect("/admin/settings?import=invalid");
}
const obj = parsed as Record<string, unknown>;
if (wantSettings && obj.settings) {
saveSettings(normalizeSettings(obj.settings));
}
if (wantPosts && Array.isArray(obj.posts)) {
const posts = (obj.posts as Record<string, unknown>[]).map(
(p): PostInput => ({
slug: typeof p.slug === "string" ? p.slug : undefined,
title: typeof p.title === "string" ? p.title : "Untitled",
excerpt: typeof p.excerpt === "string" ? p.excerpt : "",
body: typeof p.body === "string" ? p.body : "",
author: typeof p.author === "string" ? p.author : "webmaster",
tags: Array.isArray(p.tags)
? (p.tags as string[]).join(",")
: 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
: typeof p.created_at === "string"
? (p.created_at as string)
: undefined,
})
);
if (postMode === "append") {
for (const p of posts) createPost(p);
} else {
replaceAllPosts(posts);
}
}
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");
}
+821
View File
@@ -0,0 +1,821 @@
/* ============================================================
RetroBlog — admin panel styles
Deliberately theme-agnostic: a clean, neutral control surface
that stays readable no matter which public skin is active.
Everything is scoped under .rb-admin.
============================================================ */
.rb-admin {
--a-bg: #f4f5f7;
--a-surface: #ffffff;
--a-border: #d8dce3;
--a-text: #1c2330;
--a-muted: #69707d;
--a-primary: #2563eb;
--a-primary-text: #ffffff;
--a-danger: #dc2626;
--a-ok-bg: #e7f6ec;
--a-ok-text: #16693a;
--a-err-bg: #fdecec;
--a-err-text: #a11212;
--a-radius: 8px;
position: fixed;
inset: 0;
overflow: auto;
background: var(--a-bg);
color: var(--a-text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
z-index: 100;
}
.rb-admin * {
box-sizing: border-box;
}
/* ---- shell / top bar ---- */
.rb-admin-shell {
min-height: 100%;
display: flex;
flex-direction: column;
}
.rb-admin-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 0 20px;
height: 52px;
background: var(--a-surface);
border-bottom: 1px solid var(--a-border);
position: sticky;
top: 0;
z-index: 10;
}
.rb-admin-brand {
font-weight: 700;
text-decoration: none;
color: var(--a-text);
}
.rb-admin-nav {
display: flex;
gap: 16px;
flex: 1;
}
.rb-admin-nav a {
color: var(--a-muted);
text-decoration: none;
padding: 4px 2px;
}
.rb-admin-nav a:hover {
color: var(--a-text);
}
.rb-admin-logout {
margin: 0;
}
.rb-admin-main {
flex: 1;
width: min(880px, 100%);
margin: 0 auto;
padding: 28px 20px 60px;
}
/* ---- headings / text ---- */
.rb-admin-h1 {
font-size: 22px;
margin: 0 0 18px;
}
.rb-admin-h2 {
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--a-muted);
margin: 22px 0 12px;
}
.rb-admin-h2:first-child {
margin-top: 0;
}
.rb-admin-muted {
color: var(--a-muted);
}
.rb-admin-mono,
.rb-mono {
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas,
monospace;
}
.rb-admin-page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.rb-admin-page-head .rb-admin-h1 {
margin: 0;
}
/* ---- cards / grid ---- */
.rb-admin-card {
background: var(--a-surface);
border: 1px solid var(--a-border);
border-radius: var(--a-radius);
padding: 20px;
margin-bottom: 20px;
}
.rb-admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.rb-admin-grid .rb-admin-card {
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.rb-admin-stat {
font-size: 34px;
font-weight: 700;
margin: 0;
}
.rb-admin-stat-sm {
font-size: 16px;
font-weight: 600;
margin: 0;
}
/* ---- banners ---- */
.rb-admin-ok,
.rb-admin-warn,
.rb-admin-error {
border-radius: var(--a-radius);
padding: 10px 14px;
margin: 0 0 16px;
font-weight: 500;
}
.rb-admin-ok {
background: var(--a-ok-bg);
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);
}
/* ---- forms ---- */
.rb-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.rb-field > span {
font-weight: 600;
font-size: 13px;
}
.rb-field em {
font-style: normal;
font-weight: 400;
}
.rb-field input,
.rb-field select,
.rb-field textarea {
font: inherit;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 11px;
width: 100%;
}
.rb-field textarea {
resize: vertical;
}
.rb-field input:focus,
.rb-field select:focus,
.rb-field textarea:focus {
outline: 2px solid var(--a-primary);
outline-offset: -1px;
border-color: var(--a-primary);
}
.rb-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.rb-fieldset {
border: 1px solid var(--a-border);
border-radius: var(--a-radius);
padding: 14px 16px;
margin: 0 0 16px;
}
.rb-fieldset legend {
font-weight: 600;
font-size: 13px;
padding: 0 6px;
}
.rb-check {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 6px 0;
cursor: pointer;
}
.rb-check input {
margin-top: 3px;
}
.rb-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 4px 16px;
}
.rb-form-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
/* ---- buttons ---- */
.rb-btn {
display: inline-block;
font: inherit;
font-weight: 500;
line-height: 1;
text-decoration: none;
color: var(--a-text);
background: #fff;
border: 1px solid var(--a-border);
border-radius: 6px;
padding: 9px 14px;
cursor: pointer;
}
.rb-btn:hover {
background: #f0f2f5;
}
.rb-btn-primary {
background: var(--a-primary);
border-color: var(--a-primary);
color: var(--a-primary-text);
}
.rb-btn-primary:hover {
filter: brightness(1.07);
background: var(--a-primary);
}
.rb-btn-ghost {
background: transparent;
border-color: transparent;
}
.rb-btn-ghost:hover {
background: #e9ebef;
}
.rb-btn-danger {
color: var(--a-danger);
border-color: #f0c4c4;
}
.rb-btn-danger:hover {
background: var(--a-err-bg);
}
.rb-btn-sm {
padding: 5px 9px;
font-size: 13px;
}
.rb-btn-group,
.rb-form-actions {
flex-wrap: wrap;
}
.rb-btn-group {
display: flex;
gap: 10px;
}
/* ---- table ---- */
.rb-admin-table {
width: 100%;
border-collapse: collapse;
}
.rb-admin-table th,
.rb-admin-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--a-border);
vertical-align: middle;
}
.rb-admin-table th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--a-muted);
}
.rb-col-actions {
text-align: right;
white-space: nowrap;
}
.rb-col-actions .rb-btn {
margin-left: 6px;
}
.rb-inline-form {
display: inline;
margin: 0;
}
/* ---- login ---- */
.rb-admin-login {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.rb-login-card {
width: min(360px, 100%);
margin: 0;
}
.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;
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse, type NextRequest } from "next/server";
import { isAdmin } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getAllPosts } from "@/lib/posts";
// GET /admin/export?settings=1&posts=1
// Returns a JSON file download with whichever sections were requested. Defaults
// to exporting everything if no flags are given. Guarded by middleware, with a
// belt-and-braces check here too.
export async function GET(req: NextRequest) {
if (!(await isAdmin())) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const sp = req.nextUrl.searchParams;
const noFlags = !sp.has("settings") && !sp.has("posts");
const includeSettings = noFlags || sp.get("settings") === "1";
const includePosts = noFlags || sp.get("posts") === "1";
const payload: Record<string, unknown> = {
schema: "retroblog-export",
version: 1,
exportedAt: new Date().toISOString(),
};
if (includeSettings) payload.settings = getSettings();
if (includePosts) {
payload.posts = getAllPosts().map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
body: p.body,
author: p.author,
tags: p.tags,
downloads: p.downloads,
createdAt: p.createdAt,
}));
}
const stamp = new Date().toISOString().slice(0, 10);
const parts = [
includeSettings ? "settings" : null,
includePosts ? "posts" : null,
].filter(Boolean);
const filename = `retroblog-${parts.join("-")}-${stamp}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import "./admin.css";
// Outer admin wrapper. Theme-agnostic on purpose: the panel keeps one clean,
// readable look regardless of which public skin is active. Auth is enforced by
// middleware (and re-checked in the (panel) layout).
export default function AdminLayout({ children }: { children: ReactNode }) {
return <div className="rb-admin">{children}</div>;
}
+38
View File
@@ -0,0 +1,38 @@
import { loginAction } from "../actions";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string; next?: string }>;
}) {
const { error, next } = await searchParams;
return (
<div className="rb-admin-login">
<form className="rb-admin-card rb-login-card" action={loginAction}>
<h1 className="rb-admin-h1">RetroBlog Admin</h1>
<p className="rb-admin-muted">Sign in to manage posts and settings.</p>
{error && (
<p className="rb-admin-error">Incorrect password. Try again.</p>
)}
<label className="rb-field">
<span>Password</span>
<input
type="password"
name="password"
autoFocus
autoComplete="current-password"
/>
</label>
{next && <input type="hidden" name="next" value={next} />}
<button className="rb-btn rb-btn-primary" type="submit">
Sign in
</button>
</form>
</div>
);
}
+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>
);
}
+348
View File
@@ -50,6 +50,11 @@ img {
padding: 0 0 40px; /* room for the fixed taskbar */
}
/* Opt-in decorative background field (themes enable it). */
.rb-bg {
display: none;
}
.rb-window {
flex: 1;
display: flex;
@@ -188,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; }
}
+8 -4
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
import { getTheme } from "@/themes/server";
import { getSettings } from "@/lib/settings";
export const metadata: Metadata = {
title: "RetroBlog",
description: "A blog engine wearing the skins of retro OSes and consoles.",
};
export async function generateMetadata(): Promise<Metadata> {
const settings = getSettings();
return {
title: settings.title,
description: settings.subtitle,
};
}
export default async function RootLayout({
children,
+4 -4
View File
@@ -2,18 +2,18 @@ import Link from "next/link";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { getAllPosts, formatDate } from "@/lib/posts";
import { getSettings } from "@/lib/settings";
export default async function HomePage() {
const theme = await getTheme();
const posts = getAllPosts();
const settings = getSettings();
return (
<Shell theme={theme} title="Home">
<header className="rb-page-head">
<h1 className="rb-page-title">RetroBlog</h1>
<p className="rb-page-sub">
Words from the past, rendered in the chrome of the past.
</p>
<h1 className="rb-page-title">{settings.title}</h1>
<p className="rb-page-sub">{settings.subtitle}</p>
</header>
<ul className="rb-post-list">
+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",
},
});
}
+71 -15
View File
@@ -1,12 +1,15 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { type ThemeId } from "@/themes/registry";
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";
// Shared, theme-agnostic page chrome. Every theme restyles these `rb-` classes
// to look like its own OS/console. The HTML skeleton never changes.
export default function Shell({
export default async function Shell({
theme,
title,
children,
@@ -15,12 +18,53 @@ export default function Shell({
title: string;
children: ReactNode;
}) {
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;
const switcherThemes = admin
? THEMES
: THEMES.filter((t) => settings.allowedThemes.includes(t.id));
return (
<div className="rb-desktop">
{/* Decorative background field. Hidden by default; themes opt in
(PS2 uses it for the BIOS bobbing-cube field). */}
<div className="rb-bg" aria-hidden>
<div className="rb-bg-grid">
{Array.from({ length: 15 * 13 }).map((_, i) => {
// deterministic pseudo-random thickness + bob phase per cube
const h = 34 + ((i * 53) % 78);
const delay = (((i * 37) % 100) / 100) * 4;
return (
<span
key={i}
className="rb-cube"
style={
{
"--cube-h": `${h}px`,
"--cube-delay": `${delay.toFixed(2)}s`,
} as CSSProperties
}
/>
);
})}
</div>
</div>
<div className="rb-window" role="application">
<div className="rb-titlebar">
<span className="rb-titlebar-icon" aria-hidden />
<span className="rb-titlebar-text">{title} RetroBlog</span>
<span className="rb-titlebar-text">
{title} {settings.title}
</span>
<div className="rb-titlebar-buttons" aria-hidden>
<button className="rb-tb-btn rb-tb-min" tabIndex={-1}>
<span>_</span>
@@ -41,16 +85,20 @@ export default function Shell({
<Link className="rb-menu-link" href="/about">
About
</Link>
<a
className="rb-menu-link"
href="https://github.com"
target="_blank"
rel="noreferrer"
>
Links
</a>
{bioEnabled && (
<Link className="rb-menu-link" href="/bio">
Bio
</Link>
)}
{admin && (
<Link className="rb-menu-link" href="/admin">
Admin
</Link>
)}
<span className="rb-spacer" />
<ThemeSwitcher initial={theme} />
{showSwitcher && (
<ThemeSwitcher initial={theme} themes={switcherThemes} />
)}
</nav>
<div className="rb-content">{children}</div>
@@ -58,7 +106,7 @@ export default function Shell({
<div className="rb-statusbar">
<span className="rb-status-cell">Ready</span>
<span className="rb-status-cell rb-status-grow">
RetroBlog v0.1
{settings.footer}
</span>
</div>
</div>
@@ -71,10 +119,18 @@ export default function Shell({
<div className="rb-tasks">
<button className="rb-task rb-task-active">
<span className="rb-task-icon" aria-hidden />
RetroBlog
{settings.title}
</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>
);
}
+15 -3
View File
@@ -1,9 +1,21 @@
"use client";
import { useState } from "react";
import { THEMES, THEME_COOKIE, type ThemeId } from "@/themes/registry";
import {
THEMES,
THEME_COOKIE,
type ThemeId,
type ThemeMeta,
} from "@/themes/registry";
export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
export default function ThemeSwitcher({
initial,
themes = THEMES,
}: {
initial: ThemeId;
/** Skins offered in the dropdown. Defaults to all registered themes. */
themes?: ThemeMeta[];
}) {
const [theme, setTheme] = useState<ThemeId>(initial);
function apply(id: ThemeId) {
@@ -22,7 +34,7 @@ export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
onChange={(e) => apply(e.target.value as ThemeId)}
aria-label="Theme selector"
>
{THEMES.map((t) => (
{themes.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.era})
</option>
+30
View File
@@ -0,0 +1,30 @@
import "server-only";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ADMIN_COOKIE, verifySessionToken } from "./session";
// The admin password. Set ADMIN_PASSWORD in the environment for production; the
// dev fallback exists only so the panel works out of the box locally.
export function adminPassword(): string {
return process.env.ADMIN_PASSWORD || "admin";
}
export function checkPassword(input: string): boolean {
const expected = adminPassword();
if (input.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < input.length; i++)
diff |= input.charCodeAt(i) ^ expected.charCodeAt(i);
return diff === 0;
}
// True if the current request carries a valid admin session cookie.
export async function isAdmin(): Promise<boolean> {
const store = await cookies();
return verifySessionToken(store.get(ADMIN_COOKIE)?.value);
}
// Guard for admin server components: bounce to login when unauthenticated.
export async function requireAdmin(): Promise<void> {
if (!(await isAdmin())) redirect("/admin/login");
}
+35
View File
@@ -35,7 +35,42 @@ function migrate(db: Database.Database) {
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Single-row key/value blob holding the site settings JSON.
CREATE TABLE IF NOT EXISTS settings (
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 };
}
+239
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,
};
}
@@ -51,10 +126,174 @@ export function getPostBySlug(slug: string): Post | null {
return row ? toPost(row) : null;
}
export function getPostById(id: number): Post | null {
const row = getDb()
.prepare("SELECT * FROM posts WHERE id = ?")
.get(id) as Row | undefined;
return row ? toPost(row) : null;
}
export type PostInput = {
slug?: string;
title: string;
excerpt?: string;
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 {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function uniqueSlug(base: string, ignoreId?: number): string {
const db = getDb();
let slug = base || "post";
let n = 1;
for (;;) {
const clash = db
.prepare("SELECT id FROM posts WHERE slug = ?")
.get(slug) as { id: number } | undefined;
if (!clash || clash.id === ignoreId) return slug;
slug = `${base}-${++n}`;
}
}
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, links, downloads, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @links, @downloads, @created_at)`
)
.run({
slug,
title: input.title.trim(),
excerpt: input.excerpt?.trim() ?? "",
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))!;
}
export function updatePost(id: number, input: PostInput): Post | null {
const existing = getPostById(id);
if (!existing) return null;
const slug = uniqueSlug(
slugify(input.slug?.trim() || input.title),
id
);
getDb()
.prepare(
`UPDATE posts
SET slug = @slug, title = @title, excerpt = @excerpt,
body = @body, author = @author, tags = @tags,
links = @links, downloads = @downloads, created_at = @created_at
WHERE id = @id`
)
.run({
id,
slug,
title: input.title.trim(),
excerpt: input.excerpt?.trim() ?? "",
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);
}
export function deletePost(id: number): void {
getDb().prepare("DELETE FROM posts WHERE id = ?").run(id);
}
// Replace every post with the supplied set (used by import "replace" mode).
export function replaceAllPosts(posts: PostInput[]): number {
const db = getDb();
const tx = db.transaction((rows: PostInput[]) => {
db.prepare("DELETE FROM posts").run();
for (const r of rows) createPost(r);
});
tx(posts);
return posts.length;
}
function normalizeTags(tags: string | undefined): string {
if (!tags) return "";
return tags
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.join(",");
}
function nowStamp(): string {
return new Date().toISOString().slice(0, 19).replace("T", " ");
}
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;
+61
View File
@@ -0,0 +1,61 @@
// Stateless signed-session helpers built on the Web Crypto API so the same code
// runs in both the Edge middleware and Node server actions. A session token is
// `${issuedAt}.${hmac(issuedAt)}`; we recompute the HMAC to verify and reject
// anything older than MAX_AGE. No DB round-trip, no per-session storage.
export const ADMIN_COOKIE = "rb_admin";
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
function secret(): string {
return (
process.env.ADMIN_SESSION_SECRET ||
process.env.ADMIN_PASSWORD ||
"retroblog-insecure-dev-secret"
);
}
async function hmacHex(message: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Constant-time-ish comparison to avoid leaking via early exit.
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export async function createSessionToken(): Promise<string> {
const issued = Date.now().toString();
const sig = await hmacHex(issued);
return `${issued}.${sig}`;
}
export async function verifySessionToken(
token: string | undefined | null
): Promise<boolean> {
if (!token) return false;
const dot = token.indexOf(".");
if (dot <= 0) return false;
const issued = token.slice(0, dot);
const sig = token.slice(dot + 1);
const issuedAt = Number(issued);
if (!Number.isFinite(issuedAt)) return false;
if (Date.now() - issuedAt > MAX_AGE_SECONDS * 1000) return false;
const expected = await hmacHex(issued);
return safeEqual(sig, expected);
}
export const SESSION_MAX_AGE = MAX_AGE_SECONDS;
+86
View File
@@ -0,0 +1,86 @@
import "server-only";
import { getDb } from "./db";
import {
DEFAULT_THEME,
THEMES,
isThemeId,
type ThemeId,
} from "@/themes/registry";
// Site-wide settings, editable from the admin panel and persisted as a single
// JSON row in SQLite. Adding a field = extend the type, the defaults, and the
// admin form; reads everywhere go through getSettings().
export type Settings = {
/** Branding */
title: string;
subtitle: string;
footer: string;
version: string;
/** Theme that new visitors get before they pick one. */
defaultTheme: ThemeId;
/** Whether the theme switcher is shown to the public (admins always see it). */
publicThemeToggle: boolean;
/** Which skins appear in the public switcher. */
allowedThemes: ThemeId[];
};
export const DEFAULT_SETTINGS: Settings = {
title: "RetroBlog",
subtitle: "Words from the past, rendered in the chrome of the past.",
footer: "RetroBlog v0.1",
version: "v0.1",
defaultTheme: DEFAULT_THEME,
publicThemeToggle: true,
allowedThemes: THEMES.map((t) => t.id),
};
// Coerce arbitrary parsed JSON into a valid Settings object, falling back to
// defaults field-by-field so a partial or hand-edited import can't corrupt the
// site. Returns a clean, fully-populated Settings.
export function normalizeSettings(input: unknown): Settings {
const o = (input ?? {}) as Record<string, unknown>;
const allowed = Array.isArray(o.allowedThemes)
? (o.allowedThemes.filter(isThemeId) as ThemeId[])
: DEFAULT_SETTINGS.allowedThemes;
return {
title: str(o.title, DEFAULT_SETTINGS.title),
subtitle: str(o.subtitle, DEFAULT_SETTINGS.subtitle),
footer: str(o.footer, DEFAULT_SETTINGS.footer),
version: str(o.version, DEFAULT_SETTINGS.version),
defaultTheme: isThemeId(o.defaultTheme as string)
? (o.defaultTheme as ThemeId)
: DEFAULT_SETTINGS.defaultTheme,
publicThemeToggle:
typeof o.publicThemeToggle === "boolean"
? o.publicThemeToggle
: DEFAULT_SETTINGS.publicThemeToggle,
allowedThemes: allowed.length ? allowed : DEFAULT_SETTINGS.allowedThemes,
};
}
function str(v: unknown, fallback: string): string {
return typeof v === "string" && v.trim().length ? v : fallback;
}
export function getSettings(): Settings {
const row = getDb()
.prepare("SELECT data FROM settings WHERE id = 1")
.get() as { data: string } | undefined;
if (!row) return DEFAULT_SETTINGS;
try {
return normalizeSettings(JSON.parse(row.data));
} catch {
return DEFAULT_SETTINGS;
}
}
export function saveSettings(next: Settings): Settings {
const clean = normalizeSettings(next);
getDb()
.prepare(
`INSERT INTO settings (id, data) VALUES (1, @data)
ON CONFLICT(id) DO UPDATE SET data = @data`
)
.run({ data: JSON.stringify(clean) });
return clean;
}
+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";
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse, type NextRequest } from "next/server";
import { ADMIN_COOKIE, verifySessionToken } from "@/lib/session";
// Gate every /admin route behind a valid session, except the login page and its
// POST action. Runs on the Edge runtime, so it relies only on Web Crypto
// (see lib/session.ts) — no Node APIs.
export async function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname === "/admin/login") return NextResponse.next();
const token = req.cookies.get(ADMIN_COOKIE)?.value;
if (await verifySessionToken(token)) return NextResponse.next();
const url = req.nextUrl.clone();
url.pathname = "/admin/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/admin/:path*"],
};
+235 -116
View File
@@ -1,249 +1,368 @@
/* ============================================================
Theme: Sega Dreamcast — scoped to [data-theme="dreamcast"]
Cream BIOS calm, the orange swirl, friendly blue type, bubbly
rounded VMU-flavored chrome.
The boot dashboard: watery sky-blue gradient, opaque cream
top/bottom bars with a charcoal wordmark + clock, and bubbly
pastel pills that glow like Play / File / Music / Settings.
============================================================ */
[data-theme="dreamcast"] {
--dc-orange: #f47b20;
--dc-orange-dk: #d85f0a;
--dc-blue: #143d8c;
--dc-blue-lt: #2f6ad0;
--dc-cream: #f3efe6;
--dc-ink: #2a2f3a;
font-family: "Futura", "Century Gothic", "Trebuchet MS", sans-serif;
/* dashboard pastels */
--dc-peach: #e0a079;
--dc-peach-glow: #f4c79e;
--dc-green: #6cba89;
--dc-green-glow: #9fe6b4;
--dc-cyan: #5fb3bf;
--dc-cyan-glow: #97e0ea;
--dc-pink: #cd7fb2;
--dc-pink-glow: #eca5d6;
--dc-purple: #9d8fd1;
--dc-cream: #ece4d6;
--dc-cream-lt: #f4eee2;
--dc-charcoal: #555a5e;
--dc-slate: #2f5a7a;
--dc-orange: #f08020;
font-family: "Trebuchet MS", "Century Gothic", "Segoe UI", sans-serif;
font-size: 14px;
color: var(--dc-ink);
color: var(--dc-slate);
}
/* the signature watery boot gradient: warm cream top fading down
through pale sky into rippled blue water */
[data-theme="dreamcast"] body {
background: linear-gradient(180deg, #fbf9f3 0%, #e9e3d6 100%) fixed;
background:
radial-gradient(120% 70% at 50% 108%, rgba(255, 255, 255, 0.55), transparent 55%),
radial-gradient(150% 80% at 50% 100%, rgba(110, 165, 205, 0.45), transparent 60%),
linear-gradient(
180deg,
#efe8dd 0%,
#f1ece3 7%,
#dfe7e6 20%,
#bcd6e6 45%,
#a0c8e2 72%,
#93c0dd 100%
)
fixed;
}
/* reusable swirl */
/* ---- the iconic swirl (orange-on-blue spiral disc) ---- */
[data-theme="dreamcast"] .rb-titlebar-icon,
[data-theme="dreamcast"] .rb-start-logo,
[data-theme="dreamcast"] .rb-task-icon {
background: conic-gradient(
from 0deg,
var(--dc-orange),
#ffd0a0,
var(--dc-orange),
var(--dc-orange-dk),
var(--dc-orange)
);
border-radius: 50%;
background:
conic-gradient(
from -20deg,
var(--dc-orange) 0deg,
#ffd9b0 70deg,
var(--dc-orange) 140deg,
#c85a08 220deg,
var(--dc-orange) 320deg
);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
0 0 5px rgba(240, 128, 32, 0.55);
}
/* ---- window: borderless, content floats on the water ---- */
[data-theme="dreamcast"] .rb-window {
background: #fff;
border: 1px solid #ded7c8;
border-radius: 20px;
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: 0 10px 30px rgba(212, 95, 10, 0.15);
box-shadow: none;
}
/* ---- top cream bar with charcoal wordmark + clock vibe ---- */
[data-theme="dreamcast"] .rb-titlebar {
height: 44px;
padding: 0 20px;
border-radius: 20px 20px 0 0;
background: linear-gradient(180deg, #ffffff, var(--dc-cream));
border-bottom: 2px solid var(--dc-orange);
color: var(--dc-blue);
font-weight: bold;
text-transform: lowercase;
letter-spacing: 0.5px;
height: 46px;
padding: 0 22px;
border-radius: 14px;
background: linear-gradient(180deg, var(--dc-cream-lt), var(--dc-cream));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 2px 6px rgba(60, 90, 120, 0.18);
color: var(--dc-charcoal);
}
[data-theme="dreamcast"] .rb-titlebar-text {
font-weight: 600;
letter-spacing: 0.4px;
color: var(--dc-charcoal);
}
[data-theme="dreamcast"] .rb-titlebar-icon {
width: 18px;
height: 18px;
width: 20px;
height: 20px;
}
[data-theme="dreamcast"] .rb-titlebar-buttons {
display: none;
}
/* ---- nav links become bubbly glowing pills ---- */
[data-theme="dreamcast"] .rb-menubar {
padding: 10px 16px;
border-bottom: 1px solid #ece6d8;
padding: 16px 8px 6px;
gap: 12px;
}
[data-theme="dreamcast"] .rb-menu-link {
text-decoration: none;
padding: 6px 16px;
border-radius: 18px;
color: var(--dc-blue);
padding: 9px 26px;
border-radius: 999px;
color: #fff;
font-weight: bold;
text-transform: lowercase;
transition: all 0.15s;
letter-spacing: 0.5px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
transition: transform 0.15s, box-shadow 0.15s, filter 0.15s;
}
/* per-pill colors, mirroring the dashboard tiles */
[data-theme="dreamcast"] .rb-menu-link:nth-of-type(1) {
background: var(--dc-peach);
box-shadow: 0 0 0 2px var(--dc-peach-glow), 0 0 14px 2px rgba(244, 199, 158, 0.85);
}
[data-theme="dreamcast"] .rb-menu-link:nth-of-type(2) {
background: var(--dc-green);
box-shadow: 0 0 0 2px var(--dc-green-glow), 0 0 14px 2px rgba(159, 230, 180, 0.85);
}
[data-theme="dreamcast"] .rb-menu-link:nth-of-type(3) {
background: var(--dc-pink);
box-shadow: 0 0 0 2px var(--dc-pink-glow), 0 0 14px 2px rgba(236, 165, 214, 0.85);
}
[data-theme="dreamcast"] .rb-menu-link:hover {
background: var(--dc-orange);
color: #fff;
transform: translateY(-1px) scale(1.03);
filter: brightness(1.06);
}
/* ---- theme switcher as a pastel cyan pill ---- */
[data-theme="dreamcast"] .rb-switcher-label {
color: var(--dc-orange-dk);
color: var(--dc-slate);
font-weight: bold;
font-size: 12px;
text-transform: lowercase;
}
[data-theme="dreamcast"] .rb-switcher-select {
font: 13px "Trebuchet MS", sans-serif;
border: 2px solid var(--dc-orange);
border-radius: 18px;
padding: 4px 12px;
font: bold 13px "Trebuchet MS", sans-serif;
border: none;
border-radius: 999px;
padding: 7px 16px;
color: #fff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
background: var(--dc-cyan);
box-shadow: 0 0 0 2px var(--dc-cyan-glow), 0 0 12px 2px rgba(151, 224, 234, 0.8);
cursor: pointer;
}
[data-theme="dreamcast"] .rb-switcher-select option {
color: var(--dc-slate);
text-shadow: none;
background: #fff;
color: var(--dc-blue);
}
[data-theme="dreamcast"] .rb-content {
padding: 26px 28px;
padding: 14px 8px 28px;
}
/* ---- bottom cream bar (status) ---- */
[data-theme="dreamcast"] .rb-statusbar {
border-top: 1px solid #ece6d8;
padding: 8px 20px;
margin-top: 14px;
padding: 9px 20px;
border-radius: 12px;
background: linear-gradient(180deg, var(--dc-cream-lt), var(--dc-cream));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 2px 6px rgba(60, 90, 120, 0.15);
font-size: 12px;
color: #8a8270;
text-transform: lowercase;
color: var(--dc-charcoal);
}
/* ---- taskbar = the cream dashboard header strip ---- */
[data-theme="dreamcast"] .rb-taskbar {
height: 40px;
background: linear-gradient(180deg, #fffdf8, var(--dc-cream));
border-top: 2px solid var(--dc-orange);
height: 42px;
background: linear-gradient(180deg, var(--dc-cream-lt), var(--dc-cream));
box-shadow: 0 -2px 8px rgba(60, 90, 120, 0.18);
}
[data-theme="dreamcast"] .rb-start {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 18px;
border: 2px solid var(--dc-orange);
border-radius: 18px;
background: #fff;
color: var(--dc-blue);
height: 30px;
padding: 0 20px;
border: none;
border-radius: 999px;
background: var(--dc-peach);
color: #fff;
font: bold 13px "Trebuchet MS", sans-serif;
text-transform: lowercase;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 0 2px var(--dc-peach-glow), 0 0 12px 2px rgba(244, 199, 158, 0.8);
cursor: pointer;
}
[data-theme="dreamcast"] .rb-start:hover {
background: var(--dc-orange);
color: #fff;
filter: brightness(1.06);
}
[data-theme="dreamcast"] .rb-start-logo {
width: 14px;
height: 14px;
width: 16px;
height: 16px;
}
[data-theme="dreamcast"] .rb-task {
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 16px;
border: 1px solid #e3dccb;
border-radius: 18px;
background: #fff;
color: var(--dc-blue);
height: 30px;
padding: 0 18px;
border: none;
border-radius: 999px;
background: var(--dc-cyan);
color: #fff;
font-weight: bold;
font-size: 13px;
text-transform: lowercase;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
box-shadow: 0 0 0 2px var(--dc-cyan-glow), 0 0 10px 1px rgba(151, 224, 234, 0.7);
cursor: default;
}
[data-theme="dreamcast"] .rb-task-active {
border-color: var(--dc-orange);
box-shadow: 0 0 0 2px rgba(244, 123, 32, 0.25);
filter: brightness(1.05);
}
[data-theme="dreamcast"] .rb-task-icon {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
}
[data-theme="dreamcast"] .rb-clock {
color: var(--dc-blue);
font-weight: bold;
color: var(--dc-charcoal);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.5px;
padding: 0 16px;
}
/* ---- page heads float on the water with a soft halo ---- */
[data-theme="dreamcast"] .rb-page-title {
margin: 0 0 4px;
margin: 4px 0 4px;
font-size: 30px;
font-weight: bold;
text-transform: lowercase;
color: var(--dc-orange);
color: var(--dc-slate);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="dreamcast"] .rb-page-sub {
margin: 0 0 20px;
color: var(--dc-blue-lt);
margin: 0 0 18px;
color: #3f6f93;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
}
/* ---- post cards = glossy translucent water tiles ---- */
[data-theme="dreamcast"] .rb-post-card {
border: 1px solid #ece6d8;
border-radius: 16px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fbf8f1);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
padding: 18px 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(244, 250, 253, 0.82));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 16px rgba(60, 100, 140, 0.18);
transition: box-shadow 0.2s, transform 0.2s;
}
[data-theme="dreamcast"] .rb-post-card:hover {
box-shadow: 0 6px 20px rgba(244, 123, 32, 0.25);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 8px 26px rgba(60, 110, 160, 0.3);
transform: translateY(-2px);
}
[data-theme="dreamcast"] .rb-post-card-title a {
color: var(--dc-blue);
color: var(--dc-slate);
text-decoration: none;
font-weight: bold;
}
[data-theme="dreamcast"] .rb-post-card-title a:hover {
color: var(--dc-orange-dk);
color: var(--dc-pink);
}
[data-theme="dreamcast"] .rb-post-date {
font-size: 12px;
color: #a89c84;
text-transform: lowercase;
color: #7e93a3;
}
/* ---- tags = tiny green dashboard pills ---- */
[data-theme="dreamcast"] .rb-tag {
font-size: 11px;
background: #fff0e3;
border: 1px solid #ffc99c;
border-radius: 12px;
padding: 1px 10px;
color: var(--dc-orange-dk);
background: var(--dc-green);
border: none;
border-radius: 999px;
padding: 2px 12px;
color: #fff;
font-weight: bold;
text-transform: lowercase;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25);
box-shadow: 0 0 0 1px var(--dc-green-glow);
}
/* ---- read-more / back ---- */
[data-theme="dreamcast"] .rb-readmore,
[data-theme="dreamcast"] .rb-back {
color: var(--dc-orange-dk);
color: #fff;
text-decoration: none;
font-weight: bold;
font-size: 13px;
text-transform: lowercase;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
[data-theme="dreamcast"] .rb-readmore {
padding: 5px 16px;
border-radius: 999px;
background: var(--dc-pink);
box-shadow: 0 0 0 2px var(--dc-pink-glow), 0 0 10px 1px rgba(236, 165, 214, 0.7);
}
/* the iconic spiral BACK button */
[data-theme="dreamcast"] .rb-back {
display: inline-block;
margin-bottom: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 6px 18px 6px 8px;
border-radius: 999px;
background: var(--dc-purple);
box-shadow: 0 0 0 2px #c3b8ef, 0 0 10px 1px rgba(157, 143, 209, 0.7);
}
[data-theme="dreamcast"] .rb-back::before {
content: "";
width: 18px;
height: 18px;
border-radius: 50%;
background:
conic-gradient(
from -20deg,
var(--dc-orange),
#ffd9b0 70deg,
var(--dc-orange) 140deg,
#c85a08 220deg,
var(--dc-orange) 320deg
);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
/* ---- article body on a soft readable white tile ---- */
[data-theme="dreamcast"] .rb-article-title {
color: var(--dc-orange);
color: var(--dc-slate);
font-weight: bold;
text-transform: lowercase;
margin: 0 0 8px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7);
}
[data-theme="dreamcast"] .rb-prose {
line-height: 1.75;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(245, 251, 253, 0.86));
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
padding: 22px 26px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 4px 16px rgba(60, 100, 140, 0.18);
}
[data-theme="dreamcast"] .rb-prose a {
color: var(--dc-blue-lt);
color: var(--dc-cyan);
font-weight: bold;
}
[data-theme="dreamcast"] .rb-prose code {
background: #fff0e3;
border: 1px solid #ffc99c;
background: #eef6f9;
border: 1px solid #cfe2ea;
border-radius: 6px;
padding: 0 5px;
font-family: "Courier New", monospace;
color: var(--dc-orange-dk);
color: var(--dc-slate);
}
[data-theme="dreamcast"] .rb-prose blockquote {
border-left: 4px solid var(--dc-orange);
border-left: 4px solid var(--dc-cyan);
margin: 14px 0;
padding: 6px 16px;
background: #fdf6ef;
border-radius: 0 10px 10px 0;
background: #eef6f9;
border-radius: 0 12px 12px 0;
}
+73 -48
View File
@@ -1,29 +1,34 @@
/* ============================================================
Theme: Jeuxvideo.com circa 2002 — scoped to [data-theme="jv2002"]
Early-2000s French gaming portal: dense boxy tables, Verdana
11px, hard gray borders, gradient section bars, blue links,
a loud red/orange masthead. Webpage, not an OS window.
Early-2000s French gaming portal. Signatures matched to the
reference shot: white masthead with a red italic wordmark and
orange underline, a light nav strip of bold links, a pale-yellow
"today" band, gold-gradient box title bars, red section headings,
blue underlined links on dense white tables. Webpage, not an OS.
============================================================ */
[data-theme="jv2002"] {
--jv-red: #c4161c;
--jv-orange: #f08000;
--jv-red: #cc0000;
--jv-red-deep: #990000;
--jv-orange: #ff8000;
--jv-blue: #003399;
--jv-link: #0033cc;
--jv-bar1: #e9e9e9;
--jv-bar2: #c4c4c4;
--jv-border: #9a9a9a;
/* the iconic warm box header bars */
--jv-gold1: #f7dd96;
--jv-gold2: #e6b34d;
--jv-gold-border: #b9863a;
--jv-border: #999999;
font-family: Verdana, Geneva, Arial, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #222;
color: #1a1a1a;
}
[data-theme="jv2002"] body {
background: #6b6b6b url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='4' height='4' fill='%23707070'/%3E%3Crect width='2' height='2' fill='%23686868'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23686868'/%3E%3C/svg%3E");
}
/* the page = a centered bordered table */
/* the page = a centered bordered table on gray */
[data-theme="jv2002"] .rb-window {
width: min(800px, calc(100% - 16px));
margin: 10px auto;
@@ -34,56 +39,65 @@
box-shadow: 0 0 0 1px #fff, 0 4px 12px rgba(0, 0, 0, 0.5);
}
/* masthead */
/* masthead: white bar, red italic wordmark, orange rule under it */
[data-theme="jv2002"] .rb-titlebar {
height: 46px;
height: 44px;
padding: 0 12px;
background: linear-gradient(180deg, #e2231a 0%, var(--jv-red) 50%, #8e0f12 100%);
color: #fff;
background: #ffffff;
color: var(--jv-red);
font-weight: bold;
font-size: 18px;
font-size: 20px;
font-style: italic;
letter-spacing: -0.5px;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
text-shadow: none;
border-bottom: 2px solid var(--jv-orange);
}
[data-theme="jv2002"] .rb-titlebar-icon {
width: 18px;
height: 18px;
background: var(--jv-orange);
width: 16px;
height: 16px;
background: var(--jv-red);
transform: rotate(45deg);
box-shadow: inset 0 0 0 2px #fff;
}
[data-theme="jv2002"] .rb-titlebar-text {
color: var(--jv-red);
}
[data-theme="jv2002"] .rb-titlebar-buttons {
display: none;
}
/* nav tabs */
/* nav: light gray strip, bold links separated like Sommaire | PC | Xbox */
[data-theme="jv2002"] .rb-menubar {
padding: 3px 6px;
background: linear-gradient(180deg, var(--jv-blue), #001f66);
gap: 2px;
padding: 4px 8px;
background: linear-gradient(180deg, #ededed, #d6d6d6);
border-bottom: 1px solid var(--jv-border);
gap: 0;
align-items: center;
}
[data-theme="jv2002"] .rb-menu-link {
text-decoration: none;
padding: 3px 10px;
color: #fff;
padding: 2px 9px;
color: var(--jv-blue);
font-weight: bold;
background: linear-gradient(180deg, #3a5fb8, #14307e);
border: 1px solid #6f8fd0;
border-bottom: none;
background: transparent;
border: none;
border-right: 1px solid #b5b5b5;
}
[data-theme="jv2002"] .rb-menu-link:first-of-type {
padding-left: 2px;
}
[data-theme="jv2002"] .rb-menu-link:hover {
background: var(--jv-orange);
color: #000;
color: var(--jv-red);
text-decoration: underline;
background: transparent;
}
[data-theme="jv2002"] .rb-switcher-label {
color: #fff;
color: #333;
font-weight: bold;
}
[data-theme="jv2002"] .rb-switcher-select {
font: 11px Verdana, sans-serif;
border: 1px solid #000;
border: 1px solid #888;
border-radius: 0;
padding: 0 2px;
background: #fff;
@@ -98,7 +112,7 @@
padding: 3px 8px;
font-size: 10px;
color: #444;
background: var(--jv-bar1);
background: #ededed;
border-top: 1px solid var(--jv-border);
}
@@ -117,7 +131,7 @@
padding: 0 10px;
border: 1px solid #888;
border-radius: 0;
background: linear-gradient(180deg, #f08000, #c46400);
background: linear-gradient(180deg, #ff9a1f, #d96e00);
color: #fff;
font: bold 11px Verdana, sans-serif;
text-transform: uppercase;
@@ -158,17 +172,18 @@
padding: 0 8px;
}
/* ---- content: dense boxy article list ---- */
/* ---- content: "Aujourd'hui sur..." pale-yellow band ---- */
[data-theme="jv2002"] .rb-page-head {
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
border: 1px solid var(--jv-border);
background: linear-gradient(180deg, #fffdf0, #fff4c9);
border: 1px solid var(--jv-gold-border);
padding: 6px 10px;
margin-bottom: 10px;
}
[data-theme="jv2002"] .rb-page-title {
margin: 0;
font-size: 16px;
font-size: 17px;
font-weight: bold;
font-style: italic;
color: var(--jv-red);
}
[data-theme="jv2002"] .rb-page-sub {
@@ -176,6 +191,8 @@
font-size: 11px;
color: #555;
}
/* ---- dense boxy article list with gold header bars ---- */
[data-theme="jv2002"] .rb-post-list {
gap: 8px;
}
@@ -186,16 +203,16 @@
background: #fff;
}
[data-theme="jv2002"] .rb-post-card-head {
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
border-bottom: 1px solid var(--jv-border);
background: linear-gradient(180deg, var(--jv-gold1), var(--jv-gold2));
border-bottom: 1px solid var(--jv-gold-border);
padding: 4px 8px;
margin: 0;
}
[data-theme="jv2002"] .rb-post-card-title {
font-size: 12px;
font-size: 13px;
}
[data-theme="jv2002"] .rb-post-card-title a {
color: var(--jv-blue);
color: var(--jv-red-deep);
text-decoration: none;
font-weight: bold;
}
@@ -205,7 +222,8 @@
}
[data-theme="jv2002"] .rb-post-date {
font-size: 10px;
color: #666;
color: #7a5a14;
font-weight: bold;
}
[data-theme="jv2002"] .rb-post-excerpt {
padding: 6px 8px;
@@ -214,6 +232,7 @@
[data-theme="jv2002"] .rb-post-meta {
padding: 4px 8px 6px;
font-size: 10px;
border-top: 1px solid #eee;
}
[data-theme="jv2002"] .rb-tag {
font-size: 9px;
@@ -229,22 +248,28 @@
text-decoration: underline;
font-size: 11px;
}
[data-theme="jv2002"] .rb-readmore:hover,
[data-theme="jv2002"] .rb-prose a:hover {
color: var(--jv-red);
}
[data-theme="jv2002"] .rb-back {
display: inline-block;
margin-bottom: 8px;
font-weight: bold;
}
/* ---- single article ---- */
[data-theme="jv2002"] .rb-article {
border: 1px solid var(--jv-border);
padding: 0;
}
[data-theme="jv2002"] .rb-article-title {
background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
border-bottom: 1px solid var(--jv-border);
background: linear-gradient(180deg, var(--jv-gold1), var(--jv-gold2));
border-bottom: 1px solid var(--jv-gold-border);
margin: 0;
padding: 6px 10px;
font-size: 15px;
color: var(--jv-red);
color: var(--jv-red-deep);
}
[data-theme="jv2002"] .rb-article-meta {
padding: 5px 10px;
@@ -260,7 +285,7 @@
}
[data-theme="jv2002"] .rb-prose h2 {
font-size: 13px;
color: var(--jv-blue);
color: var(--jv-red);
border-bottom: 1px solid #ccc;
padding-bottom: 2px;
}
@@ -277,7 +302,7 @@
background: #fff7ec;
}
/* JV2002 didn't do back-link inside article box nicely; keep it outside */
/* keep the back-link outside the boxed article header */
[data-theme="jv2002"] .rb-article > .rb-back {
display: block;
padding: 6px 10px 0;
+191 -105
View File
@@ -1,202 +1,276 @@
/* ============================================================
Theme: Nintendo DS — scoped to [data-theme="nds"]
The DS firmware menu: brushed silver shell, glossy cyan
gradients, dual-screen bezel framing, chunky rounded touch
targets.
The real 2004 DS firmware menu: white graph-paper grid
backdrop, SQUARE tiles with thick dark double-borders,
brushed-metal status/hinge bars, silver icon blocks, and a
blue dashed selection cursor. Calendar pastels on tags.
============================================================ */
[data-theme="nds"] {
--d-cyan: #1ba1c4;
--d-cyan-lt: #7fd6ec;
--d-silver: #c9d2d8;
--d-silver-dk: #9aa6ae;
--d-ink: #2b3a42;
font-family: "Trebuchet MS", "Segoe UI", Tahoma, sans-serif;
--d-ink: #2b3035; /* near-black menu text */
--d-sub: #6f7b83; /* gray secondary text */
--d-edge: #2f3437; /* thick tile outline (near-black) */
--d-sel: #2f6fc4; /* dashed blue selection */
--d-sel-lt: #d3e6f9; /* selection fill */
--d-red: #d4506a; /* calendar Sunday */
--d-sat: #3f7fd0; /* calendar Saturday */
/* brushed metal stops */
--m-tile: linear-gradient(180deg, #ffffff 0%, #eef1f3 48%, #dde3e7 100%);
--m-bar: linear-gradient(180deg, #dfe5e9 0%, #bdc7cd 50%, #a6b1b8 100%);
--m-icon: linear-gradient(180deg, #f6f8f9 0%, #ccd3d8 55%, #b0b9bf 100%);
font-family: Tahoma, Geneva, "Segoe UI", sans-serif;
font-size: 14px;
color: var(--d-ink);
}
/* white graph-paper grid — the DS menu canvas */
[data-theme="nds"] body {
background: linear-gradient(160deg, #aeb9c0 0%, #7e8b93 100%) fixed;
background-color: #fbfcfc;
background-image: linear-gradient(#dde2e6 1px, transparent 1px),
linear-gradient(90deg, #dde2e6 1px, transparent 1px);
background-size: 34px 34px;
background-attachment: fixed;
}
/* the window is the silver shell; content is the touch screen */
/* window is just the canvas; tiles float on the grid */
[data-theme="nds"] .rb-window {
background: linear-gradient(180deg, #e7edf0, var(--d-silver));
border: 1px solid #fff;
border-radius: 14px;
padding: 6px;
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4),
inset 0 0 0 1px var(--d-silver-dk);
background: transparent;
border: none;
border-radius: 0;
padding: 8px;
box-shadow: none;
}
/* ---- top status bar: dark brushed blue-gray strip ---- */
[data-theme="nds"] .rb-titlebar {
height: 38px;
padding: 0 16px;
border-radius: 10px 10px 0 0;
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
height: 30px;
padding: 0 12px;
border-radius: 0;
background: linear-gradient(180deg, #8493a0, #596b77 55%, #4e5f6a);
border: 1px solid #3c4a54;
color: #fff;
font-weight: bold;
letter-spacing: 0.5px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
letter-spacing: 0.4px;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.45);
}
[data-theme="nds"] .rb-titlebar-icon {
width: 15px;
height: 15px;
border-radius: 4px;
background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
/* green battery / cart chip */
width: 18px;
height: 11px;
border-radius: 1px;
background: linear-gradient(180deg, #b7f0a0, #5fb049);
box-shadow: inset 0 0 0 1.5px #2f6a23, 0 0 0 1px rgba(0, 0, 0, 0.3);
}
[data-theme="nds"] .rb-titlebar-buttons {
display: none;
}
/* ---- menubar = brushed-metal hinge band with striations ---- */
[data-theme="nds"] .rb-menubar {
margin-top: 6px;
padding: 8px 10px;
background: linear-gradient(180deg, #eef3f5, var(--d-silver));
border-radius: 0;
background: var(--m-bar);
background-image: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.35) 0 1px,
transparent 1px 3px
),
var(--m-bar);
border: 1px solid #7a858c;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6),
inset 0 -1px 0 rgba(0, 0, 0, 0.18);
}
/* square metallic touch buttons */
[data-theme="nds"] .rb-menu-link {
text-decoration: none;
padding: 5px 14px;
border-radius: 16px;
border-radius: 2px;
color: var(--d-ink);
font-weight: bold;
background: linear-gradient(180deg, #ffffff, #dde5e9);
border: 1px solid var(--d-silver-dk);
transition: all 0.15s;
background: var(--m-tile);
border: 2px solid var(--d-edge);
box-shadow: inset 0 1px 0 #fff;
transition: none;
}
/* DS selection = blue dashed cursor + pale fill */
[data-theme="nds"] .rb-menu-link:hover {
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
color: #fff;
border-color: var(--d-cyan);
background: var(--d-sel-lt);
color: var(--d-sel);
outline: 2px dashed var(--d-sel);
outline-offset: 1px;
}
[data-theme="nds"] .rb-switcher-label {
font-weight: bold;
font-size: 12px;
color: var(--d-cyan);
color: var(--d-ink);
}
[data-theme="nds"] .rb-switcher-select {
font: 13px "Trebuchet MS", sans-serif;
border: 1px solid var(--d-silver-dk);
border-radius: 14px;
padding: 4px 10px;
background: #fff;
font: 13px Tahoma, sans-serif;
border: 2px solid var(--d-edge);
border-radius: 2px;
padding: 3px 8px;
background: var(--m-tile);
color: var(--d-ink);
}
/* the lower touch screen */
/* ---- content = transparent so the grid shows through ---- */
[data-theme="nds"] .rb-content {
background: #f7fbfc;
margin: 6px 2px;
padding: 22px 24px;
border-radius: 10px;
border: 3px solid #2b3a42;
box-shadow: inset 0 0 18px rgba(27, 161, 196, 0.12);
background: transparent;
margin: 6px 0;
padding: 16px 2px;
border-radius: 0;
border: none;
box-shadow: none;
}
[data-theme="nds"] .rb-statusbar {
padding: 5px 12px;
padding: 5px 10px;
font-size: 11px;
color: #5a6a72;
color: var(--d-sub);
}
/* ---- bottom dock = brushed-metal band ---- */
[data-theme="nds"] .rb-taskbar {
height: 38px;
background: linear-gradient(180deg, #b9c4cb, #8e9aa2);
border-top: 1px solid #fff;
height: 36px;
margin-top: 6px;
border-radius: 0;
background: var(--m-bar);
background-image: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.35) 0 1px,
transparent 1px 3px
),
var(--m-bar);
border: 1px solid #7a858c;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6),
inset 0 -1px 0 rgba(0, 0, 0, 0.18);
}
[data-theme="nds"] .rb-start {
display: flex;
align-items: center;
gap: 7px;
height: 28px;
padding: 0 16px;
border: 1px solid var(--d-silver-dk);
border-radius: 16px;
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
color: #fff;
font: bold 13px "Trebuchet MS", sans-serif;
height: 26px;
padding: 0 14px;
border: 2px solid var(--d-edge);
border-radius: 2px;
background: var(--d-sel-lt);
color: var(--d-sel);
font: bold 13px Tahoma, sans-serif;
cursor: pointer;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
outline: 2px dashed var(--d-sel);
outline-offset: 1px;
text-shadow: none;
}
[data-theme="nds"] .rb-start-logo {
width: 13px;
height: 13px;
border-radius: 4px;
background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
/* little DS console chip */
width: 16px;
height: 12px;
border-radius: 1px;
background: var(--m-icon);
box-shadow: inset 0 0 0 1px #6a737a;
}
[data-theme="nds"] .rb-task {
display: flex;
align-items: center;
gap: 7px;
height: 28px;
padding: 0 14px;
border: 1px solid var(--d-silver-dk);
border-radius: 16px;
background: linear-gradient(180deg, #ffffff, #dde5e9);
height: 26px;
padding: 0 12px;
border: 2px solid var(--d-edge);
border-radius: 2px;
background: var(--m-tile);
box-shadow: inset 0 1px 0 #fff;
color: var(--d-ink);
font-weight: bold;
font-size: 13px;
cursor: default;
}
[data-theme="nds"] .rb-task-active {
background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
color: #fff;
background: var(--d-sel-lt);
color: var(--d-sel);
outline: 2px dashed var(--d-sel);
outline-offset: 1px;
}
[data-theme="nds"] .rb-task-icon {
width: 11px;
height: 11px;
border-radius: 3px;
background: var(--d-cyan-lt);
width: 12px;
height: 12px;
border-radius: 1px;
background: var(--m-icon);
box-shadow: inset 0 0 0 1px #6a737a;
}
[data-theme="nds"] .rb-clock {
color: var(--d-ink);
font-weight: bold;
font-size: 13px;
padding: 0 14px;
padding: 0 12px;
}
/* ---- page heading ---- */
[data-theme="nds"] .rb-page-title {
margin: 0 0 4px;
font-size: 26px;
font-size: 24px;
font-weight: bold;
color: var(--d-cyan);
color: var(--d-ink);
}
[data-theme="nds"] .rb-page-sub {
margin: 0 0 18px;
color: #5a6a72;
margin: 0 0 16px;
color: var(--d-sub);
}
/* ---- post cards = square DS launch tiles with silver icon ---- */
[data-theme="nds"] .rb-post-card {
border: 1px solid var(--d-silver-dk);
border-radius: 12px;
padding: 16px 18px;
background: linear-gradient(180deg, #ffffff, #eef4f6);
transition: box-shadow 0.2s, transform 0.2s;
position: relative;
border: 2px solid var(--d-edge);
border-radius: 2px;
padding: 14px 16px 14px 84px; /* room for the icon block */
min-height: 72px;
background: var(--m-tile);
box-shadow: inset 0 1px 0 #fff, inset 0 0 0 1px rgba(255, 255, 255, 0.5);
transition: none;
}
/* the brushed-silver icon square on the left of every tile */
[data-theme="nds"] .rb-post-card::before {
content: "";
position: absolute;
left: 8px;
top: 8px;
bottom: 8px;
width: 60px;
background: var(--m-icon);
border: 1px solid #6a737a;
border-radius: 1px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7);
}
[data-theme="nds"] .rb-post-card:hover {
box-shadow: 0 5px 16px rgba(27, 161, 196, 0.25);
transform: translateY(-2px);
outline: 2px dashed var(--d-sel);
outline-offset: 2px;
}
[data-theme="nds"] .rb-post-card-title a {
color: var(--d-cyan);
color: var(--d-ink);
text-decoration: none;
font-weight: bold;
}
[data-theme="nds"] .rb-post-card-title a:hover {
color: var(--d-sel);
}
[data-theme="nds"] .rb-post-date {
font-size: 12px;
color: #8a98a0;
color: var(--d-sub);
}
/* tags = tiny square calendar-pastel chips */
[data-theme="nds"] .rb-tag {
font-size: 11px;
background: #e4f4f9;
border: 1px solid var(--d-cyan-lt);
border-radius: 12px;
padding: 1px 9px;
color: var(--d-cyan);
background: var(--d-sel-lt);
border: 1.5px solid var(--d-sat);
border-radius: 2px;
padding: 1px 7px;
color: #1f5aa6;
font-weight: bold;
}
[data-theme="nds"] .rb-readmore,
[data-theme="nds"] .rb-back {
color: var(--d-cyan);
color: var(--d-sel);
text-decoration: none;
font-weight: bold;
font-size: 13px;
@@ -205,28 +279,40 @@
display: inline-block;
margin-bottom: 12px;
}
/* ---- article view ---- */
[data-theme="nds"] .rb-article-title {
color: var(--d-cyan);
color: var(--d-ink);
font-weight: bold;
margin: 0 0 8px;
}
[data-theme="nds"] .rb-prose {
line-height: 1.7;
background: var(--m-tile);
border: 2px solid var(--d-edge);
border-radius: 2px;
padding: 16px 18px;
box-shadow: inset 0 1px 0 #fff;
}
[data-theme="nds"] .rb-prose a {
color: var(--d-cyan);
color: var(--d-sel);
}
[data-theme="nds"] .rb-prose code {
background: #e4f4f9;
border: 1px solid var(--d-cyan-lt);
border-radius: 6px;
background: #eef2f4;
border: 1px solid #9aa4ab;
border-radius: 2px;
padding: 0 5px;
font-family: "Courier New", monospace;
}
[data-theme="nds"] .rb-prose blockquote {
border-left: 4px solid var(--d-cyan);
border-left: 4px solid var(--d-sel);
margin: 12px 0;
padding: 6px 14px;
background: #eef7fa;
border-radius: 0 8px 8px 0;
background: #eef5fc;
border-radius: 0;
}
[data-theme="nds"] .rb-prose h1,
[data-theme="nds"] .rb-prose h2,
[data-theme="nds"] .rb-prose h3 {
color: var(--d-ink);
}
+186 -61
View File
@@ -1,49 +1,110 @@
/* ============================================================
Theme: PlayStation 1 — scoped to [data-theme="ps1"]
The gray BIOS shell: cool charcoal gradient, soft gray
panels, blocky understated type. Memory-card-manager calm.
Not the calm BIOS shell — the GAME. Late-90s real-time 3D:
no perspective correction (affine texture swim), unfiltered
polygons with jagged seams, low color depth faked with
ordered dithering on every surface. Murky fog palette,
chunky pixelated edges, vertices that won't sit still.
============================================================ */
[data-theme="ps1"] {
--p1-bg1: #3a4351;
--p1-bg2: #1c222c;
--p1-panel: #c7ccd4;
--p1-panel-dk: #9aa1ad;
--p1-ink: #2a2f38;
--p1-accent: #6b7585;
/* Murky low-bit polygon palette — fogged blue-black void,
CD-startup diamond magenta/cyan, dirty parchment UI. */
--p1-void1: #1a2230;
--p1-void2: #07090f;
--p1-fog: #3b4a63;
--p1-panel: #b9b29c; /* dirty unfiltered "texture" beige */
--p1-panel-dk: #837c66;
--p1-ink: #20242c;
--p1-mag: #c0307a; /* startup diamond */
--p1-cyan: #2f9fb3;
--p1-seam: #efe9d6; /* bright untextured polygon edge */
font-family: "Verdana", Geneva, Tahoma, sans-serif;
font-size: 13px;
color: var(--p1-ink);
/* keep every pixel hard — no smoothing, like the real hardware */
image-rendering: pixelated;
}
/* Ordered 4x4 dither tiles, reused everywhere for the low-color look.
Two-tone Bayer-ish stipple — tiny so it reads as texture grain. */
[data-theme="ps1"] {
--p1-dither-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect x='0' y='0' width='1' height='1' fill='%23000' fill-opacity='0.28'/%3E%3Crect x='2' y='2' width='1' height='1' fill='%23000' fill-opacity='0.28'/%3E%3Crect x='2' y='0' width='1' height='1' fill='%23fff' fill-opacity='0.10'/%3E%3Crect x='0' y='2' width='1' height='1' fill='%23fff' fill-opacity='0.10'/%3E%3C/svg%3E");
--p1-dither-fog: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect x='1' y='0' width='1' height='1' fill='%2307090f' fill-opacity='0.45'/%3E%3Crect x='3' y='2' width='1' height='1' fill='%2307090f' fill-opacity='0.45'/%3E%3Crect x='3' y='0' width='1' height='1' fill='%233b4a63' fill-opacity='0.5'/%3E%3Crect x='1' y='2' width='1' height='1' fill='%233b4a63' fill-opacity='0.5'/%3E%3C/svg%3E");
}
/* Affine "texture swim": PS1 had no perspective-correct mapping, so
surfaces warped as the camera moved. Fake it with a slow skew wobble. */
@keyframes ps1-swim {
0% { transform: skewX(0deg) skewY(0deg) scale(1); }
25% { transform: skewX(0.6deg) skewY(-0.4deg) scale(1.004); }
50% { transform: skewX(0deg) skewY(0.5deg) scale(1); }
75% { transform: skewX(-0.6deg) skewY(-0.3deg) scale(0.997); }
100% { transform: skewX(0deg) skewY(0deg) scale(1); }
}
/* Vertex jitter: low-precision vertex coords snapped to a grid made
geometry shudder. Tiny stepped translate on interactive bits. */
@keyframes ps1-jitter {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(0.5px, -0.5px); }
40% { transform: translate(-0.5px, 0.5px); }
60% { transform: translate(0.5px, 0.5px); }
80% { transform: translate(-0.5px, -0.5px); }
}
[data-theme="ps1"] body {
background: linear-gradient(160deg, var(--p1-bg1) 0%, var(--p1-bg2) 100%) fixed;
/* fogged void: dark dither over a vertical gradient = banding kill */
background:
var(--p1-dither-fog),
linear-gradient(178deg, var(--p1-void1) 0%, var(--p1-void2) 100%) fixed;
background-size: 4px 4px, cover;
}
[data-theme="ps1"] .rb-window {
background: var(--p1-panel);
border: 2px solid #fff;
border-radius: 3px;
background:
var(--p1-dither-dark),
var(--p1-panel);
background-size: 4px 4px, cover;
border: 0;
border-radius: 0;
padding: 0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
/* jagged, untextured polygon silhouette — notched corners */
clip-path: polygon(
0 6px, 6px 6px, 6px 0,
calc(100% - 8px) 0, calc(100% - 8px) 5px, 100% 5px,
100% calc(100% - 7px), calc(100% - 6px) calc(100% - 7px), calc(100% - 6px) 100%,
7px 100%, 7px calc(100% - 6px), 0 calc(100% - 6px)
);
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.65);
/* bright seam ring like flat-shaded geometry catching light */
outline: 2px solid var(--p1-seam);
outline-offset: -2px;
}
[data-theme="ps1"] .rb-titlebar {
height: 30px;
padding: 0 12px;
background: linear-gradient(180deg, #5b6473, #424b59);
color: #eef1f6;
background:
var(--p1-dither-dark),
linear-gradient(180deg, var(--p1-fog), var(--p1-void1));
background-size: 4px 4px, cover;
color: var(--p1-seam);
letter-spacing: 3px;
text-transform: uppercase;
font-size: 12px;
text-shadow: 1px 1px 0 #000; /* hard 1px shadow, no blur */
}
[data-theme="ps1"] .rb-titlebar-icon {
width: 14px;
height: 14px;
background: #eef1f6;
/* tiny CD glint */
border-radius: 50%;
box-shadow: inset 0 0 0 4px #5b6473, inset 0 0 0 5px #eef1f6;
/* startup diamond, hard-edged */
background: var(--p1-mag);
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
box-shadow: inset 0 0 0 2px var(--p1-cyan);
border-radius: 0;
image-rendering: pixelated;
animation: ps1-jitter 0.45s steps(2, end) infinite;
}
[data-theme="ps1"] .rb-titlebar-buttons {
display: none;
@@ -51,8 +112,11 @@
[data-theme="ps1"] .rb-menubar {
padding: 6px 10px;
background: var(--p1-panel-dk);
border-bottom: 1px solid #fff;
background:
var(--p1-dither-dark),
var(--p1-panel-dk);
background-size: 4px 4px, cover;
border-bottom: 2px solid var(--p1-void2);
}
[data-theme="ps1"] .rb-menu-link {
text-decoration: none;
@@ -63,8 +127,9 @@
letter-spacing: 1px;
}
[data-theme="ps1"] .rb-menu-link:hover {
background: var(--p1-ink);
color: #fff;
background: var(--p1-mag);
color: var(--p1-seam);
animation: ps1-jitter 0.4s steps(2, end) infinite;
}
[data-theme="ps1"] .rb-switcher-label {
font-size: 11px;
@@ -72,32 +137,39 @@
}
[data-theme="ps1"] .rb-switcher-select {
font: 12px Verdana, sans-serif;
background: #eef1f6;
border: 1px solid #fff;
border-radius: 2px;
background: var(--p1-seam);
color: var(--p1-ink);
border: 2px solid var(--p1-void2);
border-radius: 0;
padding: 2px 4px;
}
[data-theme="ps1"] .rb-content {
background: #e6e9ee;
background:
var(--p1-dither-dark),
#cfc8b2;
background-size: 4px 4px, cover;
margin: 8px;
padding: 20px 22px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px var(--p1-panel-dk);
border-radius: 0;
box-shadow: inset 0 0 0 2px var(--p1-seam), inset 0 0 0 4px var(--p1-panel-dk);
}
[data-theme="ps1"] .rb-statusbar {
padding: 4px 12px;
font-size: 11px;
color: #4a525f;
color: #2a2017;
letter-spacing: 1px;
text-transform: uppercase;
}
[data-theme="ps1"] .rb-taskbar {
height: 34px;
background: linear-gradient(180deg, #424b59, var(--p1-bg2));
border-top: 1px solid #5b6473;
background:
var(--p1-dither-dark),
linear-gradient(180deg, var(--p1-void1), var(--p1-void2));
background-size: 4px 4px, cover;
border-top: 2px solid var(--p1-fog);
}
[data-theme="ps1"] .rb-start {
display: flex;
@@ -105,21 +177,27 @@
gap: 7px;
height: 24px;
padding: 0 14px;
border: 1px solid #6b7585;
border-radius: 2px;
background: #4a525f;
color: #eef1f6;
border: 2px solid var(--p1-seam);
border-radius: 0;
background: var(--p1-void1);
color: var(--p1-seam);
font: 11px Verdana, sans-serif;
letter-spacing: 2px;
text-transform: uppercase;
cursor: pointer;
clip-path: polygon(4px 0, 100% 0, 100% calc(100% - 4px), calc(100% - 4px) 100%, 0 100%, 0 4px);
}
[data-theme="ps1"] .rb-start:hover {
animation: ps1-jitter 0.4s steps(2, end) infinite;
}
[data-theme="ps1"] .rb-start-logo {
width: 12px;
height: 12px;
border-radius: 50%;
background: #eef1f6;
box-shadow: inset 0 0 0 3px #4a525f, inset 0 0 0 4px #eef1f6;
border-radius: 0;
background: var(--p1-mag);
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
box-shadow: inset 0 0 0 2px var(--p1-cyan);
image-rendering: pixelated;
}
[data-theme="ps1"] .rb-task {
display: flex;
@@ -127,26 +205,28 @@
gap: 7px;
height: 24px;
padding: 0 12px;
border: 1px solid transparent;
border: 2px solid transparent;
background: transparent;
color: #aab2c0;
color: #8b93a3;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
cursor: default;
}
[data-theme="ps1"] .rb-task-active {
border-color: #6b7585;
background: #4a525f;
color: #eef1f6;
border-color: var(--p1-seam);
background: var(--p1-void1);
color: var(--p1-seam);
}
[data-theme="ps1"] .rb-task-icon {
width: 10px;
height: 10px;
background: #aab2c0;
background: var(--p1-cyan);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
image-rendering: pixelated;
}
[data-theme="ps1"] .rb-clock {
color: #cfd5de;
color: var(--p1-seam);
font-size: 11px;
padding: 0 14px;
letter-spacing: 2px;
@@ -158,17 +238,36 @@
letter-spacing: 3px;
text-transform: uppercase;
color: var(--p1-ink);
text-shadow: 2px 2px 0 var(--p1-mag); /* hard offset, no AA */
}
[data-theme="ps1"] .rb-page-sub {
margin: 0 0 18px;
color: #5a626f;
color: #3a2f22;
}
[data-theme="ps1"] .rb-post-card {
background: #f1f3f6;
border: 1px solid #fff;
border-radius: 2px;
background:
var(--p1-dither-dark),
#ddd6c0;
background-size: 4px 4px, cover;
border: 0;
border-radius: 0;
padding: 14px 16px;
box-shadow: 0 1px 0 var(--p1-panel-dk), inset 0 0 0 1px #d2d7df;
/* rough notched polygon card */
clip-path: polygon(
0 0, calc(100% - 7px) 0, 100% 7px,
100% 100%, 7px 100%, 0 calc(100% - 7px)
);
box-shadow: inset 0 0 0 2px var(--p1-seam), 3px 3px 0 var(--p1-void2);
/* the affine swim — slow, unsettling, always on */
animation: ps1-swim 6s ease-in-out infinite;
transform-origin: center;
}
[data-theme="ps1"] .rb-post-card:hover {
/* lean into the warp on focus */
animation-duration: 2.4s;
outline: 2px solid var(--p1-mag);
outline-offset: -2px;
}
[data-theme="ps1"] .rb-post-card-title a {
color: var(--p1-ink);
@@ -176,30 +275,37 @@
letter-spacing: 1px;
}
[data-theme="ps1"] .rb-post-card-title a:hover {
color: #000;
color: var(--p1-mag);
text-decoration: underline;
}
[data-theme="ps1"] .rb-post-date {
font-size: 11px;
color: #6b7585;
color: #5b513c;
text-transform: uppercase;
letter-spacing: 1px;
}
[data-theme="ps1"] .rb-tag {
font-size: 10px;
background: #c7ccd4;
border: 1px solid #fff;
background: var(--p1-void1);
color: var(--p1-seam);
border: 0;
padding: 1px 7px;
text-transform: uppercase;
letter-spacing: 1px;
clip-path: polygon(3px 0, 100% 0, calc(100% - 3px) 100%, 0 100%);
}
[data-theme="ps1"] .rb-readmore,
[data-theme="ps1"] .rb-back {
color: #3a4351;
color: var(--p1-cyan);
text-decoration: none;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 0 var(--p1-void2);
}
[data-theme="ps1"] .rb-readmore:hover,
[data-theme="ps1"] .rb-back:hover {
color: var(--p1-mag);
}
[data-theme="ps1"] .rb-back {
display: inline-block;
@@ -209,20 +315,39 @@
letter-spacing: 2px;
text-transform: uppercase;
margin: 0 0 8px;
text-shadow: 2px 2px 0 var(--p1-mag);
}
[data-theme="ps1"] .rb-prose {
line-height: 1.7;
}
[data-theme="ps1"] .rb-prose code {
background: #c7ccd4;
border: 1px solid #fff;
border-radius: 2px;
background: var(--p1-void1);
color: var(--p1-seam);
border: 0;
border-radius: 0;
padding: 0 4px;
font-family: "Courier New", monospace;
}
[data-theme="ps1"] .rb-prose img {
/* unfiltered textures: nearest-neighbor scaling */
image-rendering: pixelated;
}
[data-theme="ps1"] .rb-prose blockquote {
border-left: 3px solid var(--p1-accent);
border-left: 4px solid var(--p1-mag);
margin: 12px 0;
padding: 4px 14px;
background: #dde1e8;
background:
var(--p1-dither-dark),
#c8c1ab;
background-size: 4px 4px, cover;
}
/* Respect reduced-motion: kill the swim/jitter, keep the look. */
@media (prefers-reduced-motion: reduce) {
[data-theme="ps1"] .rb-post-card,
[data-theme="ps1"] .rb-titlebar-icon,
[data-theme="ps1"] .rb-start:hover,
[data-theme="ps1"] .rb-menu-link:hover {
animation: none;
}
}
+124 -16
View File
@@ -18,34 +18,142 @@
[data-theme="ps2"] body {
background:
radial-gradient(120% 80% at 50% -10%, #0b2f63 0%, #051a38 45%, #01060f 100%) fixed;
radial-gradient(120% 90% at 50% 120%, #0a3a7a 0%, #062250 35%, #02112e 65%, #00060f 100%) fixed;
position: relative;
overflow-x: hidden;
}
/* the drifting translucent "towers" */
[data-theme="ps2"] body::before {
/* ---- PS2 BIOS: the bobbing cube field ----
The boot screen's grid of textured cubes, seen from above and a
touch off-axis, each cube slowly bobbing up and down. */
[data-theme="ps2"] .rb-bg {
display: block;
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
overflow: hidden;
perspective: 1100px;
perspective-origin: 50% 34%;
}
[data-theme="ps2"] .rb-bg-grid {
position: absolute;
left: 50%;
top: 44%;
transform: translate(-50%, -50%) rotateX(64deg) rotateZ(-7deg) scale(1.4);
transform-style: preserve-3d;
display: grid;
grid-template-columns: repeat(15, 52px);
grid-auto-rows: 52px;
gap: 12px;
}
[data-theme="ps2"] .rb-cube {
position: relative;
width: 52px;
height: 52px;
transform-style: preserve-3d;
/* the bob — overridden wholly by the keyframes */
animation: ps2-bob 4.5s ease-in-out infinite;
animation-delay: var(--cube-delay, 0s);
/* lit top face */
background:
radial-gradient(120% 120% at 30% 25%, rgba(245, 250, 255, 0.97), rgba(150, 180, 220, 0.82) 70%),
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.06) 0 2px, transparent 2px 4px);
box-shadow: 0 0 2px rgba(190, 220, 255, 0.5);
}
/* front (south) face — faces the camera, darker */
[data-theme="ps2"] .rb-cube::before {
content: "";
position: absolute;
left: 0;
top: 100%;
width: 52px;
height: var(--cube-h, 60px);
transform-origin: top center;
transform: rotateX(-90deg);
background: linear-gradient(180deg, rgba(120, 150, 195, 0.9), rgba(34, 52, 88, 0.9));
}
/* right (east) face — caught by the slight rotation, darkest */
[data-theme="ps2"] .rb-cube::after {
content: "";
position: absolute;
left: 100%;
top: 0;
width: var(--cube-h, 60px);
height: 52px;
transform-origin: left center;
transform: rotateY(90deg);
background: linear-gradient(90deg, rgba(96, 126, 170, 0.85), rgba(24, 38, 66, 0.92));
}
/* slow vertical bob, toward and away from the camera */
@keyframes ps2-bob {
0%,
100% {
transform: translateZ(-10px);
}
50% {
transform: translateZ(26px);
}
}
/* ---- PS2 BIOS: floating glowing orbs ----
The drifting save-data spheres. Two parallax layers for depth. */
[data-theme="ps2"] body::after,
[data-theme="ps2"] .rb-desktop::before {
content: "";
position: fixed;
inset: -20% -10% 0;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
90deg,
transparent 0 60px,
rgba(120, 180, 255, 0.05) 60px 64px,
rgba(150, 200, 255, 0.1) 64px 90px,
transparent 90px 150px
);
filter: blur(1px);
animation: ps2-drift 26s linear infinite;
z-index: 0;
}
@keyframes ps2-drift {
[data-theme="ps2"] body::after {
/* far, small, faint orbs */
background-image:
radial-gradient(circle, rgba(190, 225, 255, 0.9) 0%, rgba(120, 180, 255, 0.25) 40%, transparent 70%),
radial-gradient(circle, rgba(200, 230, 255, 0.85) 0%, rgba(120, 180, 255, 0.2) 40%, transparent 70%),
radial-gradient(circle, rgba(170, 215, 255, 0.8) 0%, rgba(110, 170, 255, 0.2) 40%, transparent 70%),
radial-gradient(circle, rgba(210, 235, 255, 0.85) 0%, rgba(130, 185, 255, 0.2) 40%, transparent 70%);
background-repeat: no-repeat;
background-size: 5px 5px, 4px 4px, 6px 6px, 4px 4px;
background-position: 18% 90%, 42% 80%, 67% 95%, 85% 85%;
filter: drop-shadow(0 0 5px rgba(150, 200, 255, 0.7));
animation: ps2-orbs-far 24s linear infinite;
}
[data-theme="ps2"] .rb-desktop::before {
/* near, larger, brighter orbs (faster, more drift) */
background-image:
radial-gradient(circle, #fff 0%, rgba(160, 210, 255, 0.6) 35%, transparent 70%),
radial-gradient(circle, #eaf6ff 0%, rgba(150, 200, 255, 0.55) 35%, transparent 70%),
radial-gradient(circle, #fff 0%, rgba(170, 215, 255, 0.6) 35%, transparent 70%);
background-repeat: no-repeat;
background-size: 11px 11px, 9px 9px, 13px 13px;
background-position: 28% 100%, 58% 100%, 80% 100%;
filter: drop-shadow(0 0 10px rgba(170, 215, 255, 0.9));
animation: ps2-orbs-near 18s linear infinite;
}
/* orbs drift upward and gently sideways, then loop */
@keyframes ps2-orbs-far {
from {
transform: translateX(0) skewX(-2deg);
transform: translate3d(0, 0, 0);
opacity: 0;
}
10% { opacity: 0.8; }
90% { opacity: 0.8; }
to {
transform: translateX(-150px) skewX(-2deg);
transform: translate3d(20px, -85vh, 0);
opacity: 0;
}
}
@keyframes ps2-orbs-near {
from {
transform: translate3d(0, 0, 0);
opacity: 0;
}
12% { opacity: 1; }
88% { opacity: 1; }
to {
transform: translate3d(-36px, -105vh, 0);
opacity: 0;
}
}
+216 -73
View File
@@ -1,62 +1,114 @@
/* ============================================================
Theme: PlayStation 3 — scoped to [data-theme="ps3"]
XMB era: deep gradient that drifts through the day, a flowing
light wave, glossy black-glass panels, hair-thin white type.
XMB era: a teal→blue sky that drifts through the day, a slow
flowing light ribbon, thin white type, and the signature soft
glowing selection bar with glassy translucent panels.
============================================================ */
[data-theme="ps3"] {
--p3-ink: #f4f8ff;
--p3-dim: #9fb4cf;
--p3-line: rgba(255, 255, 255, 0.16);
--p3-glass: rgba(8, 14, 28, 0.55);
--p3-dim: #b7cbe2;
--p3-line: rgba(255, 255, 255, 0.18);
--p3-glass: rgba(8, 22, 44, 0.42);
/* the soft white selection sheen, brighter in the middle */
--p3-sel: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.18) 22%,
rgba(255, 255, 255, 0.34) 50%,
rgba(255, 255, 255, 0.18) 78%,
rgba(255, 255, 255, 0) 100%
);
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-weight: 200;
font-size: 14px;
color: var(--p3-ink);
}
/* signature XMB teal→blue, light pooling from the top, slow hue drift */
[data-theme="ps3"] body {
background: linear-gradient(120deg, #04122e, #0a2a5e, #04122e, #1a0a3e);
background-size: 400% 400%;
animation: ps3-sky 40s ease infinite;
background:
radial-gradient(
130% 90% at 50% -12%,
#34bcd6 0%,
#1d8bbd 30%,
#135a98 58%,
#0a3a72 82%,
#062a5a 100%
);
background-attachment: fixed;
animation: ps3-sky 60s ease-in-out infinite;
}
@keyframes ps3-sky {
0% {
background-position: 0% 50%;
filter: hue-rotate(0deg) saturate(1);
}
50% {
background-position: 100% 50%;
filter: hue-rotate(-22deg) saturate(1.12);
}
100% {
background-position: 0% 50%;
filter: hue-rotate(0deg) saturate(1);
}
}
/* the flowing XMB wave */
/* the flowing XMB light ribbon — a slow, soft diagonal streak */
[data-theme="ps3"] body::before {
content: "";
position: fixed;
left: -10%;
right: -10%;
top: 38%;
height: 220px;
left: -20%;
right: -20%;
top: 46%;
height: 320px;
pointer-events: none;
background: radial-gradient(
60% 100% at 50% 50%,
rgba(150, 200, 255, 0.22),
transparent 70%
);
filter: blur(8px);
transform: skewY(-4deg);
animation: ps3-wave 14s ease-in-out infinite alternate;
background:
radial-gradient(
55% 120% at 38% 50%,
rgba(190, 232, 255, 0.28),
transparent 70%
),
radial-gradient(
45% 90% at 72% 45%,
rgba(150, 210, 255, 0.2),
transparent 72%
);
filter: blur(14px);
transform: skewY(-5deg);
animation: ps3-wave 22s ease-in-out infinite alternate;
z-index: 0;
}
@keyframes ps3-wave {
from {
transform: skewY(-5deg) translateY(-30px);
transform: skewY(-6deg) translateY(-46px);
}
to {
transform: skewY(3deg) translateY(40px);
transform: skewY(2deg) translateY(54px);
}
}
/* a second, fainter ribbon for depth */
[data-theme="ps3"] body::after {
content: "";
position: fixed;
left: -20%;
right: -20%;
top: 22%;
height: 240px;
pointer-events: none;
background: radial-gradient(
60% 120% at 55% 50%,
rgba(220, 245, 255, 0.12),
transparent 70%
);
filter: blur(20px);
transform: skewY(4deg);
animation: ps3-wave2 30s ease-in-out infinite alternate;
z-index: 0;
}
@keyframes ps3-wave2 {
from {
transform: skewY(5deg) translateY(30px);
}
to {
transform: skewY(-3deg) translateY(-40px);
}
}
[data-theme="ps3"] .rb-desktop {
@@ -69,50 +121,59 @@
border: 1px solid var(--p3-line);
border-radius: 4px;
padding: 0;
backdrop-filter: blur(10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
/* top gloss highlight like the XMB glass panels */
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 24px 70px rgba(0, 12, 30, 0.55);
}
/* header row: small glowing orb + hair-thin title, hairline beneath */
[data-theme="ps3"] .rb-titlebar {
height: 40px;
padding: 0 18px;
height: 44px;
padding: 0 20px;
border-bottom: 1px solid var(--p3-line);
color: var(--p3-ink);
font-weight: 200;
letter-spacing: 1px;
font-size: 15px;
letter-spacing: 0.5px;
text-shadow: 0 1px 8px rgba(0, 20, 50, 0.5);
}
[data-theme="ps3"] .rb-titlebar-icon {
width: 12px;
height: 12px;
width: 13px;
height: 13px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
box-shadow: 0 0 12px rgba(120, 180, 255, 0.8);
background: radial-gradient(circle at 34% 28%, #fff, #7fb6ff 55%, #2f74d8);
box-shadow: 0 0 14px rgba(140, 200, 255, 0.9);
}
[data-theme="ps3"] .rb-titlebar-buttons {
display: none;
}
[data-theme="ps3"] .rb-menubar {
padding: 10px 16px;
padding: 9px 16px;
border-bottom: 1px solid var(--p3-line);
}
[data-theme="ps3"] .rb-menu-link {
text-decoration: none;
padding: 5px 14px;
padding: 6px 16px;
border-radius: 3px;
color: var(--p3-dim);
font-size: 13px;
transition: color 0.25s, text-shadow 0.25s;
letter-spacing: 0.3px;
transition: color 0.25s, text-shadow 0.25s, background 0.25s;
}
[data-theme="ps3"] .rb-menu-link:hover {
color: #fff;
text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
background: var(--p3-sel);
text-shadow: 0 0 12px rgba(170, 215, 255, 0.95);
}
[data-theme="ps3"] .rb-switcher-label {
color: var(--p3-dim);
font-size: 12px;
}
[data-theme="ps3"] .rb-switcher-select {
background: rgba(4, 12, 28, 0.85);
background: rgba(6, 20, 42, 0.8);
color: var(--p3-ink);
border: 1px solid var(--p3-line);
border-radius: 3px;
@@ -121,28 +182,44 @@
}
[data-theme="ps3"] .rb-content {
padding: 26px 30px;
padding: 28px 32px;
}
/* bottom command hints, X / O style like the XMB footer */
[data-theme="ps3"] .rb-statusbar {
border-top: 1px solid var(--p3-line);
padding: 7px 18px;
padding: 9px 20px;
font-size: 11px;
color: var(--p3-dim);
letter-spacing: 1px;
letter-spacing: 0.8px;
}
[data-theme="ps3"] .rb-status-cell::before {
content: "✕ ";
color: #cfe2ff;
font-size: 10px;
}
[data-theme="ps3"] .rb-status-grow::before {
content: "○ ";
color: #cfe2ff;
font-size: 10px;
}
[data-theme="ps3"] .rb-taskbar {
height: 38px;
background: linear-gradient(180deg, rgba(8, 18, 40, 0.75), rgba(2, 6, 16, 0.9));
height: 40px;
background: linear-gradient(
180deg,
rgba(10, 28, 56, 0.7),
rgba(2, 8, 20, 0.88)
);
border-top: 1px solid var(--p3-line);
backdrop-filter: blur(8px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
}
[data-theme="ps3"] .rb-start {
display: flex;
align-items: center;
gap: 9px;
height: 26px;
height: 27px;
padding: 0 18px;
border: 1px solid var(--p3-line);
border-radius: 14px;
@@ -151,23 +228,24 @@
font: 200 12px "Segoe UI", sans-serif;
letter-spacing: 1px;
cursor: pointer;
transition: background 0.25s;
}
[data-theme="ps3"] .rb-start:hover {
background: rgba(120, 180, 255, 0.18);
background: var(--p3-sel);
}
[data-theme="ps3"] .rb-start-logo {
width: 11px;
height: 11px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
box-shadow: 0 0 10px rgba(120, 180, 255, 0.8);
background: radial-gradient(circle at 34% 28%, #fff, #7fb6ff 55%, #2f74d8);
box-shadow: 0 0 10px rgba(140, 200, 255, 0.85);
}
[data-theme="ps3"] .rb-task {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 14px;
height: 27px;
padding: 0 16px;
border: 1px solid transparent;
background: transparent;
color: var(--p3-dim);
@@ -175,16 +253,17 @@
cursor: default;
}
[data-theme="ps3"] .rb-task-active {
border-color: var(--p3-line);
border-radius: 14px;
color: #fff;
background: rgba(255, 255, 255, 0.06);
background: var(--p3-sel);
box-shadow: 0 0 16px rgba(150, 210, 255, 0.25);
}
[data-theme="ps3"] .rb-task-icon {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6fa8ff;
background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
box-shadow: 0 0 8px rgba(140, 200, 255, 0.8);
}
[data-theme="ps3"] .rb-clock {
color: var(--p3-ink);
@@ -196,27 +275,37 @@
[data-theme="ps3"] .rb-page-title {
margin: 0 0 4px;
font-size: 34px;
font-size: 36px;
font-weight: 100;
letter-spacing: 1px;
letter-spacing: 0.5px;
color: #fff;
text-shadow: 0 2px 20px rgba(120, 180, 255, 0.5);
text-shadow: 0 2px 24px rgba(140, 200, 255, 0.55);
}
[data-theme="ps3"] .rb-page-sub {
margin: 0 0 24px;
color: var(--p3-dim);
}
/* post rows behave like XMB list items: a glassy bar that lights up
with the soft white selection sheen when picked */
[data-theme="ps3"] .rb-post-card {
border: 1px solid var(--p3-line);
border-radius: 4px;
padding: 18px 20px;
background: rgba(255, 255, 255, 0.04);
transition: background 0.25s, box-shadow 0.25s, transform 0.25s;
padding: 18px 22px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.07),
rgba(255, 255, 255, 0.02)
);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
transition: background 0.25s, box-shadow 0.25s, transform 0.2s;
}
[data-theme="ps3"] .rb-post-card:hover {
background: rgba(120, 180, 255, 0.12);
box-shadow: 0 0 30px rgba(80, 150, 255, 0.3);
transform: translateX(4px);
background: var(--p3-sel);
box-shadow:
inset 0 0 24px rgba(255, 255, 255, 0.12),
0 0 30px rgba(120, 195, 255, 0.32);
transform: translateX(5px);
}
[data-theme="ps3"] .rb-post-card-title a {
color: #fff;
@@ -224,25 +313,25 @@
font-weight: 200;
}
[data-theme="ps3"] .rb-post-card-title a:hover {
text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
text-shadow: 0 0 12px rgba(170, 215, 255, 0.95);
}
[data-theme="ps3"] .rb-post-date {
font-size: 11px;
color: var(--p3-dim);
}
[data-theme="ps3"] .rb-post-excerpt {
color: #d3e2f7;
color: #d8e6f9;
}
[data-theme="ps3"] .rb-tag {
font-size: 10px;
border: 1px solid var(--p3-line);
border-radius: 12px;
padding: 1px 10px;
color: #bcd4f5;
color: #c4d9f7;
}
[data-theme="ps3"] .rb-readmore,
[data-theme="ps3"] .rb-back {
color: #9fc6ff;
color: #a9cdff;
text-decoration: none;
font-size: 12px;
}
@@ -253,19 +342,19 @@
[data-theme="ps3"] .rb-article-title {
font-weight: 100;
color: #fff;
text-shadow: 0 2px 18px rgba(120, 180, 255, 0.45);
text-shadow: 0 2px 20px rgba(140, 200, 255, 0.5);
margin: 0 0 8px;
}
[data-theme="ps3"] .rb-prose {
line-height: 1.85;
color: #dce8f9;
color: #e0ecfa;
font-weight: 300;
}
[data-theme="ps3"] .rb-prose a {
color: #9fc6ff;
color: #a9cdff;
}
[data-theme="ps3"] .rb-prose code {
background: rgba(4, 12, 28, 0.7);
background: rgba(6, 18, 40, 0.7);
border: 1px solid var(--p3-line);
border-radius: 3px;
padding: 0 5px;
@@ -275,5 +364,59 @@
border-left: 2px solid #6fa8ff;
margin: 14px 0;
padding: 6px 16px;
background: rgba(120, 180, 255, 0.1);
background: rgba(120, 195, 255, 0.1);
}
/* glassy input bars like the SSID field, with a top gloss line */
[data-theme="ps3"] .rb-input,
[data-theme="ps3"] input[type="text"],
[data-theme="ps3"] input[type="email"],
[data-theme="ps3"] input[type="password"],
[data-theme="ps3"] input[type="search"],
[data-theme="ps3"] textarea,
[data-theme="ps3"] select {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0.04)
);
border: 1px solid var(--p3-line);
border-radius: 4px;
color: var(--p3-ink);
padding: 9px 14px;
font: 200 14px "Segoe UI", sans-serif;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
[data-theme="ps3"] input::placeholder,
[data-theme="ps3"] textarea::placeholder {
color: rgba(200, 218, 240, 0.55);
}
[data-theme="ps3"] .rb-input:focus,
[data-theme="ps3"] input:focus,
[data-theme="ps3"] textarea:focus,
[data-theme="ps3"] select:focus {
outline: none;
border-color: rgba(150, 210, 255, 0.6);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.3),
0 0 18px rgba(120, 195, 255, 0.4);
}
/* buttons read like the X/O command bar — pill, soft sheen on hover */
[data-theme="ps3"] .rb-btn,
[data-theme="ps3"] button[type="submit"] {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--p3-line);
border-radius: 16px;
color: var(--p3-ink);
padding: 8px 22px;
font: 200 13px "Segoe UI", sans-serif;
letter-spacing: 0.5px;
cursor: pointer;
transition: background 0.25s, box-shadow 0.25s;
}
[data-theme="ps3"] .rb-btn:hover,
[data-theme="ps3"] button[type="submit"]:hover {
background: var(--p3-sel);
box-shadow: 0 0 18px rgba(120, 195, 255, 0.35);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const THEMES: ThemeMeta[] = [
{
id: "ps1",
name: "PlayStation 1",
blurb: "Gray BIOS. Memory card and CD player.",
blurb: "Affine texture swim, dithered polygons.",
era: "1994",
},
{

Some files were not shown because too many files have changed in this diff Show More