Author SHA1 Message Date
mr.zero 9ffbc7a2ca wiki multilang support
ci/woodpecker/push/deploy Pipeline was successful
2026-08-28 20:52:49 +02:00
mr.zero 653894a876 wp version upgrade
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:14:51 +02:00
mr.zero 97d71a7c99 advanced pipeline fix
ci/woodpecker/push/deploy Pipeline was successful
2026-08-26 00:08:09 +02:00
mr.zero a58e91520e advanced pipeline
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/warp_engine Pipeline was successful
2026-08-25 23:50:11 +02:00
mr.zero 259de781bb ignored user agents
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 15:09:34 +02:00
mr.zero 6f2a39fe1b X-Forwarded-For fix 3 2026-08-25 15:03:02 +02:00
mr.zero fbdfbe954e X-Forwarded-For fix 2 2026-08-25 15:00:38 +02:00
mr.zero b51bf0d094 X-Forwarded-For fix 2026-08-25 14:57:08 +02:00
mr.zero fc1076f8fc refact
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-25 14:49:23 +02:00
mr.zero 3e70717e8f gitlab
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-24 13:46:15 +02:00
mr.zero c0252928c6 batocera info update 2026-08-23 21:59:57 +02:00
mr.zeroandClaude Opus 5 3a524d5003 The file manager spec gets a directory nobody else can be in
Twice in a long session the suite came back with one failure in this file, and
neither run could be reproduced afterwards — not by seed, not by repetition.
The one thing this spec has that the other 39 examples do not is a fixed path
under `tmp/`, which anything else on the machine can also be inside.

`Dir.mktmpdir` instead, so two runs cannot see each other's folders at all. If
a failure survives this, it is about the code and not about the directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:50:56 +02:00
mr.zeroandClaude Opus 5 2bf0b20b77 A checklist of the release assets that exist and the ones the CI could still build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Nothing said whether a release was finished. A pipeline step can fail, a
platform can gain a target, a title can be published from a laptop — and the
catalog happily serves a release with three of its seven assets. The only way
to find out was to open `/api/softwares/<name>/builds` one name at a time.

    $ bin/rails warp_engine:builds:check

    rabbitroller  ebitengine  v1.1.1  1/7
      [x] html
      [ ] win_x86
      [ ] win_x64
      [ ] linux_x64
      [ ] linux_arm64
      [-] mac_x64
      [-] mac_arm64

    The CI can build these and the release does not have them:
      rabbitroller v1.1.1 (ebitengine): win_x86, win_x64, linux_x64, linux_arm64

    5 softwares, 5 releases, 14/32 assets present, 16 missing from CI-built kinds

The third marker is the point. Two different questions were being conflated:
what the **updater** can ingest (`Platform#expected_kinds`) and what the
**pipeline** actually produces. For six of the seven platforms they are the
same list; for ebitengine they are not — the updater accepts macOS builds, the
Woodpecker builder cannot cross-compile them (osxcross), so `mac_x64` and
`mac_arm64` would be reported as gaps on every ebitengine release, for ever.
`[ ]` is now a real gap and `[-]` is "upload it by hand or not at all", so the
list stays worth reading.

That knowledge is data, next to the templates that produce it:
`PipelineConfig::BUILT_KINDS`, reachable through the adapter as
`built_kinds(platform)`. An adapter that cannot answer — no CI configured, a
platform with no builder — returns nil, and then nothing is excused: every
missing kind counts as a gap.

`WarpEngine::AssetCoverage` computes it (`#rows`, `#missing_rows`, `#totals`)
and `AssetCoverageChecklist` renders it, so the numbers are testable without a
terminal. Options are env vars: NAME, PLATFORM, RELEASES=all, ONLY=missing, and
STRICT=1 for a non-zero exit in CI. The default is the newest release per
software, using the same non-`dev-` rule the catalog API uses for "latest".

33 new examples, including two that keep BUILT_KINDS honest: every claimed kind
has to be a kind the updater knows, and has to appear in the platform's rendered
pipeline. Adding a platform without teaching this list about it fails the suite.

Ships in 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:34:23 +02:00
mr.zeroandClaude Opus 5 e6ef4f4191 The host lockfile follows the engine to 0.8.0
The path gem carries its version into apps/api/Gemfile.lock; without this the
next bundle in a fresh container is the one that updates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:14:37 +02:00
mr.zeroandClaude Opus 5 81708fd84d The file manager spec cleans its directory before it fills it
One run out of a dozen failed on "creates a folder and returns to the
directory": the spec created its artifact directory under `tmp/` and only
removed it afterwards, so anything left behind by an interrupted run was still
there when the next one started, and `mkdir` on an existing folder answers with
the alert instead of the notice.

It removes the directory first now. The suite is green across ten consecutive
runs and every seed tried, including the one that failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:07:17 +02:00
mr.zeroandClaude Opus 5 688d956107 WarpEngine 0.8.0
ci/woodpecker/push/woodpecker Pipeline was successful
What a host gets by moving up from 0.7:

- `WarpEngine::Platform` — the platform registry. `PlatformLink::SUPPORTED_PLATFORMS`
  keeps working as an alias.
- `WarpEngine::ApiErrorRendering` — the engine's JSON error bodies, includable
  by the host's own API controllers.
- `WarpEngine::Storage.mime_for` and `ReleaseAsset.for_relative_path`.
- `c.site_url` — where the catalog's public site lives, for the admin's link to
  a title's page. nil by default, and the link is simply left out.
- `Software` now validates `platform` and `status` against their value sets. A
  host writing a status outside development/demo/released/archived with
  `update!` will start hearing about it — which is the point — and one writing
  it with `update_columns` still will not.
- Ordering left `PlatformLink`'s and `SoftwareImage`'s `default_scope` for an
  `ordered` scope. The API's output order is unchanged; code that relied on the
  implicit order of a bare `PlatformLink.all` has to ask for it.
- The generated `Image` model writes its file `after_commit` instead of
  `before_save`, and gained `#url`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:03:01 +02:00
mr.zeroandClaude Opus 5 244b0e46cb No application code reads ENV, and the wiki has one address again
The API read its environment wherever it happened to need it: `Image` in the
model, `RssService` and `WikiService` in class-level constants, the softwares
admin page in a sidebar. Two of those wanted the same wiki and disagreed about
its name — `RssService::WIKI_URL` against `WikiService::GRAV_URL` — and only
the second one is set in docker-compose, so the howtos feed had been linking to
the hard-coded default all along.

Every `ENV` read is in `config/application.rb` now, as `config.x.site_url`,
`config.x.wiki_url` and `config.x.images.container_path`, and the code asks
`Rails.configuration.x`. One place says what this app needs from its
environment, and a test can override it.

