Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffbc7a2ca | ||
|
|
653894a876 | ||
|
|
97d71a7c99 | ||
|
|
a58e91520e | ||
|
|
259de781bb | ||
|
|
6f2a39fe1b | ||
|
|
fbdfbe954e | ||
|
|
b51bf0d094 | ||
|
|
fc1076f8fc | ||
|
|
3e70717e8f | ||
|
|
c0252928c6 | ||
|
|
3a524d5003 | ||
|
|
2bf0b20b77 | ||
|
|
e6ef4f4191 | ||
|
|
81708fd84d | ||
|
|
688d956107 | ||
|
|
244b0e46cb | ||
|
|
9f55c47269 | ||
|
|
5d50ac69a1 | ||
|
|
4fc68e1448 | ||
|
|
d01fd97cc1 | ||
|
|
76d38840fb | ||
|
|
684f06745a | ||
|
|
58b8574cb4 | ||
|
|
9d8a546efa | ||
|
|
82b87d019f | ||
|
|
f2d0b72afd | ||
|
|
92b4ae22b7 | ||
|
|
02ffedbca2 | ||
|
|
ebe3684d44 | ||
|
|
e431e2b85b | ||
|
|
e040763454 | ||
|
|
bcb95424c3 | ||
|
|
23f4c43da8 | ||
|
|
c75c5acfe2 | ||
|
|
9dadb276a5 | ||
|
|
24d6add527 | ||
|
|
066451f31c | ||
|
|
65f337bcf4 | ||
|
|
c1741f64f3 | ||
|
|
9f31c85256 | ||
|
|
f8dee01c81 | ||
|
|
021f2b9b07 | ||
|
|
9255a11254 | ||
|
|
581d3c4371 | ||
|
|
7c0f1479b4 | ||
|
|
a99834abf0 | ||
|
|
7ea8303b1a | ||
|
|
a0a7ff63e1 | ||
|
|
3d5ba3f31d | ||
|
|
bf10aa87e8 | ||
|
|
f8c9cfe996 | ||
|
|
466aeac6ca | ||
|
|
5517133e25 | ||
|
|
4dccb9a815 | ||
|
|
c85148d238 | ||
|
|
201e422be6 | ||
|
|
3eedabce43 | ||
|
|
f1536d8b39 | ||
|
|
ce904489d4 | ||
|
|
22ee724c09 | ||
|
|
6b24707c8d | ||
|
|
71a5c15b4c | ||
|
|
e8c3e2f792 | ||
|
|
87e23545d1 | ||
|
|
2b62070557 | ||
|
|
d11610b6c7 | ||
|
|
9cbb909f08 | ||
|
|
2d750a4ce7 | ||
|
|
0a7e2d14e2 | ||
|
|
763a092640 | ||
|
|
ec43d2ae46 | ||
|
|
afd1fe50c4 | ||
|
|
254d339656 | ||
|
|
9b89550766 | ||
|
|
712fdbc97b | ||
|
|
23bbccd2b1 | ||
|
|
d82bb6f468 | ||
|
|
a0ced02345 | ||
|
|
546201c886 | ||
|
|
c853cfadbc | ||
|
|
b1139a43bc | ||
|
|
bae6fc06a6 | ||
|
|
731b267aa1 | ||
|
|
508e869080 | ||
|
|
435d22b71d | ||
|
|
f499b7f2af | ||
|
|
267a13b600 | ||
|
|
b530dcd50d | ||
|
|
068c4db3f8 | ||
|
|
565c086a0d | ||
|
|
dd69dd79ab | ||
|
|
fd0b64850f | ||
|
|
61ad1a87f2 | ||
|
|
7c9c8ff510 | ||
|
|
4b32252d2b | ||
|
|
dc45f2eb35 | ||
|
|
c067d303bb |
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require "rbconfig"
|
||||
|
||||
root = IO.popen([ "git", "rev-parse", "--show-toplevel" ], &:read).strip
|
||||
check = File.join(root, "script", "warp_engine_version_check.rb")
|
||||
|
||||
exit 0 unless File.exist?(check)
|
||||
|
||||
exec(RbConfig.ruby, check, "--staged")
|
||||
@@ -1,57 +0,0 @@
|
||||
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
|
||||
# tools/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
|
||||
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
|
||||
# Forgejo rubygems registry-be is felmegy.
|
||||
#
|
||||
# Szükséges Woodpecker secret: forge_token — Forgejo access token
|
||||
# repository:write (tools/warp_engine) és package:write joggal.
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path: "libs/ruby/warp_engine/**"
|
||||
- event: manual
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 0 # a subtree splithez teljes history kell
|
||||
|
||||
steps:
|
||||
split-mirror:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- apk add --no-cache git-subtree
|
||||
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
|
||||
# a secretbe másolt token végén lehet sortörés — levágjuk
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/tools/warp_engine.git" warp-engine-split:master
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
publish-gem:
|
||||
image: ruby:3.3-slim
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- cd libs/ruby/warp_engine
|
||||
- gem build warp_engine.gemspec
|
||||
- mkdir -p ~/.gem
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- |
|
||||
printf -- '---\n:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
|
||||
- chmod 600 ~/.gem/credentials
|
||||
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
@@ -0,0 +1,125 @@
|
||||
# Éles deploy: master push -> rubocop -> host teszt -> pull + függőségek + restart.
|
||||
#
|
||||
# A woodpecker-agent ugyanabban a stackben fut, mint az api és a frontend, ezért
|
||||
# a deploy nem SSH-zik: a hoszt docker socketjét használja. A stack könyvtára a
|
||||
# konténerben UGYANARRA az útvonalra van mountolva, mint a hoszton — különben a
|
||||
# compose relatív bind mountjai (./data, ./apps) máshova oldódnának fel.
|
||||
#
|
||||
# Feltételek:
|
||||
# - Woodpecker: a repónál engedélyezni kell a "trusted: volumes" jelölést
|
||||
# (Repo settings -> Trusted, admin joggal), különben a mountok tiltottak.
|
||||
# - Woodpecker secret: forge_token — Forgejo token a services/teletypegames
|
||||
# olvasásához (ugyanaz, amit a warp_engine workflow használ).
|
||||
# - A szerveren /srv/stacks/teletype-games egy master-en álló git checkout,
|
||||
# benne az api és frontend konténerek futnak (container_name: api, frontend).
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
services:
|
||||
- name: mysql
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: softwares # a zeitwerk:check production modban bootol; a softwares_test-et a db:test:prepare csinalja
|
||||
|
||||
steps:
|
||||
rubocop:
|
||||
image: ruby:3.3
|
||||
environment:
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
# A gemek a szerveren maradnak futások között; a ruby-verzió a kulcs, mert a
|
||||
# natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
|
||||
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd apps/api
|
||||
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
|
||||
# ugyanabba a bundle könyvtárba.
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- bundle exec rubocop
|
||||
|
||||
test-host:
|
||||
image: ruby:3.3
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
DB_USER: root
|
||||
DB_NAME: softwares
|
||||
FILE_CONTAINER_PATH: /tmp/softwares
|
||||
IMAGE_CONTAINER_PATH: /tmp/images
|
||||
UPDATE_SECRET: ci-test
|
||||
SECRET_KEY_BASE: ci-test
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
- /srv/ci-cache/bundle/api-ruby3.3:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
|
||||
- mkdir -p /tmp/softwares /tmp/images
|
||||
- cd apps/api
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- |
|
||||
echo "==> Varakozas a mysql-re"
|
||||
for i in $(seq 1 60); do
|
||||
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
- bundle exec rails db:test:prepare
|
||||
- bundle exec rspec
|
||||
# Production modu eager load: a sema-betoltott teszt adatbazisra mutatunk,
|
||||
# hogy a boot ne egy ures DB-n haljon el.
|
||||
- RAILS_ENV=production DB_NAME=softwares_test bundle exec rails zeitwerk:check
|
||||
|
||||
pull:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
volumes:
|
||||
- /srv/stacks/teletype-games:/srv/stacks/teletype-games
|
||||
commands:
|
||||
- git config --global --add safe.directory /srv/stacks/teletype-games
|
||||
- cd /srv/stacks/teletype-games
|
||||
# A szerver checkoutja detached HEAD-en is állhat: akkor a pull "sikeres",
|
||||
# de a futó kód nem mozdul. Inkább bukjunk el itt.
|
||||
- |
|
||||
BRANCH="$(git symbolic-ref --short -q HEAD || true)"
|
||||
if [ "$BRANCH" != "master" ]; then
|
||||
echo "A checkout nem master-en all (HEAD: $${BRANCH:-detached}), a deploy megall."
|
||||
exit 1
|
||||
fi
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git fetch "https://ci:$${TOKEN}@git.teletypegames.org/services/teletypegames.git" master
|
||||
- git merge --ff-only FETCH_HEAD
|
||||
- |
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
echo "==> A szerver most itt all: $HEAD_SHA"
|
||||
if [ "$HEAD_SHA" != "$CI_COMMIT_SHA" ]; then
|
||||
echo "Megjegyzes: ez nem a pipeline commitja ($CI_COMMIT_SHA) — kozben ujabb push jott."
|
||||
fi
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
branch: master
|
||||
|
||||
restart:
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands:
|
||||
# Csak az api és a frontend indul újra. A stack többi szolgáltatását
|
||||
# (traefik, gitea, woodpecker, mysql) nem bántjuk: a woodpecker-server
|
||||
# restartja épp ezt a pipeline-t vágná el.
|
||||
- docker exec api bundle install
|
||||
# A migracio a restart elott fut: a motor migraciois utvonala a hoszt
|
||||
# db:migrate-jaban van, tehat ez a warp_engine migraciot is elvegzi.
|
||||
- docker exec api bundle exec rails db:migrate
|
||||
- docker restart api
|
||||
- docker exec frontend npm install
|
||||
- docker restart frontend
|
||||
- docker ps --filter name=api --filter name=frontend --format '{{.Names}} {{.Status}}'
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
branch: master
|
||||
@@ -0,0 +1,126 @@
|
||||
# WarpEngine gem: verzió -> rubocop -> teszt -> tükrözés -> gem kiadás.
|
||||
#
|
||||
# A fejlesztés ebben a monorepóban történik; a engines/warp_engine repó csak
|
||||
# publikálásra való read-only tükör, ezért CI-t oda nincs értelme tenni: a
|
||||
# subtree split minden alkalommal felülírja.
|
||||
#
|
||||
# Szükséges Woodpecker secret: forge_token — Forgejo access token
|
||||
# repository:write (engines/warp_engine) és package:write joggal.
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
path: "libs/ruby/warp_engine/**"
|
||||
- event: manual
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
partial: false
|
||||
depth: 0 # a subtree splithez teljes history kell
|
||||
|
||||
services:
|
||||
- name: mysql
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: warp_engine_test
|
||||
|
||||
steps:
|
||||
# Első kapu: a motor nem módosulhat verzióemelés nélkül. Ugyanaz a szkript
|
||||
# fut a .githooks/pre-commit hookban is, csak --staged módban.
|
||||
version-bumped:
|
||||
image: ruby:3.3
|
||||
commands:
|
||||
- ruby script/warp_engine_version_check.rb --range "$CI_PREV_COMMIT_SHA" "$CI_COMMIT_SHA"
|
||||
when:
|
||||
- event: [ push, manual ]
|
||||
|
||||
# Tag esetén a verzió már nem emelkedhet: a tagnek a VERSION-t kell hirdetnie.
|
||||
tag-matches-version:
|
||||
image: ruby:3.3
|
||||
commands:
|
||||
- ruby script/warp_engine_version_check.rb --tag "$CI_COMMIT_REF"
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
rubocop:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
# A gemek a szerveren maradnak futások között. Külön könyvtár ruby-verziónként:
|
||||
# a natív kiterjesztések (mysql2) egy ABI-hoz fordulnak.
|
||||
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd libs/ruby/warp_engine
|
||||
# A lock a párhuzamos futásokat választja szét: két pipeline nem ír egyszerre
|
||||
# ugyanabba a bundle könyvtárba.
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- bundle exec rubocop
|
||||
|
||||
test-engine:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
BUNDLE_PATH: /cache/bundle
|
||||
volumes:
|
||||
- /srv/ci-cache/bundle/warp_engine-ruby3.2:/cache/bundle
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
|
||||
- cd libs/ruby/warp_engine
|
||||
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
|
||||
- |
|
||||
echo "==> Varakozas a mysql-re"
|
||||
for i in $(seq 1 60); do
|
||||
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
- |
|
||||
echo "==> Teszt adatbazis elokeszitese"
|
||||
bundle exec rake app:db:test:prepare
|
||||
- bundle exec rspec
|
||||
|
||||
split-mirror:
|
||||
image: alpine/git
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- apk add --no-cache git-subtree
|
||||
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
|
||||
# a secretbe másolt token végén lehet sortörés — levágjuk
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/engines/warp_engine.git" warp-engine-split:master
|
||||
when:
|
||||
- event: push
|
||||
branch: master
|
||||
- event: manual
|
||||
|
||||
publish-gem:
|
||||
image: ruby:3.3-slim
|
||||
environment:
|
||||
FORGE_TOKEN:
|
||||
from_secret: forge_token
|
||||
commands:
|
||||
- cd libs/ruby/warp_engine
|
||||
- gem build warp_engine.gemspec
|
||||
- mkdir -p ~/.gem
|
||||
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
|
||||
# Nevesített kulcs, nem a hoszt-URL: a RubyGems a credentials fájl
|
||||
# kulcsait normalizálja (a pontokból __ lesz, záró perjelet kap), így egy
|
||||
# URL-kulcs sosem egyezik a --host értékével. Ilyenkor a gem push
|
||||
# bejelentkezni próbál a RubyGems sign_in végpontján, amit a Gitea nem
|
||||
# ismer — ez adta a "404 page not found"-ot.
|
||||
- |
|
||||
printf -- '---\n:gitea: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
|
||||
- chmod 600 ~/.gem/credentials
|
||||
- gem push --key gitea --host https://git.teletypegames.org/api/packages/engines/rubygems warp_engine-*.gem
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/warp_engine-v*
|
||||
@@ -16,13 +16,83 @@ libs/
|
||||
The API is split in two layers:
|
||||
|
||||
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
|
||||
(softwares, releases, release assets, images, platform links, download stats),
|
||||
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
|
||||
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
|
||||
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
|
||||
(softwares, releases, release assets, software-image links, platform links,
|
||||
download stats), the CI-callable `/build/*` publishing endpoints, the public
|
||||
read-only JSON API (`/api/software*`, `/api/builds*`, `/api/download`,
|
||||
`/file/*`) and the catalog ActiveAdmin resources.
|
||||
See its [README](libs/ruby/warp_engine/README.md).
|
||||
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
|
||||
wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets.
|
||||
It consumes WarpEngine as a path gem and mounts it at `/`.
|
||||
the image library (`Image`, `/api/image/:id`, the admin page — the engine only
|
||||
links to it through an adapter), the store registry, wiki proxy, RSS feeds,
|
||||
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
|
||||
as a path gem and mounts it at `/`.
|
||||
|
||||
## Languages
|
||||
|
||||
The site is bilingual (English and Hungarian) and so is the wiki behind it.
|
||||
`Locales` (`lib/locales.rb`) is the one list of supported codes; anything else
|
||||
falls back to `en`.
|
||||
|
||||
The wiki serves English unprefixed and Hungarian under `/hu`, so
|
||||
`Wiki::Pages` builds the language into the request path rather than into a query
|
||||
parameter:
|
||||
|
||||
```
|
||||
GET /api/wiki/pages?tag=howto&lang=hu -> https://wiki.teletypegames.org/hu/custom/pages.json?tag=howto
|
||||
GET /api/wiki/pages?tag=howto -> https://wiki.teletypegames.org/custom/pages.json?tag=howto
|
||||
```
|
||||
|
||||
Each page in the response carries a `locale` saying which language its body is
|
||||
**actually** written in. That differs from the requested `lang` for the wiki
|
||||
pages that exist only in Hungarian: they are served as-is rather than 404ing, and
|
||||
a client can label them.
|
||||
|
||||
The RSS feeds take the same `lang` parameter (`/api/rss/blog?lang=hu`), which
|
||||
sets the channel language and picks the wiki language and the feed's own strings
|
||||
from `config/locales/`.
|
||||
|
||||
The frontend passes the interface language on every wiki call and rebuilds its
|
||||
outbound wiki links with `wikiUrl()`, so switching EN/HU re-reads the catalog and
|
||||
points the links at the matching wiki pages.
|
||||
|
||||
## The store registry
|
||||
|
||||
`GET /api/stores` lists the stores a client can install from. The desktop
|
||||
graphical client reads it on first run, which is why it is **public**: a client has
|
||||
nobody to log in as.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
|
||||
]
|
||||
```
|
||||
|
||||
A `Store` row has two required fields — `name` and `catalog_url` — plus an
|
||||
**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**;
|
||||
`db/seeds.rb` creates our own.
|
||||
|
||||
**A store does not need a repository.** The store engine's own defaults already
|
||||
cover the host-to-asset mapping, the install modes, the platforms and the
|
||||
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
|
||||
that is what this row carries. With no repository the client derives the slug from
|
||||
the catalog host, writes a small config and installs. Given one, it reads that
|
||||
repository's `config.json` and points it at `catalog_url`, and that file remains the
|
||||
authority on how the store behaves; a repository without a config file is treated
|
||||
as no repository at all.
|
||||
|
||||
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
|
||||
above is the host's own endpoint and does not, because it is not part of the engine.
|
||||
|
||||
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
|
||||
one catalog and has no business knowing which stores exist for it; who ships a
|
||||
store for a catalog is a property of the site.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Model | `apps/api/app/models/store.rb` |
|
||||
| Endpoint | `apps/api/app/controllers/api/stores_controller.rb` |
|
||||
| Admin | `apps/api/app/admin/stores.rb` |
|
||||
| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) |
|
||||
|
||||
## Development environment
|
||||
|
||||
@@ -64,11 +134,14 @@ JSON contract baselines for `/api/software` and `/api/builds` live in
|
||||
### Backend (RuboCop)
|
||||
|
||||
```bash
|
||||
docker compose run --rm --no-deps api bundle exec rubocop # check
|
||||
docker compose run --rm --no-deps api bundle exec rubocop -A # autofix
|
||||
docker compose run --rm --no-deps api bundle exec rubocop # host app
|
||||
docker compose run --rm --no-deps api bundle exec rubocop -A # host app, autofix
|
||||
docker exec -w /libs/ruby/warp_engine api \
|
||||
env BUNDLE_GEMFILE=/app/Gemfile bundle exec rubocop # engine
|
||||
```
|
||||
|
||||
Config: `apps/api/.rubocop.yml` (rubocop-rails-omakase preset)
|
||||
Config: `apps/api/.rubocop.yml` and `libs/ruby/warp_engine/.rubocop.yml`
|
||||
(both rubocop-rails-omakase). Both are green, and CI keeps them that way.
|
||||
|
||||
### Frontend (ESLint)
|
||||
|
||||
@@ -78,3 +151,98 @@ docker compose run --rm --no-deps frontend npm run lint:fix # autofix
|
||||
```
|
||||
|
||||
Config: `apps/frontend/eslint.config.js` (ESLint 9 flat config, Vue + TypeScript)
|
||||
|
||||
## Pipelines
|
||||
|
||||
Woodpecker reads every file in `.woodpecker/` as its own workflow, each with its
|
||||
own trigger.
|
||||
|
||||
### `.woodpecker/warp_engine.yaml` — the gem
|
||||
|
||||
Runs when a push to master touches `libs/ruby/warp_engine/**`, on a manual run,
|
||||
and on a `warp_engine-v*` tag:
|
||||
|
||||
| Step | What it guards |
|
||||
|---|---|
|
||||
| `version-bumped` | the engine changed but `WarpEngine::VERSION` did not — fails first, before anything else runs |
|
||||
| `tag-matches-version` | tag events only: `warp_engine-v0.9.1` must find `VERSION = "0.9.1"` |
|
||||
| `rubocop` | `libs/ruby/warp_engine` against its own `.rubocop.yml` |
|
||||
| `test-engine` | the engine suite (dummy app, `warp_engine_test` DB) |
|
||||
| `split-mirror` | pushes the subtree split to `engines/warp_engine` (master pushes only) |
|
||||
| `publish-gem` | `gem push` to the Forgejo registry (tags only) |
|
||||
|
||||
Nothing is mirrored or published until the version check, RuboCop and the suite
|
||||
have all passed. The mirror repository deliberately has no CI of its own: the
|
||||
subtree split overwrites it on every run.
|
||||
|
||||
### `.woodpecker/deploy.yaml` — the deploy
|
||||
|
||||
Runs on every push to master (and manually): `rubocop` → `test-host` (host
|
||||
suite + a production-mode `zeitwerk:check`) → `pull` → `restart`.
|
||||
|
||||
The `pull` and `restart` steps reach the server through the host Docker socket
|
||||
rather than SSH — the `woodpecker-agent` runs in the same stack as `api` and
|
||||
`frontend`. Two things this depends on:
|
||||
|
||||
- The stack directory is bind-mounted **at the same path** it has on the host
|
||||
(`/srv/stacks/teletype-games`), so the compose file's relative bind mounts
|
||||
(`./data`, `./apps`) still resolve where they did.
|
||||
- Woodpecker only allows step volumes on a **trusted** repository: enable
|
||||
*Trusted → Volumes* in the repo settings (admin only), or the deploy steps
|
||||
are rejected.
|
||||
|
||||
`pull` fails if the server checkout is not on `master` (a detached HEAD would
|
||||
make the pull look successful while the running code never moves). `restart`
|
||||
installs dependencies, runs `db:migrate` and only then restarts — and it
|
||||
touches only `api` and `frontend`: restarting `woodpecker-server` would cut off
|
||||
the very pipeline doing the deploy.
|
||||
|
||||
`db:migrate` in the host app covers the engine too — WarpEngine appends its own
|
||||
`db/migrate` to the host's migration paths. A failing migration stops the
|
||||
deploy before the restart, so the old code keeps running.
|
||||
|
||||
### Gem cache
|
||||
|
||||
Both workflows install gems into a host directory that survives runs:
|
||||
|
||||
| Workflow | Host path | `BUNDLE_PATH` |
|
||||
|---|---|---|
|
||||
| `warp_engine.yaml` | `/srv/ci-cache/bundle/warp_engine-ruby3.2` | `/cache/bundle` |
|
||||
| `deploy.yaml` | `/srv/ci-cache/bundle/api-ruby3.3` | `/cache/bundle` |
|
||||
|
||||
Cold install is ~35 s, warm ~6 s (113 MB of gems). The directory is keyed by
|
||||
Ruby version because native extensions (mysql2) are built against one ABI; the
|
||||
`flock` around `bundle install` keeps two concurrent pipelines from writing the
|
||||
same directory at once. Docker creates the directories on first run — to drop
|
||||
the cache, delete them.
|
||||
|
||||
This needs the same *Trusted → Volumes* flag the deploy does.
|
||||
|
||||
### Why a MySQL service and not a stub
|
||||
|
||||
Two thirds of the engine suite (22 of 36 spec files, all 11 request specs)
|
||||
create rows and assert on what comes back, and both `schema.rb` files are
|
||||
MySQL-shaped (`charset: utf8mb4`, `collation: utf8mb4_0900_ai_ci`, unsigned
|
||||
keys). A null adapter answers every query with nothing, and SQLite would test a
|
||||
database we do not run. The service container costs a start, not a download —
|
||||
the agent's Docker daemon already has the image.
|
||||
|
||||
Secret used by both workflows: `forge_token` — a Forgejo token with
|
||||
repository read/write and package:write.
|
||||
|
||||
## Version bump hook
|
||||
|
||||
The engine's version rule is enforced twice, by the same script:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .githooks # once per clone
|
||||
```
|
||||
|
||||
`.githooks/pre-commit` runs `script/warp_engine_version_check.rb --staged`: a
|
||||
commit that touches `libs/ruby/warp_engine/**` must also raise
|
||||
`WarpEngine::VERSION` above the one in `HEAD`. The pipeline runs the same script
|
||||
in `--range` mode over the pushed commits, so nothing slips through a
|
||||
`--no-verify`.
|
||||
|
||||
Escape hatch when a bump genuinely does not belong:
|
||||
`SKIP_WARP_ENGINE_VERSION_CHECK=1 git commit ...`
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# Refaktorálási Terv - BEFEJEZETT
|
||||
|
||||
## ✅ ELVÉGZETT REFAKTORÁLÁSOK
|
||||
|
||||
### 1. Elnevezési Inkonzisztenciák
|
||||
|
||||
#### ✅ 1.1 Interface nevek konvertálása
|
||||
- `SoftwareRepository` → `SoftwareRepositoryInterface`
|
||||
- `ReleaseRepository` → `ReleaseRepositoryInterface`
|
||||
- `SoftwareService` → `SoftwareServiceInterface`
|
||||
- `DownloadService` → `DownloadServiceInterface`
|
||||
- `SoftwareUpdaterService` → `SoftwareUpdaterServiceInterface`
|
||||
- `SoftwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80ServiceInterface`
|
||||
|
||||
#### ✅ 1.2 Implementációs nevek szabványosítása
|
||||
- `softwareRepository` → `SoftwareRepository` (struct)
|
||||
- `releaseRepository` → `ReleaseRepository` (struct)
|
||||
- `softwareService` → `SoftwareService` (struct)
|
||||
- `downloadService` → `DownloadService` (struct)
|
||||
- `softwareUpdaterService` → `SoftwareUpdaterService` (struct)
|
||||
- `softwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80Service` (struct)
|
||||
|
||||
#### ✅ 1.3 Method nevek a resource tárgya nélkül
|
||||
- `DownloadSource()` → `GetLatestSource()`
|
||||
- `DownloadCartridge()` → `GetLatestCartridge()`
|
||||
- `DownloadSourceByVersion()` → `GetSource()`
|
||||
- `DownloadCartridgeByVersion()` → `GetCartridge()`
|
||||
- `PlayGame()` → `Play()`
|
||||
- `ServeGameContent()` → `ServeContent()`
|
||||
- `UpdateTIC80Software()` → `Update()`
|
||||
- `UpdateSoftware()` → `Update()`
|
||||
- `serveReleaseFile()` → `serve()`
|
||||
|
||||
---
|
||||
|
||||
### 2. Kód Duplikáció és DRY Elvek Megsértése
|
||||
|
||||
#### ✅ 2.1 Download Controller - Kód duplikáció eltávolítása
|
||||
- Létrehozva `serve()` helper metódus (a `serveReleaseFile()` helyett)
|
||||
- Létrehozva `handleError()` helper metódus az ismétlődő error handling csökkentésére
|
||||
- 4 metódus helyett az első 2 metódus kliens kódja:
|
||||
- `GetLatestSource()` / `GetLatestCartridge()`
|
||||
- `GetSource()` / `GetCartridge()`
|
||||
|
||||
#### ✅ 2.2 Template Parsing - Duplikáció és Teljesítmény
|
||||
- Létrehozva `lib/template_utils/cache.go` - Thread-safe template cache
|
||||
- Integrálva az összes controller-ben:
|
||||
- `SoftwareController.index()` és `releases()` - template cache-t használ
|
||||
- `PlayController.Play()` - template cache-t használ
|
||||
- Template-ek már nem parse-olódnak minden request-ben
|
||||
|
||||
#### ✅ 2.3 Redundáns Service Layer eltávolítása
|
||||
- MEGTARTVA az interfészeket (kontra a REFACT.md 3.4 sugallatára)
|
||||
- Hozzáadva konstruktor függvények: `NewSoftwareService()`, `NewDownloadService()`, stb.
|
||||
- Ez lehetővé teszi a jövőbeni business logic hozzáadást
|
||||
|
||||
---
|
||||
|
||||
### 3. Architektúra Problémák
|
||||
|
||||
#### ✅ 3.1 Rossz rétegek elválasztása
|
||||
- Létrehozva `FileRepositoryInterface` és `FileRepository` struct
|
||||
- A file operációk kiszervezve a `SoftwareUpdaterTIC80Service`-ből:
|
||||
- `UnzipHTMLContent()` - ZIP fájlok kicsomagolása
|
||||
- `FileExists()` - Fájl létezésének ellenőrzése
|
||||
- `CreateDir()` - Könyvtár létrehozása
|
||||
- `DeleteFile()` - Fájl törlése
|
||||
- `MoveFile()` - Fájl mozgatása
|
||||
- `ReadMetaFromFile()` - Metadatok olvasása (korábban `parseMeta()`)
|
||||
- `GetSoftwareDir()`, `GetCartridgePath()`, `GetSourcePath()` - Path helper-ek
|
||||
- `SoftwareUpdaterTIC80Service` mostantól csak business logic-ot tartalmaz:
|
||||
- `handleHTMLContent()` - HTML content feldolgozása
|
||||
- `handleLuaCartridge()` - Lua cartridge feldolgozása
|
||||
- `moveCartridgeFiles()` - Fájlok mozgatása
|
||||
- `parseMeta()` - Metadatok feldolgozása (de `FileRepository.ReadMetaFromFile()` segítségével)
|
||||
|
||||
#### ✅ 3.2 Environment Variables - Centralizált konfiguráció
|
||||
- `FILE_CONTAINER_PATH` és `FILE_CONTAINER_PATH` továbbra is `os.Getenv()`-el hívódnak
|
||||
- MEGLÉPÉS: Az env vars a Domain inicializációban továbbra is szétszórva vannak
|
||||
- TODO: Config struct még nem készült (de nem kritikus)
|
||||
|
||||
#### ✅ 3.3 Domain Model - GORM duplikáció eltávolítása
|
||||
- Eltávolítva az `ID` mezőt a `Software` struct-ből (gorm.Model már tartalmazza)
|
||||
- Eltávolítva az `ID` mezőt a `Release` struct-ből (gorm.Model már tartalmazza)
|
||||
|
||||
#### ✅ 3.4 Interface Megtartása
|
||||
- MEGTARTVA az összes interfész (tanács szerint)
|
||||
- Hozzáadva constructor függvények (dependency injection)
|
||||
- Ez lehetővé teszi a mocking-ot és a jövőbeni kiterjesztést
|
||||
|
||||
#### ✅ 3.5 Error Handling javítása
|
||||
- Eltávolítva az elnyomott hibák a `parseMeta()` és `ReadMetaFromFile()` funkcióból
|
||||
- Most megfelelő error handling van:
|
||||
```go
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
#### ✅ 3.6 Erőforrás nevek megtisztítása
|
||||
- `FILE_CONTAINER_PATH` → `FILE_CONTAINER_PATH` (nem "game" szó)
|
||||
- Összes referencia frissítve
|
||||
|
||||
---
|
||||
|
||||
### 4. Teljesítmény Problémák
|
||||
|
||||
#### ✅ 4.1 Template Cache
|
||||
- Megoldva az 2.2 pontban (Template parsing duplikáció)
|
||||
- Thread-safe implementáció: `sync.RWMutex` háttérrel
|
||||
|
||||
#### ✅ 4.2 N+1 Query probléma
|
||||
- MEGLÉPÉS: GORM `Preload()` továbbra is jó (nem szükséges módosítás)
|
||||
|
||||
---
|
||||
|
||||
### 5. Dependency Injection
|
||||
|
||||
#### ✅ Hozzáadva Constructor függvények
|
||||
- `NewSoftwareService(repository SoftwareRepositoryInterface) *SoftwareService`
|
||||
- `NewDownloadService(softwareRepository, releaseRepository) *DownloadService`
|
||||
- `NewSoftwareUpdaterService(tic80Updater) *SoftwareUpdaterService`
|
||||
- `NewSoftwareUpdaterTIC80Service(softwareRepository, releaseRepository, fileRepository) *SoftwareUpdaterTIC80Service`
|
||||
- `NewFileRepository() *FileRepository`
|
||||
- `NewSoftwareController(service SoftwareServiceInterface) *SoftwareController`
|
||||
- `NewSoftwareUpdaterController(service SoftwareUpdaterServiceInterface) *SoftwareUpdaterController`
|
||||
- `NewDownloadController(service DownloadServiceInterface) *DownloadController`
|
||||
- `NewPlayController() *PlayController`
|
||||
- `NewRouter(controllers...) *Router`
|
||||
|
||||
#### ✅ Domain inicializáció frissítve
|
||||
- `domain.go` mostantól a constructor-okat használja
|
||||
- Összes dependency inject-álva a Domain struct-be
|
||||
|
||||
---
|
||||
|
||||
## 📊 Refaktorálás Összefoglalása
|
||||
|
||||
### Fájlok módosítva:
|
||||
1. ✅ `domain/model.software.go` - ID mező eltávolítva
|
||||
2. ✅ `domain/model.release.go` - ID mező eltávolítva
|
||||
3. ✅ `domain/repository.software.go` - Interface konverzió
|
||||
4. ✅ `domain/repository.release.go` - Interface konverzió
|
||||
5. ✅ `domain/service.software.go` - Interface konverzió, constructor
|
||||
6. ✅ `domain/service.download.go` - Interface konverzió, constructor
|
||||
7. ✅ `domain/service.software_updater.go` - Interface konverzió, constructor, method nevek
|
||||
8. ✅ `domain/service.software_updater_tic80.go` - NAGY refaktor, FileRepository integrálás
|
||||
9. ✅ `domain/domain.go` - Inicializáció frissítve
|
||||
10. ✅ `lib/template_utils/cache.go` - ÚJ FILE - Template cache
|
||||
11. ✅ `domain/repository.file.go` - ÚJ FILE - FileRepository
|
||||
12. ✅ `http/controller.software.go` - Constructor, template cache
|
||||
13. ✅ `http/controller.download.go` - NAGY refaktor, DRY, helper methods
|
||||
14. ✅ `http/controller.software_updater.go` - Constructor, method nevek
|
||||
15. ✅ `http/controller.play.go` - Constructor, method nevek, template cache
|
||||
16. ✅ `http/router.go` - Constructor frissítve, method nevek
|
||||
17. ✅ `http/http.go` - Inicializáció frissítve
|
||||
|
||||
### Fájlok NEM módosítva:
|
||||
- `main.go` - Működik az új struktúrával
|
||||
- `lib/http_utils/` - Nem szükséges módosítás
|
||||
- `lib/mysql_utils/` - Nem szükséges módosítás
|
||||
- `domain/model.migrate.go` - Nem szükséges módosítás
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Az elvégzett refaktorálások hatása
|
||||
|
||||
### Kódminőség javulása:
|
||||
- ✅ DRY elv betartása (duplikáció csökkentve)
|
||||
- ✅ Interface konvenciók (Interface + Impl naming)
|
||||
- ✅ SOLID elvek jobb betartása
|
||||
- ✅ Separation of Concerns (FileRepository szeparálva)
|
||||
- ✅ Dependency Injection (konstruktorok)
|
||||
|
||||
### Teljesítmény javulása:
|
||||
- ✅ Template cache (~100% gyorsabb template rendering)
|
||||
- ✅ Nincs N+1 probléma (GORM Preload)
|
||||
|
||||
### Testability javulása:
|
||||
- ✅ Interfészek könnyebb mockálhatók
|
||||
- ✅ FileRepository szeparálva (könnyebb file operációk tesztere)
|
||||
- ✅ Konstruktor-based DI (könnyebb test setup)
|
||||
|
||||
### Karbantarthatóság javulása:
|
||||
- ✅ Tiszta elnevezési konvenciók
|
||||
- ✅ Szeparált file operációk (FileRepository)
|
||||
- ✅ Csökkentett kód duplikáció
|
||||
- ✅ Jobb error handling
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maradandó TODO-k (Jövőbeli fejlesztések)
|
||||
|
||||
### P1 (Erősen ajánlott)
|
||||
1. **Config struct** - ENV variables centralizálása
|
||||
- `type Config struct { ContentsDir, UpdateSecret string }`
|
||||
- Inject a Domain-ba és controller-ekbe
|
||||
|
||||
2. **Extended Testing**
|
||||
- `FileRepository` unit tesztek
|
||||
- `SoftwareUpdaterTIC80Service` unit tesztek
|
||||
- Controller integration tesztek
|
||||
|
||||
3. **Logging abstraction**
|
||||
- Logger interface a helyett a direkter `fmt.Printf()`
|
||||
- Inject a service-ekbe
|
||||
|
||||
### P2 (Nice to have)
|
||||
4. **Error Context** - `errors.Wrap()` vagy `fmt.Errorf()` wrapper
|
||||
5. **Validation layer** - Input validation middleware
|
||||
6. **Database error handling** - Specifikus error típusok (not found, conflict, stb.)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Véglegesen elért állapot: 8.5/10
|
||||
|
||||
**Az eredeti 6/10-ről:**
|
||||
- ✅ DRY elvek betartása
|
||||
- ✅ Architektúra szeparáció (FileRepository)
|
||||
- ✅ Teljesítmény (Template cache)
|
||||
- ✅ Interface konvenciók
|
||||
- ✅ Error handling javítás
|
||||
- ✅ Dependency Injection
|
||||
|
||||
**Még nem teljesen befejezett:**
|
||||
- ⚠️ Config struct (de nem kritikus)
|
||||
- ⚠️ Komprehenzív test coverage
|
||||
- ⚠️ Logger abstraction
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: ../libs/ruby/warp_engine
|
||||
specs:
|
||||
warp_engine (0.1.0)
|
||||
warp_engine (0.9.1)
|
||||
apipie-rails
|
||||
blueprinter
|
||||
rails (>= 8.0)
|
||||
@@ -10,31 +10,31 @@ PATH
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.18)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -42,21 +42,21 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.3)
|
||||
actiontext (8.1.3.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activeadmin (3.5.1)
|
||||
activeadmin (3.5.2)
|
||||
arbre (~> 1.2, >= 1.2.1)
|
||||
csv
|
||||
formtastic (>= 3.1)
|
||||
@@ -68,22 +68,22 @@ GEM
|
||||
ransack (>= 4.0)
|
||||
activeadmin_blaze_theme (1.1.0)
|
||||
activeadmin (>= 2.0, < 4)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.3)
|
||||
activesupport (8.1.3.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -107,18 +107,18 @@ GEM
|
||||
bcrypt (3.1.22)
|
||||
bigdecimal (4.1.2)
|
||||
blueprinter (1.3.0)
|
||||
bootsnap (1.24.3)
|
||||
msgpack (~> 1.2)
|
||||
bootsnap (1.25.0)
|
||||
msgpack (~> 1.5)
|
||||
builder (3.3.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
csv (3.3.5)
|
||||
crass (1.0.7)
|
||||
csv (3.3.6)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (5.0.3)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
@@ -126,7 +126,7 @@ GEM
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.6.2)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.7)
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.6.0)
|
||||
activesupport (>= 6.1.0)
|
||||
@@ -138,19 +138,19 @@ GEM
|
||||
formtastic (6.0.0)
|
||||
actionpack (>= 7.2.0)
|
||||
formtastic_i18n (0.7.0)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
has_scope (0.9.0)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
i18n (1.14.8)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
inherited_resources (1.14.0)
|
||||
actionpack (>= 6.0)
|
||||
has_scope (>= 0.6)
|
||||
railties (>= 6.0)
|
||||
responders (>= 2)
|
||||
io-console (0.8.2)
|
||||
io-console (0.9.1)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
@@ -160,7 +160,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.5)
|
||||
json (2.21.2)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
@@ -173,27 +173,27 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.5)
|
||||
language_server-protocol (3.17.0.6)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
loofah (2.25.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (1.2.1)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.8.4)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-imap (0.6.4)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -203,23 +203,20 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
nokogiri (1.19.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.12.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
puma (8.0.1)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
@@ -230,30 +227,30 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.3)
|
||||
railties (= 8.1.3.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
rails-html-sanitizer (1.7.1)
|
||||
loofah (~> 2.25, >= 2.25.2)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -266,12 +263,17 @@ GEM
|
||||
activerecord (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
i18n
|
||||
rdoc (7.2.0)
|
||||
rbs (4.1.2)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
@@ -296,7 +298,7 @@ GEM
|
||||
rspec-support (3.13.7)
|
||||
rss (0.3.3)
|
||||
rexml
|
||||
rubocop (1.86.1)
|
||||
rubocop (1.89.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -307,14 +309,14 @@ GEM
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.1)
|
||||
rubocop-ast (1.50.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
rubocop-rails (2.36.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -346,9 +348,8 @@ GEM
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
tilt (2.8.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -360,11 +361,11 @@ GEM
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.7.5)
|
||||
zeitwerk (2.8.3)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
ActiveAdmin.register Image do
|
||||
permit_params :file_upload
|
||||
|
||||
menu priority: 5, label: "🖼️ Images"
|
||||
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
|
||||
|
||||
# SoftwareImage a natív használó; a hoston regisztrált image_owners
|
||||
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
|
||||
used_ids = -> {
|
||||
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
|
||||
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
|
||||
WarpEngine::SoftwareImage.distinct.pluck(:image_id) +
|
||||
Member.where.not(image_id: nil).distinct.pluck(:image_id)
|
||||
}
|
||||
|
||||
scope :all, default: true
|
||||
@@ -16,7 +14,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
|
||||
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
|
||||
orphan_ids = ids.map(&:to_i) - used_ids.call
|
||||
WarpEngine::Image.where(id: orphan_ids).find_each do |img|
|
||||
Image.where(id: orphan_ids).find_each do |img|
|
||||
File.delete(img.file_path) if File.exist?(img.file_path)
|
||||
img.destroy
|
||||
end
|
||||
@@ -30,13 +28,13 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
column :content_type
|
||||
column(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
end
|
||||
end
|
||||
column(:usage) do |img|
|
||||
uses = []
|
||||
uses << "#{img.software_images.size} software(s)" if img.software_images.any?
|
||||
uses.concat(WarpEngine.config.image_owners.filter_map { |o| o[:usage_label].call(img) })
|
||||
uses << "member" if img.members.any?
|
||||
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
|
||||
end
|
||||
column :created_at
|
||||
@@ -54,7 +52,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
row :content_type
|
||||
row(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
else
|
||||
"File not found on disk"
|
||||
end
|
||||
@@ -73,7 +71,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software_images)
|
||||
super.includes(:software_images, :members)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,7 @@ ActiveAdmin.register Member do
|
||||
f.template.image_tag("/api/image/#{resource.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
|
||||
end
|
||||
f.input :image_id, as: :select, label: "Image",
|
||||
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
|
||||
collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
|
||||
include_blank: "— no image (use avatar_filename) —",
|
||||
hint: img_hint
|
||||
end
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
ActiveAdmin.register Store do
|
||||
permit_params :name, :catalog_url, :active
|
||||
|
||||
menu priority: 5, label: "🛒 Stores"
|
||||
|
||||
scope("Active", default: true) { |scope| scope.active }
|
||||
scope("Inactive") { |scope| scope.where(active: false) }
|
||||
scope :all
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
column :active do |store|
|
||||
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
|
||||
end
|
||||
column :updated_at
|
||||
actions defaults: true do |store|
|
||||
link_to store.active? ? "Hide" : "List",
|
||||
toggle_admin_store_path(store),
|
||||
method: :put
|
||||
end
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :catalog_url
|
||||
filter :active
|
||||
|
||||
member_action :toggle, method: :put do
|
||||
resource.update!(active: !resource.active?)
|
||||
redirect_back fallback_location: admin_stores_path,
|
||||
notice: "#{resource.name} is now #{resource.active? ? 'listed' : 'hidden'}"
|
||||
end
|
||||
|
||||
batch_action :list do |ids|
|
||||
Store.where(id: ids).update_all(active: true, updated_at: Time.current)
|
||||
redirect_to admin_stores_path, notice: "#{ids.size} store(s) listed"
|
||||
end
|
||||
|
||||
batch_action :hide do |ids|
|
||||
Store.where(id: ids).update_all(active: false, updated_at: Time.current)
|
||||
redirect_to admin_stores_path, notice: "#{ids.size} store(s) hidden"
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
row :active do |store|
|
||||
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
|
||||
end
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
para do
|
||||
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
|
||||
"and a catalog are the whole record: the client carries its own store engine " \
|
||||
"and configures itself from this much, deriving the store's slug from the " \
|
||||
"catalog host. An inactive store is left out of that answer entirely — the " \
|
||||
"client has no state for 'there but switched off'."
|
||||
end
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :name, hint: "What the client shows in its store picker"
|
||||
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
|
||||
f.input :active, hint: "Off keeps the record but leaves it out of GET /api/stores"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
||||
@@ -1,2 +1 @@
|
||||
//= link active_admin.css
|
||||
//= link active_admin.js
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//= require active_admin/base
|
||||
|
||||
(function() {
|
||||
var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])';
|
||||
var _pickerTarget = null;
|
||||
@@ -89,7 +87,6 @@
|
||||
|
||||
var html = '';
|
||||
|
||||
// Breadcrumb
|
||||
var parts = dir ? dir.split('/').filter(function(p){return p;}) : [];
|
||||
html += '<div class="fm-breadcrumbs"><a href="#" data-dir="">softwares/</a>';
|
||||
var acc = '';
|
||||
@@ -99,19 +96,16 @@
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Upload bar (also a drop zone)
|
||||
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
|
||||
html += '<input type="file" id="fm-picker-file" />';
|
||||
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
|
||||
html += '<span class="fm-drop-hint">or drag & drop files here</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Table
|
||||
html += '<table class="fm-table"><thead><tr>';
|
||||
html += '<th style="width:50px">Type</th><th>Name</th><th style="width:90px">Size</th>';
|
||||
html += '<th style="width:80px"></th></tr></thead><tbody>';
|
||||
|
||||
// Parent dir
|
||||
if (dir) {
|
||||
var parentDir = dir.indexOf('/') >= 0 ? dir.substring(0, dir.lastIndexOf('/')) : '';
|
||||
html += '<tr><td class="fm-type">DIR</td>';
|
||||
@@ -139,10 +133,8 @@
|
||||
html += '</tbody></table>';
|
||||
body.innerHTML = html;
|
||||
|
||||
// Event delegation
|
||||
body.addEventListener('click', handlePickerClick);
|
||||
|
||||
// Drag & drop
|
||||
var dropzone = document.getElementById('fm-picker-dropzone');
|
||||
if (dropzone) {
|
||||
['dragenter', 'dragover'].forEach(function(evt) {
|
||||
@@ -162,21 +154,18 @@
|
||||
function handlePickerClick(e) {
|
||||
var t = e.target;
|
||||
|
||||
// Navigate directory
|
||||
if (t.tagName === 'A' && t.hasAttribute('data-dir')) {
|
||||
e.preventDefault();
|
||||
loadDir(t.getAttribute('data-dir'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Select file/folder
|
||||
if (t.hasAttribute('data-select')) {
|
||||
e.preventDefault();
|
||||
selectPath(t.getAttribute('data-select'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload button
|
||||
if (t.id === 'fm-picker-upload-btn') {
|
||||
e.preventDefault();
|
||||
var fileInput = document.getElementById('fm-picker-file');
|
||||
@@ -233,7 +222,6 @@
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Live image preview for software_images image_id selects
|
||||
function addImagePreviewListeners() {
|
||||
var selects = document.querySelectorAll('select[id*="image_id"]:not([data-preview-added])');
|
||||
selects.forEach(function(sel) {
|
||||
@@ -241,7 +229,6 @@
|
||||
sel.addEventListener('change', function() {
|
||||
var hint = sel.closest('li').querySelector('.inline-hints');
|
||||
if (!hint) {
|
||||
// Create hint element if it doesn't exist
|
||||
hint = document.createElement('p');
|
||||
hint.className = 'inline-hints';
|
||||
sel.closest('li').appendChild(hint);
|
||||
@@ -255,7 +242,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone Files page drag & drop
|
||||
function initPageDropzone() {
|
||||
var dz = document.getElementById('fm-page-dropzone');
|
||||
if (!dz) return;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@import "active_admin/base";
|
||||
@import "activeadmin_blaze_theme/theme";
|
||||
|
||||
// Force utility navigation visible (blaze theme may hide it)
|
||||
#utility_nav { display: flex !important; visibility: visible !important; }
|
||||
#utility_nav #site_link a {
|
||||
color: #fff !important;
|
||||
@@ -11,7 +10,6 @@
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
// File Manager
|
||||
.fm-breadcrumbs {
|
||||
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
|
||||
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
|
||||
@@ -37,7 +35,6 @@
|
||||
.fm-icon-danger:hover { filter: brightness(1.3); }
|
||||
.fm-danger { color: #dc3545; }
|
||||
|
||||
// Emoji action buttons in AA index tables
|
||||
.table_actions {
|
||||
a.view_link, a.edit_link, a.delete_link {
|
||||
font-size: 0 !important;
|
||||
@@ -61,14 +58,12 @@
|
||||
.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; }
|
||||
.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; }
|
||||
|
||||
// Browse button in release forms
|
||||
.fm-browse-btn {
|
||||
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff !important; border-radius: 3px; font-size: 11px; text-decoration: none !important; vertical-align: middle;
|
||||
&:hover { background: #5a6268; color: #fff !important; }
|
||||
&:visited { color: #fff !important; }
|
||||
}
|
||||
|
||||
// Inline file picker modal
|
||||
.fm-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Api::ImagesController < ApiController
|
||||
resource_description do
|
||||
short "Images"
|
||||
formats [ "binary" ]
|
||||
end
|
||||
|
||||
api :GET, "/api/image/:id", "Get image by ID"
|
||||
param :id, :number, required: true, desc: "Image ID"
|
||||
returns code: 200, desc: "Image binary data"
|
||||
error code: 404, desc: "Image not found"
|
||||
def show
|
||||
image = Image.find(params[:id])
|
||||
send_file image.file_path, type: image.content_type, disposition: "inline"
|
||||
end
|
||||
end
|
||||
@@ -5,23 +5,26 @@ class Api::RssController < ApiController
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/blog", "Blog RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of blog posts"
|
||||
def blog
|
||||
xml = RssService.new.blog_feed
|
||||
xml = Rss::BlogFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/releases", "Software releases RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of software releases"
|
||||
def releases
|
||||
xml = RssService.new.releases_feed
|
||||
xml = Rss::ReleasesFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/howtos", "HowTos RSS feed"
|
||||
param :lang, String, required: false, desc: "Feed language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "RSS XML feed of tech howtos"
|
||||
def howtos
|
||||
xml = RssService.new.howtos_feed
|
||||
xml = Rss::HowtosFeed.new(lang: params[:lang]).xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class Api::StoresController < ApiController
|
||||
resource_description do
|
||||
short "Stores"
|
||||
end
|
||||
|
||||
api :GET, "/api/stores", "List the stores a client can install from"
|
||||
desc <<~DESC
|
||||
The registry the graphical desktop client reads on first run: which catalogs
|
||||
exist. Public on purpose: a client has nobody to log in as.
|
||||
|
||||
A name and a catalog URL are the whole record. How a store behaves is fixed per
|
||||
installed client — it carries its own store engine and knows its own machine — so
|
||||
the registry says what a store *is* and nothing about how it works.
|
||||
DESC
|
||||
returns code: 200, desc: "Array of stores" do
|
||||
property :name, String, desc: "Display name of the store"
|
||||
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
|
||||
end
|
||||
def index
|
||||
render json: StoreService.new.index
|
||||
end
|
||||
end
|
||||
@@ -7,8 +7,10 @@ class Api::WikiController < ApiController
|
||||
param :tag, String, required: false, desc: "Filter by tag (blog, howto, engine)"
|
||||
param :limit, :number, required: false, desc: "Limit number of results"
|
||||
param :body, String, required: false, desc: "Include body content (1 = yes)"
|
||||
param :lang, String, required: false, desc: "Content language (en, hu). Unknown values fall back to en"
|
||||
returns code: 200, desc: "Wiki pages response" do
|
||||
property :tag, String, desc: "Applied tag filter"
|
||||
property :lang, String, desc: "Requested content language"
|
||||
property :count, Integer, desc: "Number of pages returned"
|
||||
property :pages, Array, desc: "Array of wiki pages" do
|
||||
property :id, String, desc: "Page ID"
|
||||
@@ -17,7 +19,7 @@ class Api::WikiController < ApiController
|
||||
property :description, String, desc: "Short description"
|
||||
property :createdAt, String, desc: "Created date (ISO 8601)"
|
||||
property :updatedAt, String, desc: "Updated date (ISO 8601)"
|
||||
property :locale, String, desc: "Locale code"
|
||||
property :locale, String, desc: "Language the page body is actually written in (differs from lang when the page has no translation)"
|
||||
property :route, String, desc: "URL slug"
|
||||
property :tags, Array, of: String, desc: "Tags"
|
||||
property :repo, String, desc: "Git repository URL (from page metadata, engines)"
|
||||
@@ -26,12 +28,13 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
property :error, String, desc: "Error message (null on success)"
|
||||
end
|
||||
# GET /api/wiki/pages?tag=blog|howto[&limit=30][&body=1]
|
||||
|
||||
def index
|
||||
render json: WikiService.new.index(
|
||||
render json: Wiki::Pages.new.fetch(
|
||||
tag: params[:tag],
|
||||
limit: params[:limit],
|
||||
body: params[:body]
|
||||
body: params[:body],
|
||||
lang: params[:lang]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
class ApiController < ActionController::API
|
||||
include WarpEngine::ApiErrorRendering
|
||||
|
||||
resource_description do
|
||||
api_version "1.0"
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path)
|
||||
ext = File.extname(path.to_s).delete_prefix(".")
|
||||
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SyncsAdminCookie
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module SyncsAdminCookie
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :sync_admin_cookie
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class AdminUser < ApplicationRecord
|
||||
devise :database_authenticatable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
devise :database_authenticatable, :rememberable, :validatable
|
||||
|
||||
belongs_to :member, optional: true
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class Image < ApplicationRecord
|
||||
def self.upload_path
|
||||
Rails.configuration.x.images.container_path
|
||||
end
|
||||
|
||||
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
|
||||
has_many :members
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_validation :assign_upload_attributes, if: -> { file_upload.present? }
|
||||
after_commit :store_upload_file, on: [ :create, :update ], if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at deleted_at filename id original_filename updated_at]
|
||||
end
|
||||
|
||||
def file_path
|
||||
File.join(self.class.upload_path, filename.to_s)
|
||||
end
|
||||
|
||||
def url = "/api/image/#{id}"
|
||||
|
||||
private
|
||||
|
||||
def assign_upload_attributes
|
||||
self.original_filename = file_upload.original_filename
|
||||
self.content_type = file_upload.content_type.presence || "application/octet-stream"
|
||||
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
|
||||
end
|
||||
|
||||
def store_upload_file
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Member < ApplicationRecord
|
||||
belongs_to :image, class_name: "WarpEngine::Image", optional: true
|
||||
belongs_to :image, optional: true
|
||||
has_one :admin_user
|
||||
|
||||
validates :nick, presence: true, uniqueness: true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Store < ApplicationRecord
|
||||
URL = %r{\Ahttps?://\S+\z}
|
||||
|
||||
validates :name, presence: true
|
||||
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[id name catalog_url active created_at updated_at]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class StoreSerializer < Blueprinter::Base
|
||||
include WarpEngine::TimestampFields
|
||||
|
||||
field :name
|
||||
|
||||
field(:catalogUrl) { |store| store.catalog_url }
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class BlogFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.blog.title")
|
||||
def link = "#{site_url}/blog"
|
||||
def description = translate("rss.blog.description")
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "blog", limit: 30, lang: lang).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{site_url}/blog/#{page['route']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
require "rss"
|
||||
|
||||
module Rss
|
||||
class Feed
|
||||
def initialize(lang: nil)
|
||||
@lang = Locales.resolve(lang)
|
||||
end
|
||||
|
||||
def xml
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = title
|
||||
maker.channel.link = link
|
||||
maker.channel.description = description
|
||||
maker.channel.language = lang
|
||||
|
||||
items.each do |item|
|
||||
maker.items.new_item do |rss_item|
|
||||
rss_item.title = item[:title]
|
||||
rss_item.link = item[:link]
|
||||
rss_item.description = item[:description].to_s
|
||||
rss_item.pubDate = item[:published_at]
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :lang
|
||||
|
||||
def site_url = Rails.configuration.x.site_url
|
||||
|
||||
def wiki_url = "#{Rails.configuration.x.wiki_url}#{Wiki::Pages.path_prefix(lang)}"
|
||||
|
||||
def translate(key, **options) = I18n.t(key, locale: lang, **options)
|
||||
|
||||
def parse_time(value)
|
||||
Time.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class HowtosFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.howtos.title")
|
||||
def link = "#{site_url}/howtos"
|
||||
def description = translate("rss.howtos.description")
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "howto", limit: 30, lang: lang).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{wiki_url}/#{page['path']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Rss
|
||||
class ReleasesFeed < Feed
|
||||
private
|
||||
|
||||
def title = translate("rss.releases.title")
|
||||
def link = "#{site_url}/catalog"
|
||||
def description = translate("rss.releases.description")
|
||||
|
||||
def items
|
||||
WarpEngine::Release.includes(:software).order(created_at: :desc).limit(50).filter_map do |release|
|
||||
software = release.software
|
||||
next if software.nil?
|
||||
|
||||
{
|
||||
title: "#{software.title} v#{release.version}",
|
||||
link: "#{site_url}/catalog/#{software.name}",
|
||||
description: translate("rss.releases.item_description", title: software.title, version: release.version, desc: software.desc),
|
||||
published_at: release.created_at.to_time
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,73 +0,0 @@
|
||||
require "rss"
|
||||
|
||||
class RssService
|
||||
SITE_URL = ENV.fetch("SITE_URL", "https://teletypegames.org").freeze
|
||||
WIKI_URL = ENV.fetch("WIKI_URL", "https://wiki.teletypegames.org").freeze
|
||||
|
||||
def blog_feed
|
||||
pages = WikiService.new.pages(tag: "blog", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Blog"
|
||||
maker.channel.link = "#{SITE_URL}/blog"
|
||||
maker.channel.description = "Latest blog posts from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{SITE_URL}/blog/#{page["route"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def releases_feed
|
||||
releases = WarpEngine::Release.includes(:software)
|
||||
.order(created_at: :desc)
|
||||
.limit(50)
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Releases"
|
||||
maker.channel.link = "#{SITE_URL}/catalog"
|
||||
maker.channel.description = "Latest game releases from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
releases.each do |release|
|
||||
sw = release.software
|
||||
next unless sw
|
||||
|
||||
maker.items.new_item do |item|
|
||||
item.title = "#{sw.title} v#{release.version}"
|
||||
item.link = "#{SITE_URL}/catalog/#{sw.name}"
|
||||
item.description = "#{sw.title} #{release.version} released – #{sw.desc}"
|
||||
item.pubDate = release.created_at.to_time
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def howtos_feed
|
||||
pages = WikiService.new.pages(tag: "howto", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games HowTos"
|
||||
maker.channel.link = "#{SITE_URL}/howtos"
|
||||
maker.channel.description = "Latest tech howtos from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{WIKI_URL}/#{page["path"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class StoreService
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.active.ordered)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
module Wiki
|
||||
class Pages
|
||||
def self.path_prefix(language)
|
||||
language == Locales::DEFAULT ? "" : "/#{language}"
|
||||
end
|
||||
|
||||
def fetch(tag:, limit: nil, body: nil, lang: nil)
|
||||
language = Locales.resolve(lang)
|
||||
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{Rails.configuration.x.wiki_url}#{self.class.path_prefix(language)}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
return empty(tag, language, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
empty(tag, language, e.message)
|
||||
end
|
||||
|
||||
def all(tag:, limit: nil, lang: nil)
|
||||
fetch(tag: tag, limit: limit, lang: lang).fetch("pages", [])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def empty(tag, language, error)
|
||||
{ "tag" => tag, "lang" => language, "count" => 0, "pages" => [], "error" => error }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
# Fetches wiki pages (blog, howto, …) from the Grav backend, filtered by tag.
|
||||
#
|
||||
# Grav exposes: GET {WIKI_GRAV_URL}/custom/pages.json?tag=<tag>[&limit=<n>][&body=1]
|
||||
# and returns:
|
||||
# {
|
||||
# "tag": "howto", "count": 24,
|
||||
# "pages": [
|
||||
# { "id", "path", "title", "description",
|
||||
# "createdAt", "updatedAt", "locale", "route", "tags",
|
||||
# # with body=1 also:
|
||||
# "render" (rendered HTML),
|
||||
# "content" (raw markdown) }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# This replaces the frontend's direct WikiJS GraphQL calls with a simple GET,
|
||||
# and points at Grav (which is taking over from wiki.teletypegames.org).
|
||||
class WikiService
|
||||
GRAV_URL = ENV.fetch("WIKI_GRAV_URL", "http://localhost:8080").freeze
|
||||
|
||||
def index(tag:, limit: nil, body: nil)
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{GRAV_URL}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
return { "tag" => tag, "count" => 0, "pages" => [], "error" => "grav responded #{response.code}" }
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
# Az RSS feedek ezen a néven hívják.
|
||||
alias_method :pages, :index
|
||||
end
|
||||
@@ -22,10 +22,20 @@ module Api
|
||||
config.hosts << ENV["WEBAPP_TECHNICAL_DOMAIN"] if ENV["WEBAPP_TECHNICAL_DOMAIN"].present?
|
||||
config.hosts << "teletypegames.org"
|
||||
|
||||
# Traefik terminates SSL so Rails sees HTTP internally; disable the origin
|
||||
# header check (CSRF token itself is still validated)
|
||||
config.action_controller.forgery_protection_origin_check = false
|
||||
|
||||
# nginx → Traefik → Rails proxy chain: trust Docker + loopback ranges
|
||||
# so request.remote_ip reads the real client IP from X-Forwarded-For
|
||||
config.action_dispatch.trusted_proxies = ActionDispatch::RemoteIp::TRUSTED_PROXIES + [
|
||||
IPAddr.new("172.16.0.0/12"),
|
||||
IPAddr.new("10.0.0.0/8"),
|
||||
IPAddr.new("192.168.0.0/16")
|
||||
]
|
||||
|
||||
config.autoload_lib(ignore: %w[assets tasks])
|
||||
|
||||
config.x.site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
|
||||
config.x.wiki_url = ENV.fetch("WIKI_GRAV_URL", "https://wiki.teletypegames.org")
|
||||
config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,5 @@ Rails.application.configure do
|
||||
|
||||
config.log_level = :warn
|
||||
|
||||
# Rack::Test example.org hostját ne blokkolja a host authorization
|
||||
config.hosts.clear
|
||||
end
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Set a JS-readable cookie when an admin signs in/out.
|
||||
# Devise's remember_admin_user_token cookie is HttpOnly,
|
||||
# so the frontend cannot read it. This non-HttpOnly cookie
|
||||
# lets the frontend show/hide the Admin menu link.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ApplicationController.class_eval do
|
||||
after_action :sync_admin_cookie
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
Devise.setup do |config|
|
||||
config.mailer_sender = "noreply@teletype.hu"
|
||||
|
||||
require "devise/orm/active_record"
|
||||
|
||||
config.case_insensitive_keys = [:email]
|
||||
@@ -11,6 +9,5 @@ Devise.setup do |config|
|
||||
config.expire_all_remember_me_on_sign_out = true
|
||||
config.password_length = 6..128
|
||||
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
|
||||
config.reset_password_within = 6.hours
|
||||
config.sign_out_via = :delete
|
||||
end
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
|
||||
# értékadás (nem <<), hogy idempotens legyen.
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# A /build/* DB-tokenjeinek tulajdonosa. A :database módra váltás
|
||||
# (c.application_token_source = :database) csak azután jöhet, hogy a CI már
|
||||
# DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et.
|
||||
|
||||
c.application_token_source = :database
|
||||
c.application_token_owner_class = "AdminUser"
|
||||
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
c.image_class_name = "Image"
|
||||
|
||||
c.site_url = Rails.configuration.x.site_url
|
||||
|
||||
c.ignored_user_agents = %w[warpstore/*]
|
||||
|
||||
c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
|
||||
url: ENV["WOODPECKER_URL"],
|
||||
api_token: ENV["WOODPECKER_API_TOKEN"],
|
||||
repo_owner: ENV["WOODPECKER_REPO_OWNER"],
|
||||
public_key_url: "https://ci.teletypegames.org/api/signature/public-key",
|
||||
update_server: "https://teletypegames.org",
|
||||
platforms: {
|
||||
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
|
||||
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
|
||||
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
|
||||
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
|
||||
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
|
||||
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
|
||||
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
|
||||
}
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,3 +5,14 @@ en:
|
||||
date:
|
||||
formats:
|
||||
long: "%Y-%m-%d"
|
||||
rss:
|
||||
blog:
|
||||
title: "Teletype Games Blog"
|
||||
description: "Latest blog posts from Teletype Games"
|
||||
howtos:
|
||||
title: "Teletype Games HowTos"
|
||||
description: "Latest tech howtos from Teletype Games"
|
||||
releases:
|
||||
title: "Teletype Games Releases"
|
||||
description: "Latest game releases from Teletype Games"
|
||||
item_description: "%{title} %{version} released – %{desc}"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
hu:
|
||||
time:
|
||||
formats:
|
||||
long: "%Y-%m-%d %H:%M"
|
||||
date:
|
||||
formats:
|
||||
long: "%Y-%m-%d"
|
||||
rss:
|
||||
blog:
|
||||
title: "Teletype Games blog"
|
||||
description: "A Teletype Games legfrissebb blogbejegyzései"
|
||||
howtos:
|
||||
title: "Teletype Games útmutatók"
|
||||
description: "A Teletype Games legfrissebb technikai útmutatói"
|
||||
releases:
|
||||
title: "Teletype Games kiadások"
|
||||
description: "A Teletype Games legfrissebb játékkiadásai"
|
||||
item_description: "Megjelent a %{title} %{version} – %{desc}"
|
||||
@@ -7,14 +7,13 @@ Rails.application.routes.draw do
|
||||
get "swagger", to: "swagger#index"
|
||||
get "events", to: "events#index"
|
||||
get "members", to: "members#index"
|
||||
get "image/:id", to: "images#show"
|
||||
get "stores", to: "stores#index"
|
||||
get "wiki/pages", to: "wiki#index"
|
||||
get "rss/blog", to: "rss#blog"
|
||||
get "rss/releases", to: "rss#releases"
|
||||
get "rss/howtos", to: "rss#howtos"
|
||||
end
|
||||
|
||||
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
|
||||
# az engine adja.
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
|
||||
@@ -9,7 +9,6 @@ class AddCascadeToSoftwareForeignKeys < ActiveRecord::Migration[8.1]
|
||||
remove_foreign_key :software_images, :softwares
|
||||
add_foreign_key :software_images, :softwares, on_delete: :cascade
|
||||
|
||||
# Keep download stats when a release is removed.
|
||||
remove_foreign_key :downloads, name: "fk_downloads_release"
|
||||
add_foreign_key :downloads, :releases, name: "fk_downloads_release", on_delete: :nullify
|
||||
end
|
||||
|
||||
@@ -20,8 +20,6 @@ class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1]
|
||||
add_index :release_assets, :path, name: "idx_release_assets_path"
|
||||
add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade
|
||||
|
||||
# A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik,
|
||||
# így az unscoped alapú admin/updater logika konzisztens marad.
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at)
|
||||
|
||||
@@ -17,7 +17,6 @@ class DropLegacyPathColumnsFromReleases < ActiveRecord::Migration[8.1]
|
||||
add_column :releases, column, :string
|
||||
end
|
||||
|
||||
# rollbacknél az adat a release_assets-ből áll vissza, nem veszik el
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
UPDATE releases r
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class CreateStores < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :stores, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
|
||||
if_not_exists: true do |t|
|
||||
|
||||
t.string :name, null: false
|
||||
t.string :catalog_url, null: false
|
||||
t.string :store_repository_url, null: false
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3
|
||||
|
||||
t.index :deleted_at, name: "idx_stores_deleted_at"
|
||||
t.index :name, name: "idx_stores_name"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
change_column_null :stores, :store_repository_url, true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
add_column :stores, :config, :json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
remove_column :stores, :config, :json
|
||||
remove_column :stores, :store_repository_url, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddActiveToStores < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
add_column :stores, :active, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -45,6 +45,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "application_token_id", unsigned: true
|
||||
t.datetime "approved_at", precision: 3
|
||||
t.string "client_name", limit: 128
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "denied_at", precision: 3
|
||||
t.string "device_code", limit: 64, null: false
|
||||
t.datetime "expires_at", precision: 3, null: false
|
||||
t.string "issued_token", limit: 64
|
||||
t.bigint "subject_id", unsigned: true
|
||||
t.string "subject_type", limit: 128
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.string "user_code", limit: 16, null: false
|
||||
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
|
||||
t.index ["expires_at"], name: "idx_device_grants_expires_at"
|
||||
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -196,6 +214,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
t.index ["image_id"], name: "index_members_on_image_id"
|
||||
end
|
||||
|
||||
create_table "pipelines", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.datetime "last_pipeline_at", precision: 3
|
||||
t.string "last_pipeline_status", limit: 32
|
||||
t.string "platform", limit: 32, null: false
|
||||
t.string "repo_name", limit: 128, null: false
|
||||
t.string "repo_owner", limit: 128, null: false
|
||||
t.bigint "software_id", unsigned: true
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.bigint "woodpecker_repo_id", null: false, unsigned: true
|
||||
t.index ["deleted_at"], name: "idx_pipelines_deleted"
|
||||
t.index ["repo_owner", "repo_name"], name: "idx_pipelines_owner_name", unique: true
|
||||
t.index ["software_id"], name: "idx_pipelines_software"
|
||||
t.index ["woodpecker_repo_id"], name: "idx_pipelines_wp_id", unique: true
|
||||
end
|
||||
|
||||
create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -263,10 +299,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
|
||||
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
|
||||
end
|
||||
|
||||
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "catalog_url", null: false
|
||||
t.datetime "created_at", precision: 3, null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", precision: 3, null: false
|
||||
t.index ["deleted_at"], name: "idx_stores_deleted_at"
|
||||
t.index ["name"], name: "idx_stores_name"
|
||||
end
|
||||
|
||||
add_foreign_key "admin_users", "members"
|
||||
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
|
||||
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
|
||||
add_foreign_key "members", "images"
|
||||
add_foreign_key "pipelines", "softwares", name: "fk_pipelines_software", on_delete: :nullify
|
||||
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
|
||||
add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade
|
||||
add_foreign_key "software_images", "images"
|
||||
|
||||
@@ -15,3 +15,7 @@ end
|
||||
m.avatar_filename = attrs[:avatar_filename]
|
||||
end
|
||||
end
|
||||
|
||||
Store.find_or_create_by!(name: "Teletype Games") do |store|
|
||||
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Locales
|
||||
SUPPORTED = %w[en hu].freeze
|
||||
DEFAULT = "en"
|
||||
|
||||
def self.resolve(value)
|
||||
normalized = value.to_s.strip.downcase
|
||||
SUPPORTED.include?(normalized) ? normalized : DEFAULT
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace :admin_user do
|
||||
desc "Set an admin user password: rake admin_user:password EMAIL=... PASSWORD=..."
|
||||
task password: :environment do
|
||||
email = ENV["EMAIL"].to_s.strip
|
||||
password = ENV["PASSWORD"].to_s
|
||||
|
||||
if email.empty? || password.empty?
|
||||
abort "Használat: rake admin_user:password EMAIL=admin@teletype.hu PASSWORD=újjelszó"
|
||||
end
|
||||
|
||||
user = AdminUser.find_by(email: email)
|
||||
abort "Nincs ilyen admin user: #{email}" if user.nil?
|
||||
|
||||
user.password = password
|
||||
user.password_confirmation = password
|
||||
|
||||
if user.save
|
||||
puts "Jelszó frissítve: #{user.email}"
|
||||
else
|
||||
abort "Sikertelen: #{user.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
desc "List admin users"
|
||||
task list: :environment do
|
||||
AdminUser.order(:email).each { |u| puts "#{u.id}\t#{u.email}" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::StoresController, type: :request do
|
||||
describe "GET /api/stores" do
|
||||
it "lists stores without a login, in name order" do
|
||||
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
|
||||
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.map { |s| s["name"] }).to eq([ "Apex Games", "Zed Games" ])
|
||||
end
|
||||
|
||||
it "answers with the fields a client needs, camelCased" do
|
||||
create(:store)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
store = JSON.parse(response.body).first
|
||||
|
||||
expect(store.keys).to contain_exactly("name", "catalogUrl")
|
||||
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
|
||||
end
|
||||
|
||||
it "leaves out soft-deleted stores" do
|
||||
create(:store, name: "Gone").update!(deleted_at: Time.current)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
|
||||
it "leaves out inactive stores" do
|
||||
create(:store, name: "Listed")
|
||||
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body).map { |s| s["name"] }).to eq([ "Listed" ])
|
||||
end
|
||||
|
||||
it "still says nothing about the flag itself" do
|
||||
create(:store)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body).first.keys).to contain_exactly("name", "catalogUrl")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
FactoryBot.define do
|
||||
factory :store do
|
||||
name { "Teletype Games" }
|
||||
catalog_url { "https://teletypegames.org" }
|
||||
|
||||
trait :inactive do
|
||||
active { false }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Locales do
|
||||
describe ".resolve" do
|
||||
it "accepts the supported languages" do
|
||||
expect(Locales.resolve("en")).to eq("en")
|
||||
expect(Locales.resolve("hu")).to eq("hu")
|
||||
end
|
||||
|
||||
it "normalises case and whitespace" do
|
||||
expect(Locales.resolve(" HU ")).to eq("hu")
|
||||
end
|
||||
|
||||
it "falls back to the default for anything else" do
|
||||
expect(Locales.resolve("de")).to eq(Locales::DEFAULT)
|
||||
expect(Locales.resolve(nil)).to eq(Locales::DEFAULT)
|
||||
expect(Locales.resolve("")).to eq(Locales::DEFAULT)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ RSpec.describe Event, type: :model do
|
||||
future = create(:event, date: 1.week.from_now)
|
||||
create(:event, date: 1.week.ago)
|
||||
|
||||
expect(Event.upcoming).to eq([future])
|
||||
expect(Event.upcoming).to eq([ future ])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,6 @@ RSpec.describe Member, type: :model do
|
||||
subject { build(:member) }
|
||||
|
||||
it { should validate_presence_of(:nick) }
|
||||
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
|
||||
|
||||
it { should validate_uniqueness_of(:nick).case_insensitive }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Store, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:catalog_url) }
|
||||
|
||||
it "rejects a catalog url that is not http(s)" do
|
||||
store = build(:store, catalog_url: "git@example.org:thing.git")
|
||||
expect(store).not_to be_valid
|
||||
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
|
||||
end
|
||||
|
||||
describe ".ordered" do
|
||||
it "lists stores by name" do
|
||||
later = create(:store, name: "Zed Games")
|
||||
first = create(:store, name: "Apex Games")
|
||||
|
||||
expect(Store.ordered).to eq([ first, later ])
|
||||
end
|
||||
end
|
||||
|
||||
it "hides soft-deleted stores" do
|
||||
store = create(:store)
|
||||
store.update!(deleted_at: Time.current)
|
||||
|
||||
expect(Store.all).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e
|
||||
abort e.to_s.strip
|
||||
end
|
||||
|
||||
# A katalógus-factory-k (software, release, download, platform_link) az engine-ben élnek
|
||||
FactoryBot.definition_file_paths << WarpEngine::Engine.root.join("spec/factories")
|
||||
FactoryBot.reload
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
require "tmpdir"
|
||||
|
||||
RSpec.describe "Admin file manager", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "files-spec@example.org", password: "password123") }
|
||||
let(:container) { Dir.mktmpdir("files-spec") }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(File.join(container, "mygame-1.0"))
|
||||
File.write(File.join(container, "mygame-1.0.zip"), "zipdata")
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(container)
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after do
|
||||
Warden.test_reset!
|
||||
FileUtils.rm_rf(container)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "lists the artifact directory with an icon per entry" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
expect(response.body).to include(WarpEngine::FileIcon.for("mygame-1.0.zip"))
|
||||
expect(response.body).to include(WarpEngine::FileIcon::DIRECTORY)
|
||||
end
|
||||
|
||||
it "puts the file name into the rename and delete prompts" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("prompt('New name:','mygame-1.0.zip')")
|
||||
expect(response.body).to include("Delete \\'mygame-1.0.zip\\'")
|
||||
end
|
||||
|
||||
it "shows the download count next to a file that was downloaded" do
|
||||
create(:download, file_path: "mygame-1.0.zip")
|
||||
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
end
|
||||
|
||||
it "creates a folder and returns to the directory it was created in" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "docs" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.directory?(File.join(container, "mygame-1.0", "docs"))).to be(true)
|
||||
end
|
||||
|
||||
it "reports a failed folder creation instead of raising" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(flash[:alert]).to include("Failed")
|
||||
end
|
||||
|
||||
it "deletes a file and returns to its parent directory" do
|
||||
File.write(File.join(container, "mygame-1.0", "readme.txt"), "hi")
|
||||
|
||||
delete "/admin/files/delete", params: { path: "mygame-1.0/readme.txt" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.exist?(File.join(container, "mygame-1.0", "readme.txt"))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin images", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "images-spec@example.org", password: "password123") }
|
||||
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(upload_path)
|
||||
allow(Image).to receive(:upload_path).and_return(upload_path)
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after do
|
||||
Warden.test_reset!
|
||||
FileUtils.rm_rf(upload_path)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
def stored_image(name)
|
||||
image = create(:image, filename: name, original_filename: name)
|
||||
File.write(image.file_path, "not really a png")
|
||||
image
|
||||
end
|
||||
|
||||
it "lists an image with a preview" do
|
||||
image = stored_image("cover.png")
|
||||
|
||||
get "/admin/images"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("cover.png")
|
||||
expect(response.body).to include("/api/image/#{image.id}")
|
||||
end
|
||||
|
||||
it "says which software uses an image" do
|
||||
image = stored_image("used.png")
|
||||
software = create(:software)
|
||||
WarpEngine::SoftwareImage.create!(software: software, image_id: image.id, is_default: true)
|
||||
|
||||
get "/admin/images", params: { scope: "in_use" }
|
||||
|
||||
expect(response.body).to include("used.png")
|
||||
expect(response.body).to include("1 software(s)")
|
||||
end
|
||||
|
||||
it "says when a member uses an image" do
|
||||
image = stored_image("avatar.png")
|
||||
Member.create!(nick: "mr.zero", real_nick: "Zero", image_id: image.id)
|
||||
|
||||
get "/admin/images", params: { scope: "in_use" }
|
||||
|
||||
expect(response.body).to include("avatar.png")
|
||||
expect(response.body).to include("member")
|
||||
end
|
||||
|
||||
it "leaves an unused image in the orphan scope" do
|
||||
stored_image("orphan.png")
|
||||
|
||||
get "/admin/images", params: { scope: "orphan" }
|
||||
|
||||
expect(response.body).to include("orphan.png")
|
||||
end
|
||||
|
||||
it "deletes an orphan with its file" do
|
||||
orphan = stored_image("gone.png")
|
||||
kept = stored_image("kept.png")
|
||||
Member.create!(nick: "keeper", real_nick: "Keeper", image_id: kept.id)
|
||||
|
||||
post "/admin/images/batch_action",
|
||||
params: { batch_action: "delete_orphans", collection_selection: [ orphan.id, kept.id ] }
|
||||
|
||||
expect(Image.where(id: orphan.id)).to be_empty
|
||||
expect(File.exist?(orphan.file_path)).to be(false)
|
||||
expect(Image.where(id: kept.id)).to be_present
|
||||
expect(File.exist?(kept.file_path)).to be(true)
|
||||
end
|
||||
|
||||
it "opens an image's own page" do
|
||||
image = stored_image("single.png")
|
||||
|
||||
get "/admin/images/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("single.png")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin pipelines", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "pipelines-spec@example.org", password: "password123") }
|
||||
let(:software) { create(:software) }
|
||||
let!(:holder) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_001, repo_owner: "spec", repo_name: "holder",
|
||||
platform: "tic80", software: software)
|
||||
end
|
||||
let!(:taker) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_002, repo_owner: "spec", repo_name: "taker",
|
||||
platform: "tic80")
|
||||
end
|
||||
|
||||
before do
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "saves the form" do
|
||||
put "/admin/pipelines/#{taker.id}", params: { pipeline: { platform: "godot" } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.platform).to eq("godot")
|
||||
end
|
||||
|
||||
it "moves the software off the pipeline that had it" do
|
||||
put "/admin/pipelines/#{taker.id}",
|
||||
params: { pipeline: { platform: "tic80", software_id: software.id } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.software).to eq(software)
|
||||
expect(holder.reload.software).to be_nil
|
||||
expect(software.reload.pipeline).to eq(taker)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin stores", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "stores-spec@example.org", password: "password123") }
|
||||
let!(:listed) { create(:store, name: "Listed", catalog_url: "https://listed.example") }
|
||||
let!(:hidden) { create(:store, :inactive, name: "Hidden", catalog_url: "https://hidden.example") }
|
||||
|
||||
before do
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "opens with the active ones" do
|
||||
get "/admin/stores"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Listed")
|
||||
expect(response.body).not_to include("Hidden")
|
||||
end
|
||||
|
||||
it "shows the hidden ones under their own scope" do
|
||||
get "/admin/stores", params: { scope: "inactive" }
|
||||
|
||||
expect(response.body).to include("Hidden")
|
||||
end
|
||||
|
||||
it "renders a store's page" do
|
||||
get "/admin/stores/#{hidden.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "saves the flag from the form" do
|
||||
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(listed.reload).not_to be_active
|
||||
end
|
||||
|
||||
it "toggles from the index in one click" do
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(listed.reload).not_to be_active
|
||||
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
expect(listed.reload).to be_active
|
||||
end
|
||||
|
||||
it "lists and hides in bulk" do
|
||||
post "/admin/stores/batch_action",
|
||||
params: { batch_action: "list", collection_selection: [ hidden.id ] }
|
||||
|
||||
expect(hidden.reload).to be_active
|
||||
|
||||
post "/admin/stores/batch_action",
|
||||
params: { batch_action: "hide", collection_selection: [ hidden.id ] }
|
||||
|
||||
expect(hidden.reload).not_to be_active
|
||||
end
|
||||
|
||||
it "is what /api/stores answers with" do
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "GET /api/image/:id", type: :request do
|
||||
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(upload_path)
|
||||
allow(Image).to receive(:upload_path).and_return(upload_path)
|
||||
end
|
||||
|
||||
after { FileUtils.rm_rf(upload_path) }
|
||||
|
||||
it "serves the file with the content type it was stored with" do
|
||||
image = create(:image, filename: "cover.png", content_type: "image/png")
|
||||
File.write(image.file_path, "not really a png")
|
||||
|
||||
get "/api/image/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq("image/png")
|
||||
expect(response.body).to eq("not really a png")
|
||||
end
|
||||
|
||||
it "answers 404 for an image that does not exist" do
|
||||
get "/api/image/999999"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "errors, as it always has, when the row is there but the file is not" do
|
||||
image = create(:image, filename: "missing.png")
|
||||
|
||||
get "/api/image/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Api::Wiki", type: :request do
|
||||
let(:service) { instance_double(Wiki::Pages) }
|
||||
|
||||
before do
|
||||
host! "teletypegames.org"
|
||||
allow(Wiki::Pages).to receive(:new).and_return(service)
|
||||
end
|
||||
|
||||
it "passes the requested language through to the wiki service" do
|
||||
expect(service).to receive(:fetch)
|
||||
.with(tag: "howto", limit: nil, body: nil, lang: "hu")
|
||||
.and_return({ "tag" => "howto", "lang" => "hu", "count" => 0, "pages" => [] })
|
||||
|
||||
get "/api/wiki/pages", params: { tag: "howto", lang: "hu" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body["lang"]).to eq("hu")
|
||||
end
|
||||
|
||||
it "leaves the fallback to the service when no language is asked for" do
|
||||
expect(service).to receive(:fetch)
|
||||
.with(tag: "howto", limit: nil, body: nil, lang: nil)
|
||||
.and_return({ "tag" => "howto", "lang" => "en", "count" => 0, "pages" => [] })
|
||||
|
||||
get "/api/wiki/pages", params: { tag: "howto" }
|
||||
|
||||
expect(response.parsed_body["lang"]).to eq("en")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Wiki::Pages do
|
||||
let(:requested_paths) { [] }
|
||||
|
||||
before do
|
||||
http = instance_double(Net::HTTP)
|
||||
response = Net::HTTPOK.new("1.1", "200", "OK")
|
||||
allow(response).to receive(:body).and_return({ tag: "howto", lang: "en", count: 0, pages: [] }.to_json)
|
||||
allow(http).to receive(:get) do |path|
|
||||
requested_paths << path
|
||||
response
|
||||
end
|
||||
allow(Net::HTTP).to receive(:start) { |*_args, **_opts, &block| block.call(http) }
|
||||
end
|
||||
|
||||
describe "#fetch" do
|
||||
it "requests the default language without a path prefix" do
|
||||
described_class.new.fetch(tag: "howto")
|
||||
|
||||
expect(requested_paths.first).to start_with("/custom/pages.json")
|
||||
end
|
||||
|
||||
it "prefixes the path with the requested language" do
|
||||
described_class.new.fetch(tag: "howto", lang: "hu")
|
||||
|
||||
expect(requested_paths.first).to start_with("/hu/custom/pages.json")
|
||||
end
|
||||
|
||||
it "falls back to the default language for an unsupported one" do
|
||||
described_class.new.fetch(tag: "howto", lang: "de")
|
||||
|
||||
expect(requested_paths.first).to start_with("/custom/pages.json")
|
||||
end
|
||||
|
||||
it "reports the resolved language when grav is unreachable" do
|
||||
allow(Net::HTTP).to receive(:start).and_raise(Errno::ECONNREFUSED)
|
||||
|
||||
result = described_class.new.fetch(tag: "howto", lang: "hu")
|
||||
|
||||
expect(result["lang"]).to eq("hu")
|
||||
expect(result["pages"]).to eq([])
|
||||
expect(result["error"]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,15 @@
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Teletype Games</title>
|
||||
<script>
|
||||
(function () {
|
||||
var m = document.cookie.match(/(?:^|; )theme=([^;]*)/)
|
||||
var theme = m && decodeURIComponent(m[1]) === 'light' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
var r = document.cookie.match(/(?:^|; )retro-mode=([^;]*)/)
|
||||
document.documentElement.setAttribute('data-retro', r && r[1] === '1' ? '1' : '0')
|
||||
})()
|
||||
</script>
|
||||
<link rel="alternate" type="application/rss+xml" title="Teletype Games Blog" href="/api/rss/blog" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Teletype Games Releases" href="/api/rss/releases" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Teletype Games HowTos" href="/api/rss/howtos" />
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.2.0",
|
||||
"tuicss": "^2.1.2",
|
||||
"vue": "^3.5.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { i18n } from '../../i18n'
|
||||
import { WIKI_BASE, wikiUrl } from '../wiki.api'
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = 'en'
|
||||
})
|
||||
|
||||
describe('wikiUrl', () => {
|
||||
it('leaves English unprefixed', () => {
|
||||
expect(wikiUrl('development/godot', 'en')).toBe(`${WIKI_BASE}/development/godot`)
|
||||
})
|
||||
|
||||
it('prefixes Hungarian with /hu', () => {
|
||||
expect(wikiUrl('development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
|
||||
it('accepts a route that already starts with a slash', () => {
|
||||
expect(wikiUrl('/development/godot', 'hu')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
|
||||
it('returns the wiki root when no page is given', () => {
|
||||
expect(wikiUrl('', 'hu')).toBe(`${WIKI_BASE}/hu`)
|
||||
expect(wikiUrl()).toBe(WIKI_BASE)
|
||||
})
|
||||
|
||||
it('falls back to English for an unsupported language', () => {
|
||||
expect(wikiUrl('development/godot', 'de')).toBe(`${WIKI_BASE}/development/godot`)
|
||||
})
|
||||
|
||||
it('follows the active interface language when none is passed', () => {
|
||||
i18n.global.locale.value = 'hu'
|
||||
expect(wikiUrl('development/godot')).toBe(`${WIKI_BASE}/hu/development/godot`)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/interfaces/wiki.interface'
|
||||
|
||||
import { CONFIG } from '../lib/config'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
// Public content base — used only for building external links to wiki pages.
|
||||
const WIKI_BASE = CONFIG.wikiBase
|
||||
|
||||
const WIKI_LOCALES = ['en', 'hu'] as const
|
||||
const DEFAULT_WIKI_LOCALE = 'en'
|
||||
|
||||
function resolveLocale(locale?: string): string {
|
||||
const value = (locale ?? String(i18n.global.locale.value)).toLowerCase()
|
||||
return (WIKI_LOCALES as readonly string[]).includes(value) ? value : DEFAULT_WIKI_LOCALE
|
||||
}
|
||||
|
||||
// Grav serves English unprefixed and Hungarian under /hu, so a page link is the
|
||||
// base plus the prefix plus the language-neutral route.
|
||||
function wikiUrl(path = '', locale?: string): string {
|
||||
const lang = resolveLocale(locale)
|
||||
const prefix = lang === DEFAULT_WIKI_LOCALE ? '' : `/${lang}`
|
||||
const route = path ? `/${path.replace(/^\/+/, '')}` : ''
|
||||
return `${WIKI_BASE}${prefix}${route}`
|
||||
}
|
||||
|
||||
interface RawWikiPage {
|
||||
id: number
|
||||
path: string
|
||||
@@ -16,13 +33,16 @@ interface RawWikiPage {
|
||||
createdAt: string
|
||||
locale: string
|
||||
repo?: string | null
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const HIGHLIGHTED_TAG = 'highlighted'
|
||||
|
||||
async function fetchPages(
|
||||
tag: string,
|
||||
opts: { body?: boolean; limit?: number } = {},
|
||||
opts: { body?: boolean; limit?: number; locale?: string } = {},
|
||||
): Promise<RawWikiPage[]> {
|
||||
const params = new URLSearchParams({ tag })
|
||||
const params = new URLSearchParams({ tag, lang: resolveLocale(opts.locale) })
|
||||
if (opts.body) params.set('body', '1')
|
||||
if (opts.limit) params.set('limit', String(opts.limit))
|
||||
|
||||
@@ -32,8 +52,8 @@ async function fetchPages(
|
||||
return json?.pages ?? []
|
||||
}
|
||||
|
||||
const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('blog', { body: true })
|
||||
const listBlogPages = async (locale?: string): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('blog', { body: true, locale })
|
||||
return pages.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
@@ -46,8 +66,8 @@ const listBlogPages = async (): Promise<WikiPageWithContent[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
const pages = await fetchPages('blog', { body: true })
|
||||
const getBlogPage = async (slug: string, locale?: string): Promise<WikiPageContent | null> => {
|
||||
const pages = await fetchPages('blog', { body: true, locale })
|
||||
|
||||
const matched = pages.find((p) => {
|
||||
const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path
|
||||
@@ -68,23 +88,25 @@ const getBlogPage = async (slug: string): Promise<WikiPageContent | null> => {
|
||||
}
|
||||
}
|
||||
|
||||
const listEnginePages = async (): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true })
|
||||
return pages.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
title: p.title || p.path,
|
||||
description: p.description ?? '',
|
||||
content: p.content ?? '',
|
||||
updatedAt: p.updatedAt,
|
||||
createdAt: p.createdAt,
|
||||
locale: p.locale,
|
||||
repo: p.repo ?? null,
|
||||
}))
|
||||
const listEnginePages = async (locale?: string): Promise<WikiPageWithContent[]> => {
|
||||
const pages = await fetchPages('engine', { body: true, locale })
|
||||
return pages
|
||||
.filter((p) => (p.tags ?? []).includes(HIGHLIGHTED_TAG))
|
||||
.map((p): WikiPageWithContent => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
title: p.title || p.path,
|
||||
description: p.description ?? '',
|
||||
content: p.content ?? '',
|
||||
updatedAt: p.updatedAt,
|
||||
createdAt: p.createdAt,
|
||||
locale: p.locale,
|
||||
repo: p.repo ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30 })
|
||||
const listHowtoPages = async (locale?: string): Promise<WikiPage[]> => {
|
||||
const pages = await fetchPages('howto', { limit: 30, locale })
|
||||
return pages.map((p): WikiPage => ({
|
||||
id: p.id,
|
||||
path: p.path,
|
||||
@@ -96,5 +118,5 @@ const listHowtoPages = async (): Promise<WikiPage[]> => {
|
||||
}))
|
||||
}
|
||||
|
||||
export { WIKI_BASE }
|
||||
export { WIKI_BASE, WIKI_LOCALES, DEFAULT_WIKI_LOCALE, wikiUrl }
|
||||
export default { listBlogPages, getBlogPage, listHowtoPages, listEnginePages }
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<section class="media-band">
|
||||
<header class="media-band-header">
|
||||
<h2 class="media-band-label">{{ label }}</h2>
|
||||
<slot name="action" />
|
||||
</header>
|
||||
<div class="media-band-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-band {
|
||||
@apply overflow-hidden rounded-card border border-line bg-surface;
|
||||
}
|
||||
.media-band-header {
|
||||
@apply flex flex-wrap items-baseline justify-between gap-3 border-b border-line px-5 py-3 md:px-6;
|
||||
}
|
||||
.media-band-label {
|
||||
@apply font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
|
||||
}
|
||||
.media-band-body {
|
||||
@apply p-5 md:p-6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,11 @@
|
||||
<template>
|
||||
<span :class="badgeClass"><i :class="iconClass"></i> {{ platform }}</span>
|
||||
<span class="badge gap-1.5">
|
||||
<PlatformIcon :platform="platform" />{{ platform }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
|
||||
const props = defineProps<{ platform: string }>()
|
||||
|
||||
const knownPlatforms = ['tic80', 'ebitengine', 'love', 'godot']
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
tic80: 'fa-solid fa-tv',
|
||||
ebitengine: 'fa-brands fa-golang',
|
||||
love: 'fa-solid fa-heart',
|
||||
godot: 'fa-solid fa-robot',
|
||||
other: 'fa-solid fa-cube',
|
||||
}
|
||||
|
||||
const platformKey = computed(() => {
|
||||
const key = props.platform.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return knownPlatforms.includes(key) ? key : 'other'
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => `platform-badge platform-${platformKey.value}`)
|
||||
const iconClass = computed(() => platformIcons[platformKey.value])
|
||||
defineProps<{ platform: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-badge {
|
||||
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
|
||||
}
|
||||
.platform-tic80 { @apply bg-cyan-100 text-cyan-800 border border-cyan-200; }
|
||||
.platform-ebitengine { @apply bg-red-100 text-red-800 border border-red-200; }
|
||||
.platform-love { @apply bg-pink-100 text-pink-800 border border-pink-200; }
|
||||
.platform-godot { @apply bg-sky-100 text-sky-800 border border-sky-200; }
|
||||
.platform-other { @apply bg-purple-100 text-purple-800 border border-purple-200; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<BrandIcon v-if="brand" :name="brand" />
|
||||
<component :is="glyph" v-else v-bind="ICON" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { Bot, Box, Boxes, Heart, Save, Tv, Zap } from 'lucide-vue-next'
|
||||
import BrandIcon from './icons/BrandIcon.vue'
|
||||
import { ICON } from './icons'
|
||||
|
||||
const props = defineProps<{ platform: string }>()
|
||||
|
||||
const GLYPHS: Record<string, Component> = {
|
||||
tic80: Tv,
|
||||
love: Heart,
|
||||
godot: Bot,
|
||||
bevy: Boxes,
|
||||
phaser: Zap,
|
||||
c64: Save,
|
||||
}
|
||||
|
||||
const key = computed(() => props.platform.toLowerCase().replace(/[^a-z0-9]/g, ''))
|
||||
const brand = computed(() => (key.value === 'ebitengine' ? 'go' : null))
|
||||
const glyph = computed(() => GLYPHS[key.value] ?? Box)
|
||||
</script>
|
||||
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<div class="animate-pulse">
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div v-for="n in count" :key="n" class="skeleton-card">
|
||||
<div class="skeleton-card-inner">
|
||||
<div class="skeleton-line w-1/4 mb-4"></div>
|
||||
<div class="skeleton-line-lg w-3/4 mb-3"></div>
|
||||
<div class="skeleton-line w-full mb-2"></div>
|
||||
<div class="skeleton-line w-5/6"></div>
|
||||
</div>
|
||||
<div class="skeleton-line w-1/4"></div>
|
||||
<div class="skeleton-line-lg w-3/4"></div>
|
||||
<div class="skeleton-line w-full"></div>
|
||||
<div class="skeleton-line w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,15 +15,12 @@ withDefaults(defineProps<{ count?: number }>(), { count: 3 })
|
||||
|
||||
<style scoped>
|
||||
.skeleton-card {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6;
|
||||
}
|
||||
.skeleton-card-inner {
|
||||
@apply p-6 md:p-8;
|
||||
@apply flex flex-col gap-3 rounded-card border border-line bg-surface p-5 md:p-6;
|
||||
}
|
||||
.skeleton-line {
|
||||
@apply h-4 bg-gray-200 rounded;
|
||||
@apply h-4 rounded bg-raised;
|
||||
}
|
||||
.skeleton-line-lg {
|
||||
@apply h-6 bg-gray-200 rounded;
|
||||
@apply h-6 rounded bg-raised;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlatformBadge from '../PlatformBadge.vue'
|
||||
import { statusBadgeClass } from '../../lib/softwareUtils'
|
||||
|
||||
describe('PlatformBadge', () => {
|
||||
it('shows the platform name next to its own glyph', () => {
|
||||
const wrapper = mount(PlatformBadge, { props: { platform: 'TIC-80' } })
|
||||
expect(wrapper.text()).toContain('TIC-80')
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to a glyph for a platform it has never seen', () => {
|
||||
const wrapper = mount(PlatformBadge, { props: { platform: 'Amiga' } })
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusBadgeClass', () => {
|
||||
it('gives every live status its own hue and leaves the archived ones neutral', () => {
|
||||
expect(statusBadgeClass('released')).toContain('status-released')
|
||||
expect(statusBadgeClass('demo')).toContain('status-demo')
|
||||
expect(statusBadgeClass('development')).toContain('status-development')
|
||||
expect(statusBadgeClass('archived')).toBe('status-badge')
|
||||
expect(statusBadgeClass('whatever')).toBe('status-badge')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24"
|
||||
fill="currentColor" :aria-hidden="label ? undefined : 'true'"
|
||||
:role="label ? 'img' : undefined" :aria-label="label || undefined"
|
||||
class="inline-block shrink-0 align-[-0.125em]"
|
||||
>
|
||||
<path v-for="(d, i) in paths" :key="i" :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BRAND_PATHS } from './brandPaths'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number | string
|
||||
label?: string
|
||||
}>(), { size: 16, label: undefined })
|
||||
|
||||
const paths = computed(() => BRAND_PATHS[props.name] ?? [])
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ICON = { size: 16, strokeWidth: 1.75 } as const
|
||||
|
||||
export const ICON_CONTROL = { size: 20, strokeWidth: 1.75 } as const
|
||||
|
||||
export { default as BrandIcon } from './BrandIcon.vue'
|
||||
export { BRAND_PATHS } from './brandPaths'
|
||||
export type { BrandName } from './brandPaths'
|
||||
@@ -36,7 +36,7 @@ describe('useLoadable', () => {
|
||||
|
||||
it('re-executes after TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { withCache } = useLoadable(100) // 100ms TTL
|
||||
const { withCache } = useLoadable(100)
|
||||
|
||||
const fn = vi.fn()
|
||||
await withCache(fn)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
|
||||
const DEFAULT_TTL = 15 * 60 * 1000
|
||||
|
||||
export function useLoadable(ttlMs?: number) {
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { watch } from 'vue'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
// Wiki content is fetched per language, so anything cached from the wiki has to
|
||||
// be dropped and re-read when the visitor switches EN/HU.
|
||||
export function useLocaleReload(reload: () => void | Promise<void>) {
|
||||
watch(
|
||||
() => i18n.global.locale.value,
|
||||
() => {
|
||||
void reload()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '../locales/en'
|
||||
import hu from '../locales/hu'
|
||||
|
||||
function paths (value: unknown, prefix = ''): string[] {
|
||||
if (typeof value !== 'object' || value === null) return [prefix]
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.flatMap(([key, nested]) => paths(nested, prefix === '' ? key : `${prefix}.${key}`))
|
||||
}
|
||||
|
||||
describe('locale bundles', () => {
|
||||
const english = paths(en).sort()
|
||||
const hungarian = paths(hu).sort()
|
||||
|
||||
it('has something to compare', () => {
|
||||
expect(english.length).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('translates every English key into Hungarian', () => {
|
||||
expect(hungarian.filter((key) => !english.includes(key))).toEqual([])
|
||||
expect(english.filter((key) => !hungarian.includes(key))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves no empty string behind', () => {
|
||||
const empty = [...paths(en), ...paths(hu)].filter((key) => {
|
||||
const read = (bundle: unknown) =>
|
||||
key.split('.').reduce<unknown>((acc, part) =>
|
||||
(acc as Record<string, unknown> | undefined)?.[part], bundle)
|
||||
return read(en) === '' || read(hu) === ''
|
||||
})
|
||||
expect(empty).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,17 @@ export default {
|
||||
retro: 'Retro Mode',
|
||||
exitRetro: 'Exit Retro Mode',
|
||||
rss: 'RSS',
|
||||
stores: 'Stores',
|
||||
builds: 'Builds',
|
||||
theme: 'Switch theme',
|
||||
menu: 'Menu',
|
||||
},
|
||||
footer: {
|
||||
wiki: 'Wiki',
|
||||
git: 'Git repositories',
|
||||
releases: 'Releases',
|
||||
license: 'All content is available under the',
|
||||
tagline: 'Built in the open — we love open source, and the freedom to learn, fork and share.',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -30,36 +41,28 @@ export default {
|
||||
startsOn: 'Starts on:',
|
||||
upcomingEvents: 'Upcoming Events',
|
||||
featuredGame: 'Featured Game',
|
||||
playInBrowser: '▶ Play in Browser',
|
||||
playInBrowser: 'Play in Browser',
|
||||
viewProjectDetails: 'View Project Details',
|
||||
latestFromYoutube: 'Latest from YouTube',
|
||||
visitChannel: 'Visit Channel ↗',
|
||||
newRelease: 'New Release',
|
||||
watchOnYoutube: 'Watch on YouTube',
|
||||
note: 'Our HowTo-s and blog posts are written by AI with human supervision.',
|
||||
catalog: {
|
||||
title: 'Catalog',
|
||||
desc: 'Here you can find our games. Discover our creations for TIC-80 and other platforms, many of which you can try directly in your browser.',
|
||||
btn: 'Browse Games',
|
||||
},
|
||||
wiki: {
|
||||
title: 'Wiki',
|
||||
desc: 'We gather our public interest development knowledge here. Read through technical descriptions and development logs.',
|
||||
btn: 'Knowledge Base',
|
||||
},
|
||||
git: {
|
||||
title: 'Git Repos',
|
||||
desc: 'All our projects are open source. Browse our code, study our solutions, or even participate in the development.',
|
||||
btn: 'Gitea',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
desc: 'Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.',
|
||||
btn: 'Watch on YouTube',
|
||||
},
|
||||
spotify: {
|
||||
openInSpotify: 'Open in Spotify ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Play on your own machine',
|
||||
allStores: 'All stores',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'Our catalog as a grid of cards on your desktop: one click installs a game into your application menu, one plays it. It sets itself up on first run.',
|
||||
point1: 'Nothing to install first — no interpreter, no admin rights',
|
||||
point2: 'Installed games become normal menu entries, so they start without the app running',
|
||||
point3: 'Browser builds too, for titles with no download for your machine',
|
||||
download: 'Download',
|
||||
more: 'How it works',
|
||||
platforms: 'Linux, macOS and Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
@@ -99,6 +102,8 @@ export default {
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
back: 'Back to the blog',
|
||||
noContent: 'No content available for this post.',
|
||||
badge: 'Community Stories',
|
||||
title: 'Developer Blog',
|
||||
subtitle: 'News, updates, and behind-the-scenes stories from the Teletype Games community.',
|
||||
@@ -109,6 +114,7 @@ export default {
|
||||
readMore: 'Read more',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Get the store',
|
||||
title: 'Our Games',
|
||||
subtitle: 'Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!',
|
||||
filter: {
|
||||
@@ -126,6 +132,9 @@ export default {
|
||||
},
|
||||
catalogShow: {
|
||||
back: 'Back to Catalog',
|
||||
close: 'Close',
|
||||
prevImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
projectInfo: 'Project Info',
|
||||
author: 'Author',
|
||||
platform: 'Platform',
|
||||
@@ -133,7 +142,7 @@ export default {
|
||||
about: 'About',
|
||||
latestStable: 'Latest Stable',
|
||||
released: 'Released:',
|
||||
playNow: '▶ Play Now',
|
||||
playNow: 'Play Now',
|
||||
download: 'Download',
|
||||
source: 'Source',
|
||||
docs: 'Docs',
|
||||
@@ -160,6 +169,7 @@ export default {
|
||||
win_x64: 'Windows (64-bit)',
|
||||
linux_x86: 'Linux (32-bit)',
|
||||
linux_x64: 'Linux (64-bit)',
|
||||
linux_arm64: 'Linux (ARM 64-bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -197,6 +207,37 @@ export default {
|
||||
title: 'Build Matrix',
|
||||
subtitle: 'Which engines build for which platforms.',
|
||||
},
|
||||
stores: {
|
||||
title: 'Stores',
|
||||
subtitle: 'Our games on your own machine, kept up to date.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Others',
|
||||
|
||||
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
|
||||
clientDownload: 'Download',
|
||||
clientPlatforms: 'Linux, macOS and Windows · nothing to install first',
|
||||
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
|
||||
|
||||
docs: 'Documentation',
|
||||
repo: 'Git repository',
|
||||
installTitle: 'Install',
|
||||
installThen: 'Then',
|
||||
useTitle: 'Use it',
|
||||
uninstallTitle: 'Remove it',
|
||||
platformsTitle: 'What gets installed',
|
||||
copy: 'Copy',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera project',
|
||||
tagline: 'The plug-and-play retro distribution',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Anything that runs RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Codebase',
|
||||
subtitle: 'Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.',
|
||||
|
||||
@@ -11,6 +11,17 @@ export default {
|
||||
retro: 'Retró mód',
|
||||
exitRetro: 'Retró mód kikapcsolása',
|
||||
rss: 'RSS',
|
||||
stores: 'Boltok',
|
||||
builds: 'Buildek',
|
||||
theme: 'Téma váltása',
|
||||
menu: 'Menü',
|
||||
},
|
||||
footer: {
|
||||
wiki: 'Wiki',
|
||||
git: 'Git tárolók',
|
||||
releases: 'Kiadások',
|
||||
license: 'A tartalom elérhető',
|
||||
tagline: 'Nyíltan fejlesztünk — szeretjük a nyílt forráskódot, és a szabadságot, hogy tanulni, forkolni és megosztani lehessen.',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -30,36 +41,28 @@ export default {
|
||||
startsOn: 'Kezdete:',
|
||||
upcomingEvents: 'Közelgő események',
|
||||
featuredGame: 'Kiemelt játék',
|
||||
playInBrowser: '▶ Játék böngészőben',
|
||||
playInBrowser: 'Játék böngészőben',
|
||||
viewProjectDetails: 'Projekt részletei',
|
||||
latestFromYoutube: 'Legújabb YouTube-ról',
|
||||
visitChannel: 'Csatorna megtekintése ↗',
|
||||
newRelease: 'Új megjelenés',
|
||||
watchOnYoutube: 'Nézd meg YouTube-on',
|
||||
note: 'A HowTo-k és blogbejegyzések mesterséges intelligenciával íródtak, emberi felügyelet mellett.',
|
||||
catalog: {
|
||||
title: 'Katalógus',
|
||||
desc: 'Itt megtalálod a játékainkat. Fedezd fel alkotásainkat TIC-80-ra és más platformokra, amelyek közül sokat közvetlenül a böngésződben is kipróbálhatsz.',
|
||||
btn: 'Játékok böngészése',
|
||||
},
|
||||
wiki: {
|
||||
title: 'Wiki',
|
||||
desc: 'Itt gyűjtjük össze a közérdekű fejlesztési tudásunkat. Olvasd át a technikai leírásokat és fejlesztési naplókat.',
|
||||
btn: 'Tudásbázis',
|
||||
},
|
||||
git: {
|
||||
title: 'Git tárolók',
|
||||
desc: 'Minden projektünk nyílt forráskódú. Böngészd a kódunkat, tanulmányozd a megoldásainkat, vagy akár részt is vehetsz a fejlesztésben.',
|
||||
btn: 'Gitea',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
desc: 'Nézd meg fejlesztési vlogjainkat, gameplay videóinkat és tutorialainkat a hivatalos YouTube-csatornánkon.',
|
||||
btn: 'Nézd meg YouTube-on',
|
||||
},
|
||||
spotify: {
|
||||
openInSpotify: 'Megnyitás Spotify-ban ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Játssz a saját gépeden',
|
||||
allStores: 'Összes bolt',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'A katalógusunk kártyákban, az asztalodon: egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első futáskor magát is beállítja.',
|
||||
point1: 'Semmit nem kell előtte feltenni — se értelmező, se rendszergazdai jog',
|
||||
point2: 'A feltelepített játék rendes menüelem lesz, tehát az app nélkül is indul',
|
||||
point3: 'Böngészős buildek is, ha egy címhez nincs letöltés a gépedre',
|
||||
download: 'Letöltés',
|
||||
more: 'Hogyan működik',
|
||||
platforms: 'Linux, macOS és Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Kapcsolat',
|
||||
@@ -99,6 +102,8 @@ export default {
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
back: 'Vissza a bloghoz',
|
||||
noContent: 'Ehhez a bejegyzéshez nincs tartalom.',
|
||||
badge: 'Közösségi történetek',
|
||||
title: 'Fejlesztői Blog',
|
||||
subtitle: 'Hírek, frissítések és kulisszák mögötti történetek a Teletype Games közösségből.',
|
||||
@@ -109,6 +114,7 @@ export default {
|
||||
readMore: 'Tovább olvasom',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Töltsd le a boltot',
|
||||
title: 'Játékaink',
|
||||
subtitle: 'Fedezd fel a TIC-80-ra és más platformokra készített játékainkat, amelyeknek közvetlenül a böngésződben is kipróbálhatsz verzióit!',
|
||||
filter: {
|
||||
@@ -120,12 +126,15 @@ export default {
|
||||
},
|
||||
author: 'Szerző:',
|
||||
platform: 'Platform:',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
moreInfo: 'Részletek',
|
||||
downloads: 'letöltés',
|
||||
},
|
||||
catalogShow: {
|
||||
back: '← Vissza a katalógushoz',
|
||||
close: 'Bezárás',
|
||||
prevImage: 'Előző kép',
|
||||
nextImage: 'Következő kép',
|
||||
projectInfo: 'Projekt infó',
|
||||
author: 'Szerző',
|
||||
platform: 'Platform',
|
||||
@@ -133,7 +142,7 @@ export default {
|
||||
about: 'Leírás',
|
||||
latestStable: 'Legújabb stabil',
|
||||
released: 'Kiadva:',
|
||||
playNow: '▶ Játék most',
|
||||
playNow: 'Játék most',
|
||||
download: 'Letöltés',
|
||||
source: 'Forráskód',
|
||||
docs: 'Dokumentáció',
|
||||
@@ -144,7 +153,7 @@ export default {
|
||||
notFound: 'A játék nem található',
|
||||
failedToLoad: 'Nem sikerült betölteni:',
|
||||
loading: 'Betöltés...',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
downloads: 'letöltés',
|
||||
links: 'Linkek',
|
||||
platformLinks: 'Platform linkek',
|
||||
@@ -160,6 +169,7 @@ export default {
|
||||
win_x64: 'Windows (64 bit)',
|
||||
linux_x86: 'Linux (32 bit)',
|
||||
linux_x64: 'Linux (64 bit)',
|
||||
linux_arm64: 'Linux (ARM 64 bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -197,6 +207,37 @@ export default {
|
||||
title: 'Build mátrix',
|
||||
subtitle: 'Melyik engine melyik platformra fordít.',
|
||||
},
|
||||
stores: {
|
||||
title: 'Store-ok',
|
||||
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Egyéb',
|
||||
|
||||
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
|
||||
clientDownload: 'Letöltés',
|
||||
clientPlatforms: 'Linux, macOS és Windows · semmit nem kell előtte feltenni',
|
||||
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
|
||||
|
||||
docs: 'Dokumentáció',
|
||||
repo: 'Git repó',
|
||||
installTitle: 'Telepítés',
|
||||
installThen: 'Utána',
|
||||
useTitle: 'Használat',
|
||||
uninstallTitle: 'Eltávolítás',
|
||||
platformsTitle: 'Mi kerül fel',
|
||||
copy: 'Másolás',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera projekt',
|
||||
tagline: 'A dugd-be-és-megy retro disztró',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Bármi, amin fut a RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Kódbázis',
|
||||
subtitle: 'Fedezd fel nyílt forráskódú projektek gyűjteményét, tanulmányozd forráskódunkat, és járulj hozzá független játékfejlesztő eszközeinkhez.',
|
||||
|
||||
@@ -1,76 +1,82 @@
|
||||
<template>
|
||||
<div :class="{ 'retro-mode': isRetroMode }" class="app-root">
|
||||
<header class="app-header">
|
||||
<RouterLink to="/" class="app-logo">Teletype Games</RouterLink>
|
||||
<div class="app-header-inner">
|
||||
<RouterLink to="/" class="app-logo">Teletype Games</RouterLink>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="nav-desktop">
|
||||
<RouterLink to="/" class="nav-link"><i class="fa-solid fa-house nav-icon"></i>{{ t('nav.home') }}</RouterLink>
|
||||
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
<a v-if="isAdmin" href="/admin" class="nav-link-admin"><i class="fa-solid fa-wrench nav-icon"></i>Admin</a>
|
||||
<nav class="nav-desktop">
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="nav-link">
|
||||
{{ t(item.label) }}
|
||||
</RouterLink>
|
||||
<a v-if="isAdmin" href="/admin" class="nav-link">Admin</a>
|
||||
</nav>
|
||||
|
||||
<div class="locale-switcher">
|
||||
<i class="fa-solid fa-globe text-gray-500 text-xs"></i>
|
||||
<button :class="locale === 'en' ? 'text-white' : 'text-gray-400 hover:text-white'" class="locale-btn" @click="switchLocale('en')">EN</button>
|
||||
<span class="locale-separator">|</span>
|
||||
<button :class="locale === 'hu' ? 'text-white' : 'text-gray-400 hover:text-white'" class="locale-btn" @click="switchLocale('hu')">HU</button>
|
||||
<div class="header-tools">
|
||||
<div class="locale-switcher">
|
||||
<button :class="localeBtnClass('en')" class="locale-btn" @click="switchLocale('en')">EN</button>
|
||||
<span class="locale-separator">/</span>
|
||||
<button :class="localeBtnClass('hu')" class="locale-btn" @click="switchLocale('hu')">HU</button>
|
||||
</div>
|
||||
|
||||
<button v-if="!isRetroMode" class="icon-btn" :aria-label="t('nav.theme')" :title="t('nav.theme')" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" v-bind="ICON_CONTROL" />
|
||||
<Moon v-else v-bind="ICON_CONTROL" />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn hamburger-btn" :aria-label="t('nav.menu')" :aria-expanded="menuOpen" @click="toggleMenu">
|
||||
<X v-if="menuOpen" v-bind="ICON_CONTROL" />
|
||||
<Menu v-else v-bind="ICON_CONTROL" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger Button (Mobile only) -->
|
||||
<button class="hamburger-btn" @click="toggleMenu">
|
||||
<i class="fa-solid fa-bars text-xl"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<nav v-show="menuOpen" class="nav-mobile">
|
||||
<RouterLink to="/" class="nav-mobile-link"><i class="fa-solid fa-house nav-icon"></i>{{ t('nav.home') }}</RouterLink>
|
||||
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
|
||||
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
|
||||
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
|
||||
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
|
||||
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
|
||||
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
|
||||
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
|
||||
<a v-if="isAdmin" href="/admin" class="nav-mobile-link"><i class="fa-solid fa-wrench nav-icon"></i>Admin</a>
|
||||
<div class="locale-switcher-mobile">
|
||||
<button :class="locale === 'en' ? 'text-white' : 'text-gray-400'" class="locale-btn-mobile" @click="switchLocale('en')">EN</button>
|
||||
<span class="locale-separator">|</span>
|
||||
<button :class="locale === 'hu' ? 'text-white' : 'text-gray-400'" class="locale-btn-mobile" @click="switchLocale('hu')">HU</button>
|
||||
</div>
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="nav-mobile-link">
|
||||
{{ t(item.label) }}
|
||||
</RouterLink>
|
||||
<a v-if="isAdmin" href="/admin" class="nav-mobile-link">Admin</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main class="app-main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<div class="retro-toggle-bar">
|
||||
<div class="retro-toggle-content">
|
||||
<button class="retro-toggle-btn" @click="toggleRetroMode">
|
||||
<i class="fa-solid fa-desktop"></i> {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
|
||||
</button>
|
||||
<div class="rss-links">
|
||||
<a href="/api/rss/blog" target="_blank" class="rss-link" title="Blog RSS">
|
||||
<i class="fa-solid fa-rss"></i> Blog
|
||||
</a>
|
||||
<span class="locale-separator">|</span>
|
||||
<a href="/api/rss/releases" target="_blank" class="rss-link" title="Releases RSS">
|
||||
<i class="fa-solid fa-rss"></i> Releases
|
||||
</a>
|
||||
<span class="locale-separator">|</span>
|
||||
<a href="/api/rss/howtos" target="_blank" class="rss-link" title="HowTos RSS">
|
||||
<i class="fa-solid fa-rss"></i> HowTos
|
||||
</a>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-inner">
|
||||
<nav class="footer-nav">
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="footer-link">
|
||||
{{ t(item.label) }}
|
||||
</RouterLink>
|
||||
<RouterLink to="/stores" class="footer-link">{{ t('nav.stores') }}</RouterLink>
|
||||
<RouterLink to="/builds" class="footer-link">{{ t('nav.builds') }}</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="footer-meta">
|
||||
<a :href="wikiUrl()" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.wiki') }}</a>
|
||||
<a :href="GIT_BASE" target="_blank" rel="noopener noreferrer" class="footer-link">{{ t('footer.git') }}</a>
|
||||
<span class="footer-rss">
|
||||
<Rss v-bind="ICON" aria-hidden="true" />
|
||||
<a href="/api/rss/blog" target="_blank" class="footer-link">{{ t('nav.blog') }}</a>
|
||||
<a href="/api/rss/releases" target="_blank" class="footer-link">{{ t('footer.releases') }}</a>
|
||||
<a href="/api/rss/howtos" target="_blank" class="footer-link">{{ t('nav.howtos') }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="footer-note">{{ t('home.note') }}</p>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-license">
|
||||
{{ t('footer.license') }}
|
||||
<a href="https://opensource.org/license/mit" target="_blank" rel="noopener noreferrer" class="footer-link">MIT</a>.
|
||||
{{ t('footer.tagline') }}
|
||||
</p>
|
||||
<button class="retro-toggle-btn" @click="toggleRetroMode">
|
||||
{{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,14 +85,30 @@ import { computed, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Menu, Moon, Rss, Sun, X } from 'lucide-vue-next'
|
||||
import { useUiStore } from '../stores/ui.store'
|
||||
import { getCookie } from '../lib/cookie'
|
||||
import { ICON, ICON_CONTROL } from '../components/icons'
|
||||
import { wikiUrl } from '../api/wiki.api'
|
||||
|
||||
const GIT_BASE = 'https://git.teletypegames.org'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'nav.home' },
|
||||
{ to: '/catalog', label: 'nav.catalog' },
|
||||
{ to: '/blog', label: 'nav.blog' },
|
||||
{ to: '/howtos', label: 'nav.howtos' },
|
||||
{ to: '/engines', label: 'nav.engines' },
|
||||
{ to: '/code', label: 'nav.code' },
|
||||
{ to: '/team', label: 'nav.team' },
|
||||
{ to: '/contact', label: 'nav.contact' },
|
||||
]
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const ui = useUiStore()
|
||||
const { isRetroMode, menuOpen } = storeToRefs(ui)
|
||||
const { toggleMenu, toggleRetroMode } = ui
|
||||
const { isRetroMode, menuOpen, theme } = storeToRefs(ui)
|
||||
const { toggleMenu, toggleRetroMode, toggleTheme } = ui
|
||||
const route = useRoute()
|
||||
const isAdmin = computed(() => !!getCookie('is_admin'))
|
||||
|
||||
@@ -98,71 +120,103 @@ function switchLocale(lang: string) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('locale', lang)
|
||||
}
|
||||
|
||||
function localeBtnClass(lang: string) {
|
||||
return locale.value === lang ? 'text-fg' : 'text-fg-subtle hover:text-fg'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@apply flex min-h-screen flex-col bg-page;
|
||||
}
|
||||
.app-root > main {
|
||||
flex: 1;
|
||||
.app-main {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@apply bg-gray-800 text-white p-4 flex justify-between items-center relative;
|
||||
@apply sticky top-0 z-40 border-b border-line bg-page/90 backdrop-blur;
|
||||
}
|
||||
.app-header-inner {
|
||||
@apply mx-auto flex w-full max-w-6xl items-center gap-4 px-4 py-3 md:px-6;
|
||||
}
|
||||
.app-logo {
|
||||
@apply text-xl font-bold;
|
||||
@apply text-sm font-semibold tracking-tight text-fg;
|
||||
}
|
||||
.nav-desktop {
|
||||
@apply hidden lg:flex items-center space-x-1;
|
||||
@apply ml-4 hidden flex-1 items-center gap-1 lg:flex;
|
||||
}
|
||||
.nav-link {
|
||||
@apply px-2.5 py-2 text-sm hover:text-purple-300 transition-colors duration-200 whitespace-nowrap;
|
||||
@apply rounded-control px-2 py-1.5 text-sm text-fg-muted transition-colors hover:text-fg;
|
||||
}
|
||||
.nav-link-admin {
|
||||
@apply px-2.5 py-2 text-sm hover:text-yellow-300 transition-colors duration-200 whitespace-nowrap;
|
||||
.nav-link.router-link-exact-active {
|
||||
@apply text-fg;
|
||||
box-shadow: inset 0 -2px 0 0 rgb(var(--c-accent));
|
||||
}
|
||||
.header-tools {
|
||||
@apply ml-auto flex items-center gap-1;
|
||||
}
|
||||
.locale-switcher {
|
||||
@apply flex items-center gap-1 text-sm font-bold ml-2 border-l border-gray-600 pl-4;
|
||||
@apply flex items-center gap-1 text-xs font-medium;
|
||||
}
|
||||
.locale-btn {
|
||||
@apply transition-colors px-1;
|
||||
@apply px-1 transition-colors;
|
||||
}
|
||||
.locale-separator {
|
||||
@apply text-gray-600;
|
||||
@apply text-fg-subtle;
|
||||
}
|
||||
/* Az ikonok viszik a legtöbb helyet a sok menüpont mellett — desktopon csak
|
||||
extra széles kijelzőn jelennek meg, a mobil menüben mindig. */
|
||||
.nav-desktop .nav-icon {
|
||||
@apply hidden xl:inline-block;
|
||||
}
|
||||
.nav-icon {
|
||||
@apply mr-1.5 text-xs opacity-70;
|
||||
.icon-btn {
|
||||
@apply rounded-control p-2 text-fg-muted transition-colors hover:bg-raised hover:text-fg
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
|
||||
}
|
||||
.hamburger-btn {
|
||||
@apply lg:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md;
|
||||
@apply lg:hidden;
|
||||
}
|
||||
|
||||
.nav-mobile {
|
||||
@apply lg:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50;
|
||||
@apply flex flex-col border-t border-line bg-page px-4 py-2 lg:hidden;
|
||||
}
|
||||
.nav-mobile-link {
|
||||
@apply block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200;
|
||||
@apply border-b border-line py-2.5 text-sm text-fg-muted last:border-b-0 hover:text-fg;
|
||||
}
|
||||
.locale-switcher-mobile {
|
||||
@apply flex items-center gap-2 pt-2 border-t border-gray-700 w-full justify-center text-sm font-bold;
|
||||
.nav-mobile-link.router-link-exact-active {
|
||||
@apply text-accent;
|
||||
}
|
||||
.locale-btn-mobile {
|
||||
@apply transition-colors px-2 py-1;
|
||||
|
||||
.app-footer {
|
||||
@apply mt-16 border-t border-line bg-surface;
|
||||
}
|
||||
.retro-toggle-content {
|
||||
@apply flex items-center justify-center gap-4;
|
||||
.footer-inner {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-8 md:px-6;
|
||||
}
|
||||
.rss-links {
|
||||
@apply flex items-center gap-2 text-xs text-gray-400;
|
||||
.footer-nav {
|
||||
@apply flex flex-wrap gap-x-5 gap-y-2;
|
||||
}
|
||||
.rss-link {
|
||||
@apply hover:text-orange-400 transition-colors;
|
||||
.footer-meta {
|
||||
@apply flex flex-wrap items-center gap-x-5 gap-y-2;
|
||||
}
|
||||
.footer-rss {
|
||||
@apply flex items-center gap-2 text-fg-subtle;
|
||||
}
|
||||
.footer-link {
|
||||
@apply text-sm text-fg-muted underline-offset-4 transition-colors hover:text-fg hover:underline;
|
||||
}
|
||||
.footer-note {
|
||||
@apply max-w-[68ch] text-xs italic text-fg-subtle;
|
||||
}
|
||||
.footer-bottom {
|
||||
@apply flex flex-wrap items-center justify-between gap-3 border-t border-line pt-4;
|
||||
}
|
||||
.footer-license {
|
||||
@apply text-xs text-fg-subtle;
|
||||
}
|
||||
.footer-license a {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.retro-toggle-btn {
|
||||
@apply rounded-control border border-line px-3 py-1.5 text-xs font-medium text-fg-muted
|
||||
transition-colors hover:border-line-strong hover:text-fg
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { i18n } from '../../i18n'
|
||||
import AppLayout from '../AppLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }],
|
||||
})
|
||||
|
||||
async function mountLayout() {
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
return mount(AppLayout, { global: { plugins: [router, i18n] } })
|
||||
}
|
||||
|
||||
describe('AppLayout', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
document.cookie = 'theme=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'
|
||||
document.cookie = 'retro-mode=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'
|
||||
})
|
||||
|
||||
it('renders every navigation entry', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
const links = wrapper.findAll('.nav-desktop .nav-link')
|
||||
expect(links).toHaveLength(8)
|
||||
expect(links.map((l) => l.text())).toContain('Catalog')
|
||||
})
|
||||
|
||||
it('carries no FontAwesome markup any more', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
expect(wrapper.html()).not.toMatch(/\bfa-(solid|brands|regular)\b/)
|
||||
})
|
||||
|
||||
it('has a footer with the RSS feeds and the retro switch', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
const footer = wrapper.get('.app-footer')
|
||||
expect(footer.html()).toContain('/api/rss/blog')
|
||||
expect(footer.html()).toContain('/api/rss/releases')
|
||||
expect(footer.html()).toContain('/api/rss/howtos')
|
||||
expect(footer.find('.retro-toggle-btn').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches the theme from the header, and writes it to the root element', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
|
||||
|
||||
await wrapper.findAll('.icon-btn')[0].trigger('click')
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
|
||||
expect(document.cookie).toContain('theme=light')
|
||||
})
|
||||
|
||||
it('drops the theme switch in retro mode, where it would do nothing', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
expect(wrapper.findAll('.icon-btn')).toHaveLength(2)
|
||||
|
||||
await wrapper.get('.retro-toggle-btn').trigger('click')
|
||||
|
||||
expect(wrapper.findAll('.icon-btn')).toHaveLength(1)
|
||||
expect(wrapper.get('.icon-btn').attributes('aria-label')).toBe('Menu')
|
||||
})
|
||||
|
||||
it('marks retro mode on the root element too, so the page background follows', async () => {
|
||||
const wrapper = await mountLayout()
|
||||
expect(document.documentElement.getAttribute('data-retro')).toBe('0')
|
||||
|
||||
await wrapper.get('.retro-toggle-btn').trigger('click')
|
||||
|
||||
expect(document.documentElement.getAttribute('data-retro')).toBe('1')
|
||||
expect(wrapper.get('.app-root').classes()).toContain('retro-mode')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,4 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/** @vitest-environment jsdom */
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitize } from '../sanitize'
|
||||
|
||||
|
||||
@@ -29,3 +29,14 @@ export function filterDevReleases(releases: Release[]): Release[] {
|
||||
export function getLatestStable(releases: Release[]): Release | undefined {
|
||||
return filterStableReleases(releases)[0]
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
released: 'status-badge status-released',
|
||||
demo: 'status-badge status-demo',
|
||||
development: 'status-badge status-development',
|
||||
archived: 'status-badge',
|
||||
}
|
||||
|
||||
export function statusBadgeClass(status: string): string {
|
||||
return STATUS_BADGE_CLASS[status] ?? 'status-badge'
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import { router } from './router/index.router'
|
||||
import { i18n } from './i18n'
|
||||
import App from './App.vue'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import 'tuicss/dist/tuicss.min.css'
|
||||
import './styles/tokens.css'
|
||||
import './styles/global.css'
|
||||
import './styles/retro.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
|
||||
|
||||