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
202 changed files with 4024 additions and 4814 deletions
+9 -6
View File
@@ -16,13 +16,16 @@ libs/
The API is split in two layers:
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
(softwares, releases, release assets, images, platform links, download stats),
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
(softwares, releases, release assets, software-image links, platform links,
download stats), the CI-callable `/build/*` publishing endpoints, the public
read-only JSON API (`/api/software*`, `/api/builds*`, `/api/download`,
`/file/*`) and the catalog ActiveAdmin resources.
See its [README](libs/ruby/warp_engine/README.md).
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
the store registry, wiki proxy, RSS feeds, Devise/ActiveAdmin authentication,
theming and assets. It consumes WarpEngine as a path gem and mounts it at `/`.
the image library (`Image`, `/api/image/:id`, the admin page — the engine only
links to it through an adapter), the store registry, wiki proxy, RSS feeds,
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
as a path gem and mounts it at `/`.
## The store registry
+1 -1
View File
@@ -1,7 +1,7 @@
PATH
remote: ../libs/ruby/warp_engine
specs:
warp_engine (0.5.0)
warp_engine (0.7.0)
apipie-rails
blueprinter
rails (>= 8.0)
@@ -1,13 +1,11 @@
ActiveAdmin.register WarpEngine::Image, as: "Image" do
ActiveAdmin.register Image do
permit_params :file_upload
menu parent: "🌀 WarpEngine", priority: 5, label: "🖼️ Images"
# SoftwareImage a natív használó; a hoston regisztrált image_owners
# (WarpEngine.config) további használókat adhat hozzá (pl. TTG Member).
used_ids = -> {
WarpEngine::SoftwareImage.unscope(:order).distinct.pluck(:image_id) +
WarpEngine.config.image_owners.flat_map { |o| o[:image_ids].call }
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
+37 -3
View File
@@ -1,8 +1,12 @@
ActiveAdmin.register Store do
permit_params :name, :catalog_url
permit_params :name, :catalog_url, :active
menu priority: 5, label: "🛒 Stores"
scope("Active", default: true) { |scope| scope.active }
scope("Inactive") { |scope| scope.where(active: false) }
scope :all
index do
selectable_column
id_column
@@ -10,12 +14,37 @@ ActiveAdmin.register Store do
column :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
column :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
column :updated_at
actions
actions defaults: true do |store|
link_to store.active? ? "Hide" : "List",
toggle_admin_store_path(store),
method: :put
end
end
filter :name
filter :catalog_url
filter :active
member_action :toggle, method: :put do
resource.update!(active: !resource.active?)
redirect_back fallback_location: admin_stores_path,
notice: "#{resource.name} is now #{resource.active? ? 'listed' : 'hidden'}"
end
batch_action :list do |ids|
Store.where(id: ids).update_all(active: true, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) listed"
end
batch_action :hide do |ids|
Store.where(id: ids).update_all(active: false, updated_at: Time.current)
redirect_to admin_stores_path, notice: "#{ids.size} store(s) hidden"
end
show do
attributes_table do
@@ -24,6 +53,9 @@ ActiveAdmin.register Store do
row :catalog_url do |store|
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
end
row :active do |store|
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
end
row :created_at
row :updated_at
end
@@ -31,7 +63,8 @@ ActiveAdmin.register Store do
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
"and a catalog are the whole record: the client carries its own store engine " \
"and configures itself from this much, deriving the store's slug from the " \
"catalog host."
"catalog host. An inactive store is left out of that answer entirely — the " \
"client has no state for 'there but switched off'."
end
end
@@ -39,6 +72,7 @@ ActiveAdmin.register Store do
f.inputs do
f.input :name, hint: "What the client shows in its store picker"
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
f.input :active, hint: "Off keeps the record but leaves it out of GET /api/stores"
end
f.actions
end
+1 -2
View File
@@ -1,2 +1 @@
//= link active_admin.css
//= link active_admin.js
@@ -1,5 +1,3 @@
//= require active_admin/base
(function() {
var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])';
var _pickerTarget = null;
@@ -89,7 +87,6 @@
var html = '';
// Breadcrumb
var parts = dir ? dir.split('/').filter(function(p){return p;}) : [];
html += '<div class="fm-breadcrumbs"><a href="#" data-dir="">softwares/</a>';
var acc = '';
@@ -99,19 +96,16 @@
}
html += '</div>';
// Upload bar (also a drop zone)
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
html += '<input type="file" id="fm-picker-file" />';
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
html += '<span class="fm-drop-hint">or drag &amp; drop files here</span>';
html += '</div>';
// Table
html += '<table class="fm-table"><thead><tr>';
html += '<th style="width:50px">Type</th><th>Name</th><th style="width:90px">Size</th>';
html += '<th style="width:80px"></th></tr></thead><tbody>';
// Parent dir
if (dir) {
var parentDir = dir.indexOf('/') >= 0 ? dir.substring(0, dir.lastIndexOf('/')) : '';
html += '<tr><td class="fm-type">DIR</td>';
@@ -139,10 +133,8 @@
html += '</tbody></table>';
body.innerHTML = html;
// Event delegation
body.addEventListener('click', handlePickerClick);
// Drag & drop
var dropzone = document.getElementById('fm-picker-dropzone');
if (dropzone) {
['dragenter', 'dragover'].forEach(function(evt) {
@@ -162,21 +154,18 @@
function handlePickerClick(e) {
var t = e.target;
// Navigate directory
if (t.tagName === 'A' && t.hasAttribute('data-dir')) {
e.preventDefault();
loadDir(t.getAttribute('data-dir'));
return;
}
// Select file/folder
if (t.hasAttribute('data-select')) {
e.preventDefault();
selectPath(t.getAttribute('data-select'));
return;
}
// Upload button
if (t.id === 'fm-picker-upload-btn') {
e.preventDefault();
var fileInput = document.getElementById('fm-picker-file');
@@ -233,7 +222,6 @@
return (bytes / 1048576).toFixed(1) + ' MB';
}
// Live image preview for software_images image_id selects
function addImagePreviewListeners() {
var selects = document.querySelectorAll('select[id*="image_id"]:not([data-preview-added])');
selects.forEach(function(sel) {
@@ -241,7 +229,6 @@
sel.addEventListener('change', function() {
var hint = sel.closest('li').querySelector('.inline-hints');
if (!hint) {
// Create hint element if it doesn't exist
hint = document.createElement('p');
hint.className = 'inline-hints';
sel.closest('li').appendChild(hint);
@@ -255,7 +242,6 @@
});
}
// Standalone Files page drag & drop
function initPageDropzone() {
var dz = document.getElementById('fm-page-dropzone');
if (!dz) return;
@@ -2,7 +2,6 @@
@import "active_admin/base";
@import "activeadmin_blaze_theme/theme";
// Force utility navigation visible (blaze theme may hide it)
#utility_nav { display: flex !important; visibility: visible !important; }
#utility_nav #site_link a {
color: #fff !important;
@@ -11,7 +10,6 @@
&:hover { text-decoration: underline; }
}
// File Manager
.fm-breadcrumbs {
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
@@ -37,7 +35,6 @@
.fm-icon-danger:hover { filter: brightness(1.3); }
.fm-danger { color: #dc3545; }
// Emoji action buttons in AA index tables
.table_actions {
a.view_link, a.edit_link, a.delete_link {
font-size: 0 !important;
@@ -61,14 +58,12 @@
.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; }
.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; }
// Browse button in release forms
.fm-browse-btn {
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff !important; border-radius: 3px; font-size: 11px; text-decoration: none !important; vertical-align: middle;
&:hover { background: #5a6268; color: #fff !important; }
&:visited { color: #fff !important; }
}
// Inline file picker modal
.fm-modal-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
}
@@ -0,0 +1,15 @@
class Api::ImagesController < ApiController
resource_description do
short "Images"
formats [ "binary" ]
end
api :GET, "/api/image/:id", "Get image by ID"
param :id, :number, required: true, desc: "Image ID"
returns code: 200, desc: "Image binary data"
error code: 404, desc: "Image not found"
def show
image = Image.find(params[:id])
send_file image.file_path, type: image.content_type, disposition: "inline"
end
end
@@ -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
+2 -12
View File
@@ -1,14 +1,3 @@
# A store a client can install from: a name and a WarpEngine catalog.
#
# That is the whole record, and deliberately so. A client takes identity from it — the
# name, the catalog, and a slug derived from the catalog host — and everything else from
# the store engine it carries. How a store behaves is fixed per installed client, which
# knows its own machine; a copy of it here would be a second authority over decisions
# the client has already made, including which directories it may delete from.
#
# This is deliberately not part of WarpEngine. The engine serves one catalog and has no
# business knowing which stores exist for it; the registry is a property of this site,
# which is what the graphical client asks.
class Store < ApplicationRecord
URL = %r{\Ahttps?://\S+\z}
@@ -17,9 +6,10 @@ class Store < ApplicationRecord
default_scope { where(deleted_at: nil) }
scope :active, -> { where(active: true) }
scope :ordered, -> { order(:name) }
def self.ransackable_attributes(auth_object = nil)
%w[id name catalog_url created_at updated_at]
%w[id name catalog_url active created_at updated_at]
end
end
+1 -2
View File
@@ -2,7 +2,6 @@ class StoreSerializer < Blueprinter::Base
include WarpEngine::TimestampFields
field :name
# camelCase, as the catalog's own payloads use — one convention for a client
# that reads both.
field(:catalogUrl) { |store| store.catalog_url }
end
+2 -1
View File
@@ -1,5 +1,6 @@
class StoreService
def index
StoreSerializer.render_as_hash(Store.ordered)
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
+17 -33
View File
@@ -1,42 +1,26 @@
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
# assignment (not <<) to stay idempotent.
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Owner of the /build/* DB tokens. Switching to :database mode
# (c.application_token_source = :database) must wait until CI uses DB
# tokens — the flip invalidates UPDATE_SECRET immediately.
c.application_token_source = :database
c.application_token_owner_class = "AdminUser"
# Woodpecker configuration extension (/build/config): the served platforms
# and their builder images. An image bump is one line here, rolled out to
# every repo by the deploy.
c.ci_platforms = {
"godot" => { builder: "git.teletypegames.org/build/godot-builder:4.7.1" },
"phaser" => { builder: "git.teletypegames.org/build/phaser-builder:latest" },
"love" => { builder: "git.teletypegames.org/build/love-builder:latest" },
"bevy" => { builder: "git.teletypegames.org/build/bevy-builder:latest" },
"c64" => { builder: "git.teletypegames.org/build/c64-builder:latest" },
"ebitengine" => { builder: "git.teletypegames.org/build/ebitengine-builder:latest" },
"tic80" => { builder: "git.teletypegames.org/build/tic80pro:latest" }
}
# Explicit URL: request.base_url would yield http:// behind the host nginx
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
# pipeline's curl steps silently no-op.
c.ci_update_server = "https://teletypegames.org"
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
c.image_class_name = "Image"
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
c.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.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
+1 -3
View File
@@ -7,6 +7,7 @@ Rails.application.routes.draw do
get "swagger", to: "swagger#index"
get "events", to: "events#index"
get "members", to: "members#index"
get "image/:id", to: "images#show"
get "stores", to: "stores#index"
get "wiki/pages", to: "wiki#index"
get "rss/blog", to: "rss#blog"
@@ -14,8 +15,5 @@ Rails.application.routes.draw do
get "rss/howtos", to: "rss#howtos"
end
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
# az engine adja.
mount WarpEngine::Engine => "/"
end
@@ -9,7 +9,6 @@ class AddCascadeToSoftwareForeignKeys < ActiveRecord::Migration[8.1]
remove_foreign_key :software_images, :softwares
add_foreign_key :software_images, :softwares, on_delete: :cascade
# Keep download stats when a release is removed.
remove_foreign_key :downloads, name: "fk_downloads_release"
add_foreign_key :downloads, :releases, name: "fk_downloads_release", on_delete: :nullify
end
@@ -20,8 +20,6 @@ class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1]
add_index :release_assets, :path, name: "idx_release_assets_path"
add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade
# A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik,
# így az unscoped alapú admin/updater logika konzisztens marad.
COLUMN_KINDS.each do |column, kind|
execute <<~SQL.squish
INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at)
@@ -17,7 +17,6 @@ class DropLegacyPathColumnsFromReleases < ActiveRecord::Migration[8.1]
add_column :releases, column, :string
end
# rollbacknél az adat a release_assets-ből áll vissza, nem veszik el
COLUMN_KINDS.each do |column, kind|
execute <<~SQL.squish
UPDATE releases r
@@ -3,9 +3,7 @@ class CreateStores < ActiveRecord::Migration[8.1]
create_table :stores, id: { type: :bigint, unsigned: true },
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
if_not_exists: true do |t|
# A store is a WarpEngine catalog plus the repository that configures a
# client for it. The registry lives here rather than in the engine: the
# engine serves one catalog and knows nothing about who ships stores for it.
t.string :name, null: false
t.string :catalog_url, null: false
t.string :store_repository_url, null: false
@@ -1,9 +1,5 @@
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
# A store repository is now optional. The client only ever needed identity from
# it — a name, a catalog and a slug — and the engine's own defaults cover
# everything else, so a record with a catalog URL is a complete store. A
# repository is still honoured when there is one: it stays the authority on how
# that store behaves.
def change
change_column_null :stores, :store_repository_url, true
end
@@ -1,15 +1,5 @@
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
# The store's own configuration moves into this record.
#
# It used to live as a `config.json` in a repository the client fetched over HTTP,
# which made a store's behaviour depend on a second thing existing and staying
# reachable. The registry already answers what a store *is*; carrying how it behaves
# in the same record makes this the one source of truth, and lets a store exist with
# no repository at all — which is the ordinary case now that the store engine ships
# inside the client.
#
# Nullable, because a store that configures nothing is still a store: the client
# falls back to the engine's built-in defaults, which need only a name and a catalog.
def change
add_column :stores, :config, :json
end
@@ -1,16 +1,5 @@
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
# Both extras go. A store record is a name and a catalog, and nothing else.
#
# `config` was added earlier today on the idea that the registry should say how each
# store behaves. It should not: the configuration is fixed per installed client — the
# client carries it and knows its own machine — so a copy on the server was a second
# authority over decisions the client had already made correctly, including where it
# may delete. Keeping two stores on one machine apart is a subfolder, which the client
# derives itself.
#
# `store_repository_url` goes for the same reason it stopped being read: the store
# engines it pointed at do not exist any more, and a URL nobody follows is a URL that
# goes stale.
def change
remove_column :stores, :config, :json
remove_column :stores, :store_repository_url, :string
@@ -0,0 +1,6 @@
class AddActiveToStores < ActiveRecord::Migration[8.1]
def change
add_column :stores, :active, :boolean, null: false, default: true
end
end
+2 -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_19_120000) do
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "deleted_at", precision: 3
@@ -300,6 +288,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_120000) do
end
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.string "catalog_url", null: false
t.datetime "created_at", precision: 3, null: false
t.datetime "deleted_at", precision: 3
-3
View File
@@ -16,9 +16,6 @@ end
end
end
# The store registry the graphical desktop client reads. Our own catalog is the
# first record; anyone running this site would add their own the same way, from
# the admin panel or here.
Store.find_or_create_by!(name: "Teletype Games") do |store|
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
end
+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
@@ -19,8 +19,7 @@ RSpec.describe Api::StoresController, type: :request do
get "/api/stores"
store = JSON.parse(response.body).first
# Exactly two fields: a name and a catalog are the whole record, and a client
# that starts reading a third would be reading something this site no longer says.
expect(store.keys).to contain_exactly("name", "catalogUrl")
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
end
@@ -32,5 +31,22 @@ RSpec.describe Api::StoresController, type: :request do
expect(JSON.parse(response.body)).to be_empty
end
it "leaves out inactive stores" do
create(:store, name: "Listed")
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
get "/api/stores"
expect(JSON.parse(response.body).map { |s| s["name"] }).to eq([ "Listed" ])
end
it "still says nothing about the flag itself" do
create(:store)
get "/api/stores"
expect(JSON.parse(response.body).first.keys).to contain_exactly("name", "catalogUrl")
end
end
end
+4
View File
@@ -2,5 +2,9 @@ FactoryBot.define do
factory :store do
name { "Teletype Games" }
catalog_url { "https://teletypegames.org" }
trait :inactive do
active { false }
end
end
end
+1 -1
View File
@@ -4,6 +4,6 @@ RSpec.describe Member, type: :model do
subject { build(:member) }
it { should validate_presence_of(:nick) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:nick).case_insensitive }
end
-1
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
@@ -1,11 +1,6 @@
require "rails_helper"
require "warden/test/helpers"
# The pipeline admin form could not save at all: the resource never declared
# `permit_params`, so ActiveAdmin handed unpermitted attributes to the model and Rails
# raised ForbiddenAttributesError on every edit. No model spec could have caught that —
# the fault was one layer up — so the check belongs here, where the host's ActiveAdmin
# instance actually runs.
RSpec.describe "Admin pipelines", type: :request do
include Warden::Test::Helpers
@@ -27,9 +22,6 @@ RSpec.describe "Admin pipelines", type: :request do
after { Warden.test_reset! }
# This app keeps forgery protection on in the test environment, and a request spec has
# no rendered form to take a token from. The token is not what is under test here, so it
# is switched off for the duration and put back afterwards.
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
@@ -0,0 +1,81 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin stores", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "stores-spec@example.org", password: "password123") }
let!(:listed) { create(:store, name: "Listed", catalog_url: "https://listed.example") }
let!(:hidden) { create(:store, :inactive, name: "Hidden", catalog_url: "https://hidden.example") }
before do
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after { Warden.test_reset! }
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "opens with the active ones" do
get "/admin/stores"
expect(response).to have_http_status(:ok)
expect(response.body).to include("Listed")
expect(response.body).not_to include("Hidden")
end
it "shows the hidden ones under their own scope" do
get "/admin/stores", params: { scope: "inactive" }
expect(response.body).to include("Hidden")
end
it "renders a store's page" do
get "/admin/stores/#{hidden.id}"
expect(response).to have_http_status(:ok)
end
it "saves the flag from the form" do
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
end
it "toggles from the index in one click" do
put "/admin/stores/#{listed.id}/toggle"
expect(response).to have_http_status(:found)
expect(listed.reload).not_to be_active
put "/admin/stores/#{listed.id}/toggle"
expect(listed.reload).to be_active
end
it "lists and hides in bulk" do
post "/admin/stores/batch_action",
params: { batch_action: "list", collection_selection: [ hidden.id ] }
expect(hidden.reload).to be_active
post "/admin/stores/batch_action",
params: { batch_action: "hide", collection_selection: [ hidden.id ] }
expect(hidden.reload).not_to be_active
end
it "is what /api/stores answers with" do
put "/admin/stores/#{listed.id}/toggle"
get "/api/stores"
expect(JSON.parse(response.body)).to be_empty
end
end
+37
View File
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "GET /api/image/:id", type: :request do
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
before do
FileUtils.mkdir_p(upload_path)
allow(Image).to receive(:upload_path).and_return(upload_path)
end
after { FileUtils.rm_rf(upload_path) }
it "serves the file with the content type it was stored with" do
image = create(:image, filename: "cover.png", content_type: "image/png")
File.write(image.file_path, "not really a png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("image/png")
expect(response.body).to eq("not really a png")
end
it "answers 404 for an image that does not exist" do
get "/api/image/999999"
expect(response).to have_http_status(:not_found)
end
it "errors, as it always has, when the row is there but the file is not" do
image = create(:image, filename: "missing.png")
get "/api/image/#{image.id}"
expect(response).to have_http_status(:internal_server_error)
end
end
+9
View File
@@ -7,6 +7,15 @@
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Teletype Games</title>
<script>
(function () {
var m = document.cookie.match(/(?:^|; )theme=([^;]*)/)
var theme = m && decodeURIComponent(m[1]) === 'light' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme)
var r = document.cookie.match(/(?:^|; )retro-mode=([^;]*)/)
document.documentElement.setAttribute('data-retro', r && r[1] === '1' ? '1' : '0')
})()
</script>
<link rel="alternate" type="application/rss+xml" title="Teletype Games Blog" href="/api/rss/blog" />
<link rel="alternate" type="application/rss+xml" title="Teletype Games Releases" href="/api/rss/releases" />
<link rel="alternate" type="application/rss+xml" title="Teletype Games HowTos" href="/api/rss/howtos" />
+222 -277
View File
@@ -8,8 +8,8 @@
"name": "ttglanding",
"version": "0.0.1",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.1",
"dompurify": "^3.4.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.0",
"tuicss": "^2.1.2",
"vue": "^3.5.0",
@@ -25,7 +25,7 @@
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.28.0",
"happy-dom": "^20.11.1",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
@@ -49,56 +49,19 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -145,23 +108,10 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
@@ -175,13 +125,13 @@
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
@@ -195,17 +145,17 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
@@ -219,21 +169,21 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
@@ -247,41 +197,16 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
@@ -295,7 +220,7 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -887,33 +812,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2472,6 +2370,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2627,16 +2535,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2904,20 +2802,6 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2931,6 +2815,20 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2938,27 +2836,27 @@
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/de-indent": {
@@ -3706,17 +3604,45 @@
"he": "bin/he"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
@@ -3928,36 +3854,35 @@
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -3968,14 +3893,27 @@
}
}
},
"node_modules/jsdom/node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/xml-name-validator": {
@@ -4345,13 +4283,20 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
"license": "ISC"
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
@@ -4363,13 +4308,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4517,6 +4455,13 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4622,26 +4567,26 @@
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -4698,13 +4643,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -5006,16 +4944,6 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5138,6 +5066,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5162,6 +5097,13 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -5574,22 +5516,22 @@
}
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -5607,29 +5549,29 @@
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
@@ -5722,16 +5664,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -6734,13 +6666,27 @@
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
@@ -6754,18 +6700,17 @@
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/which": {
+2 -2
View File
@@ -12,8 +12,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.1",
"dompurify": "^3.4.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.0",
"tuicss": "^2.1.2",
"vue": "^3.5.0",
@@ -29,7 +29,7 @@
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.28.0",
"happy-dom": "^20.11.1",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
+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.

Before

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,30 @@
<template>
<section class="media-band">
<header class="media-band-header">
<h2 class="media-band-label">{{ label }}</h2>
<slot name="action" />
</header>
<div class="media-band-body">
<slot />
</div>
</section>
</template>
<script setup lang="ts">
defineProps<{ label: string }>()
</script>
<style scoped>
.media-band {
@apply overflow-hidden rounded-card border border-line bg-surface;
}
.media-band-header {
@apply flex flex-wrap items-baseline justify-between gap-3 border-b border-line px-5 py-3 md:px-6;
}
.media-band-label {
@apply font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
}
.media-band-body {
@apply p-5 md:p-6;
}
</style>
+5 -32
View File
@@ -1,38 +1,11 @@
<template>
<span :class="badgeClass"><i :class="iconClass"></i> {{ platform }}</span>
<span class="badge gap-1.5">
<PlatformIcon :platform="platform" />{{ platform }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PlatformIcon from './PlatformIcon.vue'
const props = defineProps<{ platform: string }>()
const knownPlatforms = ['tic80', 'ebitengine', 'love', 'godot']
const platformIcons: Record<string, string> = {
tic80: 'fa-solid fa-tv',
ebitengine: 'fa-brands fa-golang',
love: 'fa-solid fa-heart',
godot: 'fa-solid fa-robot',
other: 'fa-solid fa-cube',
}
const platformKey = computed(() => {
const key = props.platform.toLowerCase().replace(/[^a-z0-9]/g, '')
return knownPlatforms.includes(key) ? key : 'other'
})
const badgeClass = computed(() => `platform-badge platform-${platformKey.value}`)
const iconClass = computed(() => platformIcons[platformKey.value])
defineProps<{ platform: string }>()
</script>
<style scoped>
.platform-badge {
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
}
.platform-tic80 { @apply bg-cyan-100 text-cyan-800 border border-cyan-200; }
.platform-ebitengine { @apply bg-red-100 text-red-800 border border-red-200; }
.platform-love { @apply bg-pink-100 text-pink-800 border border-pink-200; }
.platform-godot { @apply bg-sky-100 text-sky-800 border border-sky-200; }
.platform-other { @apply bg-purple-100 text-purple-800 border border-purple-200; }
</style>
@@ -0,0 +1,26 @@
<template>
<BrandIcon v-if="brand" :name="brand" />
<component :is="glyph" v-else v-bind="ICON" />
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { Bot, Box, Boxes, Heart, Save, Tv, Zap } from 'lucide-vue-next'
import BrandIcon from './icons/BrandIcon.vue'
import { ICON } from './icons'
const props = defineProps<{ platform: string }>()
const GLYPHS: Record<string, Component> = {
tic80: Tv,
love: Heart,
godot: Bot,
bevy: Boxes,
phaser: Zap,
c64: Save,
}
const key = computed(() => props.platform.toLowerCase().replace(/[^a-z0-9]/g, ''))
const brand = computed(() => (key.value === 'ebitengine' ? 'go' : null))
const glyph = computed(() => GLYPHS[key.value] ?? Box)
</script>
+8 -13
View File
@@ -1,12 +1,10 @@
<template>
<div class="animate-pulse">
<div class="animate-pulse space-y-4">
<div v-for="n in count" :key="n" class="skeleton-card">
<div class="skeleton-card-inner">
<div class="skeleton-line w-1/4 mb-4"></div>
<div class="skeleton-line-lg w-3/4 mb-3"></div>
<div class="skeleton-line w-full mb-2"></div>
<div class="skeleton-line w-5/6"></div>
</div>
<div class="skeleton-line w-1/4"></div>
<div class="skeleton-line-lg w-3/4"></div>
<div class="skeleton-line w-full"></div>
<div class="skeleton-line w-5/6"></div>
</div>
</div>
</template>
@@ -17,15 +15,12 @@ withDefaults(defineProps<{ count?: number }>(), { count: 3 })
<style scoped>
.skeleton-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6;
}
.skeleton-card-inner {
@apply p-6 md:p-8;
@apply flex flex-col gap-3 rounded-card border border-line bg-surface p-5 md:p-6;
}
.skeleton-line {
@apply h-4 bg-gray-200 rounded;
@apply h-4 rounded bg-raised;
}
.skeleton-line-lg {
@apply h-6 bg-gray-200 rounded;
@apply h-6 rounded bg-raised;
}
</style>
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PlatformBadge from '../PlatformBadge.vue'
import { statusBadgeClass } from '../../lib/softwareUtils'
describe('PlatformBadge', () => {
it('shows the platform name next to its own glyph', () => {
const wrapper = mount(PlatformBadge, { props: { platform: 'TIC-80' } })
expect(wrapper.text()).toContain('TIC-80')
expect(wrapper.find('svg').exists()).toBe(true)
})
it('falls back to a glyph for a platform it has never seen', () => {
const wrapper = mount(PlatformBadge, { props: { platform: 'Amiga' } })
expect(wrapper.find('svg').exists()).toBe(true)
})
})
describe('statusBadgeClass', () => {
it('gives every live status its own hue and leaves the archived ones neutral', () => {
expect(statusBadgeClass('released')).toContain('status-released')
expect(statusBadgeClass('demo')).toContain('status-demo')
expect(statusBadgeClass('development')).toContain('status-development')
expect(statusBadgeClass('archived')).toBe('status-badge')
expect(statusBadgeClass('whatever')).toBe('status-badge')
})
})
@@ -0,0 +1,23 @@
<template>
<svg
:width="size" :height="size" viewBox="0 0 24 24"
fill="currentColor" :aria-hidden="label ? undefined : 'true'"
:role="label ? 'img' : undefined" :aria-label="label || undefined"
class="inline-block shrink-0 align-[-0.125em]"
>
<path v-for="(d, i) in paths" :key="i" :d="d" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BRAND_PATHS } from './brandPaths'
const props = withDefaults(defineProps<{
name: string
size?: number | string
label?: string
}>(), { size: 16, label: undefined })
const paths = computed(() => BRAND_PATHS[props.name] ?? [])
</script>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
export const ICON = { size: 16, strokeWidth: 1.75 } as const
export const ICON_CONTROL = { size: 20, strokeWidth: 1.75 } as const
export { default as BrandIcon } from './BrandIcon.vue'
export { BRAND_PATHS } from './brandPaths'
export type { BrandName } from './brandPaths'
@@ -36,7 +36,7 @@ describe('useLoadable', () => {
it('re-executes after TTL expires', async () => {
vi.useFakeTimers()
const { withCache } = useLoadable(100) // 100ms TTL
const { withCache } = useLoadable(100)
const fn = vi.fn()
await withCache(fn)
+1 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
const DEFAULT_TTL = 15 * 60 * 1000
export function useLoadable(ttlMs?: number) {
const loading = ref(false)
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import en from '../locales/en'
import hu from '../locales/hu'
function paths (value: unknown, prefix = ''): string[] {
if (typeof value !== 'object' || value === null) return [prefix]
return Object.entries(value as Record<string, unknown>)
.flatMap(([key, nested]) => paths(nested, prefix === '' ? key : `${prefix}.${key}`))
}
describe('locale bundles', () => {
const english = paths(en).sort()
const hungarian = paths(hu).sort()
it('has something to compare', () => {
expect(english.length).toBeGreaterThan(50)
})
it('translates every English key into Hungarian', () => {
expect(hungarian.filter((key) => !english.includes(key))).toEqual([])
expect(english.filter((key) => !hungarian.includes(key))).toEqual([])
})
it('leaves no empty string behind', () => {
const empty = [...paths(en), ...paths(hu)].filter((key) => {
const read = (bundle: unknown) =>
key.split('.').reduce<unknown>((acc, part) =>
(acc as Record<string, unknown> | undefined)?.[part], bundle)
return read(en) === '' || read(hu) === ''
})
expect(empty).toEqual([])
})
})
+30 -21
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retro Mode',
exitRetro: 'Exit Retro Mode',
rss: 'RSS',
stores: 'Stores',
builds: 'Builds',
theme: 'Switch theme',
menu: 'Menu',
},
footer: {
wiki: 'Wiki',
git: 'Git repositories',
releases: 'Releases',
license: 'All content is available under the',
tagline: 'Built in the open — we love open source, and the freedom to learn, fork and share.',
},
common: {
status: {
@@ -37,29 +48,21 @@ export default {
newRelease: 'New Release',
watchOnYoutube: 'Watch on YouTube',
note: 'Our HowTo-s and blog posts are written by AI with human supervision.',
catalog: {
title: 'Catalog',
desc: 'Here you can find our games. Discover our creations for TIC-80 and other platforms, many of which you can try directly in your browser.',
btn: 'Browse Games',
},
wiki: {
title: 'Wiki',
desc: 'We gather our public interest development knowledge here. Read through technical descriptions and development logs.',
btn: 'Knowledge Base',
},
git: {
title: 'Git Repos',
desc: 'All our projects are open source. Browse our code, study our solutions, or even participate in the development.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.',
btn: 'Watch on YouTube',
},
spotify: {
openInSpotify: 'Open in Spotify ↗',
},
store: {
label: 'Play on your own machine',
allStores: 'All stores',
title: 'WarpEngine Client',
desc: 'Our catalog as a grid of cards on your desktop: one click installs a game into your application menu, one plays it. It sets itself up on first run.',
point1: 'Nothing to install first — no interpreter, no admin rights',
point2: 'Installed games become normal menu entries, so they start without the app running',
point3: 'Browser builds too, for titles with no download for your machine',
download: 'Download',
more: 'How it works',
platforms: 'Linux, macOS and Windows',
},
},
contact: {
title: 'Contact Us',
@@ -99,6 +102,8 @@ export default {
},
},
blog: {
back: 'Back to the blog',
noContent: 'No content available for this post.',
badge: 'Community Stories',
title: 'Developer Blog',
subtitle: 'News, updates, and behind-the-scenes stories from the Teletype Games community.',
@@ -109,6 +114,7 @@ export default {
readMore: 'Read more',
},
catalog: {
getTheStore: 'Get the store',
title: 'Our Games',
subtitle: 'Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!',
filter: {
@@ -126,6 +132,9 @@ export default {
},
catalogShow: {
back: 'Back to Catalog',
close: 'Close',
prevImage: 'Previous image',
nextImage: 'Next image',
projectInfo: 'Project Info',
author: 'Author',
platform: 'Platform',
@@ -206,7 +215,7 @@ export default {
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
clientDownload: 'Download',
clientPlatforms: 'Linux, macOS and Windows · needs Python 3',
clientPlatforms: 'Linux, macOS and Windows · nothing to install first',
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
docs: 'Documentation',
+30 -21
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retró mód',
exitRetro: 'Retró mód kikapcsolása',
rss: 'RSS',
stores: 'Boltok',
builds: 'Buildek',
theme: 'Téma váltása',
menu: 'Menü',
},
footer: {
wiki: 'Wiki',
git: 'Git tárolók',
releases: 'Kiadások',
license: 'A tartalom elérhető',
tagline: 'Nyíltan fejlesztünk — szeretjük a nyílt forráskódot, és a szabadságot, hogy tanulni, forkolni és megosztani lehessen.',
},
common: {
status: {
@@ -37,29 +48,21 @@ export default {
newRelease: 'Új megjelenés',
watchOnYoutube: 'Nézd meg YouTube-on',
note: 'A HowTo-k és blogbejegyzések mesterséges intelligenciával íródtak, emberi felügyelet mellett.',
catalog: {
title: 'Katalógus',
desc: 'Itt megtalálod a játékainkat. Fedezd fel alkotásainkat TIC-80-ra és más platformokra, amelyek közül sokat közvetlenül a böngésződben is kipróbálhatsz.',
btn: 'Játékok böngészése',
},
wiki: {
title: 'Wiki',
desc: 'Itt gyűjtjük össze a közérdekű fejlesztési tudásunkat. Olvasd át a technikai leírásokat és fejlesztési naplókat.',
btn: 'Tudásbázis',
},
git: {
title: 'Git tárolók',
desc: 'Minden projektünk nyílt forráskódú. Böngészd a kódunkat, tanulmányozd a megoldásainkat, vagy akár részt is vehetsz a fejlesztésben.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Nézd meg fejlesztési vlogjainkat, gameplay videóinkat és tutorialainkat a hivatalos YouTube-csatornánkon.',
btn: 'Nézd meg YouTube-on',
},
spotify: {
openInSpotify: 'Megnyitás Spotify-ban ↗',
},
store: {
label: 'Játssz a saját gépeden',
allStores: 'Összes bolt',
title: 'WarpEngine Client',
desc: 'A katalógusunk kártyákban, az asztalodon: egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első futáskor magát is beállítja.',
point1: 'Semmit nem kell előtte feltenni — se értelmező, se rendszergazdai jog',
point2: 'A feltelepített játék rendes menüelem lesz, tehát az app nélkül is indul',
point3: 'Böngészős buildek is, ha egy címhez nincs letöltés a gépedre',
download: 'Letöltés',
more: 'Hogyan működik',
platforms: 'Linux, macOS és Windows',
},
},
contact: {
title: 'Kapcsolat',
@@ -99,6 +102,8 @@ export default {
},
},
blog: {
back: 'Vissza a bloghoz',
noContent: 'Ehhez a bejegyzéshez nincs tartalom.',
badge: 'Közösségi történetek',
title: 'Fejlesztői Blog',
subtitle: 'Hírek, frissítések és kulisszák mögötti történetek a Teletype Games közösségből.',
@@ -109,6 +114,7 @@ export default {
readMore: 'Tovább olvasom',
},
catalog: {
getTheStore: 'Töltsd le a boltot',
title: 'Játékaink',
subtitle: 'Fedezd fel a TIC-80-ra és más platformokra készített játékainkat, amelyeknek közvetlenül a böngésződben is kipróbálhatsz verzióit!',
filter: {
@@ -126,6 +132,9 @@ export default {
},
catalogShow: {
back: '← Vissza a katalógushoz',
close: 'Bezárás',
prevImage: 'Előző kép',
nextImage: 'Következő kép',
projectInfo: 'Projekt infó',
author: 'Szerző',
platform: 'Platform',
@@ -206,7 +215,7 @@ export default {
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
clientDownload: 'Letöltés',
clientPlatforms: 'Linux, macOS és Windows · Python 3 kell hozzá',
clientPlatforms: 'Linux, macOS és Windows · semmit nem kell előtte feltenni',
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
docs: 'Dokumentáció',
+147 -93
View File
@@ -1,76 +1,82 @@
<template>
<div :class="{ 'retro-mode': isRetroMode }" class="app-root">
<header class="app-header">
<RouterLink to="/" class="app-logo">Teletype Games</RouterLink>
<div class="app-header-inner">
<RouterLink to="/" class="app-logo">Teletype Games</RouterLink>
<!-- Desktop Navigation -->
<nav class="nav-desktop">
<RouterLink to="/" class="nav-link"><i class="fa-solid fa-house nav-icon"></i>{{ t('nav.home') }}</RouterLink>
<RouterLink to="/catalog" class="nav-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
<a v-if="isAdmin" href="/admin" class="nav-link-admin"><i class="fa-solid fa-wrench nav-icon"></i>Admin</a>
<nav class="nav-desktop">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="nav-link">
{{ t(item.label) }}
</RouterLink>
<a v-if="isAdmin" href="/admin" class="nav-link">Admin</a>
</nav>
<div class="locale-switcher">
<i class="fa-solid fa-globe text-gray-500 text-xs"></i>
<button :class="locale === 'en' ? 'text-white' : 'text-gray-400 hover:text-white'" class="locale-btn" @click="switchLocale('en')">EN</button>
<span class="locale-separator">|</span>
<button :class="locale === 'hu' ? 'text-white' : 'text-gray-400 hover:text-white'" class="locale-btn" @click="switchLocale('hu')">HU</button>
<div class="header-tools">
<div class="locale-switcher">
<button :class="localeBtnClass('en')" class="locale-btn" @click="switchLocale('en')">EN</button>
<span class="locale-separator">/</span>
<button :class="localeBtnClass('hu')" class="locale-btn" @click="switchLocale('hu')">HU</button>
</div>
<button v-if="!isRetroMode" class="icon-btn" :aria-label="t('nav.theme')" :title="t('nav.theme')" @click="toggleTheme">
<Sun v-if="theme === 'dark'" v-bind="ICON_CONTROL" />
<Moon v-else v-bind="ICON_CONTROL" />
</button>
<button class="icon-btn hamburger-btn" :aria-label="t('nav.menu')" :aria-expanded="menuOpen" @click="toggleMenu">
<X v-if="menuOpen" v-bind="ICON_CONTROL" />
<Menu v-else v-bind="ICON_CONTROL" />
</button>
</div>
</nav>
</div>
<!-- Hamburger Button (Mobile only) -->
<button class="hamburger-btn" @click="toggleMenu">
<i class="fa-solid fa-bars text-xl"></i>
</button>
<!-- Mobile Navigation -->
<nav v-show="menuOpen" class="nav-mobile">
<RouterLink to="/" class="nav-mobile-link"><i class="fa-solid fa-house nav-icon"></i>{{ t('nav.home') }}</RouterLink>
<RouterLink to="/catalog" class="nav-mobile-link"><i class="fa-solid fa-gamepad nav-icon"></i>{{ t('nav.catalog') }}</RouterLink>
<RouterLink to="/blog" class="nav-mobile-link"><i class="fa-solid fa-newspaper nav-icon"></i>{{ t('nav.blog') }}</RouterLink>
<RouterLink to="/howtos" class="nav-mobile-link"><i class="fa-solid fa-lightbulb nav-icon"></i>{{ t('nav.howtos') }}</RouterLink>
<RouterLink to="/engines" class="nav-mobile-link"><i class="fa-solid fa-cubes nav-icon"></i>{{ t('nav.engines') }}</RouterLink>
<RouterLink to="/code" class="nav-mobile-link"><i class="fa-solid fa-code nav-icon"></i>{{ t('nav.code') }}</RouterLink>
<RouterLink to="/team" class="nav-mobile-link"><i class="fa-solid fa-users nav-icon"></i>{{ t('nav.team') }}</RouterLink>
<RouterLink to="/contact" class="nav-mobile-link"><i class="fa-solid fa-envelope nav-icon"></i>{{ t('nav.contact') }}</RouterLink>
<a v-if="isAdmin" href="/admin" class="nav-mobile-link"><i class="fa-solid fa-wrench nav-icon"></i>Admin</a>
<div class="locale-switcher-mobile">
<button :class="locale === 'en' ? 'text-white' : 'text-gray-400'" class="locale-btn-mobile" @click="switchLocale('en')">EN</button>
<span class="locale-separator">|</span>
<button :class="locale === 'hu' ? 'text-white' : 'text-gray-400'" class="locale-btn-mobile" @click="switchLocale('hu')">HU</button>
</div>
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="nav-mobile-link">
{{ t(item.label) }}
</RouterLink>
<a v-if="isAdmin" href="/admin" class="nav-mobile-link">Admin</a>
</nav>
</header>
<main>
<main class="app-main">
<slot />
</main>
<div class="retro-toggle-bar">
<div class="retro-toggle-content">
<button class="retro-toggle-btn" @click="toggleRetroMode">
<i class="fa-solid fa-desktop"></i> {{ isRetroMode ? t('nav.exitRetro') : t('nav.retro') }}
</button>
<div class="rss-links">
<a href="/api/rss/blog" target="_blank" class="rss-link" title="Blog RSS">
<i class="fa-solid fa-rss"></i> Blog
</a>
<span class="locale-separator">|</span>
<a href="/api/rss/releases" target="_blank" class="rss-link" title="Releases RSS">
<i class="fa-solid fa-rss"></i> Releases
</a>
<span class="locale-separator">|</span>
<a href="/api/rss/howtos" target="_blank" class="rss-link" title="HowTos RSS">
<i class="fa-solid fa-rss"></i> HowTos
</a>
<footer class="app-footer">
<div class="footer-inner">
<nav class="footer-nav">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" class="footer-link">
{{ t(item.label) }}
</RouterLink>
<RouterLink to="/stores" class="footer-link">{{ t('nav.stores') }}</RouterLink>
<RouterLink to="/builds" class="footer-link">{{ t('nav.builds') }}</RouterLink>
</nav>
<div class="footer-meta">
<a :href="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>
+85 -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,8 +107,6 @@ 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' },
@@ -125,8 +114,6 @@ const columnGroups = [
},
{
label: 'Mac',
icon: 'fa-brands fa-apple',
color: 'group-slate',
kinds: [
{ kind: 'mac_x64', sub: 'Intel' },
{ kind: 'mac_arm64', sub: 'ARM' },
@@ -135,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' },
@@ -164,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,57 +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>
<span class="builds-link-sep">|</span>
<RouterLink to="/stores" class="builds-link">
<i class="fa-solid fa-gamepad"></i> {{ t('stores.title') }}
</RouterLink>
</div>
</main>
</template>
@@ -80,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'
@@ -111,67 +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 flex flex-wrap items-center justify-center gap-3 mt-10;
}
.builds-link {
@apply text-sm text-gray-400 hover:text-purple-600 font-medium transition-colors;
}
.builds-link-sep {
@apply text-gray-200 text-sm;
@apply mt-5 flex flex-wrap items-center gap-2 pt-1;
}
</style>
+191 -327
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,7 +202,6 @@ 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_arm64', 'linux_x86',
@@ -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, digest }, index) in cards" :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">
@@ -49,23 +38,18 @@
<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">
<span class="engine-highlight-icon"><i class="fa-solid fa-check"></i></span>
<p class="engine-highlight-body">
<strong v-if="item.title" class="engine-highlight-lead">{{ item.title }}</strong>
<template v-if="item.title && item.text"> </template>{{ item.text }}
</p>
<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>
@@ -94,8 +78,6 @@ const cards = computed(() =>
enginePages.value.map((page) => ({ page, digest: getEngineDigest(page.content) })),
)
// Explore points at the engine's git repository (from wiki metadata);
// pages without one fall back to their wiki page.
const exploreUrl = (page: WikiPageWithContent): string =>
page.repo || `${WIKI_BASE}/${page.path}`
@@ -104,83 +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 mb-10;
@apply mt-6 border-t border-line pt-5;
}
.engine-highlights-title {
@apply text-xs font-bold uppercase tracking-[0.2em] text-emerald-400 mb-5;
@apply mb-3 font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
}
.engine-highlights-grid {
@apply grid md:grid-cols-2 gap-x-10 gap-y-4 max-w-4xl;
@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 flex items-start gap-3;
}
.engine-highlight-icon {
@apply mt-1 flex h-5 w-5 flex-none items-center justify-center rounded-md bg-emerald-500/15 text-emerald-400 text-[10px];
}
.engine-highlight-body {
@apply text-sm text-slate-400 leading-relaxed;
@apply text-sm leading-relaxed text-fg-muted;
}
.engine-highlight-lead {
@apply text-white font-semibold;
@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>
+12 -9
View File
@@ -1,17 +1,19 @@
<template>
<div class="cb">
<pre><code>{{ command }}</code></pre>
<button class="cb-copy" :title="t('stores.copy')" @click="copy">
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
<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">
// The page shows five of these install, restart, CLI, uninstall so the
// block and its copy button live in one place rather than being repeated.
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()
@@ -23,21 +25,22 @@ async function copy() {
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard API needs a secure context the command stays selectable anyway.
copied.value = false
}
}
</script>
<style scoped>
.cb {
@apply relative block bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
@apply relative block overflow-x-auto rounded-card border border-line bg-raised text-fg;
}
/* Explicitly margin-free: a `pre` carries a browser margin of its own, and the block is
spaced by whatever contains it otherwise what follows ends up against it. */
.cb pre {
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
}
.cb-copy {
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
@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>
+59 -119
View File
@@ -1,57 +1,50 @@
<template>
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-10">
<div class="hero-container">
<header class="hero-section">
<div class="hero-container hero-centered">
<h1 class="hero-title">{{ t('stores.title') }}</h1>
<p class="hero-subtitle text-emerald-50">{{ t('stores.subtitle') }}</p>
<p class="hero-subtitle">{{ t('stores.subtitle') }}</p>
</div>
</header>
<main class="main-container py-8 px-4 mt-0">
<!-- Two ways in, and almost everybody wants the first one. A thin switcher rather
than a page of prose: whoever needs the detail follows a link to the wiki. -->
<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')"
>
<i class="fa-solid fa-window-maximize"></i> {{ t('stores.viewClient') }}
{{ t('stores.viewClient') }}
</button>
<button
class="st-view" :class="{ 'st-view-active': view === 'others' }"
role="tab" :aria-selected="view === 'others'" @click="selectView('others')"
>
<i class="fa-solid fa-terminal"></i> {{ t('stores.viewOthers') }}
{{ t('stores.viewOthers') }}
</button>
</nav>
<section v-if="view === 'client'" class="st-section st-app">
<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="st-link st-link-cta"
>
<i class="fa-solid fa-download"></i> {{ t('stores.clientDownload') }}
<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="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
<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="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.repo') }}
</a>
</div>
<p class="st-app-platforms">
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
<i class="fa-brands fa-windows"></i> {{ t('stores.clientPlatforms') }}
<BrandIcon name="linux" /><BrandIcon name="apple" /><BrandIcon name="windows" />
{{ t('stores.clientPlatforms') }}
</p>
</div>
<!-- Eager, with its dimensions given: it sits in the first screen, so lazy
loading it would only buy a reflow. -->
<figure class="st-app-shot">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="2304" height="1664">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="1536" height="1110">
</figure>
</div>
</section>
@@ -67,15 +60,12 @@
:aria-selected="d.id === device"
@click="select(d.id)"
>
<i :class="d.icon" class="st-device-icon"></i>
<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">
<!-- The spacing lives on this one container. Margins on the labels and none on
the button row is what let the buttons sit flush against the last block. -->
<div class="st-cmds">
<p class="st-cmd-label">{{ t('stores.installTitle') }}</p>
<CommandBlock :command="current.installCmd" />
@@ -92,34 +82,31 @@
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
<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="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
<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="st-link st-link-emerald">
<i :class="current.icon"></i> {{ t(`stores.${device}.site`) }}
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t(`stores.${device}.site`) }}
</a>
</div>
</section>
<!-- Which catalog platforms a store engine can put on a device. The app has no
such list to show: it installs whatever the catalog offers, and says so on the
cards themselves. -->
<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">
<i :class="p.icon" class="st-platform-icon"></i>
<span class="st-platform-name">{{ p.label }}</span>
<code class="st-platform-ext">{{ p.ext }}</code>
<code class="st-platform-ext mono">{{ p.ext }}</code>
</li>
</ul>
</section>
</template>
<div class="st-back">
<RouterLink to="/catalog" class="st-back-link">{{ t('catalogShow.back') }}</RouterLink>
<div class="focus-row">
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
@@ -130,7 +117,8 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import CommandBlock from './CommandBlock.vue'
import clientScreenshot from '../../assets/warpengine-client.png'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
const { t } = useI18n()
const route = useRoute()
@@ -138,9 +126,6 @@ const router = useRouter()
const FORGE = 'https://git.teletypegames.org/stores'
// The graphical client. Its own thing rather than a link on the desktop store: it
// drives any WarpEngine store the site's registry offers, and on Windows it is the
// only way in there is no `curl | sh` there.
const CLIENT = {
repoUrl: `${FORGE}/warp-engine-client`,
releasesUrl: `${FORGE}/warp-engine-client/releases`,
@@ -149,21 +134,16 @@ const CLIENT = {
type DeviceId = 'batocera' | 'retroarch'
// The devices below are the ones a store reaches through a shell installer. An
// ordinary computer is not among them any more: there the store *is* the app in the
// section above, which carries its own engine and needs nothing installed first.
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
const devices = [
{
id: 'batocera' as DeviceId,
icon: 'fa-solid fa-tv',
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
// The installer is served straight from the forge, so this one line is the
// whole install on the device.
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`,
@@ -175,7 +155,6 @@ const devices = [
},
{
id: 'retroarch' as DeviceId,
icon: 'fa-solid fa-gamepad',
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
@@ -193,24 +172,18 @@ const devices = [
type ViewId = 'client' | 'others'
// `?view=` and `?device=` keep a link shareable, and let /batocera redirect here without
// losing which store the visitor came for. Query only: the router does not scroll for a
// query-only change, so switching does not throw the page back to the top.
const initial = devices.some((d) => d.id === route.query.device)
? (route.query.device as DeviceId)
: 'batocera'
const device = ref<DeviceId>(initial)
// The client view is what most visitors want, so it is the default except when the URL
// names a device, which is how /batocera and /retroarch redirect here. Somebody arriving
// from those wants the integrations, not the app.
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])
// The app is not tied to a device tab: it drives whichever store the site's registry
// offers, so it stands above the chooser rather than inside it.
const client = CLIENT
function select(id: DeviceId) {
@@ -224,8 +197,8 @@ function selectView(id: ViewId) {
}
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
]
</script>
@@ -233,111 +206,78 @@ const platforms = [
.st-links {
@apply flex flex-wrap gap-3;
}
.st-link {
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
}
.st-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
.st-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
.st-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
.st-section {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
@apply mt-6 rounded-card border border-line bg-surface p-5 md:p-8;
}
.st-section-title {
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
@apply mb-3 text-lg font-semibold tracking-tight text-fg;
}
.st-devices {
@apply grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6;
@apply mb-6 grid grid-cols-1 gap-3 sm:grid-cols-3;
}
.st-device {
@apply flex flex-col items-start gap-1 text-left bg-gray-50 border-2 border-gray-100 rounded-xl px-4 py-3 transition-all hover:border-emerald-200 active:scale-95;
@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 bg-emerald-50 border-emerald-500;
}
.st-device-icon {
@apply text-gray-400;
}
.st-device-active .st-device-icon {
@apply text-emerald-600;
@apply border-accent bg-accent-quiet;
}
.st-device-name {
@apply font-bold text-gray-900;
@apply font-medium text-fg;
}
.st-device-tagline {
@apply text-sm text-gray-500;
@apply text-sm text-fg-muted;
}
.st-device-active .st-device-tagline {
@apply text-accent;
}
/* The switcher is meant to be almost nothing: two words and a line under the active one. */
.st-views {
@apply flex gap-1 border-b border-gray-200 mb-6;
@apply mb-6 flex gap-1 border-b border-line;
}
.st-view {
@apply flex items-center gap-2 px-4 py-2 -mb-px text-sm font-semibold text-gray-500 border-b-2 border-transparent transition-colors hover:text-gray-800;
@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 text-emerald-700 border-emerald-500;
@apply border-accent text-fg;
}
/* One rhythm for the whole command list, so nothing depends on a margin someone
remembered to add. */
.st-cmds {
@apply space-y-2;
}
.st-cmd-label {
@apply text-xs font-semibold uppercase tracking-wide text-gray-400 pt-3;
}
.st-cmd-label:first-child {
@apply pt-0;
@apply pt-3 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle first:pt-0;
}
.st-links-after {
@apply mt-6;
}
.st-engine-link {
@apply text-indigo-600 hover:underline font-mono text-xs;
}
/* The app leads the page, so it is the one section that looks like an offer rather
than a paragraph. */
.st-app {
@apply bg-gradient-to-br from-emerald-50 to-teal-50 border-emerald-200;
}
.st-app-lead {
@apply text-gray-700 text-lg leading-relaxed mb-4;
@apply mb-4 text-base leading-relaxed text-fg-muted;
}
.st-app-platforms {
@apply flex items-center gap-2 text-sm text-gray-500 mt-4;
@apply mt-4 flex items-center gap-2 text-sm text-fg-subtle;
}
.st-app-grid {
@apply grid gap-6 items-start lg:grid-cols-2;
@apply grid items-start gap-6 lg:grid-cols-2;
}
.st-app-shot {
@apply rounded-xl overflow-hidden border border-emerald-200/70 shadow-lg bg-gray-900;
@apply m-0 overflow-hidden rounded-card border border-line;
}
.st-app-shot img {
@apply block w-full h-auto;
}
.st-link-cta {
@apply bg-emerald-600 text-white hover:bg-emerald-700 text-base px-5 py-2.5 font-semibold;
@apply block h-auto w-full;
}
.st-platforms {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
@apply grid grid-cols-1 gap-3 sm:grid-cols-2;
}
.st-platform {
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
}
.st-platform-icon {
@apply text-gray-400;
@apply flex items-center gap-3 rounded-card border border-line bg-raised px-4 py-3;
}
.st-platform-name {
@apply font-medium text-gray-800 flex-grow;
@apply flex-grow text-sm text-fg;
}
.st-platform-ext {
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
}
.st-back {
@apply mt-8 text-center;
}
.st-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
@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>
+1 -4
View File
@@ -25,11 +25,8 @@ export const router = createRouter({
...storesRouter,
],
scrollBehavior(to, from, savedPosition) {
// Back and forward land where the visitor was.
if (savedPosition) return savedPosition
// A query-only change is not a new page. The stores page writes its switcher into the
// URL so a link stays shareable, and throwing the reader back to the top for that
// felt like a reload.
if (to.path === from.path) return false
return { top: 0 }
},
+2 -5
View File
@@ -2,12 +2,9 @@ import type { RouteRecordRaw } from 'vue-router'
export const storesRouter: RouteRecordRaw[] = [
{ path: '/stores', name: 'storesIndex', component: () => import('../page/stores/StoresIndexPage.vue') },
// The Batocera store had a page to itself until the RetroArch store joined it.
// Both device names keep working as URLs — the old links, and the guess someone
// makes after reading "RetroArch store" — and each lands on its own tab.
{ path: '/batocera', redirect: { name: 'storesIndex', query: { device: 'batocera' } } },
{ path: '/retroarch', redirect: { name: 'storesIndex', query: { device: 'retroarch' } } },
// `/desktop` used to name a shell store for ordinary computers. That store is the
// app now, so the old URL lands on the app rather than on a device tab.
{ path: '/desktop', redirect: { name: 'storesIndex' } },
]
@@ -1,9 +1,6 @@
import { describe, it, expect } from 'vitest'
import { getEngineDigest } from '../engines.store'
// Generic markdown in the shape every engine-tagged wiki page must follow:
// intro paragraph, then "What You Get" as the first heading with bullets.
// Dummy text only — real content always comes from the wiki at runtime.
const SAMPLE_MARKDOWN = `
> Example Engine is a **sample** framework that turns markdown into landing cards.
@@ -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)
@@ -9,9 +9,6 @@ export interface EngineHighlight {
text: string
}
// Landing-page digest of a wiki engine page. Convention: every engine-tagged
// page opens with an intro paragraph, then a "What You Get" heading as its
// first heading, with the feature bullets underneath.
export interface EngineDigest {
intro: string
highlightsTitle: string
@@ -21,7 +18,6 @@ export interface EngineDigest {
const HIGHLIGHTS_HEADING = 'what you get'
const MAX_HIGHLIGHTS = 6
// Inline markdown (links, bold, code) stripped so the text reads as plain prose.
function stripInline(md: string): string {
return md
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
@@ -41,8 +37,6 @@ export function getEngineDigest(content: string): EngineDigest {
const heading = line.match(/^#{1,6}\s+(.*)$/)
if (heading) {
// The section ends at the next heading; and if the page's first heading
// is not "What You Get", it doesn't follow the convention — no highlights.
if (inHighlights) break
const title = stripInline(heading[1])
if (title.toLowerCase() !== HIGHLIGHTS_HEADING) break
@@ -63,7 +57,6 @@ export function getEngineDigest(content: string): EngineDigest {
continue
}
// First prose line (paragraph or blockquote) before the heading is the intro.
if (!digest.intro) {
digest.intro = stripInline(line.replace(/^>\s*/, ''))
}
+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;
}
+30
View File
@@ -1,10 +1,30 @@
const token = (name) => `rgb(var(${name}) / <alpha-value>)`
module.exports = {
content: [
"./src/**/*.{astro,html,js,ts,jsx,tsx,vue,svelte}"
],
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
page: token('--c-page'),
surface: token('--c-surface'),
raised: token('--c-raised'),
line: token('--c-line'),
'line-strong': token('--c-line-strong'),
fg: token('--c-fg'),
'fg-muted': token('--c-fg-muted'),
'fg-subtle': token('--c-fg-subtle'),
accent: token('--c-accent'),
'accent-hover': token('--c-accent-hover'),
'accent-fg': token('--c-accent-fg'),
'accent-quiet': token('--c-accent-quiet'),
info: token('--c-info'),
warn: token('--c-warn'),
danger: token('--c-danger'),
primary: {
50: '#f0fdf4',
100: '#dcfce7',
@@ -23,6 +43,16 @@ module.exports = {
amber: '#ffb000',
},
},
borderRadius: {
card: '8px',
control: '6px',
},
boxShadow: {
pop: '0 1px 2px rgb(0 0 0 / 0.06), 0 1px 1px rgb(0 0 0 / 0.04)',
float: '0 8px 24px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.08)',
},
},
},
plugins: [],
+140 -45
View File
@@ -10,7 +10,7 @@ Repository: `https://git.teletypegames.org/engines/warp_engine`
## Features
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `Image`, `SoftwareImage`, `Download` models with soft-delete
`PlatformLink`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
and calls one endpoint — WarpEngine extracts archives, parses metadata
@@ -24,6 +24,10 @@ Repository: `https://git.teletypegames.org/engines/warp_engine`
is the catalog as it always was.
- **Client sign-in**: an RFC 8628 device authorization grant for clients with no
browser of their own, over the host's own user model. Off unless configured.
- **Host-owned image library**: the catalog links a software to its images and
publishes their URLs, but the image model, its files, its endpoint and its
admin page belong to the host — the same model can serve avatars or anything
else the site has (see *Images*).
- **Pluggable storage**: artifacts are served through a storage adapter
(`:local` by default); a host can serve them from an object store without
patching the engine.
@@ -31,17 +35,15 @@ Repository: `https://git.teletypegames.org/engines/warp_engine`
`ActiveSupport::Notifications` (`warp_engine.publish`), so hosts can react
to new builds without model callbacks.
- **Public JSON API**: catalog listing, highlighted title, per-platform build
matrix, image serving, download tracking, and a static file server for
web-playable builds.
matrix, download tracking, and a static file server for web-playable builds.
- **Admin (optional)**: if the host runs ActiveAdmin, WarpEngine contributes
ready-made resources — a catalog editor with nested release/asset forms, an
image library with orphan cleanup, a file manager with a picker mode, and
download statistics. Without ActiveAdmin the engine runs headless
ready-made resources — a catalog editor with nested release/asset forms, a
file manager with a picker mode, and download statistics. Without ActiveAdmin the engine runs headless
(API + updater only).
- **Woodpecker CI management (optional)**: with a Woodpecker API token
configured, the admin also gains repo sync, per-repo pipeline history
with manual triggers, and automatic provisioning of application tokens
as Woodpecker secrets.
- **Pluggable CI**: pipeline configs, repo sync, build triggers and secret
provisioning all go through one adapter. Woodpecker ships with the engine
(`WarpEngine::CI::Woodpecker`) and is the default; `:none` turns CI off, and
another server is a host-supplied object.
## Requirements
@@ -177,12 +179,18 @@ cannot say which gem came from where.
Then:
```sh
rails g warp_engine:install # initializer + create_warp_engine_tables migration
rails g warp_engine:install # initializer, image library (model + endpoint), migrations
rails db:migrate
```
```ruby
# config/routes.rb — keep it the last entry so your own routes win
# config/routes.rb
namespace :api do
# The image library is yours; the engine only publishes these URLs.
get "image/:id", to: "images#show"
end
# keep the mount the last entry so your own routes win
mount WarpEngine::Engine => "/"
```
@@ -192,9 +200,12 @@ mount WarpEngine::Engine => "/"
# config/initializers/warp_engine.rb
Rails.application.config.to_prepare do
WarpEngine.configure do |c|
# Where CI drops build artifacts and where images are stored
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
c.image_container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Where CI drops build artifacts
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
# Your image model (see "Images" below). "Image" is the default.
# c.image_class_name = "Media::Picture"
# c.image_adapter = MyImageLibrary.new
# Shared secret for the /build/* endpoints.
# nil => the endpoints reject every request.
@@ -217,6 +228,15 @@ Rails.application.config.to_prepare do
# after backfilling owners — ownerless softwares are claimable by anyone.
# c.enforce_software_ownership = true
# Which CI server builds the games (see "CI" below). :woodpecker (default)
# reads WOODPECKER_URL / WOODPECKER_API_TOKEN from the environment; naming
# the adapter is better, because then the settings are here in the open.
# c.ci_adapter = WarpEngine::CI::Woodpecker::Adapter.new(
# url: ENV["WOODPECKER_URL"], api_token: ENV["WOODPECKER_API_TOKEN"],
# platforms: { "godot" => { builder: "registry.example/godot-builder:4.6" } }
# )
# c.ci_adapter = :none # this host builds nothing
# Who may see a title and who may download it (see "Access" below).
# :open (default) lists everything and serves everything.
# c.access_policy = MyStore::AccessPolicy.new
@@ -228,16 +248,6 @@ Rails.application.config.to_prepare do
#
# How to recognise a caller with a session instead of a bearer token.
# c.subject_resolver = ->(request) { request.env["warden"]&.user }
# If your app's own models reference catalog images, register them so the
# admin Images page counts them as "in use":
# 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? }
# }
# ]
end
end
```
@@ -291,7 +301,56 @@ accepts both:
When switching to `:database`, create the tokens and move your pipelines to
them first — the flip invalidates the shared secret immediately.
## CI pipeline configs (Woodpecker)
## CI
Every CI feature — serving pipeline configs, syncing repositories, triggering
builds, provisioning secrets — goes through **one adapter**. Woodpecker ships
with the engine as `WarpEngine::CI::Woodpecker`, and it is the default:
```ruby
# nothing set: Woodpecker, configured from ENV, exactly as before
c.ci_adapter = :woodpecker
# named explicitly (recommended — the host says what its CI is)
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.example.org/api/signature/public-key",
update_server: "https://catalog.example.org",
platforms: { "godot" => { builder: "registry.example/godot-builder:4.6" } }
)
# this host builds nothing: /api/ci/* answers 503, /build/config refuses,
# and the admin pipeline actions hide themselves
c.ci_adapter = :none
```
The engine's own code never names a provider: `WarpEngine.ci` is the adapter,
`WarpEngine::CI::Repo` and `WarpEngine::CI::Run` are what it hands back, and
`WarpEngine::CI::ConnectionError` / `ApiError` / `NotConfigured` are what it
raises. A host with a different CI server implements this contract instead:
| Method | Purpose |
| --- | --- |
| `name` | provider label, shown in the admin |
| `configured?` | false keeps every CI feature inactive |
| `platforms` | which platforms `/build/config` can serve |
| `update_server` | server URL written into the generated pipeline; nil = the request's base_url |
| `repos`, `repo(id)` | `CI::Repo` values for the repo sync |
| `activate_repo(id)`, `deactivate_repo(id)` | enable/disable a repository |
| `runs(repo_id, page:)`, `run(repo_id, number)` | `CI::Run` values (`number` may be `"latest"`) |
| `trigger(repo_id, branch:)` | start a build, returns a `CI::Run` |
| `secret_names(repo_id)`, `secret_set(repo_id, name:, value:)`, `secret_delete(repo_id, name)` | token provisioning |
| `verify_config_request(request)` | is this really the CI server calling |
| `config_marker(params)` | `{ platform:, name: }` from the request, or nil for "not ours" |
| `pipeline_config(platform:, name:, update_server:)` | the pipeline definition, or nil for an unknown platform |
| `config_response(platform:, config:)` | the body the CI server expects |
`Pipeline` records keep the provider-neutral `remote_repo_id` reader over the
historical `woodpecker_repo_id` column, so the table did not have to move.
### Pipeline configs (the Woodpecker adapter)
WarpEngine can act as a [Woodpecker configuration extension](https://woodpecker-ci.org/docs/usage/extensions/configuration-extension):
instead of a copy-pasted `.woodpecker.yaml` in every game repo, the repo holds a
@@ -305,35 +364,30 @@ platform: godot
```
- `POST /build/config` — the extension endpoint Woodpecker calls on every
pipeline start (httpsig/ed25519-signed request, verified against
`ci_extension_public_key(_url)`). Non-marker configs get a `204` so the
pipeline start (httpsig/ed25519-signed request, verified against the
adapter's `public_key` / `public_key_url`). Non-marker configs get a `204` so the
repo's own YAML keeps running — opt-in migration, and putting a full
pipeline back into the repo is the opt-out.
- `GET /build/config?platform=godot` — renders the same pipeline as a preview.
Configuration: `ci_platforms` maps platform names to builder images
The adapter's `platforms` maps platform names to builder images
(`{ "godot" => { builder: "..." }, "tic80" => { builder: ..., exporter: ... } }`);
an empty map (default) disables the feature. Set the Woodpecker side with
`WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://your-host/build/config` (or
per-repo in Settings → Extensions). Templates live in
`app/services/warp_engine/platforms/<platform>/pipeline.yaml.erb`.
per-repo in Settings → Extensions). Templates live with the adapter, in
`lib/warp_engine/ci/woodpecker/platforms/<platform>/pipeline.yaml.erb` — the
YAML dialect belongs to the provider, so a second adapter brings its own.
## Woodpecker CI management
### CI management (the Woodpecker adapter)
Beyond serving pipeline configs, WarpEngine can drive the Woodpecker REST API
itself. Set `woodpecker_url` and `woodpecker_api_token` — while either is nil
(the default), every management feature stays inactive and the admin pages
hide themselves:
```ruby
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.example.org"
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # PAT of a Woodpecker *instance admin*
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org the game repos live under
```
Beyond serving pipeline configs, the adapter drives the Woodpecker REST API
itself. Give it a `url` and an `api_token` — with either missing, `configured?`
is false, every management feature stays inactive and the admin pages hide
themselves.
What it unlocks (all surfaced in the admin):
- **Repo sync** (*Pipelines → Sync from Woodpecker*): mirrors the Woodpecker
- **Repo sync** (*Pipelines → Sync from *): mirrors the provider's
repo list into `Pipeline` records, auto-matching each repo to a catalog
`Software` by name; repos that disappear from Woodpecker are deactivated.
Platform and software links are editable by hand afterwards.
@@ -342,7 +396,7 @@ What it unlocks (all surfaced in the admin):
last-pipeline status shown on the Pipelines index. The software's admin
page links to its pipelines from the Quick Links sidebar.
- **Secret provisioning**: database application tokens are pushed to the
repos as the `application_token` Woodpecker secret — creating a token
repos as the `application_token` CI secret — creating a token
provisions it to its owner's repos (unrestricted tokens to all active
repos), deleting a token removes the secret, and *Rotate* creates a
replacement token, provisions it everywhere and revokes the old one in a
@@ -392,6 +446,47 @@ signing adapter turns them into redirects without any further change.
the admin file manager — still writes to the local disk. A remote adapter
needs its own upload path today.
## Images
An image is not a catalog concept: the same picture library serves covers,
screenshots, avatars and whatever else a site has. So the engine owns only the
link — `SoftwareImage`, ordered, with one default — while the `Image` model, the
uploaded files and the admin page live in the host. `rails g warp_engine:install`
writes a working one (`app/models/image.rb`, `app/controllers/api/images_controller.rb`,
a `create_images` migration); the engine talks to it through an adapter:
```ruby
c.image_class_name = "Image" # default
```
The default adapter expects that model to answer `original_filename`,
`content_type`, `file_path` and `file_upload=` (the generated one does), and
publishes every image as `/api/image/<id>` — the address clients have always
used, which is why the generated route serves exactly that. A host that keeps
its pictures somewhere else replaces the adapter instead:
```ruby
class MyImageLibrary
def model_name = "Media::Picture"
def model = Media::Picture
def url_for(image_id) = "https://cdn.example.com/#{image_id}.webp"
def build_from_upload(upload) = Media::Picture.new(file_upload: upload)
def label_for(record) = record.title
def available?(record) = record.stored?
def select_options = Media::Picture.order(:title).pluck(:title, :id)
end
c.image_adapter = MyImageLibrary.new
```
`url_for` is what lands in the catalog JSON (`imageUrl`, `images[].url`) and in
the admin previews; `select_options` fills the image picker on the software
form; `build_from_upload` is what "upload a new image" on that form calls.
**The `images` table stays where it is.** Moving the model out of the engine did
not touch it: the engine no longer creates it (the install generator does, as
host code), and `software_images.image_id` still points at the same rows.
## Access
Who may see a title, and who may download it. The default answers "everyone" to
@@ -523,7 +618,7 @@ its own account of who fetched what without reaching into `DownloadService`.
| `GET /api/software/highlighted` | The currently highlighted title |
| `GET /api/builds` | Expected asset kinds per platform (build matrix) |
| `GET /api/softwares/:name/builds` | Actual vs. missing build assets per release |
| `GET /api/image/:id` | Serves catalog images |
| `GET /api/image/:id` | Serves images — **the host's endpoint**, see *Images* |
| `GET /api/download?path=` | Serves an artifact and logs a download record |
| `GET /file/*path` | Serves static build output (web-playable games, docs) |
| `POST /api/auth/device` | Starts a device sign-in; returns the code pair (404 without a client identity) |

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