`RssService` was three copies of the same twenty-line `RSS::Maker` block. It is
`Rss::Feed` plus `Rss::BlogFeed`, `Rss::ReleasesFeed` and `Rss::HowtosFeed`,
each of which now only answers what its title, its link and its items are — and
the `Time.parse(...) rescue Time.current` modifier, which swallowed everything,
is a rescue of ArgumentError and TypeError. `WikiService` becomes `Wiki::Pages`
and loses `alias_method :pages, :index`: two names for one method meant the
controller and the feeds each called it something different.

The admin cookie was a monkey patch — `ApplicationController.class_eval` in an
initializer, adding an `after_action` that the three-line controller file gave
no hint of. It is a `SyncsAdminCookie` concern the controller includes.

And the engine stops reading the host's config keys: the "View on site" link in
the softwares admin asked for `Rails.configuration.x.site_url`, which is ours,
not its. `c.site_url` is an engine setting now, nil by default, and the link is
left out when the host does not set it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:46 +02:00
mr.zeroandClaude Opus 5 9f55c47269 The uploaded picture is written after the row is committed
`Image#process_upload` ran in a `before_save`: it assigned the generated file
name and copied the bytes to disk, in the same breath, before the row existed.
If the insert failed afterwards — a validation on the owning record, a
uniqueness clash, a rollback from the surrounding transaction — the file stayed
behind with nothing pointing at it. The admin's own "Orphan" scope exists to
find rows in that family; this was the other half of it, the files with no row
at all.

Split in two: `before_validation` assigns the attributes (so validations and the
generated name still see them), `after_commit` copies the bytes. A rollback now
takes the file with it, because the copy never happens.

The same model is generated into every host, so the fix goes into the
generator template as well as ours. While there: `Image#url` comes back from
Teletype Orbit, where it was added and never flowed back, and the two admin
previews use it instead of interpolating `/api/image/#{id}` by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:22 +02:00
mr.zeroandClaude Opus 5 5d50ac69a1 The file manager's rename prompt was empty: Kernel#j, not escape_javascript
The admin file manager builds its rename and delete buttons with inline
handlers, and interpolated the file name through `j`:

    onclick: "var n=prompt('New name:','#{j entry[:name]}');..."

In a view `j` is `escape_javascript`. Inside an Arbre block it is not: Arbre
resolves unknown methods through `method_missing`, and `j` is not unknown — it
is `Kernel#j`, which prints its argument as JSON to stdout and returns nil. So
every page load wrote the file names to the server log, and the browser got

    prompt('New name:','')

An admin pressing rename saw an empty prompt, and the delete confirmation asked
"Delete ''?". `escape_javascript(...)` spelled out is what those three
interpolations use now.

The page has no test, which is why nothing caught it. It has one now
(spec/requests/admin_files_spec.rb), and it asserts the file name is in both
handlers — with the icons, the folder creation, the failed folder creation and
the delete-returns-to-parent path, because those are the behaviours the
refactoring below could break silently.

Also in the page: the twenty-branch extension-to-emoji `case` moved out of the
view into `WarpEngine::FileIcon`, and the five page actions share one
`redirect_to_files` instead of repeating
`admin_files_path(dir:, picker:, field:)` six times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:02:09 +02:00
mr.zeroandClaude Opus 5 4fc68e1448 One home per rule: error bodies, mime types, asset paths, ordering
Four small duplications, each of which had already drifted or was one edit away
from it.

**The JSON error body.** The host's `ApiController` was a line-by-line copy of
the engine's four `rescue_from` blocks plus `resolve_mime`. It is a
`WarpEngine::ApiErrorRendering` concern now, included by both, so "what a
failure looks like on the wire" is decided once.

**Mime resolution.** The same three lines lived in the engine's API controller
and the host's. `WarpEngine::Storage.mime_for` owns it — next to the adapter
that hands out the files.

**Finding the asset behind a path.** `DownloadService` and `FileService` each
escaped `%` and `_` by hand and ran their own `LIKE` — the second one without
the exact-match attempt the first one had. `ReleaseAsset.for_relative_path` is
the one lookup: the absolute path first, then a suffix match anchored at `/`
rather than the old `%path%`, which could match a different file whose name
merely contained this one. Two now-unreachable helpers
(`DownloadService.base_path`, `FileService.base_path`) go with it.

