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