Author SHA1 Message Date
mr.zeroandClaude Opus 5 82b87d019f WarpEngine 0.7.0: a képtár és a CI is a hoszté, adapteren keresztül
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
Két dolog volt beépítve az engine-be, ami nem az övé.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things found on the way:

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

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

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

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

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

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

    Multiple migrations have the version number 20260819000001.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 20:57:23 +02:00
mr.zero 87e23545d1 phaser pipeline fix
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-16 16:45:16 +02:00
mr.zeroandClaude Opus 5 2b62070557 Give gem push a named credentials key
ci/woodpecker/tag/woodpecker Pipeline was successful
The push prompted for credentials and then died with "404 page not
found". Both are the same fault: RubyGems normalizes the keys in
~/.gem/credentials — dots become __ and a trailing slash is appended —
so a key written as the host URL can never match the --host value.
Finding no key, gem push falls back to signing in against the RubyGems
sign_in endpoint, which Gitea does not implement; that is the 404.

A named key is stored verbatim and is matched by --key, so the lookup
succeeds. Verified against Gem::ConfigFile with both formats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:11:17 +02:00
mr.zeroandClaude Opus 5 d11610b6c7 Point the release pipeline at the engines org
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
The publishing pipeline was already complete — subtree split to the
mirror, gem build and push on a warp_engine-v* tag — but it had never
been triggered, and after the org reorganization three of its targets
were stale: the mirror push URL and the rubygems registry namespace
(twice).

The gemspec's allowed_push_host has to match the --host that `gem push`
receives, so it now carries the full registry URL rather than the bare
forge host, plus source and documentation links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:39:40 +02:00
mr.zeroandClaude Opus 5 9cbb909f08 Point the batocera page at ttg-batocera-store
The client was split into a reusable engine and a store definition, so
the page is now about our store: new repo and wiki links, the CLI path
the installer actually writes, and a closing section pointing at
warp-engine-batocera-store for anyone who wants a store of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:33:27 +02:00
mr.zero 2d750a4ce7 remove matrix link from batocera page 2026-08-11 00:01:14 +02:00
mr.zero 0a7e2d14e2 batocera store 2026-08-10 23:58:27 +02:00
mr.zero 763a092640 warp_engine: load ActiveJob itself so production eager load works
ci/woodpecker/push/woodpecker Pipeline was successful
The engine ships an ActiveJob based job (PipelineSyncJob), but a host's
application.rb does not necessarily require active_job/railtie - apps/api does
not. With eager loading off (development, test) nothing noticed; in production
WarpEngine::ApplicationJob blew up with "uninitialized constant
WarpEngine::ActiveJob", which is exactly what `rails zeitwerk:check` in
RAILS_ENV=production reported. An engine that ships jobs has to pull in the
framework it needs, so lib/warp_engine.rb requires the railtie.

Pre-existing on 0.1.0 as well; found while verifying that the 0.2.0 changes do
not break the portal. All three api-test steps are green now.

apps/api Gemfile.lock follows the 0.1.0 -> 0.2.0 path gem bump.
2026-08-10 11:30:06 +02:00
mr.zero ec43d2ae46 warp_engine 0.2.0: pluggable storage adapter and publish notifications
ci/woodpecker/push/woodpecker Pipeline was successful
Two seams the hosts needed, both backward compatible.

Storage: artifacts are served through WarpEngine::Storage.adapter instead of
raw filesystem calls. The default :local adapter keeps the previous behaviour
byte for byte, including the path traversal guard. A host can now set
config.storage_adapter to any object answering file?/directory?/locate and
serve builds from an object store - FileService and /api/download both honour
a Location.redirect, so a signing adapter turns them into redirects.
DownloadService#create still returns an absolute path (nil when missing) for
existing callers; #locate is the new entry point that can also return a
redirect. Ingestion (upload, extraction, file manager) stays local for now.