**Ordering.** `PlatformLink` and `SoftwareImage` ordered inside their
`default_scope`, which leaks into every association and aggregate — the proof
was already in the tree: `admin/images.rb` had to write
`SoftwareImage.unscope(:order).distinct.pluck(:image_id)`, because MySQL will
not order a DISTINCT by a column it does not select. Ordering is a scope you
ask for now (`ordered`), the two places that need it ask (`for_platform`, the
`software_images` association, so the API's image order is unchanged), and the
`unscope` is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:53 +02:00
mr.zeroandClaude Opus 5 d01fd97cc1 Every CI action is a CRUD action, and the URLs do not move
`CiController` had three actions named after verbs — `pipelines`, `status`,
`trigger` — and built its JSON by hand in a private `pipeline_json`, in a
codebase where everything else is serialized by Blueprinter. Triggering a
build is the creation of a run, so it is a resource of its own:

    GET  /api/ci/pipelines             -> Api::Ci::PipelinesController#index
    GET  /api/ci/pipelines/:id/status  -> Api::Ci::PipelinesController#show
    POST /api/ci/pipelines/:id/trigger -> Api::Ci::PipelineRunsController#create

The addresses are untouched on purpose: they are what the CI and the scripts
already call, and renaming them would be a breaking change for a version that
does not need one. What changed is the shape behind them — three standard
actions across two controllers, `require_ci!` and the pipeline scope in a
shared base, and a `PipelineSerializer` that carries the fields the endpoint
already answered with, `repo_id` and `woodpecker_repo_id` included (the
duplicate stays: a client may read either, and ci_controller_spec asserts they
agree).

`/api/software/highlighted` answers with `#show` now rather than `#index`: it
returns one title, and the action name should say so. Same address, same body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:34 +02:00
mr.zeroandClaude Opus 5 76d38840fb The build endpoints state their input rules once
`POST /build/publish` opened with three guard clauses and `POST /build/upload`
with eight, each one a `return render json: { error: ... }` — name present,
version present, name format, version format, file present, filename prefix,
size, digest. That is a validation layer written by hand, in a place where it
cannot be unit tested: exercising it needs a request.

`PublishInputDto` was already there and was a bare `Struct` with no rules at
all, so the controller carried them. It is an `ActiveModel::Model` now, with
the presence and platform-inclusion validations on it, and `UploadInputDto`
joins it with the name and version formats and the `<name>-<version>` filename
convention. Each controller reads:

    return render json: { error: input.error_message }, status: :bad_request unless input.valid?

Size and digest keep their own explicit checks, because they are not the same
answer: 413 tells a caller to stop, 422 tells it to retry a truncated upload,
and a single error bag cannot say which. Every status code the endpoints
answered before, they answer now — build_publish_controller_spec and
build_uploads_controller_spec pin all of them, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:01:10 +02:00
mr.zeroandClaude Opus 5 684f06745a A platform is named once: WarpEngine::Platform
The list of platforms lived on `PlatformLink::SUPPORTED_PLATFORMS` — a model
about links to a platform's website — and was copied around it four times in
the admin (`%w[tic80 ebitengine love c64 godot bevy phaser]`), spelled out in
three apipie descriptions, and turned into a class name by string
interpolation in three services:

    "WarpEngine::Platforms::#{platform.camelize}::Service".constantize

Seven places that had to agree, and nothing that made them.

`WarpEngine::Platform` is now that one place. `Platform.names` is the list,
`Platform.find!("godot")` answers with a value object that knows its `label`,
its `expected_kinds` and its updater `service`, and the constantize is gone —
the registry holds the reference. `PublishService` and `BuildsService` ask it,
the admin selects read `Platform.names`, and
`PlatformLink::SUPPORTED_PLATFORMS` stays as an alias of `Platform::NAMES` so
a host pinned to 0.7 keeps working.

`Software` also defends its own value sets now. It validated neither `status`
nor `platform`, so a mistyped platform only surfaced later, at publish time,
as "Unsupported platform" — after the row existed. And `status` had three
different answers depending on where you looked: the admin offered
development/demo/released/archived, the API documentation claimed
"active, inactive", and the database holds the first four. `Software::STATUSES`
is the list, the inclusion validations enforce both, and the documentation
names the values the database actually has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:00:55 +02:00
Zsolt Tasnadi 58b8574cb4 schema update 2026-08-23 01:11:52 +02:00
mr.zero 9d8a546efa delete REFACT.md 2026-08-23 01:02:31 +02:00
mr.zeroandClaude Opus 5 82b87d019f WarpEngine 0.7.0: a képtár és a CI is a hoszté, adapteren keresztül
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Két dolog volt beépítve az engine-be, ami nem az övé.

A **képtár** eddig `WarpEngine::Image` volt, pedig a modell teljesen általános:
a teletypegames-ben a tagok arcképét is ez hordozza, nem csak a katalógus
borítóit. Az `Image` modell, a feltöltött fájlok, az `/api/image/:id` végpont
és az admin oldal ezért átkerült a hosztba, az engine pedig adapteren szól
hozzá (`WarpEngine::Images`): `url_for` adja a katalógus JSON `imageUrl`-jét,
`select_options` a software-form képválasztóját, `build_from_upload` a
"tölts fel új képet" ágat. Az alapértelmezés az `Image` osztály, tehát a
default útvonal bitre a régi. A `SoftwareImage` (a katalógus-kapcsolat)
maradt az engine-ben, és **az `images` tábla nem mozdult**: az engine csak
abbahagyta a létrehozását, a generátor írja meg hoszt-kódként.

A **CI** eddig végig Woodpecker volt: kliens, aláírás-ellenőrzés,
pipeline-receptek, repo-szinkron, secret-kiosztás. Mindez egy adapter mögé
került (`WarpEngine.ci`), a Woodpecker-implementáció pedig az engine-ben
maradt `WarpEngine::CI::Woodpecker` néven — kliens, adapter, httpsig-ellenőrző
és a platformonkénti pipeline-receptek, mert a YAML-dialektus a szolgáltatóé.
Az engine saját kódja már nem nevez szolgáltatót: `CI::Repo` és `CI::Run`
értékeket kap, `CI::ConnectionError`/`ApiError`/`NotConfigured` hibákat dob, a
`Pipeline` pedig `remote_repo_id`-t ad a történelmi `woodpecker_repo_id`
kolumna fölött (a tábla itt sem mozdult). `c.ci_adapter = :none` azt jelenti,
hogy ez a hoszt nem buildel: az `/api/ci/*` 503, a `/build/config` elutasít,
az admin akciók elbújnak.

Mindkét seam a hoszt initializerében van kimondva, nem alapértelmezésre
hagyva — a hoszt megnevezi, mi a képtára és mi a CI-ja.

Törés a 0.6-hoz képest: `image_container_path`, `image_owners`,
`ci_platforms`, `ci_update_server`, `ci_extension_public_key(_url)`,
`woodpecker_url`, `woodpecker_api_token`, `woodpecker_repo_owner` és a
`WarpEngine.woodpecker_configured?` megszűnt; a helyük `c.image_class_name` /
`c.image_adapter` és `c.ci_adapter`. Az `/api/ci/*` `latest_run`/`trigger`
válasza a normalizált `CI::Run` alakot adja (number, status, branch, message,
createdAt, url), a `pipelines` lista pedig `repo_id`-t is közöl a megtartott
`woodpecker_repo_id` mellett.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 00:56:01 +02:00
mr.zero f2d0b72afd password reset fix 2026-08-21 21:28:10 +02:00
mr.zeroandClaude Opus 5 92b4ae22b7 Két tengelyen kap vissza életet a felület: piktogram és szín
A facelift után a site helyenként túl csendes lett. Élet kerül vissza, de
nem dekorációként: két szabály mentén, amiket egymástól függetlenül lehet
olvasni.

A piktogram identitást jelöl — miféle dolog ez. Minden motor és platform
saját glifát kap (TIC-80, Ebitengine, LÖVE, Godot, Bevy, Phaser, C64), és
ugyanazt a jelet viszi a katalógusban, a részletlapon és a build-mátrix
motor-oszlopában, egyetlen PlatformIcon komponensből. A Contact IRC- és
BBS-csatornája is jelet kapott, mert a másik három brandnek eddig is volt,
és a hiány látszott.

A szín állapotot jelöl — mi van vele. A négy státuszból három kap hangot:
a megjelent az akcens zöldet, a demó egy kéket (új --c-info token, 6,7:1
fehéren és 7,8:1 feketén), a fejlesztés alatti a borostyánt. Az archivált
marad semleges, mert az a "már nem történik vele semmi" állapot.

A navigáció, a gombok és a szekciócímek továbbra sem kapnak ikont: ott a
szöveg megmondja, mi az.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:02:47 +02:00
mr.zeroandClaude Opus 5 02ffedbca2 Középre kerül, ami belépő pont, és sötéten indul a site
A facelift mindent balra igazított, pedig nem minden olvasnivaló. A szabály
mostantól: középre az kerül, ami tájékozódási pont és nem szöveg — az
oldalak hero-ja, a magányos állapotblokkok, és a lista végén álló egyetlen
vissza-link. Balra marad minden, amit olvasnak vagy pásztáznak: törzsszöveg,
listák, táblázatok, kártyák, vezérlők, lábléc.

A részletoldalak kivételek: a blogbejegyzés és a katalógus-részlet hero-ja
balra marad, mert ott a cím már a tartalom első sora, nem belépő pont.

Az alapértelmezett téma a sötét lett; süti nélkül az indul, és a világosat
az választja, aki kéri. Retro módban pedig eltűnik a téma-kapcsoló: ott a
retro palettája írja felül a tokeneket, a gomb nem csinált volna semmit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:02:28 +02:00
mr.zeroandClaude Opus 5 ebe3684d44 A kódbázis kommentek nélkül marad
ci/woodpecker/push/woodpecker Pipeline was successful
Kérésre: minden magyarázó komment kikerült a forrásfájlokból — 89 Ruby, 16
TypeScript, 14 Vue, plusz a CSS/JS/CJS. Nem soralapú kereséssel: a Ruby-t a
Ripper tokenizálta, a JS/TS/CSS-t állapotgép járta végig, hogy az URL-ekben,
reguláris kifejezésekben és heredocokban álló // és # jelek helyükön
maradjanak.

Három komment maradt, mert nélkülük nem indul a kód: az entrypoint.sh
shebangja, a vite-env.d.ts hármas perjeles referenciája, és a sanitize
teszt @vitest-environment direktívája (ez utóbbi a magyarázó része nélkül).

Egy helyen kódot is kellett írni: a CommandBlock másolás-hibaágán a komment
volt a catch egyetlen tartalma, és üres blokkot az eslint nem enged — a
copied jelző visszaállítása került a helyére.

A yaml, Dockerfile, Makefile, erb és markdown fájlokat nem érintettem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 12:52:47 +02:00
mr.zeroandClaude Opus 5 e431e2b85b A site megtanul témát váltani, és leszokik az ikonokról
Nyolc akcentszín volt, oldalanként másik hero-gradienssel, tizenegy
blur-blobbal és 146 ikonhasználattal — ettől nézett ki réginek. Most egy
vizuális nyelv van: semleges zinc-skála, egyetlen akcens (a saját
foszforzöldünk), 1px keretek árnyék helyett, és ikon csak ott, ahol nincs
mellette szöveg.

A választható világos/sötét téma miatt minden szín tokenen keresztül megy.
A szerepeket a tokens.css írja le mindkét témára, a Tailwind szemantikus
színei (bg-surface, text-fg-muted, border-line, bg-accent) ezekre mutatnak,
és a <html data-theme> választ. A sütit az index.html inline scriptje
olvassa ki a bundle előtt, különben minden sötét témás betöltés villanna
egy világosat. A világos téma zöldje green-700: a green-600 fehéren csak
3,3:1, linkszínnek kevés.

A retro mód ebből lett a legtöbbet nyerő: mostantól ő is csak a tokeneket
írja felül, és azt szabályozza külön, ami formában más — betűtípus, kettős
keret, tui-sáv. 1153 sor és 535 !important helyett 300 sor. Menet közben
kiderült, hogy a fejléc- és menüstílusai régóta nem is fogtak: a
@apply óta nincsenek a DOM-ban azok a Tailwind-osztályok, amikre épültek.

A FontAwesome kikerült (258 KB webfont ~50 glifért), helyette Lucide, meg
tíz saját inline márkajel. A csapatképek WebP-ben: 1,6 MB helyett 30 KB
darabja. A TuiCSS és a retro.css már csak akkor töltődik be, ha valaki
bekapcsolja. A dist 8,2 MB-ról 784 KB-ra, a fő CSS 237 KB-ról 41 KB-ra.

Nem a faceliftből: a sanitize.test.ts jsdom alatt fut, és a jsdom
függőségfája ESM-only lett — Node 20-on a fájl el sem indult, a tesztjei
csendben kimaradtak, az npm run test meg hibakóddal lépett ki. jsdom 26
az utolsó, ami ezen a Node-on megy; a négy teszt így újra fut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 12:24:42 +02:00
mr.zeroandClaude Opus 5 e040763454 The engines page leads with the three engines we lead with
Every engine-tagged wiki page landed on the page — the two store engines and
the shared warpstore core alongside the frameworks. The wiki now marks the
flagship three with a `highlighted` tag next to `engine`; the API already
carried tags through, so the list simply asks for that mark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:13:01 +02:00
mr.zeroandClaude Opus 5 bcb95424c3 A store can be listed or not
Soft deletion already said "this store is gone". What was missing is "not yet" —
a catalog still being set up, or one pulled from the picker for a while without
losing the row and its history. `GET /api/stores` now answers with the active
ones only.

The client is deliberately told nothing about the flag. It has no state for
"there but switched off", and giving it one would mean every client release
having an opinion about it; an inactive store is simply absent, which is a case
the client already handles because it is the same as never having existed. The
payload stays two fields, and a spec holds it there.

Default true, so the migration lists every store that exists today. One that
silently emptied the registry would be a client with nothing to install from.
No index: a handful of rows, read once per client on first run.

In the admin the flag is what the page is *for*, so it is not just a checkbox on
the form: Active is the default scope, the index shows listed/hidden as a status
tag with a one-click toggle beside Edit, and the two batch actions do it in bulk.
A request spec covers all of it, because none of it is reachable from a model
spec — the pipelines resource shipped without `permit_params` and every edit
raised, which is the same layer and the same lesson. The last example toggles in
the admin and then reads /api/stores, since a change here that the registry does
not reflect is the only failure that actually matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:23:20 +02:00
mr.zeroandClaude Opus 5 23f4c43da8 Put the store where people are, not at the bottom of the catalog
The client was reachable from one grey link under the whole catalog listing and
from nowhere on the home page — which is a strange place for the thing that
actually gets our games onto somebody's machine.

**Catalog**: the Stores link moves into the hero, as a button in the shape the
Code and HowTo heroes already use (subtitle, breathing room, one white button).
Somebody who has come to look at games is one step from wanting to run one, and
that step should not be below the fold. The build matrix stays at the bottom,
which is where somebody who has already read the catalog goes looking for it.

**Home**: a section for the client under the featured game — the same dark band
as the YouTube and Spotify blocks below it, so the page keeps one rhythm rather
than growing a fifth nav card and breaking the four-column grid. Screenshot,
three things it does, download and a link to the full page. The screenshot leads
on a wide screen and follows on a narrow one: it is what makes the section
legible at a glance, but it is not what somebody reads first on a phone.

`btn-hero-purple` joins teal and indigo, and retro mode covers it — that block
lists the button classes by name, so a new one is invisible there until added.

Two things found on the way:

- the stores page said the client "needs Python 3". It has not since the engine
  moved into the app; the line now says nothing has to be installed first, which
  is the actual selling point.
- there was no test holding the two locale bundles together. A missing
  translation is not an error at runtime — vue-i18n renders the key itself, so
  `home.store.title` appears on screen looking like unfinished work, and only
  after switching language. The new spec names the missing key; I checked it
  fails by deleting one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:57:28 +02:00
mr.zeroandClaude Opus 5 c75c5acfe2 warp_engine 0.5.2 — 0.5.1's tag never produced a gem
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The 0.5.1 tag build failed on the stale dummy schema, so nothing was published
under that number; the fix landed a commit later. Moving a tag would have been
the tidier answer — the gem contents are byte-identical, since spec/ is not in
`spec.files` — but deleting the remote tag is not something I can do here, and
burning a patch number is the conventional response to a burned tag anyway.

0.5.1 therefore exists in the history and in one lockfile and nowhere else.
0.5.2 is what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:42:28 +02:00
mr.zeroandClaude Opus 5 9dadb276a5 The dummy schema still stamped the old migration version
ci/woodpecker/push/woodpecker Pipeline was canceled
CI builds the engine's test database from spec/dummy/db/schema.rb, and that file
still said `version: 2026_08_19_000001`. Loading it marks every migration up to
that number as applied — which no longer includes device_grants at
20260819093412 — so rspec aborted with a pending migration before running
anything, and both the push and the tag pipeline failed.

It passed on my machine because I had renumbered the row in the live test
database by hand and never re-dumped the schema. Verified this time the way CI
does it: dropped the database, `rake app:db:test:prepare` from schema.rb, 227
examples green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:39:45 +02:00
Zsolt Tasnadi 24d6add527 gemfile.lock upgrade 2026-08-19 11:36:43 +02:00
mr.zeroandClaude Opus 5 066451f31c warp_engine 0.5.1: the device_grants migration collided with the host's
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/tag/woodpecker Pipeline failed
`db:migrate` on the catalog API stopped before running anything:

    Multiple migrations have the version number 20260819000001.

The engine appends its `db/migrate` to the host's migration paths instead of
copying migrations in, so engine and host share one version namespace. Both had
picked 20260819000001 on the same day by the same habit — the engine for
device_grants, apps/api for carry_the_store_config_in_the_registry — and neither
repository could see the other's number.

Worse than a clash of our own making: it takes the *host's* migrations down with
it, for the whole application, before anything runs.

device_grants is renumbered to 20260819093412 — a real second-resolution
timestamp, which is the actual defence. A round hand-written number is precisely
what another repository lands on. Nothing had run it in production, so this is a
rename rather than a data migration; a host that already applied the old version
renumbers its schema_migrations row.

The older engine migrations keep their round numbers: renumbering one that has
been deployed everywhere is worse than the risk it carries. They are named in the
new spec's grandfather list rather than excused by a rule that would also let the
next one through.

That spec found a second thing, older than this change: the install template
creates `application_tokens`, and so does one of our own migrations — so a fresh
host runs CREATE TABLE twice and has to delete the block from its generated copy
by hand, which is exactly what teletype-orbit's migration header describes. I had
just made it worse by putting device_grants in the template too; that is out
again, and the template says why. `application_tokens` is grandfathered and left
for a change that is not a hotfix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:29:53 +02:00
mr.zeroandClaude Opus 5 65f337bcf4 README: the registry example named the wrong org
ci/woodpecker/push/woodpecker Pipeline was successful
`api/packages/tools/rubygems` was right until the org reshuffle moved the engine
to `engines`, and it has been copy-pasteable-but-broken since. The gemspec's
allowed_push_host and the CI's `gem push --host` both say `engines`; only the
README did not.

While there: pin the example to `~> 0.5` and say why the source is block-scoped.
A second *global* source leaves Bundler unable to say which gem came from where.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:20:53 +02:00
mr.zeroandClaude Opus 5 c1741f64f3 WarpEngine: let the host say who a browser is
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The access policy asks who the caller is, and until now only a bearer token
could answer. That is what a desktop client carries — but a person clicking a
download link on the site carries a session instead, and the engine has no idea
what a session is. So a host that gated its catalog found its own signed-in
visitors refused at /api/download, which is a regression the shadowed route used
to hide.

c.subject_resolver is a callable taking the Rack request and returning the
host's subject: `->(request) { request.env["warden"]&.user }` for a Devise app.
Unset — every deployment today — a non-bearer request stays anonymous, exactly
as before. A resolver that raises is logged and treated as anonymous, because a
broken one turning every read into a 500 is worse than an anonymous request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:36:30 +02:00
mr.zeroandClaude Opus 5 9f31c85256 WarpEngine 0.5.0: a catalog that can say a title is not yours
ci/woodpecker/push/woodpecker Pipeline was successful
A desktop client reading /api/software had no way to learn that a title costs
money. There was nothing in the response to say so, no way to sign in, and no
way to be told "you do not own this" — so a store with paid titles could only
hand the client a 403 at download time and let it guess why.

The fix belongs here rather than in the client. A client serves more than one
store, so anything it knows about a particular one has to arrive from that
store's own API; a rule compiled into the client is a rule that breaks every
other catalog it reads. Three seams, each following the storage adapter's
shape — documented contract, default that is byte for byte the old behaviour,
one config key to replace it:

- **access policy** — visible_software_scope / access_for / authorize_download.
  Every catalog entry now carries an `access` block (gated, entitled, price,
  purchaseUrl, webUrl) and both /api/download and /file/* ask before serving.
  The vocabulary is deliberately generic: a word from one host's domain would
  make every client that reads it specific to that host.
- **client sign-in** — the device authorization grant (RFC 8628), over the
  host's own user model. The approval page stays the host's, because approving
  needs a session and HTML. Tokens are ApplicationTokens with a `catalog`
  scope, so publishing and reading stay separable.
- **service descriptor** — GET /api/service says what this deployment is and
  whether it has a sign-in at all, which is how a client stops guessing.

With no policy and no subject class configured — every deployment today — the
API is unchanged: /api/auth/* answers 404, /api/service reports auth: null, and
the 187 pre-existing examples pass untouched.

A policy that raises is treated as a refusal, not permission. An artifact
served because the gatekeeper crashed is the one failure mode this must not
have, so a broken policy empties the catalog and denies the download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:26:28 +02:00
mr.zero f8dee01c81 warpengine-client.png upgrade 2026-08-19 08:45:25 +02:00
mr.zeroandClaude Opus 5 021f2b9b07 A store record is a name and a catalog
Both extras go. `config` was added this morning on the idea that the registry should
say how each store behaves; that was wrong. The configuration is fixed per installed
client — the client carries its own store engine and knows its own machine — so a copy
here was a second authority over decisions the client had already made correctly,
including which directories it may delete from. Keeping two stores on one machine apart
is a subfolder, and the client derives that itself.

`store_repository_url` goes with it. The store engines it pointed at no longer exist, and
a URL nobody follows is a URL that goes stale.

The public stores page loses its desktop card for the same reason: it advertised a
`curl … | sh` for a repository that is gone, and an ordinary computer is served by the
app in the section above it. `/desktop` now lands on that app rather than on a device
tab, so the old URL still means what someone typing it wants.

Unrelated but in the way: the dead `engines` list in that page has been failing
`vue-tsc` on master, so the frontend could not be built to check any of this. Removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 07:19:52 +02:00
mr.zeroandClaude Opus 5 9255a11254 Carry a store's configuration in the registry record
A store's `config.json` lived in a repository the desktop client fetched over HTTP,
which made a store's behaviour depend on a second thing existing and staying
reachable. The registry already answers what a store *is*; it now answers how it
behaves too, in the same shape that file had, so this record is the one source of
truth and a store can be configured from the admin alone.

`storeRepositoryUrl` stays, demoted to a pointer for a person — where the store's
own repository is, when it has one. Clients released before this field still fetch a
`config.json` from it, so nothing has to move at once.

The column is nullable because a store that configures nothing is still a store: the
client falls back to the engine's built-in defaults, which need only a name and a
catalog. The admin edits it as JSON text through a pair of accessors, so the column
holds real JSON and invalid input comes back with the text kept and a message rather
than a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 06:43:09 +02:00
mr.zero 581d3c4371 new StoresIndexPage fix 2 2026-08-18 21:34:27 +02:00
mr.zero 7c0f1479b4 new StoresIndexPage fix 2026-08-18 21:33:08 +02:00
mr.zero a99834abf0 new StoresIndexPage 2026-08-18 21:25:40 +02:00
mr.zeroandClaude Opus 5 7ea8303b1a The pipeline admin form could never save
ci/woodpecker/push/woodpecker Pipeline was successful
`ActiveAdmin.register WarpEngine::Pipeline` declared no `permit_params`, so every edit
handed unpermitted attributes to the model and Rails raised ForbiddenAttributesError. That
is not new: the form has been unable to save for as long as it has existed. My flash
message on `update` sat at the top of the traceback and made it look like the cause, which
it was not — and it is gone anyway, because overriding an ActiveAdmin action to say
something is a poor trade for what it can break. The move is written to the log instead.

Adding a spec that would have caught it, in the host app, because that is where the
ActiveAdmin instance lives: it signs in, PUTs the form, and checks both that the record
saves and that the software link moves off the pipeline that had it. Driven the same way
by hand against the development database first — 302, the link moved, the previous holder
left without one.

The engine's other admin resources were checked for the same omission: downloads and
releases are read-only and the file manager posts to its own routes, so pipelines was the
only one affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:41:16 +02:00
mr.zeroandClaude Opus 5 a0a7ff63e1 Do not let a response header take the API down
The version header was set from WarpEngine::VERSION_HEADER, a constant introduced in the
same commit. The deploy that followed ran these controllers with an older `lib/`, so the
before_action raised NameError on every request and every engine endpoint answered 500 —
the catalog, the images, the file server and the config extension Woodpecker calls, which
is how it surfaced: a pipeline could no longer fetch its own configuration.

The controller now spells the header name out. A response header is not worth a dependency
that can take the API down when one half of a deploy is older than the other, and the
constant remains the documented name with a spec holding the two in step.

Confirmed in production mode against the same code that failed: 200 with the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:20:39 +02:00
mr.zeroandClaude Opus 5 3d5ba3f31d A version header on every WarpEngine response, and a movable pipeline link
ci/woodpecker/push/woodpecker Pipeline was successful
Every WarpEngine API response now carries `WarpEngine-Version`, so a client can branch on
the engine's age without a round trip to ask. Set in a before_action rather than after:
`rescue_from` never reaches an after_action, and a client needs the version most when
something came back wrong. The name lives in `WarpEngine::VERSION_HEADER`. The host's own
endpoints — the store registry — do not carry it, because they are not the engine.

A software has one pipeline, and the newest assignment now wins. Two pipelines pointing at
the same software was not an error the database caught; it was a link that silently did
nothing, with the software still showing whichever row came first. Assigning a software
another pipeline holds therefore moves it, the admin says which pipeline it was taken
from, and `Pipeline#software_taken_from` carries that for anything else that cares.
Deliberately a callback and not a unique index: rows here are soft-deleted, and a unique
index counts deleted rows, so a pipeline removed last year would block its software from
ever being linked again.

The engine is 0.4.0. The site's /stores page and its screenshot follow the client's new
name, and the shot is a fresh one showing the greyed-out titles the client now lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:46:29 +02:00
mr.zero bf10aa87e8 warpengine-client.png 2026-08-18 18:34:11 +02:00
mr.zero f8c9cfe996 new stores page 2026-08-18 18:12:46 +02:00
mr.zeroandClaude Opus 5 466aeac6ca A store registry record needs no repository
The client only ever took identity from a store repository — a slug, a name, a catalog —
and the store engine's own defaults cover everything else: the host-to-asset mapping, the
install modes, the platforms, the behaviour. So `store_repository_url` is now optional:
nullable in the schema, no presence validation, the format check only when a value is
given, and the serializer answers null rather than an empty string, because the client
branches on its absence.

Adding a store is therefore a row with two fields filled in. Given a repository the
client still reads its config.json, and that file remains the authority on how the store
behaves — the admin form and the endpoint's documentation say so.

The frontend's /stores page gains a section of its own for the graphical client on the
desktop tab: what it does, that it sets the store up itself, that it is the way in on
Windows where `curl … | sh` does not exist, and links to the releases, the repository and
the documentation — now under stores/warp-engine-client, which is where that repository
lives after the rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:53:33 +02:00
mr.zeroandClaude Opus 5 5517133e25 A store registry, so a client is not told which store to install
The desktop graphical client had our store's config URL compiled into it. That is
backwards: which stores exist for a catalog is something the site knows, and a
client should be able to ask. `GET /api/stores` answers, and the client picks from
what comes back.

A `Store` row is three fields — name, catalog_url, store_repository_url — with an
ActiveAdmin panel, a Blueprinter serializer in the catalog's camelCase, and a
service+controller pair following the events and members shape. `db/seeds.rb`
creates our own record, so a fresh database serves a working registry.

The endpoint is **public**, which is the point: the client runs on someone's
laptop before any store exists and has nobody to log in as. It exposes three URLs
that are public anyway.

This deliberately does not live in WarpEngine. The engine serves one catalog and
has no business knowing who ships stores for it; a registry of stores is a
property of this site, not of the catalog software. Anyone mounting WarpEngine can
keep their own list, or none.

16 model and controller examples pass. Worth noting for the next person who runs
them: the specs need RAILS_ENV=test, as the README says — without it rspec runs in
the development environment, host authorization rejects Rack::Test's hostname, and
every request spec fails with a 403 and an HTML body that looks nothing like a
routing problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 13:12:01 +02:00
mr.zero 4dccb9a815 Store resource 2026-08-18 13:04:21 +02:00
mr.zeroandClaude Opus 5 c85148d238 Offer the graphical client on the desktop tab
The desktop store now has a window, and it is the only one of the three that
does — so the download link and the sentence explaining it appear on that tab
alone. On Windows this is the way in, since the shell installer needs a shell
the platform does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 11:21:56 +02:00
mr.zeroandClaude Opus 5 201e422be6 Put this computer first on the stores page
The page had two device tabs, both for hardware most visitors do not own. The
desktop store makes the third one the likeliest answer, so "This computer" is
added and selected by default, and /desktop redirects to it the way /batocera and
/retroarch already do.

Device names now come from i18n rather than the component: "Batocera" and
"RetroArch" are product names either way, but "This computer" has to be
translatable. Every key the page uses was checked to exist in both locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:00:39 +02:00
mr.zeroandClaude Opus 5 3eedabce43 One /stores page for both stores, with a device chooser
The Batocera store had /batocera to itself. A RetroArch store now serves the same
cartridges on every other machine, and copying the page would have duplicated
everything the two have in common — the framing, the platform table, the engine
links — so they share one page and a Batocera/RetroArch chooser. Only the install
command, the CLI and the uninstall line change with the tab.

/batocera and /retroarch both redirect here with the matching tab preselected,
so old links keep working and the name someone guesses after reading "RetroArch
store" lands somewhere useful. `?device=` makes a link to either half shareable.

The page also documents uninstalling, which it never did, and the five command
blocks share one CommandBlock component instead of repeating the copy button.
The i18n `batocera` block becomes `stores` in both locales; every key the page
uses was checked to exist in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:33:19 +02:00
mr.zero f1536d8b39 woodpecker upgrade 2026-08-17 08:08:17 +02:00
mr.zero ce904489d4 remove ▶ chars 2026-08-17 08:00:46 +02:00
mr.zeroandClaude Opus 5 22ee724c09 Run the engine specs before mirroring and publishing
The engine's 180 specs were never run by CI: the mirror repo is
generated by a subtree split, so a pipeline committed there would be
overwritten on the next sync, and the monorepo's own pipeline only
mirrored and published. A broken engine could reach the gem registry.

The test now gates both — it runs on the same trigger the mirror does
(a change under libs/ruby/warp_engine), and the steps after it only run
if it passes. The specs need MySQL, hence the service, and a prepared
test database: on a fresh database maintain_test_schema! reports the
engine's own migrations as pending instead of loading the schema.

Verified by running the step as written in a clean ruby:3.2 container
against a fresh mysql:8 — 180 examples, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:07:56 +02:00
mr.zeroandClaude Opus 5 6b24707c8d Build bundled Phaser projects with their own toolchain
ci/woodpecker/push/woodpecker Pipeline was successful
The step assumed one project shape: plain sources concatenated with cat
and Phaser pulled from a CDN. trickster-tiles is the other shape — Vite
plus TypeScript, built with `tsc && vite build` — so `cat src/*.js`
found nothing to bundle and the build failed. The earlier guard on the
syntax check fixed only the first symptom of that mismatch.

A project with a build script now runs npm ci && npm run build and is
packaged from its own dist/, which already contains index.html and the
hashed assets. Plain projects keep the existing path untouched.

The bundler's base has to be relative, since games are served out of
/file/<name>-<version>/; the step fails loudly if the build leaves no
dist/index.html rather than shipping an empty zip.

Verified in the phaser-builder image against trickster-tiles: a 344 kB
package with index.html at the root and ./assets/ references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:12:12 +02:00
mr.zeroandClaude Opus 5 71a5c15b4c Serve the builder images from the build org
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
The org reorganization moved the toolchain repos to build/ but left
their container images in internal/: a package namespace does not
travel with the repo and gets no redirect, which is the only reason
the internal org was still alive.

All eight images now live under build/ — the six unchanged ones copied
layer-for-layer, the Ebitengine and Bevy ones rebuilt for the ARM
target. The internal org can be emptied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:57:37 +02:00
mr.zeroandClaude Opus 5 e8c3e2f792 Add linux_arm64 builds for Ebitengine and Bevy
Batocera and the ES-family distributions run on ARM as much as on
x86_64 — Raspberry Pi, Odroid, the retro handhelds — and a linux_x64
binary installs there but will not start. There was no Linux ARM asset
kind at all: KINDS had linux_x86 and linux_x64 and mac_arm64, but
nothing for 64-bit ARM Linux.

Registering the kind is deliberately separate from producing it: a
platform service only includes BuildLinuxArm64 once its pipeline builds
the artifact, otherwise /api/builds would report it missing for every
release. Hence Ebitengine and Bevy only. Godot needs a Linux arm64
export preset in each game repo first; LÖVE fuses an upstream AppImage
that ships x86_64 only; TIC-80's export command has no ARM target.

Ebitengine needs cgo on Linux, so binary_build gained a cross-compiler
argument — and unsets CC for native targets, otherwise a build after
the ARM one silently picks up the cross gcc.

Verified by cross-compiling both demos in the rebuilt images: each
produced a genuine AArch64 ELF (e_machine 183), not a silent fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:57:23 +02:00
mr.zero 87e23545d1 phaser pipeline fix
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-16 16:45:16 +02:00
294 changed files with 10057 additions and 5408 deletions
+10
View File
@@ -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")
-62
View File
@@ -1,62 +0,0 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# engines/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 (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
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/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*
+125
View File
@@ -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
+126
View File
@@ -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*
+177 -9
View File
@@ -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 ...`
-231
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.2.0)
warp_engine (0.9.1)
apipie-rails
blueprinter
rails (>= 8.0)
@@ -1,13 +1,11 @@
ActiveAdmin.register WarpEngine::Image, as: "Image" do
ActiveAdmin.register Image do
permit_params :file_upload
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
+1 -1
View File
@@ -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
+78
View File
@@ -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
View File
@@ -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 &amp; 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
+2 -24
View File
@@ -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 -2
View File
@@ -1,6 +1,5 @@
class AdminUser < ApplicationRecord
devise :database_authenticatable,
:recoverable, :rememberable, :validatable
devise :database_authenticatable, :rememberable, :validatable
belongs_to :member, optional: true
+38
View File
@@ -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 -1
View File
@@ -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
+15
View File
@@ -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
+20
View File
@@ -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
+43
View File
@@ -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
+20
View File
@@ -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
-73
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.active.ordered)
end
end
+43
View File
@@ -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
-49
View File
@@ -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
+12 -2
View File
@@ -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
-1
View File
@@ -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
-3
View File
@@ -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
+20 -32
View File
@@ -1,42 +1,30 @@
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
# assignment (not <<) to stay idempotent.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Owner of the /build/* DB tokens. Switching to :database mode
# (c.application_token_source = :database) must wait until CI uses DB
# tokens — the flip invalidates UPDATE_SECRET immediately.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): the served platforms
# and their builder images. An image bump is one line here, rolled out to
# every repo by the deploy.
c.ci_platforms = {
"godot" => { builder: "git.teletypegames.org/internal/godot-builder:4.7.1" },
"phaser" => { builder: "git.teletypegames.org/internal/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/internal/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/internal/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/internal/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/internal/ebitengine-builder:latest" },
"tic80" => { builder: "git.teletypegames.org/internal/tic80pro:latest" }
}
# Explicit URL: request.base_url would yield http:// behind the host nginx
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
# pipeline's curl steps silently no-op.
c.ci_update_server = "https://teletypegames.org"
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
c.image_class_name = "Image"
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
c.site_url = Rails.configuration.x.site_url
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.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
+11
View File
@@ -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}"
+18
View File
@@ -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}"
+2 -3
View File
@@ -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
+30 -1
View File
@@ -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_06_000002) 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_06_000002) 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
@@ -281,6 +299,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) 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
+4
View File
@@ -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
+9
View File
@@ -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
+28
View File
@@ -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
+10
View File
@@ -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
+20
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+28
View File
@@ -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
-1
View File
@@ -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(&#39;New name:&#39;,&#39;mygame-1.0.zip&#39;)")
expect(response.body).to include("Delete \\&#39;mygame-1.0.zip\\&#39;")
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
+37
View File
@@ -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
+31
View File
@@ -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
+46
View File
@@ -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
+9
View File
@@ -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" />
+222 -277
View File
@@ -8,8 +8,8 @@
"name": "ttglanding",
"version": "0.0.1",
"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",
@@ -25,7 +25,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",
@@ -49,56 +49,19 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -145,23 +108,10 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
@@ -175,13 +125,13 @@
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
@@ -195,17 +145,17 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
@@ -219,21 +169,21 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
@@ -247,41 +197,16 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
@@ -295,7 +220,7 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -887,33 +812,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2472,6 +2370,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2627,16 +2535,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2904,20 +2802,6 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2931,6 +2815,20 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2938,27 +2836,27 @@
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/de-indent": {
@@ -3706,17 +3604,45 @@
"he": "bin/he"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
@@ -3928,36 +3854,35 @@
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -3968,14 +3893,27 @@
}
}
},
"node_modules/jsdom/node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/xml-name-validator": {
@@ -4345,13 +4283,20 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
"license": "ISC"
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
@@ -4363,13 +4308,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4517,6 +4455,13 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4622,26 +4567,26 @@
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -4698,13 +4643,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -5006,16 +4944,6 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5138,6 +5066,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5162,6 +5097,13 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -5574,22 +5516,22 @@
}
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -5607,29 +5549,29 @@
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
@@ -5722,16 +5664,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -6734,13 +6666,27 @@
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
@@ -6754,18 +6700,17 @@
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/which": {
+2 -2
View File
@@ -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`)
})
})
+45 -23
View File
@@ -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 }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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>
+5 -32
View File
@@ -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>
+8 -13
View File
@@ -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>
File diff suppressed because one or more lines are too long
@@ -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 -1
View File
@@ -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([])
})
})
+58 -35
View File
@@ -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,23 +207,36 @@ export default {
title: 'Build Matrix',
subtitle: 'Which engines build for which platforms.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'Our catalog, straight on your retro box.',
lead: 'ttg-batocera-store puts our catalog on Batocera, the plug-and-play retro gaming distribution. It pulls our games into the box\'s ROM folders together with box art and EmulationStation metadata, and can be re-run any time from the Ports menu to pick up new releases. Python 3 standard library only — nothing to install alongside it.',
repo: 'Git repository',
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',
batoceraProject: 'Batocera project',
repo: 'Git repository',
installTitle: 'Install',
installDesc: 'Run this over SSH on the Batocera box. It installs the client, writes the config, creates the Ports entry and runs the first sync.',
installNote: 'Then restart EmulationStation so the games show up:',
installThen: 'Then',
useTitle: 'Use it',
useDesc: 'On the device: Ports ▸ "Teletype Games Store". Over SSH the client has a small CLI:',
platformsTitle: 'What it installs',
platformsDesc: 'A catalog platform is installable when its release asset boots directly on a Batocera system. Other platforms ship web and desktop builds instead, so they are not mapped.',
engineTitle: 'Run your own store',
engineDesc: 'Nothing here is specific to us. Our store is just a config file on top of warp-engine-batocera-store, an open engine that works with any WarpEngine-based site: point it at your own catalog, give it a name and a ROM subfolder of its own, and your games land on a Batocera box the same way. Several stores can live side by side on one machine without touching each other\'s games.',
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',
+60 -37
View File
@@ -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,23 +207,36 @@ export default {
title: 'Build mátrix',
subtitle: 'Melyik engine melyik platformra fordít.',
},
batocera: {
title: 'Batocera Store',
subtitle: 'A katalógusunk közvetlenül a retró gépeden.',
lead: 'A ttg-batocera-store a katalógusunkat teszi fel a Batocerára, a plug-and-play retró gamer disztribúcióra. A játékainkat a gép ROM mappáiba tölti le, borítóképpel és EmulationStation metaadatokkal együtt, és a Ports menüből bármikor újrafuttatható az új kiadásokért. Csak a Python 3 alapkönyvtárát használja — nem kell mellé semmit telepíteni.',
repo: 'Git tároló',
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ó',
batoceraProject: 'Batocera projekt',
repo: 'Git repó',
installTitle: 'Telepítés',
installDesc: 'Futtasd ezt SSH-n a Batocera gépen. Telepíti a klienst, kiírja a konfigurációt, létrehozza a Ports bejegyzést és lefuttatja az első szinkront.',
installNote: 'Utána indítsd újra az EmulationStationt, hogy megjelenjenek a játékok:',
installThen: 'Utána',
useTitle: 'Használat',
useDesc: 'A gépen: Ports ▸ „Teletype Games Store”. SSH-n keresztül egy egyszerű CLI áll rendelkezésre:',
platformsTitle: 'Mit telepít',
platformsDesc: 'Egy katalógus-platform akkor telepíthető, ha a kiadás fájlja közvetlenül elindul egy Batocera rendszeren. A többi platform webes és asztali buildeket ad, ezért nincsenek leképezve.',
engineTitle: 'Csinálj saját store-t',
engineDesc: 'Itt semmi sem ránk van szabva. A mi store-unk csak egy konfigurációs fájl a warp-engine-batocera-store fölött, ami bármilyen WarpEngine alapú oldallal működik: állítsd a saját katalógusodra, adj neki nevet és saját ROM almappát, és a játékaid ugyanígy kerülnek fel egy Batocera gépre. Egy gépen több store is megfér egymás mellett anélkül, hogy egymás játékaihoz nyúlnának.',
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',
+147 -93
View File
@@ -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'
+11
View File
@@ -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'
}
+1 -3
View File
@@ -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')

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