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
287 changed files with 7832 additions and 5631 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")
+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
@@ -1,7 +1,8 @@
# 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.
# 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.
@@ -29,18 +30,51 @@ services:
MYSQL_DATABASE: warp_engine_test
steps:
# A motor tesztje kapuzza a tobbit: ha bukik, se a tukrozes, se a gem
# kiadasa nem fut le. A mirror repoba nincs ertelme CI-t tenni, mert azt a
# subtree split minden alkalommal feluliria.
# 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
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev default-mysql-client
- cd libs/ruby/warp_engine
- bundle install --jobs 4
- flock -w 900 /cache/bundle/.install.lock bundle install --jobs 4
- |
echo "==> Varakozas a mysql-re"
for i in $(seq 1 60); do
+138 -9
View File
@@ -16,13 +16,44 @@ 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,
the store registry, 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
@@ -103,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)
@@ -117,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.5.2)
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
+36 -3
View File
@@ -1,8 +1,12 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url
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
@@ -10,12 +14,36 @@ ActiveAdmin.register Store do
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
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
@@ -24,6 +52,9 @@ ActiveAdmin.register Store do
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
@@ -31,7 +62,8 @@ ActiveAdmin.register Store 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."
"catalog host. An inactive store is left out of that answer entirely — the " \
"client has no state for 'there but switched off'."
end
end
@@ -39,6 +71,7 @@ ActiveAdmin.register Store do
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
+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
@@ -15,7 +15,6 @@ class Api::StoresController < ApiController
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
@@ -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
+2 -12
View File
@@ -1,14 +1,3 @@
# A store a client can install from: a name and a WarpEngine catalog.
#
# That is the whole record, and deliberately so. A client takes identity from it — the
# name, the catalog, and a slug derived from the catalog host — and everything else from
# the store engine it carries. How a store behaves is fixed per installed client, which
# knows its own machine; a copy of it here would be a second authority over decisions
# the client has already made, including which directories it may delete from.
#
# This is deliberately not part of WarpEngine. The engine serves one catalog and has no
# business knowing which stores exist for it; the registry is a property of this site,
# which is what the graphical client asks.
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
@@ -17,9 +6,10 @@ class Store < ApplicationRecord
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 created_at updated_at]
%w[id name catalog_url active created_at updated_at]
end
end
+1 -2
View File
@@ -2,7 +2,6 @@ class StoreSerializer < Blueprinter::Base
include WarpEngine::TimestampFields
field :name
# camelCase, as the catalog's own payloads use — one convention for a client
# that reads both.
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
+1 -1
View File
@@ -1,5 +1,5 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.ordered)
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/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" }
}
# 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}"
+1 -3
View File
@@ -7,6 +7,7 @@ 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"
@@ -14,8 +15,5 @@ Rails.application.routes.draw do
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
@@ -3,9 +3,7 @@ class CreateStores < ActiveRecord::Migration[8.1]
create_table :stores, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
# A store is a WarpEngine catalog plus the repository that configures a
# client for it. The registry lives here rather than in the engine: the
# engine serves one catalog and knows nothing about who ships stores for it.
t.string :name, null: false
t.string :catalog_url, null: false
t.string :store_repository_url, null: false
@@ -1,9 +1,5 @@
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
# A store repository is now optional. The client only ever needed identity from
# it — a name, a catalog and a slug — and the engine's own defaults cover
# everything else, so a record with a catalog URL is a complete store. A
# repository is still honoured when there is one: it stays the authority on how
# that store behaves.
def change
change_column_null :stores, :store_repository_url, true
end
@@ -1,15 +1,5 @@
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
# The store's own configuration moves into this record.
#
# It used to live as a `config.json` in a repository the 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*; carrying how it behaves
# in the same record makes this the one source of truth, and lets a store exist with
# no repository at all — which is the ordinary case now that the store engine ships
# inside the client.
#
# 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.
def change
add_column :stores, :config, :json
end
@@ -1,16 +1,5 @@
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
# Both extras go. A store record is a name and a catalog, and nothing else.
#
# `config` was added earlier today on the idea that the registry should say how each
# store behaves. It should not: the configuration is fixed per installed client — the
# client carries it and knows its own machine — so a copy on the server was a second
# authority over decisions the client had already made correctly, including where it
# may delete. Keeping two stores on one machine apart is a subfolder, which the client
# derives itself.
#
# `store_repository_url` goes for the same reason it stopped being read: the store
# engines it pointed at do not exist any more, and a URL nobody follows is a URL that
# goes stale.
def change
remove_column :stores, :config, :json
remove_column :stores, :store_repository_url, :string
@@ -0,0 +1,6 @@
class AddActiveToStores < ActiveRecord::Migration[8.1]
def change
add_column :stores, :active, :boolean, null: false, default: true
end
end
+2 -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_19_120000) 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
@@ -300,6 +300,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_120000) do
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
-3
View File
@@ -16,9 +16,6 @@ end
end
end
# The store registry the graphical desktop client reads. Our own catalog is the
# first record; anyone running this site would add their own the same way, from
# the admin panel or here.
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
@@ -10,7 +10,7 @@ RSpec.describe Api::StoresController, type: :request do
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
expect(json.map { |s| s["name"] }).to eq([ "Apex Games", "Zed Games" ])
end
it "answers with the fields a client needs, camelCased" do
@@ -19,8 +19,7 @@ RSpec.describe Api::StoresController, type: :request do
get "/api/stores"
store = JSON.parse(response.body).first
# Exactly two fields: a name and a catalog are the whole record, and a client
# that starts reading a third would be reading something this site no longer says.
expect(store.keys).to contain_exactly("name", "catalogUrl")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
end
@@ -32,5 +31,22 @@ RSpec.describe Api::StoresController, type: :request do
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
+4
View File
@@ -2,5 +2,9 @@ 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
+1 -1
View File
@@ -15,7 +15,7 @@ RSpec.describe Store, type: :model do
later = create(:store, name: "Zed Games")
first = create(:store, name: "Apex Games")
expect(Store.ordered).to eq([first, later])
expect(Store.ordered).to eq([ first, later ])
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
@@ -1,11 +1,6 @@
require "rails_helper"
require "warden/test/helpers"
# The pipeline admin form could not save at all: the resource never declared
# `permit_params`, so ActiveAdmin handed unpermitted attributes to the model and Rails
# raised ForbiddenAttributesError on every edit. No model spec could have caught that —
# the fault was one layer up — so the check belongs here, where the host's ActiveAdmin
# instance actually runs.
RSpec.describe "Admin pipelines", type: :request do
include Warden::Test::Helpers
@@ -27,9 +22,6 @@ RSpec.describe "Admin pipelines", type: :request do
after { Warden.test_reset! }
# This app keeps forgery protection on in the test environment, and a request spec has
# no rendered form to take a token from. The token is not what is under test here, so it
# is switched off for the duration and put back afterwards.
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
@@ -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.

Before

Width:  |  Height:  |  Size: 878 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([])
})
})
+30 -21
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: {
@@ -37,29 +48,21 @@ export default {
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',
@@ -206,7 +215,7 @@ export default {
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 · needs Python 3',
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',
+30 -21
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: {
@@ -37,29 +48,21 @@ export default {
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: {
@@ -126,6 +132,9 @@ export default {
},
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',
@@ -206,7 +215,7 @@ export default {
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 · Python 3 kell hozzá',
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ó',
+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