Publish: PublishService emits ActiveSupport::Notifications
("warp_engine.publish") with platform/name/version/software/release, so hosts
can react to a new build without hanging callbacks on the models.
WarpEngine.instruments_publish? lets a host feature-detect and keep its
fallback for older engine versions.
2026-08-10 11:16:17 +02:00
mr.zero afd1fe50c4 package upgrades 2026-08-09 17:57:08 +02:00
mr.zero 254d339656 warp_engine: CiRepository -> Pipeline rename everywhere, ci_ service prefixes dropped, unknown platform allowed
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:46:06 +02:00
mr.zero 9b89550766 warp_engine admin: CI Dashboard removed, Pipelines page with details sidebar, badge and Created fixes
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:32:50 +02:00
mr.zero 712fdbc97b fixes
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 19:23:04 +02:00
mr.zero 23bbccd2b1 engines digest: require What You Get as first heading 2026-08-06 19:03:16 +02:00
mr.zero d82bb6f468 EnginesIndexPage digests 2026-08-06 18:19:40 +02:00
mr.zero a0ced02345 readme: woodpecker ci management
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:31:28 +02:00
mr.zero 546201c886 WarpEngine dropdown emoji
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:23:52 +02:00
mr.zero c853cfadbc WarpEngine dropdown 2026-08-06 16:22:17 +02:00
Zsolt Tasnadi b1139a43bc wp admin to compose 2026-08-06 16:05:07 +02:00
mr.zero bae6fc06a6 pipeline fix 2026-08-06 16:04:50 +02:00
mr.zero 731b267aa1 wp config fix 2 2026-08-06 15:05:55 +02:00
mr.zero 508e869080 wp config fix 2026-08-06 14:08:18 +02:00
mr.zero 435d22b71d wp config 2026-08-06 14:04:42 +02:00
Zsolt Tasnadi f499b7f2af schema update 2026-08-06 14:00:13 +02:00
mr.zero 267a13b600 woodpecker integration 2026-08-06 13:58:58 +02:00
mr.zeroandClaude Opus 4.6 b530dcd50d Update README template path to match platform directory structure
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-06 10:34:06 +02:00
mr.zero 068c4db3f8 platform files refact
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 10:00:34 +02:00
mr.zero 565c086a0d Pin the pipeline update server URL: base_url is http behind the host nginx 2026-08-06 08:58:18 +02:00
mr.zero dd69dd79ab Register the config extension endpoint globally on the Woodpecker server 2026-08-06 08:26:10 +02:00
mr.zero fd0b64850f Serve the tic80 pipeline on the existing tic80pro image
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 08:16:47 +02:00
mr.zero 61ad1a87f2 godot builder image version update 2026-08-06 07:59:07 +02:00
mr.zero 7c9c8ff510 Verify RFC 9421 signatures on the Woodpecker config endpoint
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 07:24:47 +02:00
mr.zero 4b32252d2b Translate code comments and admin strings to English
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 01:25:53 +02:00
mr.zero dc45f2eb35 Serve Woodpecker pipeline configs from the engine (/build/config) 2026-08-06 01:14:22 +02:00
mr.zero c067d303bb Remove the droparea: artifacts arrive over /build/upload
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-05 22:16:34 +02:00
254 changed files with 10659 additions and 5075 deletions
+40 -5
View File
@@ -1,10 +1,10 @@
# Read-only split mirror: a libs/ruby/warp_engine alkönyvtárat kitükrözi a
# tools/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# engines/warp_engine repóba (fejlesztés itt, a monorepóban történik; a tükör
# csak publikálásra való). Tag-elt release (warp_engine-v*) esetén a gem a
# Forgejo rubygems registry-be is felmegy.
#
# Szükséges Woodpecker secret: forge_token — Forgejo access token
# repository:write (tools/warp_engine) és package:write joggal.
# repository:write (engines/warp_engine) és package:write joggal.
when:
- event: push
@@ -21,7 +21,37 @@ clone:
partial: false
depth: 0 # a subtree splithez teljes history kell
services:
- name: mysql
image: mysql:8
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: warp_engine_test
steps:
# 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.
test-engine:
image: ruby:3.2
environment:
RAILS_ENV: test
DB_HOST: mysql
commands:
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
- cd libs/ruby/warp_engine
- bundle install --jobs 4
- |
echo "==> Varakozas a mysql-re"
for i in $(seq 1 60); do
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
sleep 2
done
- |
echo "==> Teszt adatbazis elokeszitese"
bundle exec rake app:db:test:prepare
- bundle exec rspec
split-mirror:
image: alpine/git
environment:
@@ -32,7 +62,7 @@ steps:
- git subtree split --prefix=libs/ruby/warp_engine HEAD -b warp-engine-split
# a secretbe másolt token végén lehet sortörés — levágjuk
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/tools/warp_engine.git" warp-engine-split:master
- git push --force "https://ci:$${TOKEN}@git.teletypegames.org/engines/warp_engine.git" warp-engine-split:master
when:
- event: push
branch: master
@@ -48,10 +78,15 @@ steps:
- gem build warp_engine.gemspec
- mkdir -p ~/.gem
- TOKEN="$$(printf '%s' "$${FORGE_TOKEN}" | tr -d '[:space:]')"
# Nevesített kulcs, nem a hoszt-URL: a RubyGems a credentials fájl
# kulcsait normalizálja (a pontokból __ lesz, záró perjelet kap), így egy
# URL-kulcs sosem egyezik a --host értékével. Ilyenkor a gem push
# bejelentkezni próbál a RubyGems sign_in végpontján, amit a Gitea nem
# ismer — ez adta a "404 page not found"-ot.
- |
printf -- '---\n:https://git.teletypegames.org/api/packages/tools/rubygems: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
printf -- '---\n:gitea: Bearer %s\n' "$${TOKEN}" > ~/.gem/credentials
- chmod 600 ~/.gem/credentials
- gem push --host https://git.teletypegames.org/api/packages/tools/rubygems warp_engine-*.gem
- gem push --key gitea --host https://git.teletypegames.org/api/packages/engines/rubygems warp_engine-*.gem
when:
- event: tag
ref: refs/tags/warp_engine-v*
+48 -6
View File
@@ -16,13 +16,55 @@ libs/
The API is split in two layers:
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
(softwares, releases, release assets, images, platform links, download stats),
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
(softwares, releases, release assets, software-image links, platform links,
download stats), the CI-callable `/build/*` publishing endpoints, the public
read-only JSON API (`/api/software*`, `/api/builds*`, `/api/download`,
`/file/*`) and the catalog ActiveAdmin resources.
See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets.
It consumes WarpEngine as a path gem and mounts it at `/`.
the image library (`Image`, `/api/image/:id`, the admin page — the engine only
links to it through an adapter), the store registry, wiki proxy, RSS feeds,
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
as a path gem and mounts it at `/`.
## The store registry
`GET /api/stores` lists the stores a client can install from. The desktop
graphical client reads it on first run, which is why it is **public**: a client has
nobody to log in as.
```json
[
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
]
```
A `Store` row has two required fields — `name` and `catalog_url` — plus an
**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**;
`db/seeds.rb` creates our own.
**A store does not need a repository.** The store engine's own defaults already
cover the host-to-asset mapping, the install modes, the platforms and the
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
that is what this row carries. With no repository the client derives the slug from
the catalog host, writes a small config and installs. Given one, it reads that
repository's `config.json` and points it at `catalog_url`, and that file remains the
authority on how the store behaves; a repository without a config file is treated
as no repository at all.
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
above is the host's own endpoint and does not, because it is not part of the engine.
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
one catalog and has no business knowing which stores exist for it; who ships a
store for a catalog is a property of the site.
| | |
|---|---|
| Model | `apps/api/app/models/store.rb` |
| Endpoint | `apps/api/app/controllers/api/stores_controller.rb` |
| Admin | `apps/api/app/admin/stores.rb` |
| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) |
## Development environment
+95 -94
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.1.0)
warp_engine (0.7.0)
apipie-rails
blueprinter
rails (>= 8.0)
@@ -10,31 +10,31 @@ PATH
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.18)
action_text-trix (2.1.19)
railties
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
actioncable (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionmailbox (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
actionmailer (8.1.3.1)
actionpack (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activesupport (= 8.1.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
actionpack (8.1.3.1)
actionview (= 8.1.3.1)
activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -42,21 +42,21 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.3)
actiontext (8.1.3.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
actionpack (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.3)
activesupport (= 8.1.3)
actionview (8.1.3.1)
activesupport (= 8.1.3.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activeadmin (3.5.1)
activeadmin (3.5.2)
arbre (~> 1.2, >= 1.2.1)
csv
formtastic (>= 3.1)
@@ -68,22 +68,22 @@ GEM
ransack (>= 4.0)
activeadmin_blaze_theme (1.1.0)
activeadmin (>= 2.0, < 4)
activejob (8.1.3)
activesupport (= 8.1.3)
activejob (8.1.3.1)
activesupport (= 8.1.3.1)
globalid (>= 0.3.6)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
activemodel (8.1.3.1)
activesupport (= 8.1.3.1)
activerecord (8.1.3.1)
activemodel (= 8.1.3.1)
activesupport (= 8.1.3.1)
timeout (>= 0.4.0)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
activestorage (8.1.3.1)
actionpack (= 8.1.3.1)
activejob (= 8.1.3.1)
activerecord (= 8.1.3.1)
activesupport (= 8.1.3.1)
marcel (~> 1.0)
activesupport (8.1.3)
activesupport (8.1.3.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -107,18 +107,18 @@ GEM
bcrypt (3.1.22)
bigdecimal (4.1.2)
blueprinter (1.3.0)
bootsnap (1.24.3)
msgpack (~> 1.2)
bootsnap (1.25.0)
msgpack (~> 1.5)
builder (3.3.0)
concurrent-ruby (1.3.6)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
crass (1.0.7)
csv (3.3.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
devise (5.0.3)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
@@ -126,7 +126,7 @@ GEM
warden (~> 1.2.3)
diff-lcs (1.6.2)
drb (2.2.3)
erb (6.0.4)
erb (6.0.7)
erubi (1.13.1)
factory_bot (6.6.0)
activesupport (>= 6.1.0)
@@ -138,19 +138,19 @@ GEM
formtastic (6.0.0)
actionpack (>= 7.2.0)
formtastic_i18n (0.7.0)
globalid (1.3.0)
globalid (1.4.0)
activesupport (>= 6.1)
has_scope (0.9.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
i18n (1.14.8)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
inherited_resources (1.14.0)
actionpack (>= 6.0)
has_scope (>= 0.6)
railties (>= 6.0)
responders (>= 2)
io-console (0.8.2)
io-console (0.9.1)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
@@ -160,7 +160,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.21.2)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -173,27 +173,27 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.5)
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
marcel (1.2.1)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
msgpack (1.8.4)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.4)
net-imap (0.6.6)
date
net-protocol
net-pop (0.1.2)
@@ -203,23 +203,20 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
orm_adapter (0.5.0)
parallel (1.28.0)
parser (3.3.11.1)
parallel (2.1.0)
parser (3.3.12.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
puma (8.0.1)
puma (8.0.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
@@ -230,30 +227,30 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
rails (8.1.3.1)
actioncable (= 8.1.3.1)
actionmailbox (= 8.1.3.1)
actionmailer (= 8.1.3.1)
actionpack (= 8.1.3.1)
actiontext (= 8.1.3.1)
actionview (= 8.1.3.1)
activejob (= 8.1.3.1)
activemodel (= 8.1.3.1)
activerecord (= 8.1.3.1)
activestorage (= 8.1.3.1)
activesupport (= 8.1.3.1)
bundler (>= 1.15.0)
railties (= 8.1.3)
railties (= 8.1.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
railties (8.1.3.1)
actionpack (= 8.1.3.1)
activesupport (= 8.1.3.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -266,12 +263,17 @@ GEM
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
rdoc (7.2.0)
rbs (4.1.2)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
psych (>= 4.0.0)
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.12.0)
reline (0.6.3)
reline (0.7.0)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
@@ -296,7 +298,7 @@ GEM
rspec-support (3.13.7)
rss (0.3.3)
rexml
rubocop (1.86.1)
rubocop (1.89.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -307,14 +309,14 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
rubocop-ast (1.50.0)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
rubocop-rails (2.36.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -346,9 +348,8 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
tilt (2.8.0)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
@@ -360,11 +361,11 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.7.5)
zeitwerk (2.8.3)
PLATFORMS
arm64-darwin-22
@@ -1,13 +1,11 @@
ActiveAdmin.register WarpEngine::Image, as: "Image" do
ActiveAdmin.register Image do
permit_params :file_upload
menu priority: 5, label: "🖼️ Images"
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
# SoftwareImage a natív használó; a hoston regisztrált image_owners
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
used_ids = -> {
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
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
@@ -36,7 +34,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
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
@@ -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
+79
View File
@@ -0,0 +1,79 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url, :active
menu priority: 5, label: "🛒 Stores"
scope("Active", default: true) { |scope| scope.active }
scope("Inactive") { |scope| scope.where(active: false) }
scope :all
index do
selectable_column
id_column
column :name
column :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
column :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
column :updated_at
actions defaults: true do |store|
link_to store.active? ? "Hide" : "List",
toggle_admin_store_path(store),
method: :put
end
end
filter :name
filter :catalog_url
filter :active
member_action :toggle, method: :put do
resource.update!(active: !resource.active?)
redirect_back fallback_location: admin_stores_path,
notice: "#{resource.name} is now #{resource.active? ? 'listed' : 'hidden'}"
end
batch_action :list do |ids|
Store.where(id: ids).update_all(active: true, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) listed"
end
batch_action :hide do |ids|
Store.where(id: ids).update_all(active: false, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) hidden"
end
show do
attributes_table do
row :id
row :name
row :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
row :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
row :created_at
row :updated_at
end
para do
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
"and a catalog are the whole record: the client carries its own store engine " \
"and configures itself from this much, deriving the store's slug from the " \
"catalog host. An inactive store is left out of that answer entirely — the " \
"client has no state for 'there but switched off'."
end
end
form do |f|
f.inputs do
f.input :name, hint: "What the client shows in its store picker"
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
f.input :active, hint: "Off keeps the record but leaves it out of GET /api/stores"
end
f.actions
end
end
+1 -2
View File
@@ -1,2 +1 @@
//= link active_admin.css
//= link active_admin.js
@@ -1,5 +1,3 @@
//= require active_admin/base
(function() {
var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])';
var _pickerTarget = null;
@@ -89,7 +87,6 @@
var html = '';
// Breadcrumb
var parts = dir ? dir.split('/').filter(function(p){return p;}) : [];
html += '<div class="fm-breadcrumbs"><a href="#" data-dir="">softwares/</a>';
var acc = '';
@@ -99,19 +96,16 @@
}
html += '</div>';
// Upload bar (also a drop zone)
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
html += '<input type="file" id="fm-picker-file" />';
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
html += '<span class="fm-drop-hint">or drag &amp; drop files here</span>';
html += '</div>';
// Table
html += '<table class="fm-table"><thead><tr>';
html += '<th style="width:50px">Type</th><th>Name</th><th style="width:90px">Size</th>';
html += '<th style="width:80px"></th></tr></thead><tbody>';
// Parent dir
if (dir) {
var parentDir = dir.indexOf('/') >= 0 ? dir.substring(0, dir.lastIndexOf('/')) : '';
html += '<tr><td class="fm-type">DIR</td>';
@@ -139,10 +133,8 @@
html += '</tbody></table>';
body.innerHTML = html;
// Event delegation
body.addEventListener('click', handlePickerClick);
// Drag & drop
var dropzone = document.getElementById('fm-picker-dropzone');
if (dropzone) {
['dragenter', 'dragover'].forEach(function(evt) {
@@ -162,21 +154,18 @@
function handlePickerClick(e) {
var t = e.target;
// Navigate directory
if (t.tagName === 'A' && t.hasAttribute('data-dir')) {
e.preventDefault();
loadDir(t.getAttribute('data-dir'));
return;
}
// Select file/folder
if (t.hasAttribute('data-select')) {
e.preventDefault();
selectPath(t.getAttribute('data-select'));
return;
}
// Upload button
if (t.id === 'fm-picker-upload-btn') {
e.preventDefault();
var fileInput = document.getElementById('fm-picker-file');
@@ -233,7 +222,6 @@
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Live image preview for software_images image_id selects
function addImagePreviewListeners() {
var selects = document.querySelectorAll('select[id*="image_id"]:not([data-preview-added])');
selects.forEach(function(sel) {
@@ -241,7 +229,6 @@
sel.addEventListener('change', function() {
var hint = sel.closest('li').querySelector('.inline-hints');
if (!hint) {
// Create hint element if it doesn't exist
hint = document.createElement('p');
hint.className = 'inline-hints';
sel.closest('li').appendChild(hint);
@@ -255,7 +242,6 @@
});
}
// Standalone Files page drag & drop
function initPageDropzone() {
var dz = document.getElementById('fm-page-dropzone');
if (!dz) return;
@@ -2,7 +2,6 @@
@import "active_admin/base";
@import "activeadmin_blaze_theme/theme";
// Force utility navigation visible (blaze theme may hide it)
#utility_nav { display: flex !important; visibility: visible !important; }
#utility_nav #site_link a {
color: #fff !important;
@@ -11,7 +10,6 @@
&:hover { text-decoration: underline; }
}
// File Manager
.fm-breadcrumbs {
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
@@ -37,7 +35,6 @@
.fm-icon-danger:hover { filter: brightness(1.3); }
.fm-danger { color: #dc3545; }
// Emoji action buttons in AA index tables
.table_actions {
a.view_link, a.edit_link, a.delete_link {
font-size: 0 !important;
@@ -61,14 +58,12 @@
.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; }
.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; }
// Browse button in release forms
.fm-browse-btn {
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff !important; border-radius: 3px; font-size: 11px; text-decoration: none !important; vertical-align: middle;
&:hover { background: #5a6268; color: #fff !important; }
&:visited { color: #fff !important; }
}
// Inline file picker modal
.fm-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
}
@@ -0,0 +1,15 @@
class Api::ImagesController < ApiController
resource_description do
short "Images"
formats [ "binary" ]
end
api :GET, "/api/image/:id", "Get image by ID"
param :id, :number, required: true, desc: "Image ID"
returns code: 200, desc: "Image binary data"
error code: 404, desc: "Image not found"
def show
image = Image.find(params[:id])
send_file image.file_path, type: image.content_type, disposition: "inline"
end
end
@@ -0,0 +1,23 @@
class Api::StoresController < ApiController
resource_description do
short "Stores"
end
api :GET, "/api/stores", "List the stores a client can install from"
desc <<~DESC
The registry the graphical desktop client reads on first run: which catalogs
exist. Public on purpose: a client has nobody to log in as.
A name and a catalog URL are the whole record. How a store behaves is fixed per
installed client it carries its own store engine and knows its own machine so
the registry says what a store *is* and nothing about how it works.
DESC
returns code: 200, desc: "Array of stores" do
property :name, String, desc: "Display name of the store"
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
end
def index
render json: StoreService.new.index
end
end
@@ -26,7 +26,7 @@ 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(
tag: params[:tag],
+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
+33
View File
@@ -0,0 +1,33 @@
class Image < ApplicationRecord
def self.upload_path
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
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_save :process_upload, 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
private
def process_upload
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
self.filename = "#{SecureRandom.uuid}#{ext}"
IO.copy_stream(file_upload.to_io, file_path)
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
class Member < ApplicationRecord
belongs_to :image, class_name: "WarpEngine::Image", optional: true
belongs_to :image, optional: true
has_one :admin_user
validates :nick, presence: true, uniqueness: true
+15
View File
@@ -0,0 +1,15 @@
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
validates :name, presence: true
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
default_scope { where(deleted_at: nil) }
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:name) }
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url active created_at updated_at]
end
end
@@ -0,0 +1,7 @@
class StoreSerializer < Blueprinter::Base
include WarpEngine::TimestampFields
field :name
field(:catalogUrl) { |store| store.catalog_url }
end
+6
View File
@@ -0,0 +1,6 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.active.ordered)
end
end
-18
View File
@@ -1,23 +1,6 @@
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
@@ -44,6 +27,5 @@ class WikiService
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
end
# Az RSS feedek ezen a néven hívják.
alias_method :pages, :index
end
-2
View File
@@ -22,8 +22,6 @@ 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
config.autoload_lib(ignore: %w[assets tasks])
-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,8 +1,3 @@
# 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
-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
+18 -11
View File
@@ -1,19 +1,26 @@
# WarpEngine host-konfiguráció. to_prepare: reload után is újrafut, ezért
# értékadás (nem <<), hogy idempotens legyen.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# A /build/* DB-tokenjeinek tulajdonosa. A :database módra váltás
# (c.application_token_source = :database) csak azután jöhet, hogy a CI már
# DB-tokent használ — az átkapcsolás azonnal érvényteleníti az UPDATE_SECRET-et.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
c.image_owners = [
{
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
c.image_class_name = "Image"
c.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
+2 -3
View File
@@ -7,14 +7,13 @@ Rails.application.routes.draw do
get "swagger", to: "swagger#index"
get "events", to: "events#index"
get "members", to: "members#index"
get "image/:id", to: "images#show"
get "stores", to: "stores#index"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
get "rss/releases", to: "rss#releases"
get "rss/howtos", to: "rss#howtos"
end
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
# az engine adja.
mount WarpEngine::Engine => "/"
end
@@ -9,7 +9,6 @@ class AddCascadeToSoftwareForeignKeys < ActiveRecord::Migration[8.1]
remove_foreign_key :software_images, :softwares
add_foreign_key :software_images, :softwares, on_delete: :cascade
# Keep download stats when a release is removed.
remove_foreign_key :downloads, name: "fk_downloads_release"
add_foreign_key :downloads, :releases, name: "fk_downloads_release", on_delete: :nullify
end
@@ -20,8 +20,6 @@ class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1]
add_index :release_assets, :path, name: "idx_release_assets_path"
add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade
# A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik,
# így az unscoped alapú admin/updater logika konzisztens marad.
COLUMN_KINDS.each do |column, kind|
execute <<~SQL.squish
INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at)
@@ -17,7 +17,6 @@ class DropLegacyPathColumnsFromReleases < ActiveRecord::Migration[8.1]
add_column :releases, column, :string
end
# rollbacknél az adat a release_assets-ből áll vissza, nem veszik el
COLUMN_KINDS.each do |column, kind|
execute <<~SQL.squish
UPDATE releases r
@@ -0,0 +1,17 @@
class CreateStores < ActiveRecord::Migration[8.1]
def change
create_table :stores, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
t.string :name, null: false
t.string :catalog_url, null: false
t.string :store_repository_url, null: false
t.datetime :deleted_at, precision: 3
t.timestamps precision: 3
t.index :deleted_at, name: "idx_stores_deleted_at"
t.index :name, name: "idx_stores_name"
end
end
end
@@ -0,0 +1,6 @@
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
def change
change_column_null :stores, :store_repository_url, true
end
end
@@ -0,0 +1,6 @@
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
def change
add_column :stores, :config, :json
end
end
@@ -0,0 +1,7 @@
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
def change
remove_column :stores, :config, :json
remove_column :stores, :store_repository_url, :string
end
end
@@ -0,0 +1,6 @@
class AddActiveToStores < ActiveRecord::Migration[8.1]
def change
add_column :stores, :active, :boolean, null: false, default: true
end
end
+49 -13
View File
@@ -1,16 +1,4 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -45,6 +33,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
end
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "application_token_id", unsigned: true
t.datetime "approved_at", precision: 3
t.string "client_name", limit: 128
t.datetime "created_at", precision: 3
t.datetime "denied_at", precision: 3
t.string "device_code", limit: 64, null: false
t.datetime "expires_at", precision: 3, null: false
t.string "issued_token", limit: 64
t.bigint "subject_id", unsigned: true
t.string "subject_type", limit: 128
t.datetime "updated_at", precision: 3
t.string "user_code", limit: 16, null: false
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
t.index ["expires_at"], name: "idx_device_grants_expires_at"
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
end
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -196,6 +202,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
t.index ["image_id"], name: "index_members_on_image_id"
end
create_table "pipelines", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
t.datetime "last_pipeline_at", precision: 3
t.string "last_pipeline_status", limit: 32
t.string "platform", limit: 32, null: false
t.string "repo_name", limit: 128, null: false
t.string "repo_owner", limit: 128, null: false
t.bigint "software_id", unsigned: true
t.datetime "updated_at", precision: 3
t.bigint "woodpecker_repo_id", null: false, unsigned: true
t.index ["deleted_at"], name: "idx_pipelines_deleted"
t.index ["repo_owner", "repo_name"], name: "idx_pipelines_owner_name", unique: true
t.index ["software_id"], name: "idx_pipelines_software"
t.index ["woodpecker_repo_id"], name: "idx_pipelines_wp_id", unique: true
end
create_table "platform_links", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", precision: 3
t.datetime "deleted_at", precision: 3
@@ -263,10 +287,22 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_05_000003) do
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
end
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.string "catalog_url", null: false
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
t.string "name", null: false
t.datetime "updated_at", precision: 3, null: false
t.index ["deleted_at"], name: "idx_stores_deleted_at"
t.index ["name"], name: "idx_stores_name"
end
add_foreign_key "admin_users", "members"
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
add_foreign_key "members", "images"
add_foreign_key "pipelines", "softwares", name: "fk_pipelines_software", on_delete: :nullify
add_foreign_key "release_assets", "releases", name: "fk_releases_release_assets", on_delete: :cascade
add_foreign_key "releases", "softwares", name: "fk_softwares_releases", on_delete: :cascade
add_foreign_key "software_images", "images"
+4
View File
@@ -15,3 +15,7 @@ end
m.avatar_filename = attrs[:avatar_filename]
end
end
Store.find_or_create_by!(name: "Teletype Games") do |store|
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
end
+28
View File
@@ -0,0 +1,28 @@
namespace :admin_user do
desc "Set an admin user password: rake admin_user:password EMAIL=... PASSWORD=..."
task password: :environment do
email = ENV["EMAIL"].to_s.strip
password = ENV["PASSWORD"].to_s
if email.empty? || password.empty?
abort "Használat: rake admin_user:password EMAIL=admin@teletype.hu PASSWORD=újjelszó"
end
user = AdminUser.find_by(email: email)
abort "Nincs ilyen admin user: #{email}" if user.nil?
user.password = password
user.password_confirmation = password
if user.save
puts "Jelszó frissítve: #{user.email}"
else
abort "Sikertelen: #{user.errors.full_messages.join(', ')}"
end
end
desc "List admin users"
task list: :environment do
AdminUser.order(:email).each { |u| puts "#{u.id}\t#{u.email}" }
end
end
@@ -0,0 +1,52 @@
require "rails_helper"
RSpec.describe Api::StoresController, type: :request do
describe "GET /api/stores" do
it "lists stores without a login, in name order" do
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
get "/api/stores"
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
end
it "answers with the fields a client needs, camelCased" do
create(:store)
get "/api/stores"
store = JSON.parse(response.body).first
expect(store.keys).to contain_exactly("name", "catalogUrl")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
end
it "leaves out soft-deleted stores" do
create(:store, name: "Gone").update!(deleted_at: Time.current)
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
it "leaves out inactive stores" do
create(:store, name: "Listed")
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
get "/api/stores"
expect(JSON.parse(response.body).map { |s| s["name"] }).to eq([ "Listed" ])
end
it "still says nothing about the flag itself" do
create(:store)
get "/api/stores"
expect(JSON.parse(response.body).first.keys).to contain_exactly("name", "catalogUrl")
end
end
end
+10
View File
@@ -0,0 +1,10 @@
FactoryBot.define do
factory :store do
name { "Teletype Games" }
catalog_url { "https://teletypegames.org" }
trait :inactive do
active { false }
end
end
end
+1 -1
View File
@@ -4,6 +4,6 @@ RSpec.describe Member, type: :model do
subject { build(:member) }
it { should validate_presence_of(:nick) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:nick).case_insensitive }
end
+28
View File
@@ -0,0 +1,28 @@
require "rails_helper"
RSpec.describe Store, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:catalog_url) }
it "rejects a catalog url that is not http(s)" do
store = build(:store, catalog_url: "git@example.org:thing.git")
expect(store).not_to be_valid
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
end
describe ".ordered" do
it "lists stores by name" do
later = create(:store, name: "Zed Games")
first = create(:store, name: "Apex Games")
expect(Store.ordered).to eq([first, later])
end
end
it "hides soft-deleted stores" do
store = create(:store)
store.update!(deleted_at: Time.current)
expect(Store.all).to be_empty
end
end
-1
View File
@@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
# A katalógus-factory-k (software, release, download, platform_link) az engine-ben élnek
FactoryBot.definition_file_paths << WarpEngine::Engine.root.join("spec/factories")
FactoryBot.reload
@@ -0,0 +1,96 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin images", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "images-spec@example.org", password: "password123") }
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
before do
FileUtils.mkdir_p(upload_path)
allow(Image).to receive(:upload_path).and_return(upload_path)
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after do
Warden.test_reset!
FileUtils.rm_rf(upload_path)
end
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
def stored_image(name)
image = create(:image, filename: name, original_filename: name)
File.write(image.file_path, "not really a png")
image
end
it "lists an image with a preview" do
image = stored_image("cover.png")
get "/admin/images"
expect(response).to have_http_status(:ok)
expect(response.body).to include("cover.png")
expect(response.body).to include("/api/image/#{image.id}")
end
it "says which software uses an image" do
image = stored_image("used.png")
software = create(:software)
WarpEngine::SoftwareImage.create!(software: software, image_id: image.id, is_default: true)
get "/admin/images", params: { scope: "in_use" }
expect(response.body).to include("used.png")
expect(response.body).to include("1 software(s)")
end
it "says when a member uses an image" do
image = stored_image("avatar.png")
Member.create!(nick: "mr.zero", real_nick: "Zero", image_id: image.id)
get "/admin/images", params: { scope: "in_use" }
expect(response.body).to include("avatar.png")
expect(response.body).to include("member")
end
it "leaves an unused image in the orphan scope" do
stored_image("orphan.png")
get "/admin/images", params: { scope: "orphan" }
expect(response.body).to include("orphan.png")
end
it "deletes an orphan with its file" do
orphan = stored_image("gone.png")
kept = stored_image("kept.png")
Member.create!(nick: "keeper", real_nick: "Keeper", image_id: kept.id)
post "/admin/images/batch_action",
params: { batch_action: "delete_orphans", collection_selection: [ orphan.id, kept.id ] }
expect(Image.where(id: orphan.id)).to be_empty
expect(File.exist?(orphan.file_path)).to be(false)
expect(Image.where(id: kept.id)).to be_present
expect(File.exist?(kept.file_path)).to be(true)
end
it "opens an image's own page" do
image = stored_image("single.png")
get "/admin/images/#{image.id}"
expect(response).to have_http_status(:ok)
expect(response.body).to include("single.png")
end
end
@@ -0,0 +1,48 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin pipelines", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "pipelines-spec@example.org", password: "password123") }
let(:software) { create(:software) }
let!(:holder) do
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_001, repo_owner: "spec", repo_name: "holder",
platform: "tic80", software: software)
end
let!(:taker) do
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_002, repo_owner: "spec", repo_name: "taker",
platform: "tic80")
end
before do
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after { Warden.test_reset! }
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "saves the form" do
put "/admin/pipelines/#{taker.id}", params: { pipeline: { platform: "godot" } }
expect(response).to have_http_status(:found)
expect(taker.reload.platform).to eq("godot")
end
it "moves the software off the pipeline that had it" do
put "/admin/pipelines/#{taker.id}",
params: { pipeline: { platform: "tic80", software_id: software.id } }
expect(response).to have_http_status(:found)
expect(taker.reload.software).to eq(software)
expect(holder.reload.software).to be_nil
expect(software.reload.pipeline).to eq(taker)
end
end
@@ -0,0 +1,81 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin stores", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "stores-spec@example.org", password: "password123") }
let!(:listed) { create(:store, name: "Listed", catalog_url: "https://listed.example") }
let!(:hidden) { create(:store, :inactive, name: "Hidden", catalog_url: "https://hidden.example") }
before do
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after { Warden.test_reset! }
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "opens with the active ones" do
get "/admin/stores"
expect(response).to have_http_status(:ok)
expect(response.body).to include("Listed")
expect(response.body).not_to include("Hidden")
end
it "shows the hidden ones under their own scope" do
get "/admin/stores", params: { scope: "inactive" }
expect(response.body).to include("Hidden")
end
it "renders a store's page" do
get "/admin/stores/#{hidden.id}"
expect(response).to have_http_status(:ok)
end
it "saves the flag from the form" do
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
end
it "toggles from the index in one click" do
put "/admin/stores/#{listed.id}/toggle"
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
put "/admin/stores/#{listed.id}/toggle"
expect(listed.reload).to be_active
end
it "lists and hides in bulk" do
post "/admin/stores/batch_action",
params: { batch_action: "list", collection_selection: [ hidden.id ] }
expect(hidden.reload).to be_active
post "/admin/stores/batch_action",
params: { batch_action: "hide", collection_selection: [ hidden.id ] }
expect(hidden.reload).not_to be_active
end
it "is what /api/stores answers with" do
put "/admin/stores/#{listed.id}/toggle"
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
end
+37
View File
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "GET /api/image/:id", type: :request do
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
before do
FileUtils.mkdir_p(upload_path)
allow(Image).to receive(:upload_path).and_return(upload_path)
end
after { FileUtils.rm_rf(upload_path) }
it "serves the file with the content type it was stored with" do
image = create(:image, filename: "cover.png", content_type: "image/png")
File.write(image.file_path, "not really a png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("image/png")
expect(response.body).to eq("not really a png")
end
it "answers 404 for an image that does not exist" do
get "/api/image/999999"
expect(response).to have_http_status(:not_found)
end
it "errors, as it always has, when the row is there but the file is not" do
image = create(:image, filename: "missing.png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:internal_server_error)
end
end
+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" />
+1771 -1440
View File
File diff suppressed because it is too large Load Diff
+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",
+16 -12
View File
@@ -2,7 +2,6 @@ import type { WikiPage, WikiPageWithContent, WikiPageContent } from '../lib/inte
import { CONFIG } from '../lib/config'
// Public content base — used only for building external links to wiki pages.
const WIKI_BASE = CONFIG.wikiBase
interface RawWikiPage {
@@ -16,8 +15,11 @@ 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 } = {},
@@ -70,17 +72,19 @@ 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,
}))
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[]> => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,30 @@
<template>
<section class="media-band">
<header class="media-band-header">
<h2 class="media-band-label">{{ label }}</h2>
<slot name="action" />
</header>
<div class="media-band-body">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<style scoped>
.media-band {
@apply overflow-hidden rounded-card border border-line bg-surface;
}
.media-band-header {
@apply flex flex-wrap items-baseline justify-between gap-3 border-b border-line px-5 py-3 md:px-6;
}
.media-band-label {
@apply font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
}
.media-band-body {
@apply p-5 md:p-6;
}
</style>
+5 -32
View File
@@ -1,38 +1,11 @@
<template>
<span :class="badgeClass"><i :class="iconClass"></i> {{ platform }}</span>
<span class="badge gap-1.5">
<PlatformIcon :platform="platform" />{{ platform }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PlatformIcon from './PlatformIcon.vue'
const props = defineProps<{ platform: string }>()
const knownPlatforms = ['tic80', 'ebitengine', 'love', 'godot']
const platformIcons: Record<string, string> = {
tic80: 'fa-solid fa-tv',
ebitengine: 'fa-brands fa-golang',
love: 'fa-solid fa-heart',
godot: 'fa-solid fa-robot',
other: 'fa-solid fa-cube',
}
const platformKey = computed(() => {
const key = props.platform.toLowerCase().replace(/[^a-z0-9]/g, '')
return knownPlatforms.includes(key) ? key : 'other'
})
const badgeClass = computed(() => `platform-badge platform-${platformKey.value}`)
const iconClass = computed(() => platformIcons[platformKey.value])
defineProps<{ platform: string }>()
</script>
<style scoped>
.platform-badge {
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
}
.platform-tic80 { @apply bg-cyan-100 text-cyan-800 border border-cyan-200; }
.platform-ebitengine { @apply bg-red-100 text-red-800 border border-red-200; }
.platform-love { @apply bg-pink-100 text-pink-800 border border-pink-200; }
.platform-godot { @apply bg-sky-100 text-sky-800 border border-sky-200; }
.platform-other { @apply bg-purple-100 text-purple-800 border border-purple-200; }
</style>
@@ -0,0 +1,26 @@
<template>
<BrandIcon v-if="brand" :name="brand" />
<component :is="glyph" v-else v-bind="ICON" />
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { Bot, Box, Boxes, Heart, Save, Tv, Zap } from 'lucide-vue-next'
import BrandIcon from './icons/BrandIcon.vue'
import { ICON } from './icons'
const props = defineProps<{ platform: string }>()
const GLYPHS: Record<string, Component> = {
tic80: Tv,
love: Heart,
godot: Bot,
bevy: Boxes,
phaser: Zap,
c64: Save,
}
const key = computed(() => props.platform.toLowerCase().replace(/[^a-z0-9]/g, ''))
const brand = computed(() => (key.value === 'ebitengine' ? 'go' : null))
const glyph = computed(() => GLYPHS[key.value] ?? Box)
</script>
+8 -13
View File
@@ -1,12 +1,10 @@
<template>
<div class="animate-pulse">
<div class="animate-pulse space-y-4">
<div v-for="n in count" :key="n" class="skeleton-card">
<div class="skeleton-card-inner">
<div class="skeleton-line w-1/4 mb-4"></div>
<div class="skeleton-line-lg w-3/4 mb-3"></div>
<div class="skeleton-line w-full mb-2"></div>
<div class="skeleton-line w-5/6"></div>
</div>
<div class="skeleton-line w-1/4"></div>
<div class="skeleton-line-lg w-3/4"></div>
<div class="skeleton-line w-full"></div>
<div class="skeleton-line w-5/6"></div>
</div>
</div>
</template>
@@ -17,15 +15,12 @@ withDefaults(defineProps<{ count?: number }>(), { count: 3 })
<style scoped>
.skeleton-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6;
}
.skeleton-card-inner {
@apply p-6 md:p-8;
@apply flex flex-col gap-3 rounded-card border border-line bg-surface p-5 md:p-6;
}
.skeleton-line {
@apply h-4 bg-gray-200 rounded;
@apply h-4 rounded bg-raised;
}
.skeleton-line-lg {
@apply h-6 bg-gray-200 rounded;
@apply h-6 rounded bg-raised;
}
</style>
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlatformBadge from '../PlatformBadge.vue'
import { statusBadgeClass } from '../../lib/softwareUtils'
describe('PlatformBadge', () => {
it('shows the platform name next to its own glyph', () => {
const wrapper = mount(PlatformBadge, { props: { platform: 'TIC-80' } })
expect(wrapper.text()).toContain('TIC-80')
expect(wrapper.find('svg').exists()).toBe(true)
})
it('falls back to a glyph for a platform it has never seen', () => {
const wrapper = mount(PlatformBadge, { props: { platform: 'Amiga' } })
expect(wrapper.find('svg').exists()).toBe(true)
})
})
describe('statusBadgeClass', () => {
it('gives every live status its own hue and leaves the archived ones neutral', () => {
expect(statusBadgeClass('released')).toContain('status-released')
expect(statusBadgeClass('demo')).toContain('status-demo')
expect(statusBadgeClass('development')).toContain('status-development')
expect(statusBadgeClass('archived')).toBe('status-badge')
expect(statusBadgeClass('whatever')).toBe('status-badge')
})
})
@@ -0,0 +1,23 @@
<template>
<svg
:width="size" :height="size" viewBox="0 0 24 24"
fill="currentColor" :aria-hidden="label ? undefined : 'true'"
:role="label ? 'img' : undefined" :aria-label="label || undefined"
class="inline-block shrink-0 align-[-0.125em]"
>
<path v-for="(d, i) in paths" :key="i" :d="d" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BRAND_PATHS } from './brandPaths'
const props = withDefaults(defineProps<{
name: string
size?: number | string
label?: string
}>(), { size: 16, label: undefined })
const paths = computed(() => BRAND_PATHS[props.name] ?? [])
</script>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
export const ICON = { size: 16, strokeWidth: 1.75 } as const
export const ICON_CONTROL = { size: 20, strokeWidth: 1.75 } as const
export { default as BrandIcon } from './BrandIcon.vue'
export { BRAND_PATHS } from './brandPaths'
export type { BrandName } from './brandPaths'
@@ -36,7 +36,7 @@ describe('useLoadable', () => {
it('re-executes after TTL expires', async () => {
vi.useFakeTimers()
const { withCache } = useLoadable(100) // 100ms TTL
const { withCache } = useLoadable(100)
const fn = vi.fn()
await withCache(fn)
+1 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
const DEFAULT_TTL = 15 * 60 * 1000
export function useLoadable(ttlMs?: number) {
const loading = ref(false)
@@ -0,0 +1,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([])
})
})
+63 -22
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retro Mode',
exitRetro: 'Exit Retro Mode',
rss: 'RSS',
stores: 'Stores',
builds: 'Builds',
theme: 'Switch theme',
menu: 'Menu',
},
footer: {
wiki: 'Wiki',
git: 'Git repositories',
releases: 'Releases',
license: 'All content is available under the',
tagline: 'Built in the open — we love open source, and the freedom to learn, fork and share.',
},
common: {
status: {
@@ -30,36 +41,28 @@ export default {
startsOn: 'Starts on:',
upcomingEvents: 'Upcoming Events',
featuredGame: 'Featured Game',
playInBrowser: 'Play in Browser',
playInBrowser: 'Play in Browser',
viewProjectDetails: 'View Project Details',
latestFromYoutube: 'Latest from YouTube',
visitChannel: 'Visit Channel ↗',
newRelease: 'New Release',
watchOnYoutube: 'Watch on YouTube',
note: 'Our HowTo-s and blog posts are written by AI with human supervision.',
catalog: {
title: 'Catalog',
desc: 'Here you can find our games. Discover our creations for TIC-80 and other platforms, many of which you can try directly in your browser.',
btn: 'Browse Games',
},
wiki: {
title: 'Wiki',
desc: 'We gather our public interest development knowledge here. Read through technical descriptions and development logs.',
btn: 'Knowledge Base',
},
git: {
title: 'Git Repos',
desc: 'All our projects are open source. Browse our code, study our solutions, or even participate in the development.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.',
btn: 'Watch on YouTube',
},
spotify: {
openInSpotify: 'Open in Spotify ↗',
},
store: {
label: 'Play on your own machine',
allStores: 'All stores',
title: 'WarpEngine Client',
desc: 'Our catalog as a grid of cards on your desktop: one click installs a game into your application menu, one plays it. It sets itself up on first run.',
point1: 'Nothing to install first — no interpreter, no admin rights',
point2: 'Installed games become normal menu entries, so they start without the app running',
point3: 'Browser builds too, for titles with no download for your machine',
download: 'Download',
more: 'How it works',
platforms: 'Linux, macOS and Windows',
},
},
contact: {
title: 'Contact Us',
@@ -99,6 +102,8 @@ export default {
},
},
blog: {
back: 'Back to the blog',
noContent: 'No content available for this post.',
badge: 'Community Stories',
title: 'Developer Blog',
subtitle: 'News, updates, and behind-the-scenes stories from the Teletype Games community.',
@@ -109,6 +114,7 @@ export default {
readMore: 'Read more',
},
catalog: {
getTheStore: 'Get the store',
title: 'Our Games',
subtitle: 'Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!',
filter: {
@@ -126,6 +132,9 @@ export default {
},
catalogShow: {
back: 'Back to Catalog',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
projectInfo: 'Project Info',
author: 'Author',
platform: 'Platform',
@@ -133,7 +142,7 @@ export default {
about: 'About',
latestStable: 'Latest Stable',
released: 'Released:',
playNow: 'Play Now',
playNow: 'Play Now',
download: 'Download',
source: 'Source',
docs: 'Docs',
@@ -160,6 +169,7 @@ export default {
win_x64: 'Windows (64-bit)',
linux_x86: 'Linux (32-bit)',
linux_x64: 'Linux (64-bit)',
linux_arm64: 'Linux (ARM 64-bit)',
mac_x64: 'macOS (Intel)',
mac_arm64: 'macOS (Apple Silicon)',
mac_universal: 'macOS',
@@ -197,6 +207,37 @@ export default {
title: 'Build Matrix',
subtitle: 'Which engines build for which platforms.',
},
stores: {
title: 'Stores',
subtitle: 'Our games on your own machine, kept up to date.',
viewClient: 'WarpEngine Client',
viewOthers: 'Others',
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
clientDownload: 'Download',
clientPlatforms: 'Linux, macOS and Windows · nothing to install first',
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
docs: 'Documentation',
repo: 'Git repository',
installTitle: 'Install',
installThen: 'Then',
useTitle: 'Use it',
uninstallTitle: 'Remove it',
platformsTitle: 'What gets installed',
copy: 'Copy',
batocera: {
name: 'Batocera',
site: 'Batocera project',
tagline: 'The plug-and-play retro distribution',
},
retroarch: {
name: 'RetroArch',
site: 'RetroArch',
tagline: 'Anything that runs RetroArch',
},
},
code: {
title: 'Codebase',
subtitle: 'Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.',
+65 -24
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retró mód',
exitRetro: 'Retró mód kikapcsolása',
rss: 'RSS',
stores: 'Boltok',
builds: 'Buildek',
theme: 'Téma váltása',
menu: 'Menü',
},
footer: {
wiki: 'Wiki',
git: 'Git tárolók',
releases: 'Kiadások',
license: 'A tartalom elérhető',
tagline: 'Nyíltan fejlesztünk — szeretjük a nyílt forráskódot, és a szabadságot, hogy tanulni, forkolni és megosztani lehessen.',
},
common: {
status: {
@@ -30,36 +41,28 @@ export default {
startsOn: 'Kezdete:',
upcomingEvents: 'Közelgő események',
featuredGame: 'Kiemelt játék',
playInBrowser: 'Játék böngészőben',
playInBrowser: 'Játék böngészőben',
viewProjectDetails: 'Projekt részletei',
latestFromYoutube: 'Legújabb YouTube-ról',
visitChannel: 'Csatorna megtekintése ↗',
newRelease: 'Új megjelenés',
watchOnYoutube: 'Nézd meg YouTube-on',
note: 'A HowTo-k és blogbejegyzések mesterséges intelligenciával íródtak, emberi felügyelet mellett.',
catalog: {
title: 'Katalógus',
desc: 'Itt megtalálod a játékainkat. Fedezd fel alkotásainkat TIC-80-ra és más platformokra, amelyek közül sokat közvetlenül a böngésződben is kipróbálhatsz.',
btn: 'Játékok böngészése',
},
wiki: {
title: 'Wiki',
desc: 'Itt gyűjtjük össze a közérdekű fejlesztési tudásunkat. Olvasd át a technikai leírásokat és fejlesztési naplókat.',
btn: 'Tudásbázis',
},
git: {
title: 'Git tárolók',
desc: 'Minden projektünk nyílt forráskódú. Böngészd a kódunkat, tanulmányozd a megoldásainkat, vagy akár részt is vehetsz a fejlesztésben.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Nézd meg fejlesztési vlogjainkat, gameplay videóinkat és tutorialainkat a hivatalos YouTube-csatornánkon.',
btn: 'Nézd meg YouTube-on',
},
spotify: {
openInSpotify: 'Megnyitás Spotify-ban ↗',
},
store: {
label: 'Játssz a saját gépeden',
allStores: 'Összes bolt',
title: 'WarpEngine Client',
desc: 'A katalógusunk kártyákban, az asztalodon: egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első futáskor magát is beállítja.',
point1: 'Semmit nem kell előtte feltenni — se értelmező, se rendszergazdai jog',
point2: 'A feltelepített játék rendes menüelem lesz, tehát az app nélkül is indul',
point3: 'Böngészős buildek is, ha egy címhez nincs letöltés a gépedre',
download: 'Letöltés',
more: 'Hogyan működik',
platforms: 'Linux, macOS és Windows',
},
},
contact: {
title: 'Kapcsolat',
@@ -99,6 +102,8 @@ export default {
},
},
blog: {
back: 'Vissza a bloghoz',
noContent: 'Ehhez a bejegyzéshez nincs tartalom.',
badge: 'Közösségi történetek',
title: 'Fejlesztői Blog',
subtitle: 'Hírek, frissítések és kulisszák mögötti történetek a Teletype Games közösségből.',
@@ -109,6 +114,7 @@ export default {
readMore: 'Tovább olvasom',
},
catalog: {
getTheStore: 'Töltsd le a boltot',
title: 'Játékaink',
subtitle: 'Fedezd fel a TIC-80-ra és más platformokra készített játékainkat, amelyeknek közvetlenül a böngésződben is kipróbálhatsz verzióit!',
filter: {
@@ -120,12 +126,15 @@ export default {
},
author: 'Szerző:',
platform: 'Platform:',
play: 'Játék',
play: 'Játék',
moreInfo: 'Részletek',
downloads: 'letöltés',
},
catalogShow: {
back: '← Vissza a katalógushoz',
close: 'Bezárás',
prevImage: 'Előző kép',
nextImage: 'Következő kép',
projectInfo: 'Projekt infó',
author: 'Szerző',
platform: 'Platform',
@@ -133,7 +142,7 @@ export default {
about: 'Leírás',
latestStable: 'Legújabb stabil',
released: 'Kiadva:',
playNow: 'Játék most',
playNow: 'Játék most',
download: 'Letöltés',
source: 'Forráskód',
docs: 'Dokumentáció',
@@ -144,7 +153,7 @@ export default {
notFound: 'A játék nem található',
failedToLoad: 'Nem sikerült betölteni:',
loading: 'Betöltés...',
play: 'Játék',
play: 'Játék',
downloads: 'letöltés',
links: 'Linkek',
platformLinks: 'Platform linkek',
@@ -160,6 +169,7 @@ export default {
win_x64: 'Windows (64 bit)',
linux_x86: 'Linux (32 bit)',
linux_x64: 'Linux (64 bit)',
linux_arm64: 'Linux (ARM 64 bit)',
mac_x64: 'macOS (Intel)',
mac_arm64: 'macOS (Apple Silicon)',
mac_universal: 'macOS',
@@ -197,6 +207,37 @@ export default {
title: 'Build mátrix',
subtitle: 'Melyik engine melyik platformra fordít.',
},
stores: {
title: 'Store-ok',
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
viewClient: 'WarpEngine Client',
viewOthers: 'Egyéb',
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
clientDownload: 'Letöltés',
clientPlatforms: 'Linux, macOS és Windows · semmit nem kell előtte feltenni',
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
docs: 'Dokumentáció',
repo: 'Git repó',
installTitle: 'Telepítés',
installThen: 'Utána',
useTitle: 'Használat',
uninstallTitle: 'Eltávolítás',
platformsTitle: 'Mi kerül fel',
copy: 'Másolás',
batocera: {
name: 'Batocera',
site: 'Batocera projekt',
tagline: 'A dugd-be-és-megy retro disztró',
},
retroarch: {
name: 'RetroArch',
site: 'RetroArch',
tagline: 'Bármi, amin fut a RetroArch',
},
},
code: {
title: 'Kódbázis',
subtitle: 'Fedezd fel nyílt forráskódú projektek gyűjteményét, tanulmányozd forráskódunkat, és járulj hozzá független játékfejlesztő eszközeinkhez.',
+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="WIKI_BASE" 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 { WIKI_BASE } 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')
+32 -101
View File
@@ -1,70 +1,41 @@
<template>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('blog.badge') }}</div>
<h1 class="hero-title">
Developer <span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400">Blog</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('blog.subtitle') }}</p>
<header class="hero-section">
<div class="hero-container hero-centered">
<p class="hero-badge">{{ t('blog.badge') }}</p>
<h1 class="hero-title">{{ t('blog.title') }}</h1>
<p class="hero-subtitle">{{ t('blog.subtitle') }}</p>
</div>
</header>
<main class="blog-main">
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('blog.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
<h2 class="error-banner-title">{{ t('blog.errorTitle') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<div v-else-if="!loading && blogPages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('blog.noPostsTitle') }}</h2>
<p class="empty-state-desc">{{ t('blog.noPostsDesc') }}</p>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else class="blog-posts-list">
<article v-for="page in blogPages" :key="page.id" class="blog-post-card">
<div class="blog-post-content">
<div class="blog-post-meta">
<div class="flex items-center gap-4">
<div class="blog-post-timestamp">
<span v-if="isNew(page.createdAt)" class="new-post-badge">
<span class="new-post-dot"></span>
{{ t('blog.newPost') }}
</span>
<span><i class="fa-solid fa-calendar text-gray-300 mr-1"></i>{{ formatDateTime(page.createdAt) }}</span>
</div>
</div>
</div>
<div v-else class="blog-list">
<article v-for="page in blogPages" :key="page.id" class="blog-post">
<p class="blog-post-meta">
<span class="mono">{{ formatDateTime(page.createdAt) }}</span>
<span v-if="isNew(page.createdAt)" class="badge-accent">{{ t('blog.newPost') }}</span>
</p>
<h2 class="blog-post-title">
<RouterLink :to="getPermalink(page.path)" class="hover:text-indigo-600 transition-colors">
{{ page.title }}
</RouterLink>
</h2>
<h2 class="blog-post-title">
<RouterLink :to="getPermalink(page.path)">{{ page.title }}</RouterLink>
</h2>
<div class="blog-post-preview">
<p v-if="page.description" class="blog-post-description">{{ page.description }}</p>
<p v-if="page.content" class="blog-post-body">{{ getCleanPreview(page.content) }}</p>
</div>
<p v-if="page.description" class="blog-post-description">{{ page.description }}</p>
<p v-if="page.content" class="blog-post-body">{{ getCleanPreview(page.content) }}</p>
<div class="mt-6">
<RouterLink :to="getPermalink(page.path)" class="read-more-btn">
{{ t('blog.readMore') }}
<i class="fa-solid fa-arrow-right text-sm ml-1"></i>
</RouterLink>
</div>
</div>
<RouterLink :to="getPermalink(page.path)" class="link text-sm">{{ t('blog.readMore') }}</RouterLink>
</article>
</div>
</main>
@@ -91,68 +62,28 @@ onMounted(() => store.fetch())
<style scoped>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
@apply min-h-screen bg-page;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
.blog-list {
@apply flex max-w-[68ch] flex-col;
}
.blog-header-decor {
@apply absolute inset-0 opacity-30;
}
.blog-main {
@apply max-w-5xl mx-auto px-4 md:px-8 -mt-12 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-white rounded-2xl shadow-xl p-12 text-center border border-gray-100;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-gray-900 mb-2; }
.empty-state-desc { @apply text-gray-500 max-w-md mx-auto; }
.blog-posts-list {
@apply flex flex-col gap-12;
}
.blog-post-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden;
}
.blog-post-content {
@apply p-6 md:p-10;
.blog-post {
@apply flex flex-col items-start gap-2 border-b border-line py-8 first:pt-0 last:border-b-0;
}
.blog-post-meta {
@apply flex items-center justify-between mb-4;
}
.blog-post-timestamp {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400;
}
.new-post-badge {
@apply flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-100;
}
.new-post-dot {
@apply w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse;
@apply flex flex-wrap items-center gap-3 text-xs text-fg-subtle;
}
.blog-post-title {
@apply text-2xl md:text-3xl font-black text-gray-900 mb-4 leading-tight;
@apply text-xl font-semibold leading-snug tracking-tight text-fg md:text-2xl;
}
.blog-post-preview {
@apply relative overflow-hidden;
max-height: 8rem;
}
.blog-post-preview::after {
content: "";
@apply absolute bottom-0 left-0 w-full h-16 bg-gradient-to-b from-transparent to-white pointer-events-none;
.blog-post-title a {
@apply transition-colors hover:text-accent;
}
.blog-post-description {
@apply text-lg text-gray-800 font-bold mb-2 leading-relaxed;
@apply text-base leading-relaxed text-fg;
}
.blog-post-body {
@apply text-base text-gray-500 font-medium leading-relaxed;
}
.read-more-btn {
@apply inline-flex items-center text-indigo-600 font-bold hover:text-indigo-800 transition-colors;
@apply line-clamp-3 text-sm leading-relaxed text-fg-muted;
}
</style>
+16 -56
View File
@@ -1,35 +1,25 @@
<template>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="post-hero-container">
<RouterLink to="/blog" class="back-link"><i class="fa-solid fa-arrow-left mr-1"></i> Back to Blog</RouterLink>
<header class="hero-section">
<div class="hero-container">
<RouterLink to="/blog" class="link text-sm">{{ t('blog.back') }}</RouterLink>
<template v-if="pageContent">
<h1 class="hero-title mt-4">{{ pageContent.title }}</h1>
<p class="hero-subtitle mb-4">{{ pageContent.description }}</p>
<div class="blog-post-timestamp text-indigo-200"><i class="fa-solid fa-calendar mr-1"></i>{{ formatDateTime(pageContent.createdAt) }}</div>
<p class="hero-subtitle">{{ pageContent.description }}</p>
<p class="post-timestamp mono">{{ formatDateTime(pageContent.createdAt) }}</p>
</template>
</div>
</header>
<main class="blog-main">
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">Error</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
<h2 class="error-banner-title">{{ t('common.error') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<article v-else-if="pageContent" class="blog-post-card">
<div class="blog-post-content">
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="sanitize(pageContent.render)" />
<div v-else class="blog-post-fallback">No content available for this post.</div>
</div>
<article v-else-if="pageContent">
<div v-if="pageContent.render" class="wiki-content" v-html="sanitize(pageContent.render)" />
<p v-else class="text-fg-muted">{{ t('blog.noContent') }}</p>
</article>
<SkeletonCard v-else-if="loading" :count="1" />
@@ -41,11 +31,13 @@
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { sanitize } from '../../lib/sanitize'
import { useBlogStore } from '../../stores/blog.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
const route = useRoute()
const store = useBlogStore()
const { currentPage: pageContent, pageLoading: loading, pageError: error } = storeToRefs(store)
@@ -55,41 +47,9 @@ onMounted(() => store.fetchPage(route.params.slug as string))
<style scoped>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
@apply min-h-screen bg-page;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
.post-timestamp {
@apply mt-4 text-xs text-fg-subtle;
}
.blog-header-decor {
@apply absolute inset-0 opacity-30;
}
.post-hero-container {
@apply relative z-10 max-w-4xl mx-auto px-4;
}
.back-link {
@apply inline-block text-indigo-300 hover:text-white transition-colors font-medium;
}
.hero-title {
@apply text-4xl md:text-5xl font-black mb-4 leading-tight;
}
.hero-subtitle {
@apply text-xl text-indigo-100 font-medium;
}
.blog-post-timestamp {
@apply text-sm font-semibold uppercase tracking-wider;
}
.blog-main {
@apply max-w-4xl mx-auto px-4 md:px-8 -mt-12 relative z-10;
}
.blog-post-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden;
}
.blog-post-content {
@apply p-6 md:p-12;
}
.error-banner {
@apply max-w-7xl mx-auto bg-red-50 border-l-4 border-red-500 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
</style>
+86 -184
View File
@@ -1,32 +1,38 @@
<template>
<header class="hero-section-gradient from-slate-700 to-gray-800 py-16">
<div class="hero-container">
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('builds.title') }}</h1>
<p class="hero-subtitle text-slate-300">{{ t('builds.subtitle') }}</p>
<p class="hero-subtitle">{{ t('builds.subtitle') }}</p>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div v-if="error" class="builds-error">{{ error }}</div>
<main class="main-container">
<div v-if="error" class="error-banner">
<h2 class="error-banner-title">{{ t('common.error') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<SkeletonCard v-else-if="!data" :count="3" />
<!-- Desktop table -->
<div v-else class="builds-desktop">
<div class="builds-table-wrap">
<div class="table-wrap">
<table class="builds-table">
<thead>
<tr>
<th rowspan="2" class="builds-th-engine">Engine</th>
<th v-for="group in columnGroups" :key="group.label" :colspan="group.kinds.length" :class="['builds-th-group', group.color]">
<i :class="group.icon" class="builds-group-icon"></i>
<span class="builds-group-label">{{ group.label }}</span>
<th
v-for="group in columnGroups" :key="group.label"
:colspan="group.kinds.length" class="builds-th-group builds-group-start"
>
{{ group.label }}
</th>
</tr>
<tr>
<template v-for="group in columnGroups" :key="group.label">
<th v-for="(col, colIdx) in group.kinds" :key="col.kind" :class="['builds-th-sub', group.color, { 'builds-group-start': colIdx === 0 }]">
<i :class="group.icon" class="builds-sub-icon"></i>
<th
v-for="(col, colIdx) in group.kinds" :key="col.kind"
:class="['builds-th-sub', { 'builds-group-start': colIdx === 0 }]"
>
{{ col.sub }}
</th>
</template>
@@ -35,13 +41,15 @@
<tbody>
<tr v-for="(info, platform) in data.platforms" :key="platform" class="builds-row">
<td class="builds-td-engine">
<i :class="engineIcons[platform as string] || defaultEngineIcon" class="builds-engine-icon"></i>
{{ info.label }}
<span class="builds-engine"><PlatformIcon :platform="platform as string" />{{ info.label }}</span>
</td>
<template v-for="group in columnGroups" :key="group.label">
<td v-for="(col, colIdx) in group.kinds" :key="col.kind" :class="['builds-td-kind', group.color, { 'builds-group-start': colIdx === 0 }]">
<i v-if="info.kinds.includes(col.kind)" class="fa-solid fa-check builds-check"></i>
<i v-else class="fa-solid fa-xmark builds-xmark"></i>
<td
v-for="(col, colIdx) in group.kinds" :key="col.kind"
:class="['builds-td-kind', { 'builds-group-start': colIdx === 0 }]"
>
<Check v-if="info.kinds.includes(col.kind)" class="builds-check" :size="18" :stroke-width="2" />
<X v-else class="builds-xmark" :size="16" :stroke-width="1.75" />
</td>
</template>
</tr>
@@ -50,21 +58,17 @@
</div>
</div>
<!-- Mobile cards -->
<div v-if="data" class="builds-mobile">
<div v-for="(info, platform) in data.platforms" :key="platform" class="builds-card">
<h3 class="builds-card-title">
<i :class="engineIcons[platform as string] || defaultEngineIcon" class="builds-engine-icon"></i>
{{ info.label }}
</h3>
<div v-for="group in columnGroups" :key="group.label" :class="['builds-card-group', group.color]">
<div class="builds-card-group-header">
<i :class="group.icon" class="builds-card-group-icon"></i>
<span>{{ group.label }}</span>
</div>
<h2 class="builds-card-title">
<PlatformIcon :platform="platform as string" />{{ info.label }}
</h2>
<div v-for="group in columnGroups" :key="group.label" class="builds-card-group">
<h3 class="builds-card-group-header">{{ group.label }}</h3>
<div class="builds-card-kinds">
<div v-for="col in group.kinds" :key="col.kind" class="builds-card-kind">
<i :class="info.kinds.includes(col.kind) ? 'fa-solid fa-check builds-check' : 'fa-solid fa-xmark builds-xmark'"></i>
<Check v-if="info.kinds.includes(col.kind)" class="builds-check" :size="16" :stroke-width="2" />
<X v-else class="builds-xmark" :size="14" :stroke-width="1.75" />
<span class="builds-card-kind-label">{{ col.sub }}</span>
</div>
</div>
@@ -72,10 +76,8 @@
</div>
</div>
<div class="builds-back">
<RouterLink to="/catalog" class="builds-back-link">
{{ t('catalogShow.back') }}
</RouterLink>
<div class="focus-row">
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
@@ -85,30 +87,19 @@ import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Check, X } from 'lucide-vue-next'
import { useBuildsStore } from '../../stores/builds.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
import PlatformIcon from '../../components/PlatformIcon.vue'
const { t } = useI18n()
const store = useBuildsStore()
const { data, error } = storeToRefs(store)
const engineIcons: Record<string, string> = {
tic80: 'fa-solid fa-tv',
ebitengine: 'fa-brands fa-golang',
love: 'fa-solid fa-heart',
godot: 'fa-solid fa-robot',
c64: 'fa-solid fa-floppy-disk',
bevy: 'fa-solid fa-cubes',
phaser: 'fa-solid fa-bolt',
}
const defaultEngineIcon = 'fa-solid fa-cube'
const columnGroups = [
{
label: 'Windows',
icon: 'fa-brands fa-windows',
color: 'group-blue',
kinds: [
{ kind: 'win_x64', sub: '64 bit' },
{ kind: 'win_x86', sub: '32 bit' },
@@ -116,16 +107,13 @@ const columnGroups = [
},
{
label: 'Linux',
icon: 'fa-brands fa-linux',
color: 'group-amber',
kinds: [
{ kind: 'linux_x64', sub: '64 bit' },
{ kind: 'linux_arm64', sub: 'ARM 64 bit' },
],
},
{
label: 'Mac',
icon: 'fa-brands fa-apple',
color: 'group-slate',
kinds: [
{ kind: 'mac_x64', sub: 'Intel' },
{ kind: 'mac_arm64', sub: 'ARM' },
@@ -134,24 +122,18 @@ const columnGroups = [
},
{
label: 'Web',
icon: 'fa-solid fa-globe',
color: 'group-green',
kinds: [
{ kind: 'html', sub: 'HTML5' },
],
},
{
label: 'Cartridge',
icon: 'fa-solid fa-gamepad',
color: 'group-purple',
kinds: [
{ kind: 'cartridge', sub: 'Native' },
],
},
{
label: 'Others',
icon: 'fa-solid fa-folder-open',
color: 'group-gray',
kinds: [
{ kind: 'source', sub: 'Source' },
{ kind: 'docs', sub: 'Docs' },
@@ -163,150 +145,70 @@ onMounted(() => store.fetch())
</script>
<style scoped>
.builds-error {
@apply text-center text-red-500 font-bold py-12;
}
.builds-loading {
@apply text-center text-gray-400 py-12;
}
.builds-table-wrap {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-x-auto;
}
.builds-table {
@apply w-full text-sm;
}
.builds-th-engine {
@apply px-6 py-4 text-left text-xs font-bold text-gray-400 uppercase tracking-widest bg-gray-50 border-b border-gray-200;
}
.builds-th-group {
@apply px-2 py-3 text-center border-b border-gray-200;
}
.builds-group-icon {
@apply text-base;
}
.builds-group-label {
@apply ml-1.5 text-xs font-bold uppercase tracking-wider;
}
.builds-th-sub {
@apply px-3 py-2 text-center text-[10px] font-bold uppercase tracking-wider;
}
.builds-sub-icon {
@apply text-[9px] mr-0.5 opacity-60;
}
.builds-group-start {
@apply border-l-2;
}
/* Group color themes */
.group-blue .builds-group-icon,
.group-blue .builds-group-label { @apply text-blue-500; }
.th-group.group-blue, th.group-blue { background-color: rgb(239 246 255); }
td.group-blue { background-color: rgb(239 246 255 / 0.4); }
th.group-blue.builds-group-start, td.group-blue.builds-group-start { @apply border-l-blue-300; }
.group-amber .builds-group-icon,
.group-amber .builds-group-label { @apply text-amber-600; }
th.group-amber { background-color: rgb(255 251 235); }
td.group-amber { background-color: rgb(255 251 235 / 0.4); }
th.group-amber.builds-group-start, td.group-amber.builds-group-start { @apply border-l-amber-300; }
.group-slate .builds-group-icon,
.group-slate .builds-group-label { @apply text-slate-500; }
th.group-slate { background-color: rgb(248 250 252); }
td.group-slate { background-color: rgb(248 250 252 / 0.4); }
th.group-slate.builds-group-start, td.group-slate.builds-group-start { @apply border-l-slate-300; }
.group-green .builds-group-icon,
.group-green .builds-group-label { @apply text-emerald-500; }
th.group-green { background-color: rgb(236 253 245); }
td.group-green { background-color: rgb(236 253 245 / 0.4); }
th.group-green.builds-group-start, td.group-green.builds-group-start { @apply border-l-emerald-300; }
.group-purple .builds-group-icon,
.group-purple .builds-group-label { @apply text-purple-500; }
th.group-purple { background-color: rgb(250 245 255); }
td.group-purple { background-color: rgb(250 245 255 / 0.4); }
th.group-purple.builds-group-start, td.group-purple.builds-group-start { @apply border-l-purple-300; }
.group-gray .builds-group-icon,
.group-gray .builds-group-label { @apply text-gray-500; }
th.group-gray { background-color: rgb(249 250 251); }
td.group-gray { background-color: rgb(249 250 251 / 0.4); }
th.group-gray.builds-group-start, td.group-gray.builds-group-start { @apply border-l-gray-300; }
/* Sub-header inherits group tint */
.builds-th-sub.group-blue { @apply text-blue-400; }
.builds-th-sub.group-amber { @apply text-amber-500; }
.builds-th-sub.group-slate { @apply text-slate-400; }
.builds-th-sub.group-green { @apply text-emerald-400; }
.builds-th-sub.group-purple { @apply text-purple-400; }
.builds-th-sub.group-gray { @apply text-gray-400; }
.builds-row {
@apply border-t border-gray-100 transition-colors;
}
.builds-row:hover td {
filter: brightness(0.97);
}
.builds-td-engine {
@apply px-6 py-4 font-bold text-gray-900 whitespace-nowrap;
}
.builds-engine-icon {
@apply text-gray-400 text-sm mr-2;
}
.builds-td-kind {
@apply px-3 py-4 text-center;
}
.builds-check {
@apply text-green-500 text-lg;
}
.builds-xmark {
@apply text-gray-200 text-lg;
}
/* Responsive visibility */
.builds-desktop {
@apply hidden md:block;
}
.builds-mobile {
@apply md:hidden space-y-4;
.builds-table {
@apply w-full border-collapse text-sm;
}
.builds-th-engine,
.builds-th-group,
.builds-th-sub {
@apply bg-raised px-3 py-2.5 font-mono text-xs font-medium uppercase tracking-[0.1em] text-fg-subtle;
}
.builds-th-engine {
@apply px-5 text-left;
}
.builds-th-group {
@apply text-center text-fg;
}
.builds-th-sub {
@apply text-center text-[10px];
}
/* Mobile cards */
.builds-group-start {
@apply border-l border-line;
}
.builds-row {
@apply border-t border-line transition-colors hover:bg-surface;
}
.builds-td-engine {
@apply whitespace-nowrap px-5 py-3 font-medium text-fg;
}
.builds-engine,
.builds-card-title {
@apply flex items-center gap-2;
}
.builds-td-kind {
@apply px-3 py-3 text-center;
}
.builds-check {
@apply mx-auto text-accent;
}
.builds-xmark {
@apply mx-auto text-fg-subtle opacity-50;
}
.builds-mobile {
@apply flex flex-col gap-4 md:hidden;
}
.builds-card {
@apply bg-white rounded-2xl shadow-lg border border-gray-100 p-5;
@apply rounded-card border border-line bg-surface p-5;
}
.builds-card-title {
@apply text-lg font-bold text-gray-900 mb-4 pb-3 border-b border-gray-100;
@apply mb-3 border-b border-line pb-3 text-base font-semibold text-fg;
}
.builds-card-group {
@apply py-2 px-3 rounded-lg my-1;
@apply py-1.5;
}
.builds-card-group.group-blue { background-color: rgb(239 246 255 / 0.5); }
.builds-card-group.group-amber { background-color: rgb(255 251 235 / 0.5); }
.builds-card-group.group-slate { background-color: rgb(248 250 252 / 0.5); }
.builds-card-group.group-green { background-color: rgb(236 253 245 / 0.5); }
.builds-card-group.group-purple { background-color: rgb(250 245 255 / 0.5); }
.builds-card-group.group-gray { background-color: rgb(249 250 251 / 0.5); }
.builds-card-group-header {
@apply flex items-center gap-2 text-xs font-bold uppercase tracking-wider mb-2;
}
.builds-card-group-icon {
@apply text-sm;
@apply mb-1 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle;
}
.builds-card-kinds {
@apply flex flex-wrap gap-x-5 gap-y-1 pl-5 mb-2;
@apply flex flex-wrap gap-x-4 gap-y-1;
}
.builds-card-kind {
@apply flex items-center gap-1.5 text-sm;
}
.builds-card-kind-label {
@apply text-gray-600;
}
.builds-back {
@apply mt-8 text-center;
}
.builds-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
@apply flex items-center gap-1.5 text-sm text-fg-muted;
}
</style>
@@ -1,17 +1,23 @@
<template>
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
<div class="hero-container">
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('catalog.title') }}</h1>
<p class="hero-subtitle text-purple-50">{{ t('catalog.subtitle') }}</p>
<p class="hero-subtitle">{{ t('catalog.subtitle') }}</p>
<div class="hero-actions">
<RouterLink to="/stores" class="btn-accent">{{ t('catalog.getTheStore') }}</RouterLink>
<RouterLink to="/builds" class="link text-sm">{{ t('builds.title') }}</RouterLink>
</div>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div class="filter-buttons">
<main class="main-container">
<div class="filter-bar" role="tablist">
<button
v-for="f in filters"
:key="f"
:class="['filter-btn', { active: activeFilter === f }]"
role="tab"
:aria-selected="activeFilter === f"
@click="activeFilter = f"
>
{{ t(`catalog.filter.${f}`) }}
@@ -20,53 +26,50 @@
<SkeletonCard v-if="loading" :count="4" />
<div v-else class="software-list">
<div v-else class="software-grid">
<template v-for="{ software, releases } in softwares" :key="software.name">
<div v-show="activeFilter === 'all' || software.status === activeFilter" class="software-card group">
<div class="software-card-content">
<div class="software-card-layout">
<div v-if="getDefaultImageUrl(software)" class="flex-shrink-0">
<img :src="getDefaultImageUrl(software)!" :alt="software.title" class="software-thumb" />
</div>
<div class="flex-grow">
<RouterLink :to="`/catalog/${software.name}`" class="hover:text-purple-600 transition-colors">
<h2 class="software-title mb-0">{{ software.title }}</h2>
</RouterLink>
<div class="software-badges">
<span :class="`status-badge status-${software.status}`">{{ t(`common.status.${software.status}`) }}</span>
<PlatformBadge :platform="software.platform" />
</div>
<p class="software-desc max-w-2xl">{{ software.desc }}</p>
<div class="software-meta">
<span>{{ t('catalog.author') }} <span class="text-gray-900 font-medium">{{ software.author }}</span></span>
</div>
</div>
<article v-show="activeFilter === 'all' || software.status === activeFilter" class="software-card">
<RouterLink :to="`/catalog/${software.name}`" class="software-thumb-link">
<img
v-if="getDefaultImageUrl(software)"
:src="getDefaultImageUrl(software)!" :alt="software.title" class="software-thumb" loading="lazy"
/>
<span v-else class="software-thumb-empty">{{ software.title.slice(0, 1) }}</span>
</RouterLink>
<div class="software-actions">
<a
v-if="getLatestStable(releases)?.htmlFolderPath"
:href="getLatestStable(releases)?.htmlFolderPath"
target="_blank"
class="btn-play-sm"
><i class="fa-solid fa-play mr-1.5 text-xs"></i>{{ t('catalog.play') }}</a>
<RouterLink :to="`/catalog/${software.name}`" class="btn-info-sm">
<i class="fa-solid fa-circle-info"></i> {{ t('catalog.moreInfo') }}
</RouterLink>
<span v-if="getTotalDownloads(software.name)" class="software-download-count">
<i class="fa-solid fa-download text-[10px]"></i> {{ getTotalDownloads(software.name) }} {{ t('catalog.downloads') }}
</span>
</div>
<div class="software-body">
<div class="software-badges">
<span :class="statusBadgeClass(software.status)">{{ t(`common.status.${software.status}`) }}</span>
<PlatformBadge :platform="software.platform" />
</div>
<h2 class="software-title">
<RouterLink :to="`/catalog/${software.name}`">{{ software.title }}</RouterLink>
</h2>
<p class="software-desc">{{ software.desc }}</p>
<p class="software-meta">
{{ t('catalog.author') }} <span class="text-fg">{{ software.author }}</span>
<template v-if="getTotalDownloads(software.name)">
· <span class="mono">{{ getTotalDownloads(software.name) }}</span> {{ t('catalog.downloads') }}
</template>
</p>
<div class="software-actions">
<a
v-if="getLatestStable(releases)?.htmlFolderPath"
:href="getLatestStable(releases)?.htmlFolderPath"
target="_blank"
class="btn-accent btn-sm"
>{{ t('catalog.play') }}</a>
<RouterLink :to="`/catalog/${software.name}`" class="btn-ghost btn-sm">
{{ t('catalog.moreInfo') }}
</RouterLink>
</div>
</div>
</div>
</article>
</template>
</div>
<div class="builds-link-row">
<RouterLink to="/builds" class="builds-link">
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
</RouterLink>
</div>
</main>
</template>
@@ -76,7 +79,7 @@ import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSoftwareStore } from '../../stores/software.store'
import { getDefaultImageUrl, getLatestStable } from '../../lib/softwareUtils'
import { getDefaultImageUrl, getLatestStable, statusBadgeClass } from '../../lib/softwareUtils'
import PlatformBadge from '../../components/PlatformBadge.vue'
import SkeletonCard from '../../components/SkeletonCard.vue'
import type { SoftwareEntry } from '../../lib/interfaces/software.interface'
@@ -107,64 +110,52 @@ onMounted(() => store.fetch())
</script>
<style scoped>
.filter-buttons {
@apply flex flex-wrap gap-4 mb-8 justify-center pb-4;
.filter-bar {
@apply mb-8 inline-flex flex-wrap gap-1 rounded-control border border-line bg-surface p-1;
}
.filter-btn {
@apply px-4 py-2 rounded-full border-2 border-purple-600 text-purple-600 font-bold transition-all hover:bg-purple-600 hover:text-white;
@apply rounded-control px-3 py-1.5 text-sm text-fg-muted transition-colors hover:text-fg
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.filter-btn.active {
@apply bg-purple-600 text-white;
@apply bg-raised text-fg;
}
.status-badge {
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
.software-grid {
@apply grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3;
}
.status-released { @apply bg-green-100 text-green-800 border border-green-200; }
.status-demo { @apply bg-blue-100 text-blue-800 border border-blue-200; }
.status-development { @apply bg-yellow-100 text-yellow-800 border border-yellow-200; }
.status-archived { @apply bg-gray-100 text-gray-800 border border-gray-200; }
.software-card {
@apply bg-white shadow-lg relative rounded-xl overflow-hidden hover:shadow-2xl transition-shadow duration-300;
@apply flex flex-col overflow-hidden rounded-card border border-line bg-surface transition-colors hover:border-line-strong;
}
.software-card-content {
@apply p-6;
.software-thumb-link {
@apply flex aspect-video items-center justify-center overflow-hidden border-b border-line bg-raised;
}
.software-title {
@apply text-2xl font-bold mb-0;
}
.software-desc {
@apply text-gray-600 mb-3;
}
.software-meta {
@apply text-sm text-gray-500;
}
.btn-base-sm {
@apply inline-flex items-center justify-center font-bold py-2.5 px-5 rounded-xl text-sm transition-all active:scale-95;
}
.btn-play-sm { @apply btn-base-sm bg-purple-600 hover:bg-purple-700 text-white shadow-lg shadow-purple-600/20; }
.btn-info-sm { @apply btn-base-sm bg-gray-100 hover:bg-gray-200 text-gray-700; }
.software-thumb {
@apply w-24 h-24 object-cover rounded-xl border border-gray-100 shadow-sm;
@apply h-full w-full object-contain p-4;
}
.software-list {
@apply grid grid-cols-1 gap-8;
.software-thumb-empty {
@apply font-mono text-4xl text-fg-subtle;
}
.software-card-layout {
@apply flex flex-col md:flex-row md:items-center justify-between gap-4;
.software-body {
@apply flex flex-1 flex-col p-5;
}
.software-badges {
@apply flex items-center gap-2 mt-1 mb-2;
@apply mb-3 flex flex-wrap items-center gap-2;
}
.software-title {
@apply text-lg font-semibold tracking-tight text-fg;
}
.software-title a {
@apply transition-colors hover:text-accent;
}
.software-desc {
@apply mt-2 line-clamp-3 text-sm leading-relaxed text-fg-muted;
}
.software-meta {
@apply mt-3 text-xs text-fg-subtle;
}
.software-actions {
@apply flex flex-wrap gap-2 items-center;
}
.software-download-count {
@apply text-xs text-gray-400 font-medium ml-1;
}
.builds-link-row {
@apply text-center mt-10;
}
.builds-link {
@apply text-sm text-gray-400 hover:text-purple-600 font-medium transition-colors;
@apply mt-5 flex flex-wrap items-center gap-2 pt-1;
}
</style>
+192 -328
View File
@@ -1,250 +1,139 @@
<template>
<div class="catalog-show-page">
<header class="hero-section-gradient from-purple-700 to-indigo-800 py-12 md:py-20">
<header class="hero-section">
<div class="hero-container">
<nav class="mb-8">
<RouterLink to="/catalog" class="catalog-back-link">
<i class="fa-solid fa-arrow-left"></i> {{ t('catalogShow.back') }}
</RouterLink>
</nav>
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
<div v-if="software" class="catalog-header-content">
<div class="flex-grow">
<div class="catalog-badges-group">
<span :class="`status-badge status-${software.status} px-3 py-1`">{{ t(`common.status.${software.status}`) }}</span>
<PlatformBadge :platform="software.platform" />
</div>
<h1 class="catalog-title">{{ software.title }}</h1>
<p class="catalog-subtitle">{{ software.desc }}</p>
<template v-if="software">
<div class="catalog-badges">
<span :class="statusBadgeClass(software.status)">{{ t(`common.status.${software.status}`) }}</span>
<PlatformBadge :platform="software.platform" />
</div>
</div>
<h1 class="hero-title">{{ software.title }}</h1>
<p class="hero-subtitle">{{ software.desc }}</p>
</template>
</div>
</header>
<main v-if="software" class="main-container -mt-10">
<main v-if="software" class="main-container">
<div class="catalog-layout">
<!-- Sidebar Info -->
<div class="catalog-sidebar">
<div class="catalog-card">
<div v-if="defaultImageUrl" class="mb-6">
<div class="flex justify-center">
<img
:src="defaultImageUrl"
:alt="software.title"
:class="['catalog-featured-image', softwareImages.length > 0 ? 'cursor-pointer hover:opacity-80 transition-opacity' : '']"
@click="openLightbox(defaultImageIndex)"
/>
</div>
<div v-if="softwareImages.length > 1" class="catalog-thumbs">
<img
v-for="(img, idx) in softwareImages"
:key="idx"
:src="img.url"
:alt="`${software.title} ${idx + 1}`"
:class="['catalog-thumb', img.isDefault ? 'border-purple-400' : 'border-gray-200 hover:border-purple-300']"
@click="openLightbox(idx)"
/>
</div>
</div>
<h2 class="catalog-section-heading">
<i class="fa-solid fa-circle-info text-purple-600"></i> {{ t('catalogShow.projectInfo') }}
</h2>
<dl class="space-y-4">
<div>
<dt class="catalog-info-label">{{ t('catalogShow.author') }}</dt>
<dd class="catalog-info-value">{{ software.author }}</dd>
</div>
<div>
<dt class="catalog-info-label">{{ t('catalogShow.platform') }}</dt>
<dd><PlatformBadge :platform="software.platform" /></dd>
</div>
<div v-if="software.license">
<dt class="catalog-info-label">{{ t('catalogShow.license') }}</dt>
<dd class="catalog-info-value">{{ software.license }}</dd>
</div>
<div v-if="software.externalLinks?.length">
<dt class="catalog-info-label">{{ t('catalogShow.links') }}</dt>
<dd class="flex flex-col gap-1.5">
<a v-for="link in software.externalLinks" :key="link.url" :href="link.url" target="_blank" class="catalog-external-link">
{{ link.label }} <i class="fa-solid fa-up-right-from-square text-xs"></i>
<aside class="catalog-sidebar">
<div v-if="defaultImageUrl" class="catalog-image-panel">
<img
:src="defaultImageUrl"
:alt="software.title"
class="catalog-featured-image"
:class="softwareImages.length > 0 ? 'cursor-pointer' : ''"
@click="openLightbox(defaultImageIndex)"
/>
</div>
<div v-if="softwareImages.length > 1" class="catalog-thumbs">
<img
v-for="(img, idx) in softwareImages"
:key="idx"
:src="img.url"
:alt="`${software.title} ${idx + 1}`"
class="catalog-thumb"
:class="img.isDefault ? 'border-accent' : 'border-line'"
@click="openLightbox(idx)"
/>
</div>
<div class="card">
<h2 class="catalog-section-heading">{{ t('catalogShow.projectInfo') }}</h2>
<dl class="catalog-facts">
<dt>{{ t('catalogShow.author') }}</dt>
<dd>{{ software.author }}</dd>
<dt>{{ t('catalogShow.platform') }}</dt>
<dd>{{ software.platform }}</dd>
<template v-if="software.license">
<dt>{{ t('catalogShow.license') }}</dt>
<dd>{{ software.license }}</dd>
</template>
<template v-if="software.externalLinks?.length">
<dt>{{ t('catalogShow.links') }}</dt>
<dd class="flex flex-col items-start gap-1">
<a v-for="link in software.externalLinks" :key="link.url" :href="link.url" target="_blank" rel="noopener" class="link">
{{ link.label }}<ExternalLink v-bind="ICON" class="ml-1 inline" />
</a>
</dd>
</div>
<div v-if="software.platformLinks?.length">
<dt class="catalog-info-label">{{ t('catalogShow.platformLinks') }}</dt>
<dd class="flex flex-col gap-1.5">
<a v-for="pl in software.platformLinks" :key="pl.url" :href="pl.url" target="_blank" class="catalog-external-link">
{{ pl.name }} <i class="fa-solid fa-up-right-from-square text-xs"></i>
</template>
<template v-if="software.platformLinks?.length">
<dt>{{ t('catalogShow.platformLinks') }}</dt>
<dd class="flex flex-col items-start gap-1">
<a v-for="pl in software.platformLinks" :key="pl.url" :href="pl.url" target="_blank" rel="noopener" class="link">
{{ pl.name }}<ExternalLink v-bind="ICON" class="ml-1 inline" />
</a>
</dd>
</div>
</template>
</dl>
</div>
</div>
</aside>
<!-- Main Content Area -->
<div class="catalog-main">
<div v-if="software.story" class="catalog-card">
<h2 class="catalog-about-title">{{ t('catalogShow.about') }}</h2>
<section v-if="software.story" class="card">
<h2 class="catalog-section-heading">{{ t('catalogShow.about') }}</h2>
<p class="catalog-about-text">{{ software.story }}</p>
</div>
</section>
<div v-if="latestStable" class="latest-release-card">
<div class="latest-release-header">
<section v-if="latestStable" class="card">
<div class="release-headline">
<div>
<div class="latest-release-label-row">
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
</div>
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
<span class="badge-accent">{{ t('catalogShow.latestStable') }}</span>
<h2 class="release-version-lead mono">{{ latestStable.version }}</h2>
<p class="release-date">{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
</div>
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="btn-accent">
{{ t('catalogShow.playNow') }}
</a>
</div>
<div v-if="tableAssets(latestStable).length" class="latest-release-assets">
<div class="assets-section-label">{{ t('catalogShow.assetsTitle') }}</div>
<!-- Desktop asset table -->
<table class="asset-table-desktop">
<tbody>
<tr v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-table-row-light">
<td class="asset-kind-col-light"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
<td class="asset-path-col-light">{{ assetFileName(asset.path) }}</td>
<td class="asset-action-col">
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-link-white">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="asset-link-white">{{ t('catalogShow.assetDownload') }}</a>
</td>
</tr>
</tbody>
</table>
<!-- Mobile asset list -->
<div class="asset-mobile-list">
<div v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-mobile-item-light">
<div class="asset-mobile-row">
<span class="asset-mobile-kind-light"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</span>
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-mobile-link-white">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="asset-mobile-link-white">{{ t('catalogShow.assetDownload') }}</a>
</div>
<div class="asset-mobile-path-light">{{ assetFileName(asset.path) }}</div>
<div v-if="tableAssets(latestStable).length" class="asset-list">
<h3 class="asset-list-title">{{ t('catalogShow.assetsTitle') }}</h3>
<div v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-row">
<span class="asset-kind">{{ t('catalogShow.assetKind.' + asset.kind) }}</span>
<span class="asset-path mono">{{ assetFileName(asset.path) }}</span>
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="link text-sm">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="link text-sm">{{ t('catalogShow.assetDownload') }}</a>
</div>
</div>
</section>
<section class="card-bare">
<h2 class="releases-title">{{ t('catalogShow.allReleases') }}</h2>
<article v-for="release in allReleases" :key="release.version" class="release-item">
<div class="release-item-head">
<span class="release-version mono">{{ release.version }}</span>
<span v-if="release.version.startsWith('dev-')" class="badge">dev</span>
<span v-if="release.downloadCount" class="release-downloads mono">
{{ release.downloadCount }} {{ t('catalogShow.downloads') }}
</span>
<span class="release-date mono ml-auto">{{ formatDateTime(release.updatedAt) }}</span>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="link text-sm">
{{ t('catalogShow.play') }}
</a>
</div>
<div v-if="tableAssets(release).length" class="asset-list">
<div v-for="asset in tableAssets(release)" :key="asset.kind" class="asset-row">
<span class="asset-kind">{{ t('catalogShow.assetKind.' + asset.kind) }}</span>
<span class="asset-path mono">{{ assetFileName(asset.path) }}</span>
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="link text-sm">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="link text-sm">{{ t('catalogShow.assetDownload') }}</a>
</div>
</div>
</div>
</div>
<div class="releases-card">
<div class="releases-header">
<h2 class="releases-title">{{ t('catalogShow.allReleases') }}</h2>
</div>
<div class="p-0">
<!-- Desktop Table -->
<div class="hidden md:block">
<table class="w-full text-left">
<thead class="release-table-head">
<tr>
<th class="px-8 py-4">{{ t('catalogShow.version') }}</th>
<th class="px-8 py-4">{{ t('catalogShow.releaseDate') }}</th>
</tr>
</thead>
<tbody>
<template v-for="release in stableReleases" :key="release.version">
<tr class="release-table-row">
<td class="px-8 py-4">
<div class="release-version-row">
<span class="release-version">{{ release.version }}</span>
<span v-if="release.downloadCount" class="release-download-count"><i class="fa-solid fa-download text-[10px]"></i> {{ release.downloadCount }}</span>
</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length">
<td colspan="2" class="px-8 pb-4 pt-0">
<table class="w-full text-sm">
<tbody>
<tr v-for="asset in tableAssets(release)" :key="asset.kind" class="release-asset-row">
<td class="release-asset-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
<td class="release-asset-path">{{ assetFileName(asset.path) }}</td>
<td class="release-asset-action">
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="release-asset-link">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="release-asset-link">{{ t('catalogShow.assetDownload') }}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
<tr v-if="devReleases.length > 0" class="dev-versions-header">
<td colspan="2" class="dev-versions-label">{{ t('catalogShow.devVersions') }}</td>
</tr>
<template v-for="release in devReleases" :key="release.version">
<tr class="dev-release-row">
<td class="px-8 py-4">
<div class="dev-release-version">{{ release.version }}</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
</td>
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
</tr>
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
<td colspan="2" class="px-8 pb-4 pt-0">
<table class="w-full text-sm">
<tbody>
<tr v-for="asset in tableAssets(release)" :key="asset.kind" class="release-asset-row">
<td class="release-asset-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
<td class="release-asset-path">{{ assetFileName(asset.path) }}</td>
<td class="release-asset-action">
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="release-asset-link">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="release-asset-link">{{ t('catalogShow.assetDownload') }}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Mobile View -->
<div class="md:hidden divide-y divide-gray-100">
<div
v-for="release in allReleases"
:key="release.version"
:class="['p-6', release.version.startsWith('dev-') ? 'bg-yellow-50/20' : '']"
>
<div class="mobile-release-header">
<div>
<div class="mobile-release-version">{{ release.version }}</div>
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
</div>
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
</div>
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="mobile-play-btn">{{ t('catalogShow.play') }}</a>
<div v-if="tableAssets(release).length" class="mt-3 space-y-1">
<div v-for="asset in tableAssets(release)" :key="asset.kind" class="asset-mobile-item">
<div class="asset-mobile-row">
<span class="asset-mobile-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</span>
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-mobile-link">{{ t('catalogShow.assetOpen') }}</a>
<a v-else :href="downloadUrl(asset.path)" class="asset-mobile-link">{{ t('catalogShow.assetDownload') }}</a>
</div>
<div class="asset-mobile-path">{{ assetFileName(asset.path) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</article>
</section>
</div>
</div>
</main>
<main v-else class="main-container -mt-10">
<div class="loading-card">
{{ error || t('catalogShow.loading') }}
</div>
<main v-else class="main-container">
<p class="state-message">{{ error || t('catalogShow.loading') }}</p>
</main>
<ImageLightbox
@@ -262,10 +151,12 @@ import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { useSoftwareStore } from '../../stores/software.store'
import { filterStableReleases, filterDevReleases } from '../../lib/softwareUtils'
import { filterStableReleases, filterDevReleases, statusBadgeClass } from '../../lib/softwareUtils'
import type { Release, Software, SoftwareImage } from '../../lib/interfaces/software.interface'
import { ExternalLink } from 'lucide-vue-next'
import ImageLightbox from './ImageLightbox.vue'
import PlatformBadge from '../../components/PlatformBadge.vue'
import { ICON } from '../../components/icons'
const { t } = useI18n()
const route = useRoute()
@@ -311,10 +202,9 @@ const latestStable = computed(() => stableReleases.value[0] ?? null)
const downloadUrl = (path: string) =>
`/api/download?path=${encodeURIComponent(path.replace('/file/', ''))}`
// az asset-táblázat sorai és sorrendjük; a html nem letöltés, azt a Play gomb viszi
const TABLE_KIND_ORDER = [
'cartridge', 'source', 'docs',
'win_x64', 'win_x86', 'linux_x64', 'linux_x86',
'win_x64', 'win_x86', 'linux_x64', 'linux_arm64', 'linux_x86',
'mac_universal', 'mac_arm64', 'mac_x64',
]
@@ -325,15 +215,6 @@ const tableAssets = (release: Release) =>
const assetFileName = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? ''
const assetKindIcon = (kind: string) =>
kind.startsWith('win_') ? 'fa-brands fa-windows'
: kind.startsWith('linux_') ? 'fa-brands fa-linux'
: kind.startsWith('mac_') ? 'fa-brands fa-apple'
: kind === 'cartridge' ? 'fa-solid fa-gamepad'
: kind === 'source' ? 'fa-solid fa-code'
: kind === 'docs' ? 'fa-solid fa-book'
: ''
onMounted(async () => {
try {
await store.fetch()
@@ -351,105 +232,88 @@ onMounted(async () => {
</script>
<style scoped>
/* Page */
.catalog-show-page { @apply bg-gray-50 min-h-screen pb-20; }
.catalog-back-link { @apply text-purple-200 hover:text-white transition-colors flex items-center gap-2 font-medium; }
.catalog-header-content { @apply flex flex-col md:flex-row md:items-end justify-between gap-6; }
.catalog-badges-group { @apply flex items-center justify-center gap-3 mb-4; }
.catalog-title { @apply text-4xl md:text-6xl font-black text-white mb-4 leading-tight; }
.catalog-subtitle { @apply text-xl text-purple-100 max-w-3xl mx-auto leading-relaxed; }
.catalog-show-page {
@apply min-h-screen bg-page;
}
.catalog-badges {
@apply mb-3 mt-6 flex flex-wrap items-center gap-2;
}
.catalog-layout {
@apply grid grid-cols-1 gap-6 lg:grid-cols-[320px_minmax(0,1fr)];
}
.catalog-sidebar {
@apply flex flex-col gap-4;
}
.catalog-main {
@apply flex flex-col gap-6;
}
/* Layout */
.catalog-layout { @apply grid grid-cols-1 lg:grid-cols-3 gap-8; }
.catalog-sidebar { @apply lg:col-span-1 space-y-6; }
.catalog-main { @apply lg:col-span-2 space-y-8; }
.catalog-card { @apply bg-white rounded-3xl shadow-xl p-8 border border-gray-100; }
.catalog-image-panel {
@apply flex items-center justify-center rounded-card border border-line bg-raised p-6;
}
.catalog-featured-image {
@apply max-h-64 w-auto object-contain transition-opacity hover:opacity-80;
}
.catalog-thumbs {
@apply flex flex-wrap gap-2;
}
.catalog-thumb {
@apply h-14 w-14 cursor-pointer rounded-control border object-cover transition-colors hover:border-accent;
}
.catalog-section-heading {
@apply mb-4 text-base font-semibold tracking-tight text-fg;
}
.catalog-facts {
@apply grid grid-cols-1 gap-y-3 text-sm;
}
.catalog-facts dt {
@apply text-xs uppercase tracking-wide text-fg-subtle;
}
.catalog-facts dd {
@apply m-0 mb-2 text-fg;
}
.catalog-about-text {
@apply max-w-[68ch] whitespace-pre-line leading-relaxed text-fg-muted;
}
/* Sidebar */
.catalog-featured-image { @apply max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm; }
.catalog-thumbs { @apply flex gap-2 mt-3 justify-center; }
.catalog-thumb { @apply w-12 h-12 object-cover rounded border-2 cursor-pointer transition-all; }
.catalog-section-heading { @apply text-xl font-bold text-gray-900 mb-6 flex items-center gap-2; }
.catalog-info-label { @apply text-xs font-bold text-gray-400 uppercase tracking-widest mb-1; }
.catalog-info-value { @apply text-gray-900 font-medium; }
.catalog-external-link { @apply text-purple-600 hover:text-purple-800 font-bold inline-flex items-center gap-1; }
.release-headline {
@apply flex flex-wrap items-start justify-between gap-4;
}
.release-version-lead {
@apply mt-2 text-2xl font-semibold text-fg;
}
.release-date {
@apply text-sm text-fg-subtle;
}
.releases-title {
@apply border-b border-line px-5 py-3 text-base font-semibold tracking-tight text-fg md:px-6;
}
.release-item {
@apply border-b border-line px-5 py-4 last:border-b-0 md:px-6;
}
.release-item-head {
@apply flex flex-wrap items-center gap-3;
}
.release-version {
@apply text-sm font-medium text-fg;
}
.release-downloads {
@apply text-xs text-fg-subtle;
}
/* About */
.catalog-about-title { @apply text-xl font-bold text-gray-900 mb-4; }
.catalog-about-text { @apply text-gray-700 leading-relaxed whitespace-pre-line; }
/* Latest Release */
.latest-release-card { @apply bg-gradient-to-br from-indigo-600 to-purple-700 rounded-3xl shadow-xl p-8 text-white; }
.latest-release-header { @apply flex flex-col md:flex-row justify-between items-start md:items-center gap-6; }
.latest-release-label-row { @apply flex items-center gap-2 mb-2; }
.latest-release-badge { @apply px-2 py-0.5 bg-white/20 text-white text-[10px] font-black uppercase rounded tracking-widest; }
.latest-release-version { @apply text-4xl font-black mb-1; }
.latest-release-date { @apply text-indigo-100 text-sm; }
.latest-release-play-btn { @apply flex items-center justify-center py-3 px-6 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap; }
.latest-release-assets { @apply mt-6 pt-6 border-t border-white/15; }
.assets-section-label { @apply text-[10px] font-black uppercase tracking-widest text-indigo-200 mb-3; }
/* Asset table - light (hero) */
.asset-table-desktop { @apply hidden md:table w-full text-sm; }
.asset-table-row-light { @apply border-b border-white/10 last:border-0; }
.asset-kind-col-light { @apply py-1.5 pr-6 text-indigo-100 whitespace-nowrap; }
.asset-path-col-light { @apply py-1.5 pr-6 w-full font-mono text-xs text-indigo-200/80 break-all; }
.asset-action-col { @apply py-1.5 text-right whitespace-nowrap; }
.asset-link-white { @apply text-white font-bold hover:underline; }
/* Asset mobile - light (hero) */
.asset-mobile-list { @apply md:hidden space-y-2; }
.asset-mobile-item-light { @apply flex flex-col gap-0.5 py-2 border-b border-white/10 last:border-0; }
.asset-mobile-row { @apply flex justify-between items-center; }
.asset-mobile-kind-light { @apply text-indigo-100 text-sm whitespace-nowrap; }
.asset-mobile-link-white { @apply text-white font-bold text-sm hover:underline ml-4 shrink-0; }
.asset-mobile-path-light { @apply font-mono text-xs text-indigo-200/80 truncate; }
/* Releases section */
.releases-card { @apply bg-white rounded-3xl shadow-xl overflow-hidden border border-gray-100; }
.releases-header { @apply p-8 border-b border-gray-100; }
.releases-title { @apply text-2xl font-bold text-gray-900; }
.release-table-head { @apply bg-gray-50 text-gray-500 text-xs font-bold uppercase tracking-widest; }
.release-table-row { @apply border-t border-gray-100 first:border-t-0 hover:bg-gray-50/50 transition-colors; }
.release-version-row { @apply flex items-center gap-2 mb-2; }
.release-version { @apply font-bold text-gray-900 text-lg; }
.release-download-count { @apply text-xs text-gray-400 font-medium; }
.release-play-link { @apply text-purple-600 hover:text-purple-900 font-bold text-sm; }
.release-date-col { @apply px-8 py-4 text-gray-500 align-top pt-5; }
/* Release asset table */
.release-asset-row { @apply border-b border-gray-100 last:border-0; }
.release-asset-kind { @apply py-1 pr-6 text-gray-500 whitespace-nowrap; }
.release-asset-path { @apply py-1 pr-6 w-full font-mono text-xs text-gray-400 break-all; }
.release-asset-action { @apply py-1 text-right whitespace-nowrap; }
.release-asset-link { @apply text-blue-600 hover:text-blue-900 font-bold; }
/* Dev versions */
.dev-versions-header { @apply border-t border-gray-100 bg-yellow-50/30; }
.dev-versions-label { @apply px-8 py-3 text-[10px] font-black text-yellow-700 uppercase tracking-tighter; }
.dev-release-row { @apply border-t border-gray-100 bg-yellow-50/10 hover:bg-yellow-50/30 transition-colors; }
.dev-release-version { @apply font-bold text-yellow-800 text-lg mb-2; }
/* Mobile releases */
.mobile-release-header { @apply flex justify-between items-start mb-4; }
.mobile-release-version { @apply font-bold text-gray-900 text-lg; }
.mobile-release-date { @apply text-gray-500 text-xs mt-1; }
.dev-badge { @apply px-2 py-0.5 bg-yellow-100 text-yellow-800 text-[10px] font-black uppercase rounded; }
.mobile-play-btn { @apply flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm; }
/* Asset mobile - dark (releases) */
.asset-mobile-item { @apply flex flex-col gap-0.5 py-1.5 border-b border-gray-100 last:border-0; }
.asset-mobile-kind { @apply text-gray-500 text-sm whitespace-nowrap; }
.asset-mobile-link { @apply text-blue-600 font-bold text-sm ml-4 shrink-0; }
.asset-mobile-path { @apply font-mono text-xs text-gray-400 truncate; }
/* Status badges */
.status-badge { @apply rounded text-[10px] font-black uppercase tracking-wider; }
.status-released { @apply bg-green-500 text-white shadow-lg shadow-green-500/20; }
.status-demo { @apply bg-blue-500 text-white shadow-lg shadow-blue-500/20; }
.status-development { @apply bg-yellow-500 text-white shadow-lg shadow-yellow-500/20; }
.status-archived { @apply bg-gray-500 text-white shadow-lg shadow-gray-500/20; }
/* Loading */
.loading-card { @apply bg-white rounded-3xl shadow-xl p-12 text-center text-gray-400; }
.asset-list {
@apply mt-4 flex flex-col;
}
.asset-list-title {
@apply mb-1 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle;
}
.asset-row {
@apply flex flex-wrap items-baseline gap-x-4 gap-y-1 border-t border-line py-2 text-sm;
}
.asset-kind {
@apply min-w-[9rem] text-fg;
}
.asset-path {
@apply flex-grow truncate text-xs text-fg-subtle;
}
</style>
@@ -10,13 +10,13 @@
@keydown.left="prev"
@keydown.right="next"
>
<button class="lightbox-close-btn" @click="close"><i class="fa-solid fa-xmark"></i></button>
<button class="lightbox-close-btn" :aria-label="t('catalogShow.close')" @click="close"><X :size="24" :stroke-width="1.75" /></button>
<button
v-if="images.length > 1" class="lightbox-nav-btn left-4"
@click="prev"
:aria-label="t('catalogShow.prevImage')" @click="prev"
>
<i class="fa-solid fa-chevron-left"></i>
<ChevronLeft :size="28" :stroke-width="1.75" />
</button>
<img
@@ -27,9 +27,9 @@
<button
v-if="images.length > 1" class="lightbox-nav-btn right-4"
@click="next"
:aria-label="t('catalogShow.nextImage')" @click="next"
>
<i class="fa-solid fa-chevron-right"></i>
<ChevronRight :size="28" :stroke-width="1.75" />
</button>
<div v-if="images.length > 1" class="lightbox-counter">
@@ -41,8 +41,12 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
import type { SoftwareImage } from '../../lib/interfaces/software.interface'
const { t } = useI18n()
const props = defineProps<{
images: SoftwareImage[]
isOpen: boolean
@@ -77,15 +81,15 @@ function prev() { currentIndex.value = (currentIndex.value - 1 + props.images.le
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm;
}
.lightbox-close-btn {
@apply absolute top-4 right-4 text-white/70 hover:text-white text-3xl font-bold z-10;
@apply absolute right-4 top-4 z-10 rounded-control p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white;
}
.lightbox-nav-btn {
@apply absolute text-white/70 hover:text-white text-5xl font-bold z-10 select-none;
@apply absolute z-10 select-none rounded-control p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white;
}
.lightbox-image {
@apply max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl;
@apply max-h-[85vh] max-w-[90vw] rounded-card object-contain;
}
.lightbox-counter {
@apply absolute bottom-6 text-white/60 text-sm font-mono;
@apply absolute bottom-6 font-mono text-sm text-white/60;
}
</style>
+61 -67
View File
@@ -1,60 +1,61 @@
<template>
<header class="hero-section-gradient from-teal-600 to-green-600 py-20">
<div class="hero-container max-w-4xl">
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('code.title') }}</h1>
<p class="hero-subtitle text-teal-50 mb-10">{{ t('code.subtitle') }}</p>
<a href="https://git.teletype.hu" target="_blank" class="btn-hero-teal">
<i class="fa-solid fa-code-branch mr-2"></i>{{ t('code.openGitea') }}
</a>
<p class="hero-subtitle">{{ t('code.subtitle') }}</p>
<div class="hero-actions">
<a :href="GIT_BASE" target="_blank" rel="noopener noreferrer" class="btn-accent">{{ t('code.openGitea') }}</a>
</div>
</div>
</header>
<main class="main-container">
<div v-if="error" class="error-alert" role="alert">
<strong class="font-bold">{{ t('common.error') }}</strong>
<span class="block sm:inline"> {{ error }}</span>
<main class="main-container flex flex-col gap-12">
<div v-if="error" class="error-banner" role="alert">
<h2 class="error-banner-title">{{ t('common.error') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<SkeletonCard v-if="!error && publicRepos.length === 0 && !recentCommits.length" :count="4" />
<div v-if="!error && publicRepos.length > 0" class="card-grid mb-16">
<div v-for="repo in publicRepos" :key="repo.name" class="card-base">
<h3 class="repo-card-title">
<a :href="repo.html_url" target="_blank" class="repo-link">
<i class="fa-solid fa-code-branch mr-1 text-sm opacity-60"></i>{{ repo.owner.login }}/{{ repo.name }}
</a>
</h3>
<p v-if="repo.description" class="repo-card-desc">{{ repo.description }}</p>
<p v-if="repo.language" class="repo-card-meta"><i class="fa-solid fa-circle text-[6px] mr-1 align-middle"></i>{{ t('code.language') }} {{ repo.language }}</p>
<p class="repo-card-meta"><i class="fa-solid fa-clock text-[10px] mr-1 opacity-50"></i>{{ t('code.updated') }} {{ formatDateTime(repo.updated_at) }}</p>
</div>
</div>
<section v-if="!error && publicRepos.length > 0" class="repo-list">
<a
v-for="repo in publicRepos" :key="repo.name"
:href="repo.html_url" target="_blank" rel="noopener noreferrer" class="repo-row"
>
<span class="repo-name">{{ repo.owner.login }}/{{ repo.name }}</span>
<span v-if="repo.description" class="repo-desc">{{ repo.description }}</span>
<span class="repo-meta">
<span v-if="repo.language">{{ t('code.language') }} {{ repo.language }}</span>
<span class="mono">{{ formatDateTime(repo.updated_at) }}</span>
</span>
</a>
</section>
<h2 class="commits-section-title">{{ t('code.recentCommits') }}</h2>
<section>
<h2 class="section-title">{{ t('code.recentCommits') }}</h2>
<p v-if="!error && recentCommits.length === 0" class="empty-commits-msg">
{{ t('code.noCommits') }}
</p>
<p v-if="!error && recentCommits.length === 0" class="text-sm text-fg-muted">
{{ t('code.noCommits') }}
</p>
<div class="commits-list">
<div v-for="commit in recentCommits" :key="commit.sha" class="commit-item">
<div class="commit-icon"><i class="fa-solid fa-rocket"></i></div>
<div class="commit-content">
<div class="commit-list">
<article v-for="commit in recentCommits" :key="commit.sha" class="commit-item">
<p class="commit-message">
<a :href="commit.repo.html_url" target="_blank" class="commit-repo-link">
<span class="font-bold">{{ commit.repo.owner }}</span> / <span class="font-bold">{{ commit.repo.name }}</span>
<a :href="commit.repo.html_url" target="_blank" rel="noopener noreferrer" class="commit-repo-link">
{{ commit.repo.owner }}/{{ commit.repo.name }}
</a>
: <span class="text-gray-700">{{ commit.message.split('\n')[0] }}</span>
<span class="text-fg">{{ commit.message.split('\n')[0] }}</span>
</p>
<p class="commit-meta">
<i class="fa-solid fa-user text-[10px] mr-0.5 opacity-50"></i>{{ t('code.authoredBy') }} <span class="font-bold">{{ commit.author.name }}</span> <i class="fa-solid fa-clock text-[10px] mx-0.5 opacity-50"></i>{{ t('code.on') }} {{ formatDateTime(commit.date) }}
<a :href="commit.url" target="_blank" class="commit-sha-link">
<span class="font-mono text-xs">{{ commit.sha.substring(0, 7) }}</span>
{{ t('code.authoredBy') }} <span class="text-fg-muted">{{ commit.author.name }}</span>
{{ t('code.on') }} <span class="mono">{{ formatDateTime(commit.date) }}</span>
<a :href="commit.url" target="_blank" rel="noopener noreferrer" class="commit-sha mono">
{{ commit.sha.substring(0, 7) }}
</a>
</p>
</div>
</article>
</div>
</div>
</section>
</main>
</template>
@@ -66,6 +67,8 @@ import { formatDateTime } from '../../lib/dateFormat'
import { useGitStore } from '../../stores/git.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
const GIT_BASE = 'https://git.teletypegames.org'
const { t } = useI18n()
const store = useGitStore()
@@ -75,49 +78,40 @@ onMounted(() => store.fetch())
</script>
<style scoped>
.error-alert {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-8;
.section-title {
@apply mb-4 text-xl font-semibold tracking-tight text-fg;
}
.repo-card-title {
@apply text-xl font-semibold mb-2;
.repo-list {
@apply overflow-hidden rounded-card border border-line bg-surface;
}
.repo-link {
@apply text-blue-600 hover:text-blue-800;
.repo-row {
@apply flex flex-col gap-1 border-b border-line px-5 py-4 transition-colors last:border-b-0 hover:bg-raised;
}
.repo-card-desc {
@apply text-gray-700 text-sm;
.repo-name {
@apply font-mono text-sm text-fg;
}
.repo-card-meta {
@apply text-gray-500 text-xs mt-1;
.repo-desc {
@apply text-sm text-fg-muted;
}
.commits-section-title {
@apply text-3xl font-bold mb-8 text-center text-gray-800;
.repo-meta {
@apply flex flex-wrap gap-4 text-xs text-fg-subtle;
}
.empty-commits-msg {
@apply text-center text-gray-600 text-lg;
}
.commits-list {
@apply space-y-4 mb-12;
.commit-list {
@apply flex flex-col;
}
.commit-item {
@apply bg-white shadow-lg rounded-lg p-4 flex items-center space-x-4 border border-gray-100;
}
.commit-icon {
@apply flex-shrink-0 text-3xl;
}
.commit-content {
@apply flex-grow;
@apply border-b border-line py-3 last:border-b-0;
}
.commit-message {
@apply text-gray-800 font-semibold;
@apply flex flex-wrap items-baseline gap-2 text-sm;
}
.commit-repo-link {
@apply text-blue-600 hover:text-blue-800;
@apply font-mono text-xs text-accent;
}
.commit-meta {
@apply text-gray-600 text-sm mt-1;
@apply mt-1 flex flex-wrap items-baseline gap-1.5 text-xs text-fg-subtle;
}
.commit-sha-link {
@apply ml-2 text-blue-500 hover:text-blue-700;
.commit-sha {
@apply text-accent;
}
</style>
+105 -193
View File
@@ -1,227 +1,139 @@
<template>
<header class="hero-section-gradient from-blue-600 to-indigo-700 py-20 text-white">
<div class="hero-container text-center">
<h1 class="hero-title mb-6">{{ t('contact.title') }}</h1>
<p class="hero-subtitle mb-10 text-blue-100">{{ t('contact.subtitle') }}</p>
<a :href="DISCORD_INVITE_LINK" target="_blank" class="discord-hero-btn">
<i class="fa-brands fa-discord text-2xl"></i>
{{ t('contact.joinDiscord') }}
</a>
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('contact.title') }}</h1>
<p class="hero-subtitle">{{ t('contact.subtitle') }}</p>
<div class="hero-actions">
<a :href="DISCORD_INVITE_LINK" target="_blank" rel="noopener noreferrer" class="btn-accent">
<BrandIcon name="discord" />{{ t('contact.joinDiscord') }}
</a>
</div>
</div>
</header>
<main class="main-container py-16 px-4">
<div class="contact-grid">
<div class="contact-card">
<h2 class="contact-card-title">
<i class="fa-solid fa-envelope text-3xl text-indigo-500"></i> {{ t('contact.emailUs') }}
</h2>
<p class="contact-card-desc">{{ t('contact.emailDesc') }}</p>
<div class="email-link cursor-pointer" @click="openEmail">
contact@teletype.hu
</div>
<main class="main-container flex flex-col gap-12">
<section>
<h2 class="section-title">{{ t('contact.emailUs') }}</h2>
<p class="section-desc">{{ t('contact.emailDesc') }}</p>
<a :href="`mailto:${EMAIL}`" class="link text-lg">{{ EMAIL }}</a>
</section>
<section>
<h2 class="section-title">{{ t('contact.community') }}</h2>
<p class="section-desc">{{ t('contact.communityDesc') }}</p>
<div class="channel-grid">
<article class="channel">
<h3 class="channel-title"><BrandIcon name="discord" />Discord</h3>
<p class="channel-desc">{{ t('contact.discord.desc') }}</p>
<a :href="DISCORD_INVITE_LINK" target="_blank" rel="noopener noreferrer" class="link text-sm">
{{ t('contact.discord.link') }}
</a>
</article>
<article class="channel">
<h3 class="channel-title"><Terminal v-bind="ICON" />IRC</h3>
<p class="channel-desc">{{ t('contact.irc.desc') }}</p>
<dl class="channel-facts">
<dt>Network</dt><dd class="mono">Libera.Chat</dd>
<dt>Channel</dt><dd class="mono">#teletypegames</dd>
</dl>
</article>
<article class="channel">
<h3 class="channel-title"><RadioTower v-bind="ICON" />BBS</h3>
<p class="channel-desc">{{ t('contact.bbs.desc') }}</p>
<dl class="channel-facts">
<dt>Host</dt><dd class="mono">games.teletype.hu</dd>
<dt>Port</dt><dd class="mono">2323</dd>
</dl>
</article>
</div>
</section>
<div class="contact-card">
<h2 class="contact-card-title">
<i class="fa-solid fa-comments text-3xl text-indigo-500"></i> {{ t('contact.community') }}
</h2>
<p class="contact-card-desc">{{ t('contact.communityDesc') }}</p>
<section>
<h2 class="section-title">{{ t('contact.social') }}</h2>
<p class="section-desc">{{ t('contact.socialDesc') }}</p>
<div class="community-grid-3">
<div class="community-section border-[#5865F2]">
<h3 class="community-section-title text-[#5865F2]"><i class="fa-brands fa-discord"></i> Discord</h3>
<p class="community-section-desc">{{ t('contact.discord.desc') }}</p>
<a :href="DISCORD_INVITE_LINK" target="_blank" class="community-link-discord">
{{ t('contact.discord.link') }} <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
<div class="community-section border-green-500">
<h3 class="community-section-title text-green-700"><i class="fa-solid fa-terminal"></i> IRC</h3>
<p class="community-section-desc">{{ t('contact.irc.desc') }}</p>
<div class="irc-box">
<span class="irc-network">Network: Libera.Chat</span>
<code class="irc-channel">#teletypegames</code>
</div>
</div>
<div class="community-section border-amber-500">
<h3 class="community-section-title text-amber-700"><i class="fa-solid fa-tower-broadcast"></i> BBS</h3>
<p class="community-section-desc">{{ t('contact.bbs.desc') }}</p>
<div class="bbs-box">
<span class="bbs-host">Host: games.teletype.hu</span>
<code class="bbs-port">Port: 2323</code>
</div>
</div>
</div>
<div class="channel-grid">
<article v-for="item in socials" :key="item.brand" class="channel">
<h3 class="channel-title"><BrandIcon :name="item.brand" />{{ item.name }}</h3>
<p class="channel-desc">{{ t(item.desc) }}</p>
<a :href="item.href" target="_blank" rel="noopener noreferrer" class="link text-sm">{{ t(item.link) }}</a>
</article>
</div>
</section>
<div class="contact-card">
<h2 class="contact-card-title">
<i class="fa-solid fa-share-nodes text-3xl text-indigo-500"></i> {{ t('contact.social') }}
</h2>
<p class="contact-card-desc">{{ t('contact.socialDesc') }}</p>
<div class="social-grid">
<div class="community-section border-red-600">
<h3 class="community-section-title text-red-600"><i class="fa-brands fa-youtube"></i> YouTube</h3>
<p class="community-section-desc">{{ t('contact.youtube.desc') }}</p>
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="community-link-youtube">
{{ t('contact.youtube.link') }} <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
<div class="community-section border-[#1877F2]">
<h3 class="community-section-title text-[#1877F2]"><i class="fa-brands fa-facebook"></i> Facebook</h3>
<p class="community-section-desc">{{ t('contact.facebook.desc') }}</p>
<a href="https://www.facebook.com/teletypegames" target="_blank" class="community-link-facebook">
{{ t('contact.facebook.link') }} <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
<div class="community-section border-[#0A66C2]">
<h3 class="community-section-title text-[#0A66C2]"><i class="fa-brands fa-linkedin"></i> LinkedIn</h3>
<p class="community-section-desc">{{ t('contact.linkedin.desc') }}</p>
<a href="https://www.linkedin.com/company/teletypegames/" target="_blank" class="community-link-linkedin">
{{ t('contact.linkedin.link') }} <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
<div class="community-section border-[#1DB954]">
<h3 class="community-section-title text-[#1DB954]"><i class="fa-brands fa-spotify"></i> Spotify</h3>
<p class="community-section-desc">{{ t('contact.spotify.desc') }}</p>
<a href="https://open.spotify.com/playlist/4T7hMkdiERkkW7HaPXqgfX?si=7cdaa091c3274422" target="_blank" class="community-link-spotify">
{{ t('contact.spotify.link') }} <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
</div>
</div>
</div>
<div class="contact-card spotify-card">
<div class="spotify-card-header">
<div class="spotify-icon-wrap">
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
</svg>
</div>
<div>
<h2 class="spotify-card-title">Teletype Games Party</h2>
<p class="spotify-card-subtitle">Spotify Playlist</p>
</div>
</div>
<div class="spotify-embed-wrap">
<iframe
style="border-radius: 12px"
src="https://open.spotify.com/embed/playlist/4T7hMkdiERkkW7HaPXqgfX?utm_source=generator&theme=0"
width="100%"
height="352"
frameBorder="0"
allowfullscreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
</div>
</div>
</div>
<MediaBand label="Teletype Games Party">
<template #action>
<a :href="SPOTIFY_PLAYLIST_URL" target="_blank" rel="noopener noreferrer" class="link text-sm">
{{ t('contact.spotify.link') }}
</a>
</template>
<iframe
class="spotify-embed"
src="https://open.spotify.com/embed/playlist/4T7hMkdiERkkW7HaPXqgfX?utm_source=generator"
width="100%"
height="352"
frameBorder="0"
allowfullscreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
</MediaBand>
</main>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import MediaBand from '../../components/MediaBand.vue'
import { RadioTower, Terminal } from 'lucide-vue-next'
import { BrandIcon, ICON } from '../../components/icons'
const { t } = useI18n()
const EMAIL = 'contact@teletype.hu'
const DISCORD_INVITE_LINK = CONFIG.discordInvite
const SPOTIFY_PLAYLIST_URL = 'https://open.spotify.com/playlist/4T7hMkdiERkkW7HaPXqgfX?si=7cdaa091c3274422'
function openEmail() {
window.location.href = 'mailto:contact@teletype.hu'
}
const socials = [
{ brand: 'youtube', name: 'YouTube', href: 'https://www.youtube.com/@teletypegames', desc: 'contact.youtube.desc', link: 'contact.youtube.link' },
{ brand: 'facebook', name: 'Facebook', href: 'https://www.facebook.com/teletypegames', desc: 'contact.facebook.desc', link: 'contact.facebook.link' },
{ brand: 'linkedin', name: 'LinkedIn', href: 'https://www.linkedin.com/company/teletypegames/', desc: 'contact.linkedin.desc', link: 'contact.linkedin.link' },
{ brand: 'spotify', name: 'Spotify', href: SPOTIFY_PLAYLIST_URL, desc: 'contact.spotify.desc', link: 'contact.spotify.link' },
]
</script>
<style scoped>
.discord-hero-btn {
@apply inline-flex items-center gap-3 bg-[#5865F2] hover:bg-[#4752C4] text-white font-black py-4 px-8 rounded-full text-lg transition-all transform hover:scale-105 shadow-xl;
.section-title {
@apply text-xl font-semibold tracking-tight text-fg;
}
.contact-grid {
@apply grid grid-cols-1 gap-12 max-w-5xl mx-auto;
.section-desc {
@apply mb-5 mt-2 max-w-[68ch] text-sm leading-relaxed text-fg-muted;
}
.contact-card {
@apply bg-white p-8 rounded-2xl shadow-xl border border-gray-100;
.channel-grid {
@apply grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4;
}
.contact-card-title {
@apply text-2xl font-black text-gray-900 mb-6 flex items-center gap-3;
.channel {
@apply flex flex-col gap-2 rounded-card border border-line bg-surface p-5;
}
.contact-card-desc {
@apply text-gray-600 mb-6 leading-relaxed;
.channel-title {
@apply flex items-center gap-2 text-base font-semibold text-fg;
}
.email-link {
@apply text-indigo-600 font-bold text-xl hover:underline transition-all;
.channel-desc {
@apply flex-grow text-sm leading-relaxed text-fg-muted;
}
.community-section {
@apply border-l-4 pl-4;
.channel-facts {
@apply grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm;
}
.community-section-title {
@apply text-lg font-bold mb-2;
.channel-facts dt {
@apply text-xs uppercase tracking-wide text-fg-subtle;
}
.community-section-desc {
@apply text-gray-600 text-sm mb-3;
.channel-facts dd {
@apply m-0 text-fg;
}
.community-link-discord {
@apply text-[#5865F2] font-bold hover:underline inline-flex items-center gap-1;
}
.community-link-youtube {
@apply text-red-600 font-bold hover:underline inline-flex items-center gap-1;
}
.community-link-facebook {
@apply text-[#1877F2] font-bold hover:underline inline-flex items-center gap-1;
}
.community-link-linkedin {
@apply text-[#0A66C2] font-bold hover:underline inline-flex items-center gap-1;
}
.community-link-spotify {
@apply text-[#1DB954] font-bold hover:underline inline-flex items-center gap-1;
}
.spotify-card {
@apply bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-950 border-slate-800 text-white;
}
.spotify-card-header {
@apply flex items-center gap-4 mb-6 text-[#1DB954];
}
.spotify-icon-wrap {
@apply w-14 h-14 rounded-2xl bg-[#1DB954]/10 border border-[#1DB954]/20 flex items-center justify-center;
}
.spotify-card-title {
@apply text-2xl font-black text-white;
}
.spotify-card-subtitle {
@apply text-[#1DB954] text-sm font-bold;
}
.spotify-embed-wrap {
@apply rounded-xl overflow-hidden;
}
.community-grid-3 {
@apply grid grid-cols-1 sm:grid-cols-3 gap-4;
}
.social-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4;
}
.irc-box {
@apply bg-green-100 p-4 rounded-xl border border-green-200 mt-3 inline-block;
}
.irc-network {
@apply block text-xs font-semibold text-green-500 uppercase tracking-wider mb-1;
}
.irc-channel {
@apply text-green-800 font-mono text-lg font-bold;
}
.bbs-box {
@apply bg-amber-50 p-4 rounded-xl border border-amber-100 mt-3 inline-block;
}
.bbs-host {
@apply block text-xs font-semibold text-amber-500 uppercase tracking-wider mb-1;
}
.bbs-port {
@apply text-amber-700 font-mono text-lg font-bold;
.spotify-embed {
@apply block overflow-hidden rounded-card border border-line bg-raised;
}
</style>
@@ -1,39 +1,28 @@
<template>
<div class="engines-container">
<header class="hero-section-slate">
<div class="engines-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-emerald-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-teal-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('engines.badge') }}</div>
<h1 class="hero-title">
{{ t('engines.titleLead') }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">{{ t('engines.titleAccent') }}</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('engines.subtitle') }}</p>
<header class="hero-section">
<div class="hero-container hero-centered">
<p class="hero-badge">{{ t('engines.badge') }}</p>
<h1 class="hero-title">{{ t('engines.titleLead') }} {{ t('engines.titleAccent') }}</h1>
<p class="hero-subtitle">{{ t('engines.subtitle') }}</p>
</div>
</header>
<main class="engines-main">
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('engines.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
<h2 class="error-banner-title">{{ t('engines.errorTitle') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<SkeletonCard v-else-if="loading" :count="3" />
<div v-else-if="enginePages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('engines.noPagesTitle') }}</h2>
<p class="empty-state-desc">{{ t('engines.noPagesDesc') }}</p>
</div>
<div v-else class="engine-list">
<article v-for="(page, index) in enginePages" :key="page.id" class="engine-card group">
<article v-for="({ page, digest }, index) in cards" :key="page.id" class="engine-card">
<div class="engine-card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<div class="engine-card-body">
@@ -42,16 +31,25 @@
</h2>
<p v-if="page.description" class="engine-card-desc">{{ page.description }}</p>
<p v-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<p v-if="digest.intro" class="engine-card-preview">{{ digest.intro }}</p>
<p v-else-if="page.content" class="engine-card-preview">{{ getCleanPreview(page.content) }}</p>
<section v-if="digest.highlights.length" class="engine-highlights">
<h3 v-if="digest.highlightsTitle" class="engine-highlights-title">{{ digest.highlightsTitle }}</h3>
<ul class="engine-highlights-grid">
<li v-for="(item, i) in digest.highlights" :key="i" class="engine-highlight">
<strong v-if="item.title" class="engine-highlight-lead">{{ item.title }}</strong>
<template v-if="item.title && item.text"> </template>{{ item.text }}
</li>
</ul>
</section>
<div class="engine-card-actions">
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="engine-explore-btn">
<i class="fa-solid fa-code-branch text-sm"></i>
<a :href="exploreUrl(page)" target="_blank" rel="noopener" class="btn-accent">
{{ t('engines.explore') }}
<i class="fa-solid fa-arrow-right text-sm"></i>
</a>
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="engine-wiki-link">
<i class="fa-solid fa-book mr-1"></i>{{ t('engines.openWiki') }}
<a :href="`${WIKI_BASE}/${page.path}`" target="_blank" rel="noopener" class="btn-ghost">
{{ t('engines.openWiki') }}
</a>
</div>
</div>
@@ -62,11 +60,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { WIKI_BASE } from '../../api/wiki.api'
import { useEnginesStore } from '../../stores/engines.store'
import { useEnginesStore, getEngineDigest } from '../../stores/engines.store'
import type { WikiPageWithContent } from '../../lib/interfaces/wiki.interface'
import SkeletonCard from '../../components/SkeletonCard.vue'
@@ -76,8 +74,10 @@ const store = useEnginesStore()
const { pages: enginePages, loading, error } = storeToRefs(store)
const { getCleanPreview } = store
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const cards = computed(() =>
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
)
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
@@ -86,62 +86,49 @@ onMounted(() => store.fetch())
<style scoped>
.engines-container {
@apply bg-slate-950 min-h-screen pb-24;
@apply min-h-screen bg-page;
}
.engines-header-decor {
@apply absolute inset-0 opacity-30;
}
.engines-main {
@apply max-w-5xl mx-auto px-4 md:px-8 mt-16 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-slate-900 rounded-2xl shadow-xl p-12 text-center border border-slate-800;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-white mb-2; }
.empty-state-desc { @apply text-slate-400 max-w-md mx-auto; }
/* Few engines, so every one of them gets a full-width, poster-like card. */
.engine-list {
@apply flex flex-col gap-10;
@apply flex flex-col gap-6;
}
.engine-card {
@apply relative overflow-hidden bg-slate-900 rounded-3xl border border-slate-800 border-l-4 border-l-emerald-500 shadow-2xl transition-all hover:border-l-teal-300 hover:-translate-y-1;
@apply relative overflow-hidden rounded-card border border-line bg-surface transition-colors hover:border-line-strong;
}
.engine-card-index {
@apply absolute -top-6 right-4 text-[9rem] leading-none font-black text-slate-800/60 select-none pointer-events-none transition-colors;
}
.engine-card:hover .engine-card-index {
@apply text-slate-800;
@apply pointer-events-none absolute -top-6 right-4 select-none font-mono text-[7rem] leading-none text-line;
}
.engine-card-body {
@apply relative p-8 md:p-12;
@apply relative p-6 md:p-8;
}
.engine-card-title {
@apply text-3xl md:text-5xl font-black text-white mb-4 leading-tight;
@apply text-2xl font-semibold tracking-tight text-fg md:text-3xl;
}
.engine-card-title a {
@apply hover:text-emerald-400 transition-colors;
@apply transition-colors hover:text-accent;
}
.engine-card-desc {
@apply text-lg md:text-xl text-emerald-100/90 font-semibold mb-3 leading-relaxed max-w-3xl;
@apply mt-3 max-w-3xl text-base leading-relaxed text-fg;
}
.engine-card-preview {
@apply text-base text-slate-400 leading-relaxed mb-8 max-w-3xl;
@apply mt-2 max-w-3xl text-sm leading-relaxed text-fg-muted;
}
.engine-highlights {
@apply mt-6 border-t border-line pt-5;
}
.engine-highlights-title {
@apply mb-3 font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
}
.engine-highlights-grid {
@apply grid max-w-4xl list-disc grid-cols-1 gap-x-8 gap-y-2 pl-5 md:grid-cols-2;
}
.engine-highlight {
@apply text-sm leading-relaxed text-fg-muted;
}
.engine-highlight-lead {
@apply font-medium text-fg;
}
.engine-card-actions {
@apply flex flex-wrap items-center gap-6;
}
.engine-explore-btn {
@apply inline-flex items-center gap-2 bg-emerald-500 text-slate-950 px-6 py-3 rounded-xl font-bold hover:bg-emerald-400 transition-all shadow-lg shadow-emerald-900/40 group-hover:translate-x-1;
}
.engine-wiki-link {
@apply text-slate-400 font-semibold hover:text-white transition-colors;
@apply mt-6 flex flex-wrap items-center gap-3;
}
</style>
+190 -362
View File
@@ -1,220 +1,151 @@
<template>
<div class="home-container">
<!-- Header Section -->
<header class="hero-section-slate">
<div class="home-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('home.welcomeTo') }}</div>
<h1 class="hero-title">
Teletype <span class="text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400">Games</span>
</h1>
<header class="hero-section">
<div class="hero-container hero-centered">
<p class="hero-badge">{{ t('home.welcomeTo') }}</p>
<h1 class="hero-title">Teletype <span class="text-accent">Games</span></h1>
<p class="hero-subtitle">{{ t('home.subtitle') }}</p>
</div>
</header>
<main class="main-container max-w-6xl">
<!-- Countdown Section -->
<section v-if="nextEvent" class="mb-12">
<div class="countdown-card">
<h2 class="countdown-event-name">{{ nextEvent.name }}</h2>
<div class="countdown-timer">{{ countdownText }}</div>
<p class="countdown-date"><i class="fa-solid fa-calendar-days mr-1"></i>{{ t('home.startsOn') }} <span class="font-semibold">{{ nextEvent.dateText }}</span></p>
<main class="main-container flex flex-col gap-10">
<section v-if="nextEvent" class="countdown">
<div class="countdown-main">
<div>
<h2 class="countdown-event-name">{{ nextEvent.name }}</h2>
<p class="countdown-date">{{ t('home.startsOn') }} {{ nextEvent.dateText }}</p>
</div>
<p class="countdown-timer">{{ countdownText }}</p>
</div>
<div v-if="followingEvents.length > 0" class="upcoming-events-section">
<h3 class="upcoming-events-title">{{ t('home.upcomingEvents') }}</h3>
<div class="space-y-4">
<div v-for="event in followingEvents" :key="event.name" class="upcoming-event-item">
<span class="font-bold text-gray-800">{{ event.name }}</span>
<span class="text-sm text-gray-500 font-mono">{{ event.dateText }}</span>
</div>
</div>
<div v-if="followingEvents.length > 0" class="countdown-upcoming">
<h3 class="countdown-upcoming-title">{{ t('home.upcomingEvents') }}</h3>
<div v-for="event in followingEvents" :key="event.name" class="countdown-upcoming-row">
<span class="text-fg">{{ event.name }}</span>
<span class="mono text-fg-subtle">{{ event.dateText }}</span>
</div>
</div>
</section>
<!-- Featured Software Section -->
<section v-if="highlightedSoftware" class="mb-16">
<div class="featured-card group">
<div v-if="highlightedImageUrl" class="featured-image-panel">
<img :src="highlightedImageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
<section v-if="highlightedSoftware" class="featured">
<div v-if="highlightedImageUrl" class="featured-image-panel">
<img :src="highlightedImageUrl" :alt="highlightedSoftware.software.title" class="featured-image" />
</div>
<div class="featured-content">
<div class="featured-badges">
<span class="badge-accent">{{ t('home.featuredGame') }}</span>
<span :class="statusBadgeClass(highlightedSoftware.software.status)">
{{ t(`common.status.${highlightedSoftware.software.status}`) }}
</span>
<span v-if="highlightedStableRelease" class="badge mono">{{ highlightedStableRelease.version }}</span>
</div>
<div class="featured-content">
<div class="featured-badges-group">
<span class="featured-badge">{{ t('home.featuredGame') }}</span>
<span :class="`status-badge status-${highlightedSoftware.software.status}`">{{ t(`common.status.${highlightedSoftware.software.status}`) }}</span>
<span v-if="highlightedStableRelease" class="featured-version-badge">{{ highlightedStableRelease.version }}</span>
</div>
<h2 class="featured-title">{{ highlightedSoftware.software.title }}</h2>
<p class="featured-desc">{{ highlightedSoftware.software.desc }}</p>
<h2 class="featured-title">{{ highlightedSoftware.software.title }}</h2>
<p class="featured-desc">{{ highlightedSoftware.software.desc }}</p>
<div class="featured-actions">
<a
v-if="highlightedStableRelease?.htmlFolderPath"
:href="highlightedStableRelease.htmlFolderPath"
target="_blank"
class="featured-btn-play"
><i class="fa-solid fa-play mr-2"></i>{{ t('home.playInBrowser') }}</a>
<RouterLink :to="`/catalog/${highlightedSoftware.software.name}`" class="featured-btn-secondary">
<i class="fa-solid fa-circle-info mr-2"></i>{{ t('home.viewProjectDetails') }}
</RouterLink>
</div>
<div class="featured-actions">
<a
v-if="highlightedStableRelease?.htmlFolderPath"
:href="highlightedStableRelease.htmlFolderPath"
target="_blank"
class="btn-accent"
>{{ t('home.playInBrowser') }}</a>
<RouterLink :to="`/catalog/${highlightedSoftware.software.name}`" class="btn-ghost">
{{ t('home.viewProjectDetails') }}
</RouterLink>
</div>
</div>
</section>
<!-- Latest YouTube Video -->
<section class="mb-16">
<div class="yt-featured-wrapper group">
<div class="yt-glow"></div>
<MediaBand :label="t('home.store.label')">
<template #action>
<RouterLink to="/stores" class="link text-sm">{{ t('home.store.allStores') }}</RouterLink>
</template>
<div class="yt-header-area">
<div class="yt-header-inner">
<div class="yt-status-blob">
<div class="yt-status-dot"></div>
</div>
<span class="yt-label-text">{{ t('home.latestFromYoutube') }}</span>
</div>
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="yt-channel-link">
{{ t('home.visitChannel') }}
</a>
</div>
<div class="store-grid">
<figure class="store-shot">
<img
:src="clientScreenshot" :alt="t('stores.clientShotAlt')"
width="1536" height="1110" loading="lazy"
>
</figure>
<div class="yt-content-grid">
<div class="yt-player-container">
<div class="yt-player-wrap">
<div v-if="ytLoading" class="yt-state-overlay">
<div class="yt-loading-spinner"></div>
</div>
<div v-else-if="ytError" class="yt-state-overlay flex-col gap-2">
<div class="yt-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="yt-error-text">{{ ytError }}</div>
</div>
<div v-else-if="ytNoConfig" class="yt-state-overlay">
<div class="yt-noconfig-text">{{ t('common.missingApiConfig') }}</div>
</div>
<iframe
v-else-if="ytVideoId"
:src="`https://www.youtube.com/embed/${ytVideoId}?autoplay=0&rel=0&modestbranding=1`"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="yt-iframe"
></iframe>
</div>
<div class="store-info">
<h3 class="store-title">{{ t('home.store.title') }}</h3>
<p class="store-desc">{{ t('home.store.desc') }}</p>
<ul class="store-points">
<li>{{ t('home.store.point1') }}</li>
<li>{{ t('home.store.point2') }}</li>
<li>{{ t('home.store.point3') }}</li>
</ul>
<div class="store-actions">
<a :href="CLIENT_RELEASES_URL" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('home.store.download') }}
</a>
<RouterLink to="/stores" class="btn-ghost">{{ t('home.store.more') }}</RouterLink>
</div>
<div v-if="ytVideoId" class="yt-info-sidebar">
<div class="mb-auto">
<span class="yt-new-tag">{{ t('home.newRelease') }}</span>
<h3 class="yt-video-title">{{ ytTitle }}</h3>
<div class="yt-stats-row">
<span v-if="ytPublishDate"><i class="fa-solid fa-calendar-days"></i> {{ ytPublishDate }}</span>
<span v-if="ytViewCount"><i class="fa-solid fa-eye"></i> {{ ytViewCount }}</span>
</div>
</div>
<div class="mt-6">
<a :href="`https://www.youtube.com/watch?v=${ytVideoId}`" target="_blank" class="yt-play-button">
<i class="fa-brands fa-youtube text-xl text-red-500"></i> {{ t('home.watchOnYoutube') }}
</a>
</div>
</div>
<p class="store-platforms">
<BrandIcon name="linux" /><BrandIcon name="apple" /><BrandIcon name="windows" />
{{ t('home.store.platforms') }}
</p>
</div>
</div>
</section>
</MediaBand>
<!-- Spotify Playlist -->
<section class="mb-16">
<div class="spotify-wrapper group">
<div class="spotify-glow"></div>
<MediaBand :label="t('home.latestFromYoutube')">
<template #action>
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="link text-sm">
{{ t('home.visitChannel') }}
</a>
</template>
<div class="spotify-header">
<div class="spotify-header-inner">
<div class="spotify-pulse-wrap">
<div class="spotify-pulse-bar"></div>
<div class="spotify-pulse-bar bar-2"></div>
<div class="spotify-pulse-bar bar-3"></div>
<div class="spotify-pulse-bar bar-4"></div>
</div>
<span class="spotify-label">Teletype Games Party</span>
</div>
<a href="https://open.spotify.com/playlist/4T7hMkdiERkkW7HaPXqgfX?si=7cdaa091c3274422" target="_blank" class="spotify-open-link">
{{ t('home.spotify.openInSpotify') }}
</a>
</div>
<div class="spotify-embed-container">
<div class="yt-grid">
<div class="yt-player">
<div v-if="ytLoading" class="yt-state"><span class="yt-spinner"></span></div>
<div v-else-if="ytError" class="yt-state">{{ ytError }}</div>
<div v-else-if="ytNoConfig" class="yt-state">{{ t('common.missingApiConfig') }}</div>
<iframe
style="border-radius: 12px"
src="https://open.spotify.com/embed/playlist/4T7hMkdiERkkW7HaPXqgfX?utm_source=generator&theme=0"
width="100%"
height="352"
frameBorder="0"
v-else-if="ytVideoId"
:src="`https://www.youtube.com/embed/${ytVideoId}?autoplay=0&rel=0&modestbranding=1`"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
class="yt-iframe"
></iframe>
</div>
</div>
</section>
<!-- Navigation Grid -->
<section class="nav-grid">
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-purple-50 group-hover:bg-purple-100">
<i class="fa-solid fa-gamepad text-3xl text-purple-600"></i>
<div v-if="ytVideoId" class="yt-info">
<span class="badge-accent">{{ t('home.newRelease') }}</span>
<h3 class="yt-title">{{ ytTitle }}</h3>
<p class="yt-stats">
<span v-if="ytPublishDate" class="mono">{{ ytPublishDate }}</span>
<span v-if="ytViewCount" class="mono">{{ ytViewCount }}</span>
</p>
<a :href="`https://www.youtube.com/watch?v=${ytVideoId}`" target="_blank" class="btn-ghost mt-auto">
<BrandIcon name="youtube" />{{ t('home.watchOnYoutube') }}
</a>
</div>
<h2 class="nav-card-title">{{ t('home.catalog.title') }}</h2>
<p class="nav-card-desc">{{ t('home.catalog.desc') }}</p>
<RouterLink to="/catalog" class="btn-primary bg-purple-600 hover:bg-purple-700 text-white hover:shadow-purple-200">
{{ t('home.catalog.btn') }}
</RouterLink>
</div>
</MediaBand>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-green-50 group-hover:bg-green-100">
<i class="fa-solid fa-book text-3xl text-green-600"></i>
</div>
<h2 class="nav-card-title">{{ t('home.wiki.title') }}</h2>
<p class="nav-card-desc">{{ t('home.wiki.desc') }}</p>
<a href="https://wiki.teletype.hu" target="_blank" rel="noopener noreferrer" class="btn-primary bg-green-600 hover:bg-green-700 text-white hover:shadow-green-200">
{{ t('home.wiki.btn') }}
<MediaBand label="Teletype Games Party">
<template #action>
<a :href="SPOTIFY_PLAYLIST_URL" target="_blank" class="link text-sm">
{{ t('home.spotify.openInSpotify') }}
</a>
</div>
</template>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-slate-50 group-hover:bg-slate-100">
<i class="fa-solid fa-laptop-code text-3xl text-slate-700"></i>
</div>
<h2 class="nav-card-title">{{ t('home.git.title') }}</h2>
<p class="nav-card-desc">{{ t('home.git.desc') }}</p>
<a href="https://git.teletype.hu" target="_blank" rel="noopener noreferrer" class="btn-primary bg-slate-800 hover:bg-slate-900 text-white hover:shadow-slate-200">
{{ t('home.git.btn') }}
</a>
</div>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-red-50 group-hover:bg-red-100">
<i class="fa-solid fa-tv text-3xl text-red-600"></i>
</div>
<h2 class="nav-card-title">{{ t('home.youtube.title') }}</h2>
<p class="nav-card-desc">{{ t('home.youtube.desc') }}</p>
<a href="https://www.youtube.com/@teletypegames" target="_blank" rel="noopener noreferrer" class="btn-primary bg-red-600 hover:bg-red-700 text-white hover:shadow-red-200">
{{ t('home.youtube.btn') }}
</a>
</div>
</section>
<!-- AI Content Notice -->
<div class="ai-notice-card">
<div class="ai-notice-icon"><i class="fa-solid fa-robot text-indigo-500"></i></div>
<div class="ai-notice-text">
<strong>Note:</strong> {{ t('home.note') }}
</div>
</div>
<iframe
class="spotify-embed"
src="https://open.spotify.com/embed/playlist/4T7hMkdiERkkW7HaPXqgfX?utm_source=generator"
width="100%"
height="352"
frameBorder="0"
allowfullscreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>
</MediaBand>
</main>
</div>
</template>
@@ -227,7 +158,13 @@ import { useI18n } from 'vue-i18n'
import { useEventStore } from '../../stores/event.store'
import { useSoftwareStore } from '../../stores/software.store'
import { useYoutubeStore } from '../../stores/youtube.store'
import { getDefaultImageUrl } from '../../lib/softwareUtils'
import { getDefaultImageUrl, statusBadgeClass } from '../../lib/softwareUtils'
import MediaBand from '../../components/MediaBand.vue'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
const CLIENT_RELEASES_URL = 'https://git.teletypegames.org/stores/warp-engine-client/releases'
const SPOTIFY_PLAYLIST_URL = 'https://open.spotify.com/playlist/4T7hMkdiERkkW7HaPXqgfX?si=7cdaa091c3274422'
const { t } = useI18n()
@@ -257,219 +194,110 @@ onUnmounted(() => eventStore.cleanup())
<style scoped>
.home-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.home-header-decor {
@apply absolute inset-0 opacity-30;
@apply min-h-screen bg-page;
}
/* Featured Card */
.featured-card {
@apply relative overflow-hidden bg-gradient-to-br from-indigo-900 to-slate-900 rounded-3xl shadow-2xl flex flex-col md:flex-row min-h-[400px];
.countdown {
@apply rounded-card border border-line bg-surface;
}
.featured-content {
@apply p-8 md:p-12 flex flex-col justify-center flex-1 z-10;
}
.featured-badges-group {
@apply flex items-center gap-2 mb-4;
}
.featured-actions {
@apply flex flex-wrap gap-4 mt-8;
}
.featured-badge {
@apply px-3 py-1 bg-indigo-500/20 text-indigo-300 border border-indigo-400/30 rounded-full text-xs font-bold uppercase tracking-wider;
}
.featured-title {
@apply text-4xl md:text-5xl font-black text-white mb-6;
}
.featured-desc {
@apply text-indigo-100/80 text-lg leading-relaxed max-w-xl;
}
.featured-btn-play {
@apply px-8 py-4 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:scale-105 hover:shadow-xl hover:shadow-indigo-500/20 active:scale-95;
}
.featured-btn-secondary {
@apply px-8 py-4 bg-indigo-500/10 text-white border border-indigo-400/30 font-bold rounded-xl transition-all hover:bg-indigo-500/20;
}
.featured-image-panel {
@apply flex-shrink-0 flex items-center justify-center p-6 md:p-8;
}
.featured-image {
@apply max-h-48 w-auto object-contain border-4 border-white shadow-xl opacity-90 group-hover:opacity-100 transition-opacity;
}
.featured-version-badge {
@apply px-2 py-0.5 rounded text-[10px] font-bold bg-white/10 text-white/70 border border-white/20;
}
/* Status Badges */
.status-badge {
@apply px-2 py-0.5 rounded text-[10px] font-bold uppercase;
}
.status-released { @apply bg-green-500/20 text-green-300 border border-green-500/30; }
.status-demo { @apply bg-blue-500/20 text-blue-300 border border-blue-500/30; }
.status-development { @apply bg-yellow-500/20 text-yellow-300 border border-yellow-500/30; }
.status-archived { @apply bg-gray-500/20 text-gray-300 border border-gray-500/30; }
/* Countdown Card */
.countdown-card {
@apply bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 p-8 md:p-12 text-center transition-all duration-300;
.countdown-main {
@apply flex flex-wrap items-center justify-between gap-4 px-5 py-4 md:px-6;
}
.countdown-event-name {
@apply text-2xl font-bold text-gray-400 uppercase tracking-widest mb-2;
}
.countdown-timer {
@apply text-5xl md:text-7xl font-mono font-black text-transparent bg-clip-text bg-gradient-to-r from-violet-600 to-fuchsia-600 my-6;
@apply text-lg font-semibold tracking-tight text-fg;
}
.countdown-date {
@apply text-gray-500 text-lg;
@apply text-sm text-fg-muted;
}
.upcoming-events-section {
@apply mt-12 pt-12 border-t border-gray-100 text-left max-w-2xl mx-auto;
.countdown-timer {
@apply font-mono text-2xl tabular-nums text-accent md:text-3xl;
}
.upcoming-events-title {
@apply text-xl font-bold text-gray-900 mb-6;
.countdown-upcoming {
@apply border-t border-line px-5 py-4 md:px-6;
}
.upcoming-event-item {
@apply flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100;
.countdown-upcoming-title {
@apply mb-2 font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
}
.countdown-upcoming-row {
@apply flex items-center justify-between py-1 text-sm;
}
/* Nav Cards */
.nav-card {
@apply flex flex-col h-full;
.featured {
@apply flex flex-col overflow-hidden rounded-card border border-line bg-surface md:flex-row;
}
.nav-card-icon {
@apply w-14 h-14 rounded-xl flex items-center justify-center mb-6 transition-colors;
.featured-image-panel {
@apply flex flex-shrink-0 items-center justify-center border-b border-line bg-raised p-8 md:border-b-0 md:border-r;
}
.nav-card-title {
@apply text-2xl font-bold text-gray-900 mb-3;
.featured-image {
@apply max-h-48 w-auto object-contain;
}
.nav-card-desc {
@apply text-gray-600 mb-6 flex-grow leading-relaxed;
.featured-content {
@apply flex flex-1 flex-col justify-center p-6 md:p-8;
}
.featured-badges {
@apply mb-3 flex flex-wrap items-center gap-2;
}
.featured-title {
@apply text-2xl font-semibold tracking-tight text-fg md:text-3xl;
}
.featured-desc {
@apply mt-2 max-w-2xl leading-relaxed text-fg-muted;
}
.featured-actions {
@apply mt-6 flex flex-wrap gap-3;
}
/* YouTube Featured Wrapper */
.yt-featured-wrapper {
@apply relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-900 to-indigo-950 rounded-3xl shadow-2xl border border-slate-800 p-6 md:p-8;
.store-grid {
@apply grid grid-cols-1 gap-6 md:grid-cols-2 md:items-center;
}
.yt-glow {
@apply absolute -top-24 -right-24 w-96 h-96 bg-indigo-500/10 rounded-full blur-[100px] pointer-events-none;
.store-shot {
@apply m-0 overflow-hidden rounded-card border border-line;
}
.yt-header-area {
@apply flex items-center justify-between mb-6 relative z-10;
.store-shot img {
@apply block h-auto w-full;
}
.yt-status-blob {
@apply w-3 h-3 bg-red-500/20 rounded-full flex items-center justify-center;
.store-title {
@apply text-xl font-semibold tracking-tight text-fg;
}
.yt-status-dot {
@apply w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse;
.store-desc {
@apply mt-2 leading-relaxed text-fg-muted;
}
.yt-label-text {
@apply text-xs font-bold uppercase tracking-widest text-slate-400;
.store-points {
@apply mt-4 flex list-disc flex-col gap-1.5 pl-5 text-sm text-fg-muted;
}
.yt-channel-link {
@apply text-xs font-semibold text-indigo-400 hover:text-indigo-300 transition-colors;
.store-actions {
@apply mt-5 flex flex-wrap gap-3;
}
.yt-content-grid {
@apply flex flex-col lg:flex-row gap-8 relative z-10;
.store-platforms {
@apply mt-4 flex items-center gap-2 text-xs text-fg-subtle;
}
.yt-player-container {
@apply flex-[1.6] overflow-hidden rounded-2xl border border-white/5 shadow-inner bg-black;
.yt-grid {
@apply grid grid-cols-1 gap-6 lg:grid-cols-[1.6fr_1fr];
}
.yt-player-wrap {
@apply relative w-full aspect-video;
}
.yt-state-overlay {
@apply absolute inset-0 flex items-center justify-center bg-slate-950;
}
.yt-loading-spinner {
@apply w-10 h-10 border-4 border-white/5 border-t-indigo-500 rounded-full animate-spin;
}
.yt-error-icon {
@apply text-xl text-yellow-500;
}
.yt-error-text {
@apply text-[11px] text-slate-500 text-center px-4 font-bold uppercase tracking-tighter;
}
.yt-noconfig-text {
@apply text-slate-500 text-xs;
.yt-player {
@apply relative aspect-video overflow-hidden rounded-card border border-line bg-raised;
}
.yt-iframe {
@apply w-full h-full border-none block;
@apply absolute inset-0 h-full w-full;
}
.yt-header-inner {
@apply flex items-center gap-3;
.yt-state {
@apply absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-fg-muted;
}
.yt-info-sidebar {
@apply flex-1 flex flex-col justify-center;
.yt-spinner {
@apply h-8 w-8 animate-spin rounded-full border-2 border-line border-t-accent;
}
.yt-new-tag {
@apply inline-block px-2 py-1 bg-red-500/10 text-red-400 border border-red-500/20 rounded-md text-[10px] font-black uppercase tracking-tighter mb-4;
.yt-info {
@apply flex flex-col items-start gap-3;
}
.yt-video-title {
@apply text-2xl font-black text-white mb-4 leading-tight group-hover:text-indigo-200 transition-colors;
.yt-title {
@apply text-lg font-semibold leading-snug tracking-tight text-fg;
}
.yt-stats-row {
@apply flex gap-4 text-sm text-slate-400 font-medium;
}
.yt-play-button {
@apply flex items-center justify-center gap-2 w-full py-4 bg-white text-slate-900 font-black rounded-xl transition-all hover:scale-[1.02] hover:bg-indigo-50 active:scale-95 shadow-xl;
.yt-stats {
@apply flex flex-wrap gap-4 text-xs text-fg-subtle;
}
/* Spotify Section */
.spotify-wrapper {
@apply relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-950 rounded-3xl shadow-2xl border border-slate-800 p-6 md:p-8;
}
.spotify-glow {
@apply absolute -top-24 -left-24 w-96 h-96 bg-[#1DB954]/10 rounded-full blur-[100px] pointer-events-none;
}
.spotify-header {
@apply flex items-center justify-between mb-6 relative z-10;
}
.spotify-pulse-wrap {
@apply flex items-end gap-[3px] h-4;
}
.spotify-pulse-bar {
@apply w-[3px] bg-[#1DB954] rounded-full animate-pulse;
height: 100%;
}
.spotify-pulse-bar.bar-2 {
height: 60%;
animation-delay: 0.15s;
}
.spotify-pulse-bar.bar-3 {
height: 80%;
animation-delay: 0.3s;
}
.spotify-pulse-bar.bar-4 {
height: 40%;
animation-delay: 0.45s;
}
.spotify-label {
@apply text-xs font-bold uppercase tracking-widest text-slate-400;
}
.spotify-open-link {
@apply text-xs font-semibold text-[#1DB954] hover:text-[#1ed760] transition-colors;
}
.spotify-header-inner {
@apply flex items-center gap-3;
}
.spotify-embed-container {
@apply relative z-10 rounded-xl overflow-hidden;
}
/* Nav Grid */
.nav-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6;
}
/* AI Notice */
.ai-notice-card {
@apply mb-12 mt-12 bg-white rounded-2xl border border-indigo-100 p-6 shadow-sm flex items-center gap-4;
}
.ai-notice-icon {
@apply w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center text-2xl flex-shrink-0;
}
.ai-notice-text {
@apply flex-grow text-slate-600 text-sm md:text-base italic;
.spotify-embed {
@apply block overflow-hidden rounded-card border border-line bg-raised;
}
</style>
@@ -1,36 +1,27 @@
<template>
<div class="howtos-container">
<header class="hero-section-slate">
<div class="howtos-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">{{ t('howtos.badge') }}</div>
<h1 class="hero-title">
Tech <span class="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400">HowTo Center</span>
</h1>
<p class="hero-subtitle mb-10">{{ t('howtos.subtitle') }}</p>
<a :href="WIKI_BASE" target="_blank" class="btn-hero-indigo">
<i class="fa-solid fa-book mr-2"></i>{{ t('howtos.knowledgeBase') }}
</a>
<header class="hero-section">
<div class="hero-container hero-centered">
<p class="hero-badge">{{ t('howtos.badge') }}</p>
<h1 class="hero-title">{{ t('howtos.title') }}</h1>
<p class="hero-subtitle">{{ t('howtos.subtitle') }}</p>
<div class="hero-actions">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('howtos.knowledgeBase') }}
</a>
</div>
</div>
</header>
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">{{ t('howtos.errorTitle') }}</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
<h2 class="error-banner-title">{{ t('howtos.errorTitle') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<SkeletonCard v-else-if="loading" :count="4" />
<div v-else-if="recentPages.length === 0" class="empty-state">
<div class="empty-state-icon"><i class="fa-solid fa-inbox"></i></div>
<h2 class="empty-state-title">{{ t('howtos.noPagesTitle') }}</h2>
<p class="empty-state-desc">{{ t('howtos.noPagesDesc') }}</p>
</div>
@@ -41,34 +32,26 @@
:key="page.id"
:href="`${WIKI_BASE}/${page.path}`"
target="_blank"
class="group card-interactive flex flex-col"
rel="noopener noreferrer"
class="howto-card"
>
<div class="howto-card-meta">
<div class="howto-card-timestamp">
<span v-if="isNew(page.createdAt)" class="new-badge">
<span class="new-dot"></span>
{{ t('howtos.new') }}
</span>
<span><i class="fa-solid fa-calendar text-gray-300 mr-1"></i>{{ formatDateTime(page.updatedAt) }}</span>
</div>
</div>
<p class="howto-card-meta">
<span class="mono">{{ formatDateTime(page.updatedAt) }}</span>
<span v-if="isNew(page.createdAt)" class="badge-accent">{{ t('howtos.new') }}</span>
</p>
<h2 class="howto-card-title">{{ page.title }}</h2>
<p v-if="page.description" class="howto-card-desc">{{ page.description }}</p>
<div v-else class="howto-card-no-desc">{{ t('howtos.noDesc') }}</div>
<p v-else class="howto-card-no-desc">{{ t('howtos.noDesc') }}</p>
<div class="howto-card-footer">
<span class="howto-card-path"><i class="fa-solid fa-file-lines mr-1 opacity-50"></i>{{ page.path }}</span>
<span class="howto-card-read-link">{{ t('howtos.read') }} <i class="fa-solid fa-arrow-right text-sm"></i></span>
</div>
<p class="howto-card-footer">{{ page.path }}</p>
</a>
</div>
<div v-if="!error && recentPages.length > 0" class="howtos-footer">
<a :href="WIKI_BASE" target="_blank" class="browse-wiki-btn">
<div v-if="!error && recentPages.length > 0" class="focus-row">
<a :href="WIKI_BASE" target="_blank" rel="noopener noreferrer" class="link text-sm">
{{ t('howtos.browseWiki') }}
<i class="fa-solid fa-arrow-right"></i>
</a>
</div>
</main>
@@ -95,57 +78,27 @@ onMounted(() => store.fetch())
<style scoped>
.howtos-container {
@apply bg-gray-50 min-h-screen pb-20;
@apply min-h-screen bg-page;
}
.howtos-header-decor {
@apply absolute inset-0 opacity-30;
.howto-card {
@apply flex flex-col gap-2 rounded-card border border-line bg-surface p-5 transition-colors hover:border-line-strong;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-white rounded-2xl shadow-xl p-12 text-center border border-gray-100;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-gray-900 mb-2; }
.empty-state-desc { @apply text-gray-500 max-w-md mx-auto; }
.howto-card-meta {
@apply flex items-center justify-end mb-4;
}
.howto-card-timestamp {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400;
}
.new-badge {
@apply flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-100;
}
.new-dot {
@apply w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse;
@apply flex flex-wrap items-center gap-3 text-xs text-fg-subtle;
}
.howto-card-title {
@apply text-xl font-bold text-gray-900 group-hover:text-indigo-600 transition-colors mb-2 line-clamp-2 leading-tight;
@apply line-clamp-2 text-base font-semibold leading-snug tracking-tight text-fg;
}
.howto-card:hover .howto-card-title {
@apply text-accent;
}
.howto-card-desc {
@apply text-gray-600 text-sm mb-4 line-clamp-3 leading-relaxed flex-grow;
@apply line-clamp-3 flex-grow text-sm leading-relaxed text-fg-muted;
}
.howto-card-no-desc {
@apply flex-grow mb-4 italic text-gray-400 text-sm;
@apply flex-grow text-sm italic text-fg-subtle;
}
.howto-card-footer {
@apply pt-4 border-t border-gray-100 mt-auto flex items-center justify-between;
}
.howto-card-path {
@apply text-xs font-mono text-gray-400 truncate max-w-[180px];
}
.howto-card-read-link {
@apply text-indigo-600 text-sm font-bold flex items-center gap-1 group-hover:translate-x-1 transition-transform;
}
.howtos-footer {
@apply mt-16 text-center;
}
.browse-wiki-btn {
@apply inline-flex items-center gap-2 bg-slate-900 text-white px-8 py-4 rounded-xl hover:bg-slate-800 transition-all font-bold shadow-lg hover:shadow-indigo-200/50;
@apply mt-auto truncate border-t border-line pt-3 font-mono text-xs text-fg-subtle;
}
</style>
@@ -0,0 +1,46 @@
<template>
<div class="cb">
<pre><code>{{ command }}</code></pre>
<button class="cb-copy" :aria-label="t('stores.copy')" :title="t('stores.copy')" @click="copy">
<Check v-if="copied" v-bind="ICON" />
<Copy v-else v-bind="ICON" />
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Copy } from 'lucide-vue-next'
import { ICON } from '../../components/icons'
const { command } = defineProps<{ command: string }>()
const { t } = useI18n()
const copied = ref(false)
async function copy() {
try {
await navigator.clipboard.writeText(command)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
copied.value = false
}
}
</script>
<style scoped>
.cb {
@apply relative block overflow-x-auto rounded-card border border-line bg-raised text-fg;
}
.cb pre {
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
}
.cb-copy {
@apply absolute right-2 top-2 rounded-control border border-line bg-surface p-2 text-fg-muted
transition-colors hover:text-fg
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
</style>
@@ -0,0 +1,283 @@
<template>
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('stores.title') }}</h1>
<p class="hero-subtitle">{{ t('stores.subtitle') }}</p>
</div>
</header>
<main class="main-container">
<nav class="st-views" role="tablist" :aria-label="t('stores.title')">
<button
class="st-view" :class="{ 'st-view-active': view === 'client' }"
role="tab" :aria-selected="view === 'client'" @click="selectView('client')"
>
{{ t('stores.viewClient') }}
</button>
<button
class="st-view" :class="{ 'st-view-active': view === 'others' }"
role="tab" :aria-selected="view === 'others'" @click="selectView('others')"
>
{{ t('stores.viewOthers') }}
</button>
</nav>
<section v-if="view === 'client'" class="st-section">
<div class="st-app-grid">
<div>
<p class="st-app-lead">{{ t('stores.clientDesc') }}</p>
<div class="st-links">
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('stores.clientDownload') }}
</a>
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.repo') }}
</a>
</div>
<p class="st-app-platforms">
<BrandIcon name="linux" /><BrandIcon name="apple" /><BrandIcon name="windows" />
{{ t('stores.clientPlatforms') }}
</p>
</div>
<figure class="st-app-shot">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="1536" height="1110">
</figure>
</div>
</section>
<template v-else>
<div class="st-devices" role="tablist" :aria-label="t('stores.viewOthers')">
<button
v-for="d in devices"
:key="d.id"
class="st-device"
:class="{ 'st-device-active': d.id === device }"
role="tab"
:aria-selected="d.id === device"
@click="select(d.id)"
>
<span class="st-device-name">{{ t(`stores.${d.id}.name`) }}</span>
<span class="st-device-tagline">{{ t(`stores.${d.id}.tagline`) }}</span>
</button>
</div>
<section class="st-section">
<div class="st-cmds">
<p class="st-cmd-label">{{ t('stores.installTitle') }}</p>
<CommandBlock :command="current.installCmd" />
<template v-if="current.afterInstallCmd">
<p class="st-cmd-label">{{ t('stores.installThen') }}</p>
<CommandBlock :command="current.afterInstallCmd" />
</template>
<p class="st-cmd-label">{{ t('stores.useTitle') }}</p>
<CommandBlock :command="current.cliSnippet" />
<p class="st-cmd-label">{{ t('stores.uninstallTitle') }}</p>
<CommandBlock :command="current.uninstallCmd" />
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.repo') }}
</a>
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t(`stores.${device}.site`) }}
</a>
</div>
</section>
<section class="st-section">
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
<ul class="st-platforms">
<li v-for="p in platforms" :key="p.platform" class="st-platform">
<span class="st-platform-name">{{ p.label }}</span>
<code class="st-platform-ext mono">{{ p.ext }}</code>
</li>
</ul>
</section>
</template>
<div class="focus-row">
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import CommandBlock from './CommandBlock.vue'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const FORGE = 'https://git.teletypegames.org/stores'
const CLIENT = {
repoUrl: `${FORGE}/warp-engine-client`,
releasesUrl: `${FORGE}/warp-engine-client/releases`,
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
}
type DeviceId = 'batocera' | 'retroarch'
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
const devices = [
{
id: 'batocera' as DeviceId,
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
installCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: 'batocera-es-swissknife --restart',
uninstallCmd: `curl -fsSL ${FORGE}/ttg-batocera-store/raw/branch/master/uninstall.sh | sh`,
cliSnippet: [
`${BATOCERA_CLI} list # compatible catalog entries`,
`${BATOCERA_CLI} sync # download everything new`,
`${BATOCERA_CLI} remove c64demo`,
].join('\n'),
},
{
id: 'retroarch' as DeviceId,
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
installCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/install.sh | sh`,
afterInstallCmd: '',
uninstallCmd: `curl -fsSL ${FORGE}/ttg-retroarch-store/raw/branch/master/uninstall.sh | sh`,
cliSnippet: [
`${RETROARCH_CLI} paths # where things go, and whether the cores are there`,
`${RETROARCH_CLI} list # compatible catalog entries`,
`${RETROARCH_CLI} sync # download everything new`,
`${RETROARCH_CLI} remove c64demo`,
].join('\n'),
},
]
type ViewId = 'client' | 'others'
const initial = devices.some((d) => d.id === route.query.device)
? (route.query.device as DeviceId)
: 'batocera'
const device = ref<DeviceId>(initial)
const view = ref<ViewId>(
route.query.view === 'others' || (route.query.view === undefined && route.query.device !== undefined)
? 'others'
: 'client',
)
const current = computed(() => devices.find((d) => d.id === device.value) ?? devices[0])
const client = CLIENT
function select(id: DeviceId) {
device.value = id
void router.replace({ query: { ...route.query, device: id } })
}
function selectView(id: ViewId) {
view.value = id
void router.replace({ query: { ...route.query, view: id } })
}
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
]
</script>
<style scoped>
.st-links {
@apply flex flex-wrap gap-3;
}
.st-section {
@apply mt-6 rounded-card border border-line bg-surface p-5 md:p-8;
}
.st-section-title {
@apply mb-3 text-lg font-semibold tracking-tight text-fg;
}
.st-devices {
@apply mb-6 grid grid-cols-1 gap-3 sm:grid-cols-3;
}
.st-device {
@apply flex flex-col items-start gap-0.5 rounded-card border border-line bg-surface px-4 py-3 text-left transition-colors hover:border-line-strong
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.st-device-active {
@apply border-accent bg-accent-quiet;
}
.st-device-name {
@apply font-medium text-fg;
}
.st-device-tagline {
@apply text-sm text-fg-muted;
}
.st-device-active .st-device-tagline {
@apply text-accent;
}
.st-views {
@apply mb-6 flex gap-1 border-b border-line;
}
.st-view {
@apply -mb-px border-b-2 border-transparent px-4 py-2 text-sm font-medium text-fg-muted transition-colors hover:text-fg
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.st-view-active {
@apply border-accent text-fg;
}
.st-cmds {
@apply space-y-2;
}
.st-cmd-label {
@apply pt-3 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle first:pt-0;
}
.st-links-after {
@apply mt-6;
}
.st-app-lead {
@apply mb-4 text-base leading-relaxed text-fg-muted;
}
.st-app-platforms {
@apply mt-4 flex items-center gap-2 text-sm text-fg-subtle;
}
.st-app-grid {
@apply grid items-start gap-6 lg:grid-cols-2;
}
.st-app-shot {
@apply m-0 overflow-hidden rounded-card border border-line;
}
.st-app-shot img {
@apply block h-auto w-full;
}
.st-platforms {
@apply grid grid-cols-1 gap-3 sm:grid-cols-2;
}
.st-platform {
@apply flex items-center gap-3 rounded-card border border-line bg-raised px-4 py-3;
}
.st-platform-name {
@apply flex-grow text-sm text-fg;
}
.st-platform-ext {
@apply rounded border border-line bg-surface px-2 py-0.5 text-xs text-accent;
}
</style>
+18 -28
View File
@@ -1,24 +1,20 @@
<template>
<header class="hero-section-gradient from-purple-600 to-blue-700 py-20 text-white">
<div class="hero-container text-center">
<h1 class="hero-title mb-6">{{ t('team.title') }}</h1>
<p class="hero-subtitle mb-10 text-purple-100">{{ t('team.subtitle') }}</p>
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('team.title') }}</h1>
<p class="hero-subtitle">{{ t('team.subtitle') }}</p>
</div>
</header>
<main class="main-container py-16 px-4">
<main class="main-container">
<SkeletonCard v-if="loading" :count="4" />
<div v-else class="team-grid">
<div v-for="member in members" :key="member.nick" class="team-card">
<div class="team-card-image-container">
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-card-image" />
</div>
<div class="team-card-content">
<h2 class="team-card-title">{{ member.nick }}: {{ member.realNick }}</h2>
<p class="team-card-desc">{{ member.motto }}</p>
</div>
</div>
<article v-for="member in members" :key="member.nick" class="team-card">
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-avatar" width="512" height="512" loading="lazy" />
<h2 class="team-name">{{ member.nick }}: {{ member.realNick }}</h2>
<p class="team-motto">{{ member.motto }}</p>
</article>
</div>
</main>
</template>
@@ -41,24 +37,18 @@ onMounted(() => store.fetch())
<style scoped>
.team-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto;
@apply grid grid-cols-2 gap-8 lg:grid-cols-4;
}
.team-card {
@apply bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 transition-all hover:scale-105;
@apply flex flex-col items-center text-center;
}
.team-card-image-container {
@apply aspect-square overflow-hidden bg-gray-200;
.team-avatar {
@apply mb-4 h-28 w-28 rounded-full border border-line bg-raised object-cover;
}
.team-card-image {
@apply w-full h-full object-cover;
.team-name {
@apply text-base font-semibold tracking-tight text-fg;
}
.team-card-content {
@apply p-6;
}
.team-card-title {
@apply text-xl font-black text-gray-900 mb-2;
}
.team-card-desc {
@apply text-gray-600 leading-relaxed;
.team-motto {
@apply mt-1 text-sm leading-relaxed text-fg-muted;
}
</style>
+6 -1
View File
@@ -8,6 +8,7 @@ import { enginesRouter } from './engines.router'
import { howtosRouter } from './howtos.router'
import { teamRouter } from './team.router'
import { buildsRouter } from './builds.router'
import { storesRouter } from './stores.router'
export const router = createRouter({
history: createWebHistory(),
@@ -21,8 +22,12 @@ export const router = createRouter({
...howtosRouter,
...teamRouter,
...buildsRouter,
...storesRouter,
],
scrollBehavior() {
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.path === from.path) return false
return { top: 0 }
},
})
+10
View File
@@ -0,0 +1,10 @@
import type { RouteRecordRaw } from 'vue-router'
export const storesRouter: RouteRecordRaw[] = [
{ path: '/stores', name: 'storesIndex', component: () => import('../page/stores/StoresIndexPage.vue') },
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
{ path: '/desktop', redirect: { name: 'storesIndex' } },
]
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest'
import { getEngineDigest } from '../engines.store'
const SAMPLE_MARKDOWN = `
> Example Engine is a **sample** framework that turns markdown into landing cards.
# What You Get
- **First feature**: some \`inline code\` — with a longer explanation.
- **Second feature**: a [link](https://example.org) inside the text.
- Plain bullet without a bold lead.
# Later Section
- **Not picked up**: a list under a later heading.
`
describe('getEngineDigest', () => {
it('extracts the intro from the leading blockquote with inline markdown stripped', () => {
const digest = getEngineDigest(SAMPLE_MARKDOWN)
expect(digest.intro).toBe(
'Example Engine is a sample framework that turns markdown into landing cards.',
)
})
it('takes the What You Get heading as the highlights title', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN).highlightsTitle).toBe('What You Get')
})
it('parses bold-lead bullets into title and text', () => {
const [first, second] = getEngineDigest(SAMPLE_MARKDOWN).highlights
expect(first).toEqual({
title: 'First feature',
text: 'some inline code — with a longer explanation.',
})
expect(second).toEqual({
title: 'Second feature',
text: 'a link inside the text.',
})
})
it('keeps bullets without a bold lead as plain text', () => {
const third = getEngineDigest(SAMPLE_MARKDOWN).highlights[2]
expect(third).toEqual({ title: '', text: 'Plain bullet without a bold lead.' })
})
it('stops at the heading after the What You Get section', () => {
expect(getEngineDigest(SAMPLE_MARKDOWN).highlights).toHaveLength(3)
})
it('matches the heading case-insensitively', () => {
const digest = getEngineDigest('# WHAT you get\n- **A**: b.')
expect(digest.highlights).toEqual([{ title: 'A', text: 'b.' }])
})
it('yields no highlights when the first heading is not What You Get', () => {
const digest = getEngineDigest('Intro line.\n\n# Features\n- **A**: b.\n\n# What You Get\n- **C**: d.')
expect(digest.intro).toBe('Intro line.')
expect(digest.highlights).toEqual([])
})
it('caps the highlights at six items', () => {
const many = '# What You Get\n' + Array.from({ length: 9 }, (_, i) => `- item ${i}`).join('\n')
expect(getEngineDigest(many).highlights).toHaveLength(6)
})
it('uses the first paragraph as intro when there is no blockquote', () => {
const digest = getEngineDigest('Just a plain paragraph.\n\n---\n\n# What You Get\n- one')
expect(digest.intro).toBe('Just a plain paragraph.')
expect(digest.highlights).toEqual([{ title: '', text: 'one' }])
})
it('returns an empty digest for empty content', () => {
expect(getEngineDigest('')).toEqual({ intro: '', highlightsTitle: '', highlights: [] })
})
})
@@ -23,6 +23,30 @@ describe('useUiStore', () => {
expect(store.isRetroMode).toBe(false)
})
it('starts in the dark theme when no cookie is set', () => {
const store = useUiStore()
expect(store.theme).toBe('dark')
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
})
it('reads the theme back from the cookie', () => {
document.cookie = 'theme=light;path=/'
const store = useUiStore()
expect(store.theme).toBe('light')
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
})
it('toggling the theme writes the cookie and the root attribute', () => {
const store = useUiStore()
store.toggleTheme()
expect(store.theme).toBe('light')
expect(document.cookie).toContain('theme=light')
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
store.toggleTheme()
expect(store.theme).toBe('dark')
expect(document.cookie).toContain('theme=dark')
})
it('toggles menu', () => {
const store = useUiStore()
expect(store.menuOpen).toBe(false)
+63 -1
View File
@@ -4,6 +4,68 @@ import wikiApi from '../api/wiki.api'
import { useLoadable } from '../composables/useLoadable'
import type { WikiPageWithContent } from '../lib/interfaces/wiki.interface'
export interface EngineHighlight {
title: string
text: string
}
export interface EngineDigest {
intro: string
highlightsTitle: string
highlights: EngineHighlight[]
}
const HIGHLIGHTS_HEADING = 'what you get'
const MAX_HIGHLIGHTS = 6
function stripInline(md: string): string {
return md
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function getEngineDigest(content: string): EngineDigest {
const digest: EngineDigest = { intro: '', highlightsTitle: '', highlights: [] }
let inHighlights = false
for (const raw of (content || '').split('\n')) {
const line = raw.trim()
if (!line || /^([-*_])\1{2,}$/.test(line)) continue
const heading = line.match(/^#{1,6}\s+(.*)$/)
if (heading) {
if (inHighlights) break
const title = stripInline(heading[1])
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
inHighlights = true
digest.highlightsTitle = title
continue
}
if (inHighlights) {
const bullet = line.match(/^[-*]\s+(.*)$/)
if (!bullet) continue
const lead = bullet[1].match(/^\*\*([^*]+)\*\*\s*[:—–-]?\s*(.*)$/)
digest.highlights.push(
lead
? { title: stripInline(lead[1]), text: stripInline(lead[2]) }
: { title: '', text: stripInline(bullet[1]) },
)
continue
}
if (!digest.intro) {
digest.intro = stripInline(line.replace(/^>\s*/, ''))
}
}
digest.highlights = digest.highlights.slice(0, MAX_HIGHLIGHTS)
return digest
}
export const useEnginesStore = defineStore('engines', () => {
const pages = ref<WikiPageWithContent[]>([])
const { loading, error, withCache, invalidate } = useLoadable()
@@ -19,5 +81,5 @@ export const useEnginesStore = defineStore('engines', () => {
return content.replace(/[#*`_[\]()>|-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 260) + '...'
}
return { pages, loading, error, fetch, getCleanPreview, invalidate }
return { pages, loading, error, fetch, getCleanPreview, getEngineDigest, invalidate }
})
+2 -1
View File
@@ -16,7 +16,8 @@ export const useMemberStore = defineStore('member', () => {
function resolvedAvatarUrl(member: Member): string {
if (member.imageUrl) return member.imageUrl
return new URL(`../assets/team/${member.avatarFilename}`, import.meta.url).href
const file = member.avatarFilename.replace(/\.(png|jpe?g)$/i, '.webp')
return new URL(`../assets/team/${file}`, import.meta.url).href
}
return { members, loading, error, fetch, resolvedAvatarUrl, invalidate }
+47 -1
View File
@@ -2,13 +2,59 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getCookie, setCookie } from '../lib/cookie'
export type Theme = 'light' | 'dark'
const THEME_COOKIE = 'theme'
function readTheme(): Theme {
return getCookie(THEME_COOKIE) === 'light' ? 'light' : 'dark'
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme)
}
function applyRetro(on: boolean) {
document.documentElement.setAttribute('data-retro', on ? '1' : '0')
}
let retroStyles: Promise<unknown> | null = null
function loadRetroStyles(): Promise<unknown> {
if (!retroStyles) {
retroStyles = Promise.all([
import('tuicss/dist/tuicss.min.css'),
import('../styles/retro.css'),
])
}
return retroStyles
}
export const useUiStore = defineStore('ui', () => {
const isRetroMode = ref(getCookie('retro-mode') === '1')
const theme = ref<Theme>(readTheme())
const menuOpen = ref(false)
applyTheme(theme.value)
applyRetro(isRetroMode.value)
if (isRetroMode.value) loadRetroStyles()
function setTheme(next: Theme) {
theme.value = next
setCookie(THEME_COOKIE, next)
applyTheme(next)
}
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function toggleRetroMode() {
isRetroMode.value = !isRetroMode.value
setCookie('retro-mode', isRetroMode.value ? '1' : '0')
applyRetro(isRetroMode.value)
if (isRetroMode.value) loadRetroStyles()
window.scrollTo({ top: 0, behavior: 'instant' })
}
@@ -20,5 +66,5 @@ export const useUiStore = defineStore('ui', () => {
menuOpen.value = false
}
return { isRetroMode, menuOpen, toggleRetroMode, toggleMenu, closeMenu }
return { isRetroMode, theme, menuOpen, setTheme, toggleTheme, toggleRetroMode, toggleMenu, closeMenu }
})
+181 -82
View File
@@ -9,91 +9,190 @@ body {
height: 100%;
}
@layer components {
/* Hero Section */
.hero-section {
@apply relative overflow-hidden py-24 text-white text-center;
}
body {
background-color: rgb(var(--c-page));
color: rgb(var(--c-fg));
}
.hero-section-gradient {
@apply hero-section bg-gradient-to-r;
}
.hero-section-slate {
@apply hero-section bg-slate-900;
}
.hero-container {
@apply relative max-w-5xl mx-auto px-4 z-10;
}
.hero-badge {
@apply inline-block px-3 py-1 rounded-full bg-indigo-500/20 border border-indigo-400/30 text-indigo-300 text-sm font-medium mb-6 backdrop-blur-sm;
}
.hero-title {
@apply text-5xl md:text-6xl font-extrabold tracking-tight mb-6;
}
.hero-subtitle {
@apply text-lg md:text-xl text-slate-300 max-w-2xl mx-auto leading-relaxed;
}
/* Buttons */
.btn-hero {
@apply inline-flex items-center gap-3 bg-white px-8 py-4 rounded-xl transition-all font-bold shadow-xl hover:scale-105 active:scale-95;
}
.btn-hero-teal {
@apply btn-hero text-teal-700 hover:bg-teal-50;
}
.btn-hero-indigo {
@apply btn-hero text-indigo-900 hover:bg-indigo-50;
}
.btn-primary {
@apply inline-flex items-center justify-center font-bold py-3 px-8 rounded-xl transition-all shadow-lg;
}
/* Grid and Containers */
.main-container {
@apply max-w-7xl mx-auto px-4 -mt-12 relative z-10;
}
.card-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
}
/* Cards */
.card-base {
@apply bg-white rounded-2xl border border-gray-200 p-6 shadow-sm;
}
.card-interactive {
@apply bg-white rounded-2xl border border-gray-200 p-6 shadow-sm hover:shadow-xl transition-all duration-300 ease-out hover:-translate-y-1;
}
/* Decorative Background Elements */
.hero-decor-blob {
@apply absolute rounded-full blur-3xl opacity-30;
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Wiki content styles (used with v-html) */
.wiki-content h1 { @apply text-2xl font-bold mt-8 mb-4 text-gray-900; }
.wiki-content h2 { @apply text-xl font-bold mt-6 mb-3 text-gray-800; }
.wiki-content h3 { @apply text-lg font-bold mt-4 mb-2 text-gray-800; }
.wiki-content p { @apply mb-4 text-gray-700 leading-relaxed; }
.wiki-content ul { @apply list-disc ml-6 mb-4 text-gray-700; }
.wiki-content ol { @apply list-decimal ml-6 mb-4 text-gray-700; }
a:focus-visible,
button:focus-visible {
outline: 2px solid rgb(var(--c-accent));
outline-offset: 2px;
border-radius: 2px;
}
@layer components {
.main-container {
@apply mx-auto w-full max-w-6xl px-4 py-10 md:px-6 md:py-14;
}
.card-grid {
@apply grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3;
}
.hero-section {
@apply border-b border-line bg-surface py-10 md:py-14;
}
.hero-container {
@apply mx-auto w-full max-w-6xl px-4 md:px-6;
}
.hero-badge {
@apply mb-3 inline-block font-mono text-xs uppercase tracking-[0.14em] text-accent;
}
.hero-title {
@apply text-3xl font-semibold tracking-tight text-fg md:text-5xl;
}
.hero-subtitle {
@apply mt-3 max-w-2xl text-base leading-relaxed text-fg-muted md:text-lg;
}
.hero-actions {
@apply mt-6 flex flex-wrap items-center gap-3;
}
.hero-centered {
@apply text-center;
}
.hero-centered .hero-subtitle {
@apply mx-auto;
}
.hero-centered .hero-actions {
@apply justify-center;
}
.focus-row {
@apply mt-8 text-center;
}
.state-message {
@apply py-16 text-center text-fg-muted;
}
.card {
@apply rounded-card border border-line bg-surface p-5 md:p-6;
}
.card-bare {
@apply rounded-card border border-line bg-surface;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-control px-4 py-2 text-sm font-medium transition-colors
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.btn-accent {
@apply btn bg-accent text-accent-fg hover:bg-accent-hover;
}
.btn-ghost {
@apply btn border border-line text-fg hover:border-line-strong hover:bg-raised;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.link {
@apply text-accent underline-offset-4 transition-colors hover:underline;
}
.badge {
@apply inline-flex items-center rounded-control border border-line bg-raised px-2 py-0.5 text-xs font-medium text-fg-muted;
}
.badge-accent {
@apply badge border-accent/40 bg-accent-quiet text-accent;
}
.status-badge {
@apply badge uppercase tracking-wide;
}
.status-released {
@apply border-accent/40 bg-accent/15 text-accent;
}
.status-demo {
@apply border-info/40 bg-info/15 text-info;
}
.status-development {
@apply border-warn/40 bg-warn/15 text-warn;
}
.mono {
@apply font-mono tabular-nums;
}
.empty-state {
@apply rounded-card border border-line bg-surface px-6 py-16 text-center;
}
.empty-state-title {
@apply text-lg font-semibold text-fg;
}
.empty-state-desc {
@apply mx-auto mt-2 max-w-md text-sm text-fg-muted;
}
.error-banner {
@apply rounded-card border border-line border-l-2 border-l-warn bg-surface px-5 py-4;
}
.error-banner-title {
@apply font-semibold text-fg;
}
.error-banner-desc {
@apply mt-1 text-sm text-fg-muted;
}
.table-wrap {
@apply overflow-x-auto rounded-card border border-line;
}
}
.wiki-content {
@apply max-w-[68ch] text-fg-muted;
}
.wiki-content h1 { @apply mb-4 mt-10 text-2xl font-semibold tracking-tight text-fg; }
.wiki-content h2 { @apply mb-3 mt-8 text-xl font-semibold tracking-tight text-fg; }
.wiki-content h3 { @apply mb-2 mt-6 text-lg font-semibold text-fg; }
.wiki-content p { @apply mb-4 leading-relaxed; }
.wiki-content ul { @apply mb-4 ml-5 list-disc; }
.wiki-content ol { @apply mb-4 ml-5 list-decimal; }
.wiki-content li { @apply mb-1; }
.wiki-content a { @apply text-indigo-600 hover:underline; }
.wiki-content img { @apply max-w-full h-auto rounded-lg my-6 shadow-md; }
.wiki-content pre { @apply bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-6 font-mono text-sm; }
.wiki-content code { @apply bg-gray-100 text-pink-600 px-1 rounded font-mono text-sm; }
.wiki-content blockquote { @apply border-l-4 border-gray-300 pl-4 italic my-6 text-gray-600; }
.wiki-content table { @apply w-full border-collapse my-6; }
.wiki-content a { @apply text-accent underline-offset-4 hover:underline; }
.wiki-content strong { @apply font-semibold text-fg; }
.wiki-content img { @apply my-6 h-auto max-w-full rounded-card border border-line; }
.wiki-content hr { @apply my-8 border-line; }
.wiki-content pre {
@apply my-6 overflow-x-auto rounded-card border border-line bg-raised p-4 font-mono text-sm text-fg;
}
.wiki-content code { @apply rounded border border-line bg-raised px-1 py-0.5 font-mono text-[0.9em] text-fg; }
.wiki-content pre code { @apply border-0 bg-transparent p-0; }
.wiki-content blockquote { @apply my-6 border-l-2 border-accent pl-4 italic; }
.wiki-content table { @apply my-6 w-full border-collapse text-sm; }
.wiki-content th,
.wiki-content td { @apply border border-gray-200 p-3 text-left; }
.wiki-content th { @apply bg-gray-50 font-bold; }
.wiki-content td { @apply border border-line px-3 py-2 text-left; }
.wiki-content th { @apply bg-raised font-semibold text-fg; }
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
:root {
--c-page: 255 255 255;
--c-surface: 250 250 250;
--c-raised: 244 244 245;
--c-line: 228 228 231;
--c-line-strong: 212 212 216;
--c-fg: 24 24 27;
--c-fg-muted: 82 82 91;
--c-fg-subtle: 113 113 122;
--c-accent: 21 128 61;
--c-accent-hover: 22 101 52;
--c-accent-fg: 255 255 255;
--c-accent-quiet: 220 252 231;
--c-info: 29 78 216;
--c-warn: 180 83 9;
--c-danger: 185 28 28;
}
[data-theme='dark'] {
--c-page: 9 9 11;
--c-surface: 24 24 27;
--c-raised: 39 39 42;
--c-line: 39 39 42;
--c-line-strong: 63 63 70;
--c-fg: 250 250 250;
--c-fg-muted: 161 161 170;
--c-fg-subtle: 113 113 122;
--c-accent: 74 222 128;
--c-accent-hover: 134 239 172;
--c-accent-fg: 9 9 11;
--c-accent-quiet: 20 83 45;
--c-info: 96 165 250;
--c-warn: 255 176 0;
--c-danger: 248 113 113;
}

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