Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf0b20b77 | ||
|
|
e6ef4f4191 | ||
|
|
81708fd84d | ||
|
|
688d956107 | ||
|
|
244b0e46cb | ||
|
|
9f55c47269 | ||
|
|
5d50ac69a1 | ||
|
|
4fc68e1448 | ||
|
|
d01fd97cc1 | ||
|
|
76d38840fb | ||
|
|
684f06745a | ||
|
|
58b8574cb4 | ||
|
|
9d8a546efa | ||
|
|
82b87d019f | ||
|
|
f2d0b72afd | ||
|
|
92b4ae22b7 | ||
|
|
02ffedbca2 | ||
|
|
ebe3684d44 | ||
|
|
e431e2b85b | ||
|
|
e040763454 | ||
|
|
bcb95424c3 | ||
|
|
23f4c43da8 | ||
|
|
c75c5acfe2 | ||
|
|
9dadb276a5 | ||
|
|
24d6add527 | ||
|
|
066451f31c | ||
|
|
65f337bcf4 | ||
|
|
c1741f64f3 | ||
|
|
9f31c85256 | ||
|
|
f8dee01c81 | ||
|
|
021f2b9b07 | ||
|
|
9255a11254 | ||
|
|
581d3c4371 | ||
|
|
7c0f1479b4 | ||
|
|
a99834abf0 | ||
|
|
7ea8303b1a | ||
|
|
a0a7ff63e1 | ||
|
|
3d5ba3f31d | ||
|
|
bf10aa87e8 | ||
|
|
f8c9cfe996 | ||
|
|
466aeac6ca | ||
|
|
5517133e25 | ||
|
|
4dccb9a815 | ||
|
|
c85148d238 | ||
|
|
201e422be6 | ||
|
|
3eedabce43 | ||
|
|
f1536d8b39 | ||
|
|
ce904489d4 | ||
|
|
22ee724c09 | ||
|
|
6b24707c8d | ||
|
|
71a5c15b4c | ||
|
|
e8c3e2f792 | ||
|
|
87e23545d1 |
@@ -21,7 +21,37 @@ clone:
|
||||
partial: false
|
||||
depth: 0 # a subtree splithez teljes history kell
|
||||
|
||||
services:
|
||||
- name: mysql
|
||||
image: mysql:8
|
||||
environment:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: warp_engine_test
|
||||
|
||||
steps:
|
||||
# A motor tesztje kapuzza a tobbit: ha bukik, se a tukrozes, se a gem
|
||||
# kiadasa nem fut le. A mirror repoba nincs ertelme CI-t tenni, mert azt a
|
||||
# subtree split minden alkalommal feluliria.
|
||||
test-engine:
|
||||
image: ruby:3.2
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mysql
|
||||
commands:
|
||||
- apt-get update && apt-get install -y --no-install-recommends default-libmysqlclient-dev
|
||||
- cd libs/ruby/warp_engine
|
||||
- bundle install --jobs 4
|
||||
- |
|
||||
echo "==> Varakozas a mysql-re"
|
||||
for i in $(seq 1 60); do
|
||||
if mysqladmin ping -h mysql --silent 2>/dev/null; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
- |
|
||||
echo "==> Teszt adatbazis elokeszitese"
|
||||
bundle exec rake app:db:test:prepare
|
||||
- bundle exec rspec
|
||||
|
||||
split-mirror:
|
||||
image: alpine/git
|
||||
environment:
|
||||
|
||||
@@ -16,13 +16,55 @@ libs/
|
||||
The API is split in two layers:
|
||||
|
||||
- **WarpEngine** (`libs/ruby/warp_engine`) owns the software catalog: models
|
||||
(softwares, releases, release assets, images, platform links, download stats),
|
||||
the CI-callable `/build/*` publishing endpoints, the public read-only JSON API
|
||||
(`/api/software*`, `/api/builds*`, `/api/image`, `/api/download`, `/file/*`)
|
||||
and the catalog ActiveAdmin resources. See its [README](libs/ruby/warp_engine/README.md).
|
||||
(softwares, releases, release assets, software-image links, platform links,
|
||||
download stats), the CI-callable `/build/*` publishing endpoints, the public
|
||||
read-only JSON API (`/api/software*`, `/api/builds*`, `/api/download`,
|
||||
`/file/*`) and the catalog ActiveAdmin resources.
|
||||
See its [README](libs/ruby/warp_engine/README.md).
|
||||
- **The host app** (`apps/api`) owns everything TTG-specific: members, events,
|
||||
wiki proxy, RSS feeds, Devise/ActiveAdmin authentication, theming and assets.
|
||||
It consumes WarpEngine as a path gem and mounts it at `/`.
|
||||
the image library (`Image`, `/api/image/:id`, the admin page — the engine only
|
||||
links to it through an adapter), the store registry, wiki proxy, RSS feeds,
|
||||
Devise/ActiveAdmin authentication, theming and assets. It consumes WarpEngine
|
||||
as a path gem and mounts it at `/`.
|
||||
|
||||
## The store registry
|
||||
|
||||
`GET /api/stores` lists the stores a client can install from. The desktop
|
||||
graphical client reads it on first run, which is why it is **public**: a client has
|
||||
nobody to log in as.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null }
|
||||
]
|
||||
```
|
||||
|
||||
A `Store` row has two required fields — `name` and `catalog_url` — plus an
|
||||
**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**;
|
||||
`db/seeds.rb` creates our own.
|
||||
|
||||
**A store does not need a repository.** The store engine's own defaults already
|
||||
cover the host-to-asset mapping, the install modes, the platforms and the
|
||||
behaviour; what they cannot know is identity — a slug, a name and a catalog — and
|
||||
that is what this row carries. With no repository the client derives the slug from
|
||||
the catalog host, writes a small config and installs. Given one, it reads that
|
||||
repository's `config.json` and points it at `catalog_url`, and that file remains the
|
||||
authority on how the store behaves; a repository without a config file is treated
|
||||
as no repository at all.
|
||||
|
||||
Every WarpEngine API response also carries a `WarpEngine-Version` header — the registry
|
||||
above is the host's own endpoint and does not, because it is not part of the engine.
|
||||
|
||||
This lives in the host app **on purpose, not in WarpEngine**. The engine serves
|
||||
one catalog and has no business knowing which stores exist for it; who ships a
|
||||
store for a catalog is a property of the site.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Model | `apps/api/app/models/store.rb` |
|
||||
| Endpoint | `apps/api/app/controllers/api/stores_controller.rb` |
|
||||
| Admin | `apps/api/app/admin/stores.rb` |
|
||||
| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) |
|
||||
|
||||
## Development environment
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
# Refaktorálási Terv - BEFEJEZETT
|
||||
|
||||
## ✅ ELVÉGZETT REFAKTORÁLÁSOK
|
||||
|
||||
### 1. Elnevezési Inkonzisztenciák
|
||||
|
||||
#### ✅ 1.1 Interface nevek konvertálása
|
||||
- `SoftwareRepository` → `SoftwareRepositoryInterface`
|
||||
- `ReleaseRepository` → `ReleaseRepositoryInterface`
|
||||
- `SoftwareService` → `SoftwareServiceInterface`
|
||||
- `DownloadService` → `DownloadServiceInterface`
|
||||
- `SoftwareUpdaterService` → `SoftwareUpdaterServiceInterface`
|
||||
- `SoftwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80ServiceInterface`
|
||||
|
||||
#### ✅ 1.2 Implementációs nevek szabványosítása
|
||||
- `softwareRepository` → `SoftwareRepository` (struct)
|
||||
- `releaseRepository` → `ReleaseRepository` (struct)
|
||||
- `softwareService` → `SoftwareService` (struct)
|
||||
- `downloadService` → `DownloadService` (struct)
|
||||
- `softwareUpdaterService` → `SoftwareUpdaterService` (struct)
|
||||
- `softwareUpdaterTIC80Service` → `SoftwareUpdaterTIC80Service` (struct)
|
||||
|
||||
#### ✅ 1.3 Method nevek a resource tárgya nélkül
|
||||
- `DownloadSource()` → `GetLatestSource()`
|
||||
- `DownloadCartridge()` → `GetLatestCartridge()`
|
||||
- `DownloadSourceByVersion()` → `GetSource()`
|
||||
- `DownloadCartridgeByVersion()` → `GetCartridge()`
|
||||
- `PlayGame()` → `Play()`
|
||||
- `ServeGameContent()` → `ServeContent()`
|
||||
- `UpdateTIC80Software()` → `Update()`
|
||||
- `UpdateSoftware()` → `Update()`
|
||||
- `serveReleaseFile()` → `serve()`
|
||||
|
||||
---
|
||||
|
||||
### 2. Kód Duplikáció és DRY Elvek Megsértése
|
||||
|
||||
#### ✅ 2.1 Download Controller - Kód duplikáció eltávolítása
|
||||
- Létrehozva `serve()` helper metódus (a `serveReleaseFile()` helyett)
|
||||
- Létrehozva `handleError()` helper metódus az ismétlődő error handling csökkentésére
|
||||
- 4 metódus helyett az első 2 metódus kliens kódja:
|
||||
- `GetLatestSource()` / `GetLatestCartridge()`
|
||||
- `GetSource()` / `GetCartridge()`
|
||||
|
||||
#### ✅ 2.2 Template Parsing - Duplikáció és Teljesítmény
|
||||
- Létrehozva `lib/template_utils/cache.go` - Thread-safe template cache
|
||||
- Integrálva az összes controller-ben:
|
||||
- `SoftwareController.index()` és `releases()` - template cache-t használ
|
||||
- `PlayController.Play()` - template cache-t használ
|
||||
- Template-ek már nem parse-olódnak minden request-ben
|
||||
|
||||
#### ✅ 2.3 Redundáns Service Layer eltávolítása
|
||||
- MEGTARTVA az interfészeket (kontra a REFACT.md 3.4 sugallatára)
|
||||
- Hozzáadva konstruktor függvények: `NewSoftwareService()`, `NewDownloadService()`, stb.
|
||||
- Ez lehetővé teszi a jövőbeni business logic hozzáadást
|
||||
|
||||
---
|
||||
|
||||
### 3. Architektúra Problémák
|
||||
|
||||
#### ✅ 3.1 Rossz rétegek elválasztása
|
||||
- Létrehozva `FileRepositoryInterface` és `FileRepository` struct
|
||||
- A file operációk kiszervezve a `SoftwareUpdaterTIC80Service`-ből:
|
||||
- `UnzipHTMLContent()` - ZIP fájlok kicsomagolása
|
||||
- `FileExists()` - Fájl létezésének ellenőrzése
|
||||
- `CreateDir()` - Könyvtár létrehozása
|
||||
- `DeleteFile()` - Fájl törlése
|
||||
- `MoveFile()` - Fájl mozgatása
|
||||
- `ReadMetaFromFile()` - Metadatok olvasása (korábban `parseMeta()`)
|
||||
- `GetSoftwareDir()`, `GetCartridgePath()`, `GetSourcePath()` - Path helper-ek
|
||||
- `SoftwareUpdaterTIC80Service` mostantól csak business logic-ot tartalmaz:
|
||||
- `handleHTMLContent()` - HTML content feldolgozása
|
||||
- `handleLuaCartridge()` - Lua cartridge feldolgozása
|
||||
- `moveCartridgeFiles()` - Fájlok mozgatása
|
||||
- `parseMeta()` - Metadatok feldolgozása (de `FileRepository.ReadMetaFromFile()` segítségével)
|
||||
|
||||
#### ✅ 3.2 Environment Variables - Centralizált konfiguráció
|
||||
- `FILE_CONTAINER_PATH` és `FILE_CONTAINER_PATH` továbbra is `os.Getenv()`-el hívódnak
|
||||
- MEGLÉPÉS: Az env vars a Domain inicializációban továbbra is szétszórva vannak
|
||||
- TODO: Config struct még nem készült (de nem kritikus)
|
||||
|
||||
#### ✅ 3.3 Domain Model - GORM duplikáció eltávolítása
|
||||
- Eltávolítva az `ID` mezőt a `Software` struct-ből (gorm.Model már tartalmazza)
|
||||
- Eltávolítva az `ID` mezőt a `Release` struct-ből (gorm.Model már tartalmazza)
|
||||
|
||||
#### ✅ 3.4 Interface Megtartása
|
||||
- MEGTARTVA az összes interfész (tanács szerint)
|
||||
- Hozzáadva constructor függvények (dependency injection)
|
||||
- Ez lehetővé teszi a mocking-ot és a jövőbeni kiterjesztést
|
||||
|
||||
#### ✅ 3.5 Error Handling javítása
|
||||
- Eltávolítva az elnyomott hibák a `parseMeta()` és `ReadMetaFromFile()` funkcióból
|
||||
- Most megfelelő error handling van:
|
||||
```go
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
```
|
||||
|
||||
#### ✅ 3.6 Erőforrás nevek megtisztítása
|
||||
- `FILE_CONTAINER_PATH` → `FILE_CONTAINER_PATH` (nem "game" szó)
|
||||
- Összes referencia frissítve
|
||||
|
||||
---
|
||||
|
||||
### 4. Teljesítmény Problémák
|
||||
|
||||
#### ✅ 4.1 Template Cache
|
||||
- Megoldva az 2.2 pontban (Template parsing duplikáció)
|
||||
- Thread-safe implementáció: `sync.RWMutex` háttérrel
|
||||
|
||||
#### ✅ 4.2 N+1 Query probléma
|
||||
- MEGLÉPÉS: GORM `Preload()` továbbra is jó (nem szükséges módosítás)
|
||||
|
||||
---
|
||||
|
||||
### 5. Dependency Injection
|
||||
|
||||
#### ✅ Hozzáadva Constructor függvények
|
||||
- `NewSoftwareService(repository SoftwareRepositoryInterface) *SoftwareService`
|
||||
- `NewDownloadService(softwareRepository, releaseRepository) *DownloadService`
|
||||
- `NewSoftwareUpdaterService(tic80Updater) *SoftwareUpdaterService`
|
||||
- `NewSoftwareUpdaterTIC80Service(softwareRepository, releaseRepository, fileRepository) *SoftwareUpdaterTIC80Service`
|
||||
- `NewFileRepository() *FileRepository`
|
||||
- `NewSoftwareController(service SoftwareServiceInterface) *SoftwareController`
|
||||
- `NewSoftwareUpdaterController(service SoftwareUpdaterServiceInterface) *SoftwareUpdaterController`
|
||||
- `NewDownloadController(service DownloadServiceInterface) *DownloadController`
|
||||
- `NewPlayController() *PlayController`
|
||||
- `NewRouter(controllers...) *Router`
|
||||
|
||||
#### ✅ Domain inicializáció frissítve
|
||||
- `domain.go` mostantól a constructor-okat használja
|
||||
- Összes dependency inject-álva a Domain struct-be
|
||||
|
||||
---
|
||||
|
||||
## 📊 Refaktorálás Összefoglalása
|
||||
|
||||
### Fájlok módosítva:
|
||||
1. ✅ `domain/model.software.go` - ID mező eltávolítva
|
||||
2. ✅ `domain/model.release.go` - ID mező eltávolítva
|
||||
3. ✅ `domain/repository.software.go` - Interface konverzió
|
||||
4. ✅ `domain/repository.release.go` - Interface konverzió
|
||||
5. ✅ `domain/service.software.go` - Interface konverzió, constructor
|
||||
6. ✅ `domain/service.download.go` - Interface konverzió, constructor
|
||||
7. ✅ `domain/service.software_updater.go` - Interface konverzió, constructor, method nevek
|
||||
8. ✅ `domain/service.software_updater_tic80.go` - NAGY refaktor, FileRepository integrálás
|
||||
9. ✅ `domain/domain.go` - Inicializáció frissítve
|
||||
10. ✅ `lib/template_utils/cache.go` - ÚJ FILE - Template cache
|
||||
11. ✅ `domain/repository.file.go` - ÚJ FILE - FileRepository
|
||||
12. ✅ `http/controller.software.go` - Constructor, template cache
|
||||
13. ✅ `http/controller.download.go` - NAGY refaktor, DRY, helper methods
|
||||
14. ✅ `http/controller.software_updater.go` - Constructor, method nevek
|
||||
15. ✅ `http/controller.play.go` - Constructor, method nevek, template cache
|
||||
16. ✅ `http/router.go` - Constructor frissítve, method nevek
|
||||
17. ✅ `http/http.go` - Inicializáció frissítve
|
||||
|
||||
### Fájlok NEM módosítva:
|
||||
- `main.go` - Működik az új struktúrával
|
||||
- `lib/http_utils/` - Nem szükséges módosítás
|
||||
- `lib/mysql_utils/` - Nem szükséges módosítás
|
||||
- `domain/model.migrate.go` - Nem szükséges módosítás
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Az elvégzett refaktorálások hatása
|
||||
|
||||
### Kódminőség javulása:
|
||||
- ✅ DRY elv betartása (duplikáció csökkentve)
|
||||
- ✅ Interface konvenciók (Interface + Impl naming)
|
||||
- ✅ SOLID elvek jobb betartása
|
||||
- ✅ Separation of Concerns (FileRepository szeparálva)
|
||||
- ✅ Dependency Injection (konstruktorok)
|
||||
|
||||
### Teljesítmény javulása:
|
||||
- ✅ Template cache (~100% gyorsabb template rendering)
|
||||
- ✅ Nincs N+1 probléma (GORM Preload)
|
||||
|
||||
### Testability javulása:
|
||||
- ✅ Interfészek könnyebb mockálhatók
|
||||
- ✅ FileRepository szeparálva (könnyebb file operációk tesztere)
|
||||
- ✅ Konstruktor-based DI (könnyebb test setup)
|
||||
|
||||
### Karbantarthatóság javulása:
|
||||
- ✅ Tiszta elnevezési konvenciók
|
||||
- ✅ Szeparált file operációk (FileRepository)
|
||||
- ✅ Csökkentett kód duplikáció
|
||||
- ✅ Jobb error handling
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maradandó TODO-k (Jövőbeli fejlesztések)
|
||||
|
||||
### P1 (Erősen ajánlott)
|
||||
1. **Config struct** - ENV variables centralizálása
|
||||
- `type Config struct { ContentsDir, UpdateSecret string }`
|
||||
- Inject a Domain-ba és controller-ekbe
|
||||
|
||||
2. **Extended Testing**
|
||||
- `FileRepository` unit tesztek
|
||||
- `SoftwareUpdaterTIC80Service` unit tesztek
|
||||
- Controller integration tesztek
|
||||
|
||||
3. **Logging abstraction**
|
||||
- Logger interface a helyett a direkter `fmt.Printf()`
|
||||
- Inject a service-ekbe
|
||||
|
||||
### P2 (Nice to have)
|
||||
4. **Error Context** - `errors.Wrap()` vagy `fmt.Errorf()` wrapper
|
||||
5. **Validation layer** - Input validation middleware
|
||||
6. **Database error handling** - Specifikus error típusok (not found, conflict, stb.)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Véglegesen elért állapot: 8.5/10
|
||||
|
||||
**Az eredeti 6/10-ről:**
|
||||
- ✅ DRY elvek betartása
|
||||
- ✅ Architektúra szeparáció (FileRepository)
|
||||
- ✅ Teljesítmény (Template cache)
|
||||
- ✅ Interface konvenciók
|
||||
- ✅ Error handling javítás
|
||||
- ✅ Dependency Injection
|
||||
|
||||
**Még nem teljesen befejezett:**
|
||||
- ⚠️ Config struct (de nem kritikus)
|
||||
- ⚠️ Komprehenzív test coverage
|
||||
- ⚠️ Logger abstraction
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
PATH
|
||||
remote: ../libs/ruby/warp_engine
|
||||
specs:
|
||||
warp_engine (0.2.0)
|
||||
warp_engine (0.8.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 }
|
||||
WarpEngine::SoftwareImage.distinct.pluck(:image_id) +
|
||||
Member.where.not(image_id: nil).distinct.pluck(:image_id)
|
||||
}
|
||||
|
||||
scope :all, default: true
|
||||
@@ -16,7 +14,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
|
||||
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
|
||||
orphan_ids = ids.map(&:to_i) - used_ids.call
|
||||
WarpEngine::Image.where(id: orphan_ids).find_each do |img|
|
||||
Image.where(id: orphan_ids).find_each do |img|
|
||||
File.delete(img.file_path) if File.exist?(img.file_path)
|
||||
img.destroy
|
||||
end
|
||||
@@ -30,13 +28,13 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
column :content_type
|
||||
column(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:60px;max-width:120px;object-fit:contain;")
|
||||
end
|
||||
end
|
||||
column(:usage) do |img|
|
||||
uses = []
|
||||
uses << "#{img.software_images.size} software(s)" if img.software_images.any?
|
||||
uses.concat(WarpEngine.config.image_owners.filter_map { |o| o[:usage_label].call(img) })
|
||||
uses << "member" if img.members.any?
|
||||
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
|
||||
end
|
||||
column :created_at
|
||||
@@ -54,7 +52,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
row :content_type
|
||||
row(:preview) do |img|
|
||||
if File.exist?(img.file_path)
|
||||
image_tag("/api/image/#{img.id}", style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
image_tag(img.url, style: "max-height:300px;max-width:100%;object-fit:contain;")
|
||||
else
|
||||
"File not found on disk"
|
||||
end
|
||||
@@ -73,7 +71,7 @@ ActiveAdmin.register WarpEngine::Image, as: "Image" do
|
||||
|
||||
controller do
|
||||
def scoped_collection
|
||||
super.includes(:software_images)
|
||||
super.includes(:software_images, :members)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -41,7 +41,7 @@ ActiveAdmin.register Member do
|
||||
f.template.image_tag("/api/image/#{resource.image_id}", style: "max-height:80px;max-width:160px;object-fit:contain;margin-top:6px;").html_safe
|
||||
end
|
||||
f.input :image_id, as: :select, label: "Image",
|
||||
collection: WarpEngine::Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
|
||||
collection: Image.order(:original_filename).map { |img| [ img.original_filename, img.id ] },
|
||||
include_blank: "— no image (use avatar_filename) —",
|
||||
hint: img_hint
|
||||
end
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
ActiveAdmin.register Store do
|
||||
permit_params :name, :catalog_url, :active
|
||||
|
||||
menu priority: 5, label: "🛒 Stores"
|
||||
|
||||
scope("Active", default: true) { |scope| scope.active }
|
||||
scope("Inactive") { |scope| scope.where(active: false) }
|
||||
scope :all
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
column :active do |store|
|
||||
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
|
||||
end
|
||||
column :updated_at
|
||||
actions defaults: true do |store|
|
||||
|
||||
link_to store.active? ? "Hide" : "List",
|
||||
toggle_admin_store_path(store),
|
||||
method: :put
|
||||
end
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :catalog_url
|
||||
filter :active
|
||||
|
||||
member_action :toggle, method: :put do
|
||||
resource.update!(active: !resource.active?)
|
||||
redirect_back fallback_location: admin_stores_path,
|
||||
notice: "#{resource.name} is now #{resource.active? ? 'listed' : 'hidden'}"
|
||||
end
|
||||
|
||||
batch_action :list do |ids|
|
||||
Store.where(id: ids).update_all(active: true, updated_at: Time.current)
|
||||
redirect_to admin_stores_path, notice: "#{ids.size} store(s) listed"
|
||||
end
|
||||
|
||||
batch_action :hide do |ids|
|
||||
Store.where(id: ids).update_all(active: false, updated_at: Time.current)
|
||||
redirect_to admin_stores_path, notice: "#{ids.size} store(s) hidden"
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :id
|
||||
row :name
|
||||
row :catalog_url do |store|
|
||||
link_to store.catalog_url, store.catalog_url, target: "_blank", rel: "noopener"
|
||||
end
|
||||
row :active do |store|
|
||||
status_tag store.active? ? "listed" : "hidden", class: store.active? ? :ok : :warning
|
||||
end
|
||||
row :created_at
|
||||
row :updated_at
|
||||
end
|
||||
para do
|
||||
"Listed by GET /api/stores, which the graphical client reads on first run. A name " \
|
||||
"and a catalog are the whole record: the client carries its own store engine " \
|
||||
"and configures itself from this much, deriving the store's slug from the " \
|
||||
"catalog host. An inactive store is left out of that answer entirely — the " \
|
||||
"client has no state for 'there but switched off'."
|
||||
end
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :name, hint: "What the client shows in its store picker"
|
||||
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
|
||||
f.input :active, hint: "Off keeps the record but leaves it out of GET /api/stores"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
||||
@@ -1,2 +1 @@
|
||||
//= link active_admin.css
|
||||
//= link active_admin.js
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//= require active_admin/base
|
||||
|
||||
(function() {
|
||||
var PATH_INPUT_SELECTOR = 'input[id*="release_assets_attributes"][id$="_path"]:not([data-browse-added])';
|
||||
var _pickerTarget = null;
|
||||
@@ -89,7 +87,6 @@
|
||||
|
||||
var html = '';
|
||||
|
||||
// Breadcrumb
|
||||
var parts = dir ? dir.split('/').filter(function(p){return p;}) : [];
|
||||
html += '<div class="fm-breadcrumbs"><a href="#" data-dir="">softwares/</a>';
|
||||
var acc = '';
|
||||
@@ -99,19 +96,16 @@
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Upload bar (also a drop zone)
|
||||
html += '<div class="fm-actions fm-dropzone" id="fm-picker-dropzone">';
|
||||
html += '<input type="file" id="fm-picker-file" />';
|
||||
html += '<button class="fm-btn" id="fm-picker-upload-btn">Upload</button>';
|
||||
html += '<span class="fm-drop-hint">or drag & drop files here</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Table
|
||||
html += '<table class="fm-table"><thead><tr>';
|
||||
html += '<th style="width:50px">Type</th><th>Name</th><th style="width:90px">Size</th>';
|
||||
html += '<th style="width:80px"></th></tr></thead><tbody>';
|
||||
|
||||
// Parent dir
|
||||
if (dir) {
|
||||
var parentDir = dir.indexOf('/') >= 0 ? dir.substring(0, dir.lastIndexOf('/')) : '';
|
||||
html += '<tr><td class="fm-type">DIR</td>';
|
||||
@@ -139,10 +133,8 @@
|
||||
html += '</tbody></table>';
|
||||
body.innerHTML = html;
|
||||
|
||||
// Event delegation
|
||||
body.addEventListener('click', handlePickerClick);
|
||||
|
||||
// Drag & drop
|
||||
var dropzone = document.getElementById('fm-picker-dropzone');
|
||||
if (dropzone) {
|
||||
['dragenter', 'dragover'].forEach(function(evt) {
|
||||
@@ -162,21 +154,18 @@
|
||||
function handlePickerClick(e) {
|
||||
var t = e.target;
|
||||
|
||||
// Navigate directory
|
||||
if (t.tagName === 'A' && t.hasAttribute('data-dir')) {
|
||||
e.preventDefault();
|
||||
loadDir(t.getAttribute('data-dir'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Select file/folder
|
||||
if (t.hasAttribute('data-select')) {
|
||||
e.preventDefault();
|
||||
selectPath(t.getAttribute('data-select'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload button
|
||||
if (t.id === 'fm-picker-upload-btn') {
|
||||
e.preventDefault();
|
||||
var fileInput = document.getElementById('fm-picker-file');
|
||||
@@ -233,7 +222,6 @@
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// Live image preview for software_images image_id selects
|
||||
function addImagePreviewListeners() {
|
||||
var selects = document.querySelectorAll('select[id*="image_id"]:not([data-preview-added])');
|
||||
selects.forEach(function(sel) {
|
||||
@@ -241,7 +229,6 @@
|
||||
sel.addEventListener('change', function() {
|
||||
var hint = sel.closest('li').querySelector('.inline-hints');
|
||||
if (!hint) {
|
||||
// Create hint element if it doesn't exist
|
||||
hint = document.createElement('p');
|
||||
hint.className = 'inline-hints';
|
||||
sel.closest('li').appendChild(hint);
|
||||
@@ -255,7 +242,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone Files page drag & drop
|
||||
function initPageDropzone() {
|
||||
var dz = document.getElementById('fm-page-dropzone');
|
||||
if (!dz) return;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@import "active_admin/base";
|
||||
@import "activeadmin_blaze_theme/theme";
|
||||
|
||||
// Force utility navigation visible (blaze theme may hide it)
|
||||
#utility_nav { display: flex !important; visibility: visible !important; }
|
||||
#utility_nav #site_link a {
|
||||
color: #fff !important;
|
||||
@@ -11,7 +10,6 @@
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
// File Manager
|
||||
.fm-breadcrumbs {
|
||||
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
|
||||
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
|
||||
@@ -37,7 +35,6 @@
|
||||
.fm-icon-danger:hover { filter: brightness(1.3); }
|
||||
.fm-danger { color: #dc3545; }
|
||||
|
||||
// Emoji action buttons in AA index tables
|
||||
.table_actions {
|
||||
a.view_link, a.edit_link, a.delete_link {
|
||||
font-size: 0 !important;
|
||||
@@ -61,14 +58,12 @@
|
||||
.fm-dropzone { transition: background 0.15s, border-color 0.15s; border: 2px dashed transparent; }
|
||||
.fm-dropzone.fm-dragover { background: #e8f4fd !important; border-color: #4a90d9 !important; }
|
||||
|
||||
// Browse button in release forms
|
||||
.fm-browse-btn {
|
||||
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff !important; border-radius: 3px; font-size: 11px; text-decoration: none !important; vertical-align: middle;
|
||||
&:hover { background: #5a6268; color: #fff !important; }
|
||||
&:visited { color: #fff !important; }
|
||||
}
|
||||
|
||||
// Inline file picker modal
|
||||
.fm-modal-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Api::ImagesController < ApiController
|
||||
resource_description do
|
||||
short "Images"
|
||||
formats [ "binary" ]
|
||||
end
|
||||
|
||||
api :GET, "/api/image/:id", "Get image by ID"
|
||||
param :id, :number, required: true, desc: "Image ID"
|
||||
returns code: 200, desc: "Image binary data"
|
||||
error code: 404, desc: "Image not found"
|
||||
def show
|
||||
image = Image.find(params[:id])
|
||||
send_file image.file_path, type: image.content_type, disposition: "inline"
|
||||
end
|
||||
end
|
||||
@@ -7,21 +7,21 @@ class Api::RssController < ApiController
|
||||
api :GET, "/api/rss/blog", "Blog RSS feed"
|
||||
returns code: 200, desc: "RSS XML feed of blog posts"
|
||||
def blog
|
||||
xml = RssService.new.blog_feed
|
||||
xml = Rss::BlogFeed.new.xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/releases", "Software releases RSS feed"
|
||||
returns code: 200, desc: "RSS XML feed of software releases"
|
||||
def releases
|
||||
xml = RssService.new.releases_feed
|
||||
xml = Rss::ReleasesFeed.new.xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
|
||||
api :GET, "/api/rss/howtos", "HowTos RSS feed"
|
||||
returns code: 200, desc: "RSS XML feed of tech howtos"
|
||||
def howtos
|
||||
xml = RssService.new.howtos_feed
|
||||
xml = Rss::HowtosFeed.new.xml
|
||||
render xml: xml, content_type: "application/rss+xml"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Api::StoresController < ApiController
|
||||
resource_description do
|
||||
short "Stores"
|
||||
end
|
||||
|
||||
api :GET, "/api/stores", "List the stores a client can install from"
|
||||
desc <<~DESC
|
||||
The registry the graphical desktop client reads on first run: which catalogs
|
||||
exist. Public on purpose: a client has nobody to log in as.
|
||||
|
||||
A name and a catalog URL are the whole record. How a store behaves is fixed per
|
||||
installed client — it carries its own store engine and knows its own machine — so
|
||||
the registry says what a store *is* and nothing about how it works.
|
||||
DESC
|
||||
returns code: 200, desc: "Array of stores" do
|
||||
property :name, String, desc: "Display name of the store"
|
||||
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
|
||||
|
||||
end
|
||||
def index
|
||||
render json: StoreService.new.index
|
||||
end
|
||||
end
|
||||
@@ -26,9 +26,9 @@ class Api::WikiController < ApiController
|
||||
end
|
||||
property :error, String, desc: "Error message (null on success)"
|
||||
end
|
||||
# GET /api/wiki/pages?tag=blog|howto[&limit=30][&body=1]
|
||||
|
||||
def index
|
||||
render json: WikiService.new.index(
|
||||
render json: Wiki::Pages.new.fetch(
|
||||
tag: params[:tag],
|
||||
limit: params[:limit],
|
||||
body: params[:body]
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
class ApiController < ActionController::API
|
||||
include WarpEngine::ApiErrorRendering
|
||||
|
||||
resource_description do
|
||||
api_version "1.0"
|
||||
formats [ "json" ]
|
||||
end
|
||||
|
||||
rescue_from StandardError do |e|
|
||||
Rails.logger.error("[#{self.class.name}] #{e.class}: #{e.message}")
|
||||
render json: { error: "Internal server error" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from Errno::ENOENT do |e|
|
||||
render json: { error: "Not found" }, status: :not_found
|
||||
end
|
||||
|
||||
rescue_from ArgumentError do |e|
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_mime(path)
|
||||
ext = File.extname(path.to_s).delete_prefix(".")
|
||||
Mime::Type.lookup_by_extension(ext) || "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SyncsAdminCookie
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
module SyncsAdminCookie
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :sync_admin_cookie
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class AdminUser < ApplicationRecord
|
||||
devise :database_authenticatable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
devise :database_authenticatable, :rememberable, :validatable
|
||||
|
||||
belongs_to :member, optional: true
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
class Image < ApplicationRecord
|
||||
def self.upload_path
|
||||
Rails.configuration.x.images.container_path
|
||||
end
|
||||
|
||||
has_many :software_images, class_name: "WarpEngine::SoftwareImage", dependent: :restrict_with_error
|
||||
has_many :members
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
attr_accessor :file_upload
|
||||
|
||||
before_validation :assign_upload_attributes, if: -> { file_upload.present? }
|
||||
after_commit :store_upload_file, on: [ :create, :update ], if: -> { file_upload.present? }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[content_type created_at deleted_at filename id original_filename updated_at]
|
||||
end
|
||||
|
||||
def file_path
|
||||
File.join(self.class.upload_path, filename.to_s)
|
||||
end
|
||||
|
||||
def url = "/api/image/#{id}"
|
||||
|
||||
private
|
||||
|
||||
def assign_upload_attributes
|
||||
self.original_filename = file_upload.original_filename
|
||||
self.content_type = file_upload.content_type.presence || "application/octet-stream"
|
||||
self.filename = "#{SecureRandom.uuid}#{File.extname(file_upload.original_filename)}"
|
||||
end
|
||||
|
||||
def store_upload_file
|
||||
FileUtils.mkdir_p(self.class.upload_path)
|
||||
IO.copy_stream(file_upload.to_io, file_path)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Member < ApplicationRecord
|
||||
belongs_to :image, class_name: "WarpEngine::Image", optional: true
|
||||
belongs_to :image, optional: true
|
||||
has_one :admin_user
|
||||
|
||||
validates :nick, presence: true, uniqueness: true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Store < ApplicationRecord
|
||||
URL = %r{\Ahttps?://\S+\z}
|
||||
|
||||
validates :name, presence: true
|
||||
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[id name catalog_url active created_at updated_at]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class StoreSerializer < Blueprinter::Base
|
||||
include WarpEngine::TimestampFields
|
||||
|
||||
field :name
|
||||
|
||||
field(:catalogUrl) { |store| store.catalog_url }
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class BlogFeed < Feed
|
||||
private
|
||||
|
||||
def title = "Teletype Games Blog"
|
||||
def link = "#{site_url}/blog"
|
||||
def description = "Latest blog posts from Teletype Games"
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "blog", limit: 30).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{site_url}/blog/#{page['route']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require "rss"
|
||||
|
||||
module Rss
|
||||
class Feed
|
||||
def xml
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = title
|
||||
maker.channel.link = link
|
||||
maker.channel.description = description
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |item|
|
||||
maker.items.new_item do |rss_item|
|
||||
rss_item.title = item[:title]
|
||||
rss_item.link = item[:link]
|
||||
rss_item.description = item[:description].to_s
|
||||
rss_item.pubDate = item[:published_at]
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def site_url = Rails.configuration.x.site_url
|
||||
|
||||
def wiki_url = Rails.configuration.x.wiki_url
|
||||
|
||||
def parse_time(value)
|
||||
Time.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
Time.current
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
module Rss
|
||||
class HowtosFeed < Feed
|
||||
private
|
||||
|
||||
def title = "Teletype Games HowTos"
|
||||
def link = "#{site_url}/howtos"
|
||||
def description = "Latest tech howtos from Teletype Games"
|
||||
|
||||
def items
|
||||
Wiki::Pages.new.all(tag: "howto", limit: 30).map do |page|
|
||||
{
|
||||
title: page["title"],
|
||||
link: "#{wiki_url}/#{page['path']}",
|
||||
description: page["description"],
|
||||
published_at: parse_time(page["createdAt"])
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
module Rss
|
||||
class ReleasesFeed < Feed
|
||||
private
|
||||
|
||||
def title = "Teletype Games Releases"
|
||||
def link = "#{site_url}/catalog"
|
||||
def description = "Latest game releases from Teletype Games"
|
||||
|
||||
def items
|
||||
WarpEngine::Release.includes(:software).order(created_at: :desc).limit(50).filter_map do |release|
|
||||
software = release.software
|
||||
next if software.nil?
|
||||
|
||||
{
|
||||
title: "#{software.title} v#{release.version}",
|
||||
link: "#{site_url}/catalog/#{software.name}",
|
||||
description: "#{software.title} #{release.version} released – #{software.desc}",
|
||||
published_at: release.created_at.to_time
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,73 +0,0 @@
|
||||
require "rss"
|
||||
|
||||
class RssService
|
||||
SITE_URL = ENV.fetch("SITE_URL", "https://teletypegames.org").freeze
|
||||
WIKI_URL = ENV.fetch("WIKI_URL", "https://wiki.teletypegames.org").freeze
|
||||
|
||||
def blog_feed
|
||||
pages = WikiService.new.pages(tag: "blog", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Blog"
|
||||
maker.channel.link = "#{SITE_URL}/blog"
|
||||
maker.channel.description = "Latest blog posts from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{SITE_URL}/blog/#{page["route"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def releases_feed
|
||||
releases = WarpEngine::Release.includes(:software)
|
||||
.order(created_at: :desc)
|
||||
.limit(50)
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games Releases"
|
||||
maker.channel.link = "#{SITE_URL}/catalog"
|
||||
maker.channel.description = "Latest game releases from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
releases.each do |release|
|
||||
sw = release.software
|
||||
next unless sw
|
||||
|
||||
maker.items.new_item do |item|
|
||||
item.title = "#{sw.title} v#{release.version}"
|
||||
item.link = "#{SITE_URL}/catalog/#{sw.name}"
|
||||
item.description = "#{sw.title} #{release.version} released – #{sw.desc}"
|
||||
item.pubDate = release.created_at.to_time
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def howtos_feed
|
||||
pages = WikiService.new.pages(tag: "howto", limit: 30)
|
||||
items = pages.fetch("pages", [])
|
||||
|
||||
RSS::Maker.make("2.0") do |maker|
|
||||
maker.channel.title = "Teletype Games HowTos"
|
||||
maker.channel.link = "#{SITE_URL}/howtos"
|
||||
maker.channel.description = "Latest tech howtos from Teletype Games"
|
||||
maker.channel.language = "hu"
|
||||
|
||||
items.each do |page|
|
||||
maker.items.new_item do |item|
|
||||
item.title = page["title"]
|
||||
item.link = "#{WIKI_URL}/#{page["path"]}"
|
||||
item.description = page["description"].to_s
|
||||
item.pubDate = Time.parse(page["createdAt"]) rescue Time.current
|
||||
end
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class StoreService
|
||||
|
||||
def index
|
||||
StoreSerializer.render_as_hash(Store.active.ordered)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
module Wiki
|
||||
class Pages
|
||||
def fetch(tag:, limit: nil, body: nil)
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{Rails.configuration.x.wiki_url}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
return empty(tag, "grav responded #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
empty(tag, e.message)
|
||||
end
|
||||
|
||||
def all(tag:, limit: nil)
|
||||
fetch(tag: tag, limit: limit).fetch("pages", [])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def empty(tag, error)
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => error }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
# Fetches wiki pages (blog, howto, …) from the Grav backend, filtered by tag.
|
||||
#
|
||||
# Grav exposes: GET {WIKI_GRAV_URL}/custom/pages.json?tag=<tag>[&limit=<n>][&body=1]
|
||||
# and returns:
|
||||
# {
|
||||
# "tag": "howto", "count": 24,
|
||||
# "pages": [
|
||||
# { "id", "path", "title", "description",
|
||||
# "createdAt", "updatedAt", "locale", "route", "tags",
|
||||
# # with body=1 also:
|
||||
# "render" (rendered HTML),
|
||||
# "content" (raw markdown) }
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# This replaces the frontend's direct WikiJS GraphQL calls with a simple GET,
|
||||
# and points at Grav (which is taking over from wiki.teletypegames.org).
|
||||
class WikiService
|
||||
GRAV_URL = ENV.fetch("WIKI_GRAV_URL", "http://localhost:8080").freeze
|
||||
|
||||
def index(tag:, limit: nil, body: nil)
|
||||
query = { tag: tag }
|
||||
query[:limit] = limit if limit.present?
|
||||
query[:body] = body if body.present?
|
||||
|
||||
uri = URI.parse("#{GRAV_URL}/custom/pages.json")
|
||||
uri.query = URI.encode_www_form(query)
|
||||
|
||||
response = Net::HTTP.start(
|
||||
uri.host, uri.port,
|
||||
use_ssl: uri.scheme == "https",
|
||||
open_timeout: 5, read_timeout: 10
|
||||
) { |http| http.get(uri.request_uri) }
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
return { "tag" => tag, "count" => 0, "pages" => [], "error" => "grav responded #{response.code}" }
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError => e
|
||||
{ "tag" => tag, "count" => 0, "pages" => [], "error" => e.message }
|
||||
end
|
||||
|
||||
# Az RSS feedek ezen a néven hívják.
|
||||
alias_method :pages, :index
|
||||
end
|
||||
@@ -22,10 +22,12 @@ 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])
|
||||
|
||||
config.x.site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
|
||||
config.x.wiki_url = ENV.fetch("WIKI_GRAV_URL", "https://wiki.teletypegames.org")
|
||||
config.x.images.container_path = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,5 @@ Rails.application.configure do
|
||||
|
||||
config.log_level = :warn
|
||||
|
||||
# Rack::Test example.org hostját ne blokkolja a host authorization
|
||||
config.hosts.clear
|
||||
end
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Set a JS-readable cookie when an admin signs in/out.
|
||||
# Devise's remember_admin_user_token cookie is HttpOnly,
|
||||
# so the frontend cannot read it. This non-HttpOnly cookie
|
||||
# lets the frontend show/hide the Admin menu link.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ApplicationController.class_eval do
|
||||
after_action :sync_admin_cookie
|
||||
|
||||
private
|
||||
|
||||
def sync_admin_cookie
|
||||
if current_admin_user
|
||||
cookies[:is_admin] = { value: "1", httponly: false, same_site: :lax, path: "/" }
|
||||
elsif cookies[:is_admin]
|
||||
cookies.delete(:is_admin, path: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
Devise.setup do |config|
|
||||
config.mailer_sender = "noreply@teletype.hu"
|
||||
|
||||
require "devise/orm/active_record"
|
||||
|
||||
config.case_insensitive_keys = [:email]
|
||||
@@ -11,6 +9,5 @@ Devise.setup do |config|
|
||||
config.expire_all_remember_me_on_sign_out = true
|
||||
config.password_length = 6..128
|
||||
config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
|
||||
config.reset_password_within = 6.hours
|
||||
config.sign_out_via = :delete
|
||||
end
|
||||
|
||||
@@ -1,42 +1,28 @@
|
||||
# WarpEngine host configuration. to_prepare: re-runs after reloads, hence
|
||||
# assignment (not <<) to stay idempotent.
|
||||
Rails.application.config.to_prepare do
|
||||
WarpEngine.configure do |c|
|
||||
# Owner of the /build/* DB tokens. Switching to :database mode
|
||||
# (c.application_token_source = :database) must wait until CI uses DB
|
||||
# tokens — the flip invalidates UPDATE_SECRET immediately.
|
||||
|
||||
c.application_token_source = :database
|
||||
c.application_token_owner_class = "AdminUser"
|
||||
|
||||
# Woodpecker configuration extension (/build/config): the served platforms
|
||||
# and their builder images. An image bump is one line here, rolled out to
|
||||
# every repo by the deploy.
|
||||
c.ci_platforms = {
|
||||
"godot" => { builder: "git.teletypegames.org/internal/godot-builder:4.7.1" },
|
||||
"phaser" => { builder: "git.teletypegames.org/internal/phaser-builder:latest" },
|
||||
"love" => { builder: "git.teletypegames.org/internal/love-builder:latest" },
|
||||
"bevy" => { builder: "git.teletypegames.org/internal/bevy-builder:latest" },
|
||||
"c64" => { builder: "git.teletypegames.org/internal/c64-builder:latest" },
|
||||
"ebitengine" => { builder: "git.teletypegames.org/internal/ebitengine-builder:latest" },
|
||||
"tic80" => { builder: "git.teletypegames.org/internal/tic80pro:latest" }
|
||||
}
|
||||
# Explicit URL: request.base_url would yield http:// behind the host nginx
|
||||
# (no X-Forwarded-Proto reaches Rails), and the resulting 301 makes the
|
||||
# pipeline's curl steps silently no-op.
|
||||
c.ci_update_server = "https://teletypegames.org"
|
||||
c.ci_extension_public_key_url = "https://ci.teletypegames.org/api/signature/public-key"
|
||||
c.image_class_name = "Image"
|
||||
|
||||
# Woodpecker CI management (repo sync, secret provisioning, pipeline control)
|
||||
c.woodpecker_url = ENV["WOODPECKER_URL"] # e.g. "https://ci.teletypegames.org"
|
||||
c.woodpecker_api_token = ENV["WOODPECKER_API_TOKEN"] # Woodpecker PAT with admin access
|
||||
c.woodpecker_repo_owner = ENV["WOODPECKER_REPO_OWNER"] # forge org (e.g. "games")
|
||||
c.site_url = Rails.configuration.x.site_url
|
||||
|
||||
c.image_owners = [
|
||||
{
|
||||
label: "member",
|
||||
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
|
||||
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
|
||||
c.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
|
||||
|
||||
@@ -7,14 +7,13 @@ Rails.application.routes.draw do
|
||||
get "swagger", to: "swagger#index"
|
||||
get "events", to: "events#index"
|
||||
get "members", to: "members#index"
|
||||
get "image/:id", to: "images#show"
|
||||
get "stores", to: "stores#index"
|
||||
get "wiki/pages", to: "wiki#index"
|
||||
get "rss/blog", to: "rss#blog"
|
||||
get "rss/releases", to: "rss#releases"
|
||||
get "rss/howtos", to: "rss#howtos"
|
||||
end
|
||||
|
||||
# Utolsó sor: a host route-jai nyernek, a katalógus-útvonalakat
|
||||
# (/api/software*, /api/builds*, /api/image, /api/download, /build/*, /file/*)
|
||||
# az engine adja.
|
||||
mount WarpEngine::Engine => "/"
|
||||
end
|
||||
|
||||
@@ -9,7 +9,6 @@ class AddCascadeToSoftwareForeignKeys < ActiveRecord::Migration[8.1]
|
||||
remove_foreign_key :software_images, :softwares
|
||||
add_foreign_key :software_images, :softwares, on_delete: :cascade
|
||||
|
||||
# Keep download stats when a release is removed.
|
||||
remove_foreign_key :downloads, name: "fk_downloads_release"
|
||||
add_foreign_key :downloads, :releases, name: "fk_downloads_release", on_delete: :nullify
|
||||
end
|
||||
|
||||
@@ -20,8 +20,6 @@ class CreateReleaseAssetsAndBackfill < ActiveRecord::Migration[8.1]
|
||||
add_index :release_assets, :path, name: "idx_release_assets_path"
|
||||
add_foreign_key :release_assets, :releases, name: "fk_releases_release_assets", on_delete: :cascade
|
||||
|
||||
# A soft-deletelt release-ek asset sorai a release deleted_at-jét öröklik,
|
||||
# így az unscoped alapú admin/updater logika konzisztens marad.
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
INSERT INTO release_assets (release_id, kind, path, deleted_at, created_at, updated_at)
|
||||
|
||||
@@ -17,7 +17,6 @@ class DropLegacyPathColumnsFromReleases < ActiveRecord::Migration[8.1]
|
||||
add_column :releases, column, :string
|
||||
end
|
||||
|
||||
# rollbacknél az adat a release_assets-ből áll vissza, nem veszik el
|
||||
COLUMN_KINDS.each do |column, kind|
|
||||
execute <<~SQL.squish
|
||||
UPDATE releases r
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class CreateStores < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :stores, id: { type: :bigint, unsigned: true },
|
||||
charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci",
|
||||
if_not_exists: true do |t|
|
||||
|
||||
t.string :name, null: false
|
||||
t.string :catalog_url, null: false
|
||||
t.string :store_repository_url, null: false
|
||||
t.datetime :deleted_at, precision: 3
|
||||
t.timestamps precision: 3
|
||||
|
||||
t.index :deleted_at, name: "idx_stores_deleted_at"
|
||||
t.index :name, name: "idx_stores_name"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
change_column_null :stores, :store_repository_url, true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class CarryTheStoreConfigInTheRegistry < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
add_column :stores, :config, :json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AStoreIsANameAndACatalog < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
remove_column :stores, :config, :json
|
||||
remove_column :stores, :store_repository_url, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddActiveToStores < ActiveRecord::Migration[8.1]
|
||||
|
||||
def change
|
||||
add_column :stores, :active, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_19_121824) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -45,6 +45,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
||||
t.index ["token_digest"], name: "idx_application_tokens_token_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "device_grants", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "application_token_id", unsigned: true
|
||||
t.datetime "approved_at", precision: 3
|
||||
t.string "client_name", limit: 128
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "denied_at", precision: 3
|
||||
t.string "device_code", limit: 64, null: false
|
||||
t.datetime "expires_at", precision: 3, null: false
|
||||
t.string "issued_token", limit: 64
|
||||
t.bigint "subject_id", unsigned: true
|
||||
t.string "subject_type", limit: 128
|
||||
t.datetime "updated_at", precision: 3
|
||||
t.string "user_code", limit: 16, null: false
|
||||
t.index ["device_code"], name: "idx_device_grants_device_code", unique: true
|
||||
t.index ["expires_at"], name: "idx_device_grants_expires_at"
|
||||
t.index ["user_code"], name: "idx_device_grants_user_code", unique: true
|
||||
end
|
||||
|
||||
create_table "downloads", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: 3
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -281,6 +299,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_06_000002) do
|
||||
t.index ["owner_type", "owner_id"], name: "idx_softwares_owner"
|
||||
end
|
||||
|
||||
create_table "stores", id: { type: :bigint, unsigned: true }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "catalog_url", null: false
|
||||
t.datetime "created_at", precision: 3, null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", precision: 3, null: false
|
||||
t.index ["deleted_at"], name: "idx_stores_deleted_at"
|
||||
t.index ["name"], name: "idx_stores_name"
|
||||
end
|
||||
|
||||
add_foreign_key "admin_users", "members"
|
||||
add_foreign_key "downloads", "releases", name: "fk_downloads_release", on_delete: :nullify
|
||||
add_foreign_key "external_links", "softwares", name: "fk_softwares_external_links", on_delete: :cascade
|
||||
|
||||
@@ -15,3 +15,7 @@ end
|
||||
m.avatar_filename = attrs[:avatar_filename]
|
||||
end
|
||||
end
|
||||
|
||||
Store.find_or_create_by!(name: "Teletype Games") do |store|
|
||||
store.catalog_url = ENV.fetch("STORE_CATALOG_URL", "https://teletypegames.org")
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace :admin_user do
|
||||
desc "Set an admin user password: rake admin_user:password EMAIL=... PASSWORD=..."
|
||||
task password: :environment do
|
||||
email = ENV["EMAIL"].to_s.strip
|
||||
password = ENV["PASSWORD"].to_s
|
||||
|
||||
if email.empty? || password.empty?
|
||||
abort "Használat: rake admin_user:password EMAIL=admin@teletype.hu PASSWORD=újjelszó"
|
||||
end
|
||||
|
||||
user = AdminUser.find_by(email: email)
|
||||
abort "Nincs ilyen admin user: #{email}" if user.nil?
|
||||
|
||||
user.password = password
|
||||
user.password_confirmation = password
|
||||
|
||||
if user.save
|
||||
puts "Jelszó frissítve: #{user.email}"
|
||||
else
|
||||
abort "Sikertelen: #{user.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
desc "List admin users"
|
||||
task list: :environment do
|
||||
AdminUser.order(:email).each { |u| puts "#{u.id}\t#{u.email}" }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Api::StoresController, type: :request do
|
||||
describe "GET /api/stores" do
|
||||
it "lists stores without a login, in name order" do
|
||||
create(:store, name: "Zed Games", catalog_url: "https://zed.example")
|
||||
create(:store, name: "Apex Games", catalog_url: "https://apex.example")
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json = JSON.parse(response.body)
|
||||
expect(json.map { |s| s["name"] }).to eq(["Apex Games", "Zed Games"])
|
||||
end
|
||||
|
||||
it "answers with the fields a client needs, camelCased" do
|
||||
create(:store)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
store = JSON.parse(response.body).first
|
||||
|
||||
expect(store.keys).to contain_exactly("name", "catalogUrl")
|
||||
expect(store["catalogUrl"]).to eq("https://teletypegames.org")
|
||||
end
|
||||
|
||||
it "leaves out soft-deleted stores" do
|
||||
create(:store, name: "Gone").update!(deleted_at: Time.current)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
|
||||
it "leaves out inactive stores" do
|
||||
create(:store, name: "Listed")
|
||||
create(:store, name: "Not yet", catalog_url: "https://soon.example", active: false)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body).map { |s| s["name"] }).to eq([ "Listed" ])
|
||||
end
|
||||
|
||||
it "still says nothing about the flag itself" do
|
||||
create(:store)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body).first.keys).to contain_exactly("name", "catalogUrl")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
FactoryBot.define do
|
||||
factory :store do
|
||||
name { "Teletype Games" }
|
||||
catalog_url { "https://teletypegames.org" }
|
||||
|
||||
trait :inactive do
|
||||
active { false }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,6 @@ RSpec.describe Member, type: :model do
|
||||
subject { build(:member) }
|
||||
|
||||
it { should validate_presence_of(:nick) }
|
||||
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
|
||||
|
||||
it { should validate_uniqueness_of(:nick).case_insensitive }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Store, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:catalog_url) }
|
||||
|
||||
it "rejects a catalog url that is not http(s)" do
|
||||
store = build(:store, catalog_url: "git@example.org:thing.git")
|
||||
expect(store).not_to be_valid
|
||||
expect(store.errors[:catalog_url]).to include("must be an http(s) URL")
|
||||
end
|
||||
|
||||
describe ".ordered" do
|
||||
it "lists stores by name" do
|
||||
later = create(:store, name: "Zed Games")
|
||||
first = create(:store, name: "Apex Games")
|
||||
|
||||
expect(Store.ordered).to eq([first, later])
|
||||
end
|
||||
end
|
||||
|
||||
it "hides soft-deleted stores" do
|
||||
store = create(:store)
|
||||
store.update!(deleted_at: Time.current)
|
||||
|
||||
expect(Store.all).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,6 @@ rescue ActiveRecord::PendingMigrationError => e
|
||||
abort e.to_s.strip
|
||||
end
|
||||
|
||||
# A katalógus-factory-k (software, release, download, platform_link) az engine-ben élnek
|
||||
FactoryBot.definition_file_paths << WarpEngine::Engine.root.join("spec/factories")
|
||||
FactoryBot.reload
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin file manager", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "files-spec@example.org", password: "password123") }
|
||||
let(:container) { Rails.root.join("tmp/files-spec").to_s }
|
||||
|
||||
before do
|
||||
FileUtils.rm_rf(container)
|
||||
FileUtils.mkdir_p(File.join(container, "mygame-1.0"))
|
||||
File.write(File.join(container, "mygame-1.0.zip"), "zipdata")
|
||||
allow(WarpEngine.config).to receive(:file_container_path).and_return(container)
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after do
|
||||
Warden.test_reset!
|
||||
FileUtils.rm_rf(container)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "lists the artifact directory with an icon per entry" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
expect(response.body).to include(WarpEngine::FileIcon.for("mygame-1.0.zip"))
|
||||
expect(response.body).to include(WarpEngine::FileIcon::DIRECTORY)
|
||||
end
|
||||
|
||||
it "puts the file name into the rename and delete prompts" do
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("prompt('New name:','mygame-1.0.zip')")
|
||||
expect(response.body).to include("Delete \\'mygame-1.0.zip\\'")
|
||||
end
|
||||
|
||||
it "shows the download count next to a file that was downloaded" do
|
||||
create(:download, file_path: "mygame-1.0.zip")
|
||||
|
||||
get "/admin/files"
|
||||
|
||||
expect(response.body).to include("mygame-1.0.zip")
|
||||
end
|
||||
|
||||
it "creates a folder and returns to the directory it was created in" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "docs" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.directory?(File.join(container, "mygame-1.0", "docs"))).to be(true)
|
||||
end
|
||||
|
||||
it "reports a failed folder creation instead of raising" do
|
||||
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(flash[:alert]).to include("Failed")
|
||||
end
|
||||
|
||||
it "deletes a file and returns to its parent directory" do
|
||||
File.write(File.join(container, "mygame-1.0", "readme.txt"), "hi")
|
||||
|
||||
delete "/admin/files/delete", params: { path: "mygame-1.0/readme.txt" }
|
||||
|
||||
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
|
||||
expect(File.exist?(File.join(container, "mygame-1.0", "readme.txt"))).to be(false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin images", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "images-spec@example.org", password: "password123") }
|
||||
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(upload_path)
|
||||
allow(Image).to receive(:upload_path).and_return(upload_path)
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after do
|
||||
Warden.test_reset!
|
||||
FileUtils.rm_rf(upload_path)
|
||||
end
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
def stored_image(name)
|
||||
image = create(:image, filename: name, original_filename: name)
|
||||
File.write(image.file_path, "not really a png")
|
||||
image
|
||||
end
|
||||
|
||||
it "lists an image with a preview" do
|
||||
image = stored_image("cover.png")
|
||||
|
||||
get "/admin/images"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("cover.png")
|
||||
expect(response.body).to include("/api/image/#{image.id}")
|
||||
end
|
||||
|
||||
it "says which software uses an image" do
|
||||
image = stored_image("used.png")
|
||||
software = create(:software)
|
||||
WarpEngine::SoftwareImage.create!(software: software, image_id: image.id, is_default: true)
|
||||
|
||||
get "/admin/images", params: { scope: "in_use" }
|
||||
|
||||
expect(response.body).to include("used.png")
|
||||
expect(response.body).to include("1 software(s)")
|
||||
end
|
||||
|
||||
it "says when a member uses an image" do
|
||||
image = stored_image("avatar.png")
|
||||
Member.create!(nick: "mr.zero", real_nick: "Zero", image_id: image.id)
|
||||
|
||||
get "/admin/images", params: { scope: "in_use" }
|
||||
|
||||
expect(response.body).to include("avatar.png")
|
||||
expect(response.body).to include("member")
|
||||
end
|
||||
|
||||
it "leaves an unused image in the orphan scope" do
|
||||
stored_image("orphan.png")
|
||||
|
||||
get "/admin/images", params: { scope: "orphan" }
|
||||
|
||||
expect(response.body).to include("orphan.png")
|
||||
end
|
||||
|
||||
it "deletes an orphan with its file" do
|
||||
orphan = stored_image("gone.png")
|
||||
kept = stored_image("kept.png")
|
||||
Member.create!(nick: "keeper", real_nick: "Keeper", image_id: kept.id)
|
||||
|
||||
post "/admin/images/batch_action",
|
||||
params: { batch_action: "delete_orphans", collection_selection: [ orphan.id, kept.id ] }
|
||||
|
||||
expect(Image.where(id: orphan.id)).to be_empty
|
||||
expect(File.exist?(orphan.file_path)).to be(false)
|
||||
expect(Image.where(id: kept.id)).to be_present
|
||||
expect(File.exist?(kept.file_path)).to be(true)
|
||||
end
|
||||
|
||||
it "opens an image's own page" do
|
||||
image = stored_image("single.png")
|
||||
|
||||
get "/admin/images/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("single.png")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin pipelines", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "pipelines-spec@example.org", password: "password123") }
|
||||
let(:software) { create(:software) }
|
||||
let!(:holder) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_001, repo_owner: "spec", repo_name: "holder",
|
||||
platform: "tic80", software: software)
|
||||
end
|
||||
let!(:taker) do
|
||||
WarpEngine::Pipeline.create!(woodpecker_repo_id: 990_002, repo_owner: "spec", repo_name: "taker",
|
||||
platform: "tic80")
|
||||
end
|
||||
|
||||
before do
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "saves the form" do
|
||||
put "/admin/pipelines/#{taker.id}", params: { pipeline: { platform: "godot" } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.platform).to eq("godot")
|
||||
end
|
||||
|
||||
it "moves the software off the pipeline that had it" do
|
||||
put "/admin/pipelines/#{taker.id}",
|
||||
params: { pipeline: { platform: "tic80", software_id: software.id } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(taker.reload.software).to eq(software)
|
||||
expect(holder.reload.software).to be_nil
|
||||
expect(software.reload.pipeline).to eq(taker)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
require "rails_helper"
|
||||
require "warden/test/helpers"
|
||||
|
||||
RSpec.describe "Admin stores", type: :request do
|
||||
include Warden::Test::Helpers
|
||||
|
||||
let(:admin) { AdminUser.create!(email: "stores-spec@example.org", password: "password123") }
|
||||
let!(:listed) { create(:store, name: "Listed", catalog_url: "https://listed.example") }
|
||||
let!(:hidden) { create(:store, :inactive, name: "Hidden", catalog_url: "https://hidden.example") }
|
||||
|
||||
before do
|
||||
Warden.test_mode!
|
||||
login_as(admin, scope: :admin_user)
|
||||
end
|
||||
|
||||
after { Warden.test_reset! }
|
||||
|
||||
around do |example|
|
||||
protection = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = false
|
||||
example.run
|
||||
ActionController::Base.allow_forgery_protection = protection
|
||||
end
|
||||
|
||||
it "opens with the active ones" do
|
||||
get "/admin/stores"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Listed")
|
||||
expect(response.body).not_to include("Hidden")
|
||||
end
|
||||
|
||||
it "shows the hidden ones under their own scope" do
|
||||
get "/admin/stores", params: { scope: "inactive" }
|
||||
|
||||
expect(response.body).to include("Hidden")
|
||||
end
|
||||
|
||||
it "renders a store's page" do
|
||||
get "/admin/stores/#{hidden.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "saves the flag from the form" do
|
||||
put "/admin/stores/#{listed.id}", params: { store: { active: "0" } }
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(listed.reload).not_to be_active
|
||||
end
|
||||
|
||||
it "toggles from the index in one click" do
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
|
||||
expect(response).to have_http_status(:found)
|
||||
expect(listed.reload).not_to be_active
|
||||
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
expect(listed.reload).to be_active
|
||||
end
|
||||
|
||||
it "lists and hides in bulk" do
|
||||
post "/admin/stores/batch_action",
|
||||
params: { batch_action: "list", collection_selection: [ hidden.id ] }
|
||||
|
||||
expect(hidden.reload).to be_active
|
||||
|
||||
post "/admin/stores/batch_action",
|
||||
params: { batch_action: "hide", collection_selection: [ hidden.id ] }
|
||||
|
||||
expect(hidden.reload).not_to be_active
|
||||
end
|
||||
|
||||
it "is what /api/stores answers with" do
|
||||
put "/admin/stores/#{listed.id}/toggle"
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body)).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "GET /api/image/:id", type: :request do
|
||||
let(:upload_path) { Rails.root.join("tmp/images-spec").to_s }
|
||||
|
||||
before do
|
||||
FileUtils.mkdir_p(upload_path)
|
||||
allow(Image).to receive(:upload_path).and_return(upload_path)
|
||||
end
|
||||
|
||||
after { FileUtils.rm_rf(upload_path) }
|
||||
|
||||
it "serves the file with the content type it was stored with" do
|
||||
image = create(:image, filename: "cover.png", content_type: "image/png")
|
||||
File.write(image.file_path, "not really a png")
|
||||
|
||||
get "/api/image/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq("image/png")
|
||||
expect(response.body).to eq("not really a png")
|
||||
end
|
||||
|
||||
it "answers 404 for an image that does not exist" do
|
||||
get "/api/image/999999"
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "errors, as it always has, when the row is there but the file is not" do
|
||||
image = create(:image, filename: "missing.png")
|
||||
|
||||
get "/api/image/#{image.id}"
|
||||
|
||||
expect(response).to have_http_status(:internal_server_error)
|
||||
end
|
||||
end
|
||||
@@ -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" />
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<section class="media-band">
|
||||
<header class="media-band-header">
|
||||
<h2 class="media-band-label">{{ label }}</h2>
|
||||
<slot name="action" />
|
||||
</header>
|
||||
<div class="media-band-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-band {
|
||||
@apply overflow-hidden rounded-card border border-line bg-surface;
|
||||
}
|
||||
.media-band-header {
|
||||
@apply flex flex-wrap items-baseline justify-between gap-3 border-b border-line px-5 py-3 md:px-6;
|
||||
}
|
||||
.media-band-label {
|
||||
@apply font-mono text-xs uppercase tracking-[0.14em] text-fg-subtle;
|
||||
}
|
||||
.media-band-body {
|
||||
@apply p-5 md:p-6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,11 @@
|
||||
<template>
|
||||
<span :class="badgeClass"><i :class="iconClass"></i> {{ platform }}</span>
|
||||
<span class="badge gap-1.5">
|
||||
<PlatformIcon :platform="platform" />{{ platform }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
|
||||
const props = defineProps<{ platform: string }>()
|
||||
|
||||
const knownPlatforms = ['tic80', 'ebitengine', 'love', 'godot']
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
tic80: 'fa-solid fa-tv',
|
||||
ebitengine: 'fa-brands fa-golang',
|
||||
love: 'fa-solid fa-heart',
|
||||
godot: 'fa-solid fa-robot',
|
||||
other: 'fa-solid fa-cube',
|
||||
}
|
||||
|
||||
const platformKey = computed(() => {
|
||||
const key = props.platform.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return knownPlatforms.includes(key) ? key : 'other'
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => `platform-badge platform-${platformKey.value}`)
|
||||
const iconClass = computed(() => platformIcons[platformKey.value])
|
||||
defineProps<{ platform: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-badge {
|
||||
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
|
||||
}
|
||||
.platform-tic80 { @apply bg-cyan-100 text-cyan-800 border border-cyan-200; }
|
||||
.platform-ebitengine { @apply bg-red-100 text-red-800 border border-red-200; }
|
||||
.platform-love { @apply bg-pink-100 text-pink-800 border border-pink-200; }
|
||||
.platform-godot { @apply bg-sky-100 text-sky-800 border border-sky-200; }
|
||||
.platform-other { @apply bg-purple-100 text-purple-800 border border-purple-200; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<BrandIcon v-if="brand" :name="brand" />
|
||||
<component :is="glyph" v-else v-bind="ICON" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import { Bot, Box, Boxes, Heart, Save, Tv, Zap } from 'lucide-vue-next'
|
||||
import BrandIcon from './icons/BrandIcon.vue'
|
||||
import { ICON } from './icons'
|
||||
|
||||
const props = defineProps<{ platform: string }>()
|
||||
|
||||
const GLYPHS: Record<string, Component> = {
|
||||
tic80: Tv,
|
||||
love: Heart,
|
||||
godot: Bot,
|
||||
bevy: Boxes,
|
||||
phaser: Zap,
|
||||
c64: Save,
|
||||
}
|
||||
|
||||
const key = computed(() => props.platform.toLowerCase().replace(/[^a-z0-9]/g, ''))
|
||||
const brand = computed(() => (key.value === 'ebitengine' ? 'go' : null))
|
||||
const glyph = computed(() => GLYPHS[key.value] ?? Box)
|
||||
</script>
|
||||
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<div class="animate-pulse">
|
||||
<div class="animate-pulse space-y-4">
|
||||
<div v-for="n in count" :key="n" class="skeleton-card">
|
||||
<div class="skeleton-card-inner">
|
||||
<div class="skeleton-line w-1/4 mb-4"></div>
|
||||
<div class="skeleton-line-lg w-3/4 mb-3"></div>
|
||||
<div class="skeleton-line w-full mb-2"></div>
|
||||
<div class="skeleton-line w-5/6"></div>
|
||||
</div>
|
||||
<div class="skeleton-line w-1/4"></div>
|
||||
<div class="skeleton-line-lg w-3/4"></div>
|
||||
<div class="skeleton-line w-full"></div>
|
||||
<div class="skeleton-line w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,15 +15,12 @@ withDefaults(defineProps<{ count?: number }>(), { count: 3 })
|
||||
|
||||
<style scoped>
|
||||
.skeleton-card {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6;
|
||||
}
|
||||
.skeleton-card-inner {
|
||||
@apply p-6 md:p-8;
|
||||
@apply flex flex-col gap-3 rounded-card border border-line bg-surface p-5 md:p-6;
|
||||
}
|
||||
.skeleton-line {
|
||||
@apply h-4 bg-gray-200 rounded;
|
||||
@apply h-4 rounded bg-raised;
|
||||
}
|
||||
.skeleton-line-lg {
|
||||
@apply h-6 bg-gray-200 rounded;
|
||||
@apply h-6 rounded bg-raised;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PlatformBadge from '../PlatformBadge.vue'
|
||||
import { statusBadgeClass } from '../../lib/softwareUtils'
|
||||
|
||||
describe('PlatformBadge', () => {
|
||||
it('shows the platform name next to its own glyph', () => {
|
||||
const wrapper = mount(PlatformBadge, { props: { platform: 'TIC-80' } })
|
||||
expect(wrapper.text()).toContain('TIC-80')
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to a glyph for a platform it has never seen', () => {
|
||||
const wrapper = mount(PlatformBadge, { props: { platform: 'Amiga' } })
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusBadgeClass', () => {
|
||||
it('gives every live status its own hue and leaves the archived ones neutral', () => {
|
||||
expect(statusBadgeClass('released')).toContain('status-released')
|
||||
expect(statusBadgeClass('demo')).toContain('status-demo')
|
||||
expect(statusBadgeClass('development')).toContain('status-development')
|
||||
expect(statusBadgeClass('archived')).toBe('status-badge')
|
||||
expect(statusBadgeClass('whatever')).toBe('status-badge')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="size" :height="size" viewBox="0 0 24 24"
|
||||
fill="currentColor" :aria-hidden="label ? undefined : 'true'"
|
||||
:role="label ? 'img' : undefined" :aria-label="label || undefined"
|
||||
class="inline-block shrink-0 align-[-0.125em]"
|
||||
>
|
||||
<path v-for="(d, i) in paths" :key="i" :d="d" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BRAND_PATHS } from './brandPaths'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number | string
|
||||
label?: string
|
||||
}>(), { size: 16, label: undefined })
|
||||
|
||||
const paths = computed(() => BRAND_PATHS[props.name] ?? [])
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ICON = { size: 16, strokeWidth: 1.75 } as const
|
||||
|
||||
export const ICON_CONTROL = { size: 20, strokeWidth: 1.75 } as const
|
||||
|
||||
export { default as BrandIcon } from './BrandIcon.vue'
|
||||
export { BRAND_PATHS } from './brandPaths'
|
||||
export type { BrandName } from './brandPaths'
|
||||
@@ -36,7 +36,7 @@ describe('useLoadable', () => {
|
||||
|
||||
it('re-executes after TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { withCache } = useLoadable(100) // 100ms TTL
|
||||
const { withCache } = useLoadable(100)
|
||||
|
||||
const fn = vi.fn()
|
||||
await withCache(fn)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DEFAULT_TTL = 15 * 60 * 1000 // 15 perc
|
||||
const DEFAULT_TTL = 15 * 60 * 1000
|
||||
|
||||
export function useLoadable(ttlMs?: number) {
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from '../locales/en'
|
||||
import hu from '../locales/hu'
|
||||
|
||||
function paths (value: unknown, prefix = ''): string[] {
|
||||
if (typeof value !== 'object' || value === null) return [prefix]
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.flatMap(([key, nested]) => paths(nested, prefix === '' ? key : `${prefix}.${key}`))
|
||||
}
|
||||
|
||||
describe('locale bundles', () => {
|
||||
const english = paths(en).sort()
|
||||
const hungarian = paths(hu).sort()
|
||||
|
||||
it('has something to compare', () => {
|
||||
expect(english.length).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('translates every English key into Hungarian', () => {
|
||||
expect(hungarian.filter((key) => !english.includes(key))).toEqual([])
|
||||
expect(english.filter((key) => !hungarian.includes(key))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves no empty string behind', () => {
|
||||
const empty = [...paths(en), ...paths(hu)].filter((key) => {
|
||||
const read = (bundle: unknown) =>
|
||||
key.split('.').reduce<unknown>((acc, part) =>
|
||||
(acc as Record<string, unknown> | undefined)?.[part], bundle)
|
||||
return read(en) === '' || read(hu) === ''
|
||||
})
|
||||
expect(empty).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,17 @@ export default {
|
||||
retro: 'Retro Mode',
|
||||
exitRetro: 'Exit Retro Mode',
|
||||
rss: 'RSS',
|
||||
stores: 'Stores',
|
||||
builds: 'Builds',
|
||||
theme: 'Switch theme',
|
||||
menu: 'Menu',
|
||||
},
|
||||
footer: {
|
||||
wiki: 'Wiki',
|
||||
git: 'Git repositories',
|
||||
releases: 'Releases',
|
||||
license: 'All content is available under the',
|
||||
tagline: 'Built in the open — we love open source, and the freedom to learn, fork and share.',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -30,36 +41,28 @@ export default {
|
||||
startsOn: 'Starts on:',
|
||||
upcomingEvents: 'Upcoming Events',
|
||||
featuredGame: 'Featured Game',
|
||||
playInBrowser: '▶ Play in Browser',
|
||||
playInBrowser: 'Play in Browser',
|
||||
viewProjectDetails: 'View Project Details',
|
||||
latestFromYoutube: 'Latest from YouTube',
|
||||
visitChannel: 'Visit Channel ↗',
|
||||
newRelease: 'New Release',
|
||||
watchOnYoutube: 'Watch on YouTube',
|
||||
note: 'Our HowTo-s and blog posts are written by AI with human supervision.',
|
||||
catalog: {
|
||||
title: 'Catalog',
|
||||
desc: 'Here you can find our games. Discover our creations for TIC-80 and other platforms, many of which you can try directly in your browser.',
|
||||
btn: 'Browse Games',
|
||||
},
|
||||
wiki: {
|
||||
title: 'Wiki',
|
||||
desc: 'We gather our public interest development knowledge here. Read through technical descriptions and development logs.',
|
||||
btn: 'Knowledge Base',
|
||||
},
|
||||
git: {
|
||||
title: 'Git Repos',
|
||||
desc: 'All our projects are open source. Browse our code, study our solutions, or even participate in the development.',
|
||||
btn: 'Gitea',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
desc: 'Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.',
|
||||
btn: 'Watch on YouTube',
|
||||
},
|
||||
spotify: {
|
||||
openInSpotify: 'Open in Spotify ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Play on your own machine',
|
||||
allStores: 'All stores',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'Our catalog as a grid of cards on your desktop: one click installs a game into your application menu, one plays it. It sets itself up on first run.',
|
||||
point1: 'Nothing to install first — no interpreter, no admin rights',
|
||||
point2: 'Installed games become normal menu entries, so they start without the app running',
|
||||
point3: 'Browser builds too, for titles with no download for your machine',
|
||||
download: 'Download',
|
||||
more: 'How it works',
|
||||
platforms: 'Linux, macOS and Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Contact Us',
|
||||
@@ -99,6 +102,8 @@ export default {
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
back: 'Back to the blog',
|
||||
noContent: 'No content available for this post.',
|
||||
badge: 'Community Stories',
|
||||
title: 'Developer Blog',
|
||||
subtitle: 'News, updates, and behind-the-scenes stories from the Teletype Games community.',
|
||||
@@ -109,6 +114,7 @@ export default {
|
||||
readMore: 'Read more',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Get the store',
|
||||
title: 'Our Games',
|
||||
subtitle: 'Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!',
|
||||
filter: {
|
||||
@@ -126,6 +132,9 @@ export default {
|
||||
},
|
||||
catalogShow: {
|
||||
back: 'Back to Catalog',
|
||||
close: 'Close',
|
||||
prevImage: 'Previous image',
|
||||
nextImage: 'Next image',
|
||||
projectInfo: 'Project Info',
|
||||
author: 'Author',
|
||||
platform: 'Platform',
|
||||
@@ -133,7 +142,7 @@ export default {
|
||||
about: 'About',
|
||||
latestStable: 'Latest Stable',
|
||||
released: 'Released:',
|
||||
playNow: '▶ Play Now',
|
||||
playNow: 'Play Now',
|
||||
download: 'Download',
|
||||
source: 'Source',
|
||||
docs: 'Docs',
|
||||
@@ -160,6 +169,7 @@ export default {
|
||||
win_x64: 'Windows (64-bit)',
|
||||
linux_x86: 'Linux (32-bit)',
|
||||
linux_x64: 'Linux (64-bit)',
|
||||
linux_arm64: 'Linux (ARM 64-bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -197,23 +207,36 @@ export default {
|
||||
title: 'Build Matrix',
|
||||
subtitle: 'Which engines build for which platforms.',
|
||||
},
|
||||
batocera: {
|
||||
title: 'Batocera Store',
|
||||
subtitle: 'Our catalog, straight on your retro box.',
|
||||
lead: 'ttg-batocera-store puts our catalog on Batocera, the plug-and-play retro gaming distribution. It pulls our games into the box\'s ROM folders together with box art and EmulationStation metadata, and can be re-run any time from the Ports menu to pick up new releases. Python 3 standard library only — nothing to install alongside it.',
|
||||
repo: 'Git repository',
|
||||
stores: {
|
||||
title: 'Stores',
|
||||
subtitle: 'Our games on your own machine, kept up to date.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Others',
|
||||
|
||||
clientDesc: 'The app: the catalog as a grid of cards, one click to install a game into your application menu, one to play it. It sets the store up itself on first run, and on Windows it is the way in.',
|
||||
clientDownload: 'Download',
|
||||
clientPlatforms: 'Linux, macOS and Windows · nothing to install first',
|
||||
clientShotAlt: 'The WarpEngine Client window: the store picker and category filters on the left, and the catalog as a grid of game cards with install and play buttons.',
|
||||
|
||||
docs: 'Documentation',
|
||||
batoceraProject: 'Batocera project',
|
||||
repo: 'Git repository',
|
||||
installTitle: 'Install',
|
||||
installDesc: 'Run this over SSH on the Batocera box. It installs the client, writes the config, creates the Ports entry and runs the first sync.',
|
||||
installNote: 'Then restart EmulationStation so the games show up:',
|
||||
installThen: 'Then',
|
||||
useTitle: 'Use it',
|
||||
useDesc: 'On the device: Ports ▸ "Teletype Games Store". Over SSH the client has a small CLI:',
|
||||
platformsTitle: 'What it installs',
|
||||
platformsDesc: 'A catalog platform is installable when its release asset boots directly on a Batocera system. Other platforms ship web and desktop builds instead, so they are not mapped.',
|
||||
engineTitle: 'Run your own store',
|
||||
engineDesc: 'Nothing here is specific to us. Our store is just a config file on top of warp-engine-batocera-store, an open engine that works with any WarpEngine-based site: point it at your own catalog, give it a name and a ROM subfolder of its own, and your games land on a Batocera box the same way. Several stores can live side by side on one machine without touching each other\'s games.',
|
||||
uninstallTitle: 'Remove it',
|
||||
platformsTitle: 'What gets installed',
|
||||
copy: 'Copy',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera project',
|
||||
tagline: 'The plug-and-play retro distribution',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Anything that runs RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Codebase',
|
||||
|
||||
@@ -11,6 +11,17 @@ export default {
|
||||
retro: 'Retró mód',
|
||||
exitRetro: 'Retró mód kikapcsolása',
|
||||
rss: 'RSS',
|
||||
stores: 'Boltok',
|
||||
builds: 'Buildek',
|
||||
theme: 'Téma váltása',
|
||||
menu: 'Menü',
|
||||
},
|
||||
footer: {
|
||||
wiki: 'Wiki',
|
||||
git: 'Git tárolók',
|
||||
releases: 'Kiadások',
|
||||
license: 'A tartalom elérhető',
|
||||
tagline: 'Nyíltan fejlesztünk — szeretjük a nyílt forráskódot, és a szabadságot, hogy tanulni, forkolni és megosztani lehessen.',
|
||||
},
|
||||
common: {
|
||||
status: {
|
||||
@@ -30,36 +41,28 @@ export default {
|
||||
startsOn: 'Kezdete:',
|
||||
upcomingEvents: 'Közelgő események',
|
||||
featuredGame: 'Kiemelt játék',
|
||||
playInBrowser: '▶ Játék böngészőben',
|
||||
playInBrowser: 'Játék böngészőben',
|
||||
viewProjectDetails: 'Projekt részletei',
|
||||
latestFromYoutube: 'Legújabb YouTube-ról',
|
||||
visitChannel: 'Csatorna megtekintése ↗',
|
||||
newRelease: 'Új megjelenés',
|
||||
watchOnYoutube: 'Nézd meg YouTube-on',
|
||||
note: 'A HowTo-k és blogbejegyzések mesterséges intelligenciával íródtak, emberi felügyelet mellett.',
|
||||
catalog: {
|
||||
title: 'Katalógus',
|
||||
desc: 'Itt megtalálod a játékainkat. Fedezd fel alkotásainkat TIC-80-ra és más platformokra, amelyek közül sokat közvetlenül a böngésződben is kipróbálhatsz.',
|
||||
btn: 'Játékok böngészése',
|
||||
},
|
||||
wiki: {
|
||||
title: 'Wiki',
|
||||
desc: 'Itt gyűjtjük össze a közérdekű fejlesztési tudásunkat. Olvasd át a technikai leírásokat és fejlesztési naplókat.',
|
||||
btn: 'Tudásbázis',
|
||||
},
|
||||
git: {
|
||||
title: 'Git tárolók',
|
||||
desc: 'Minden projektünk nyílt forráskódú. Böngészd a kódunkat, tanulmányozd a megoldásainkat, vagy akár részt is vehetsz a fejlesztésben.',
|
||||
btn: 'Gitea',
|
||||
},
|
||||
youtube: {
|
||||
title: 'YouTube',
|
||||
desc: 'Nézd meg fejlesztési vlogjainkat, gameplay videóinkat és tutorialainkat a hivatalos YouTube-csatornánkon.',
|
||||
btn: 'Nézd meg YouTube-on',
|
||||
},
|
||||
spotify: {
|
||||
openInSpotify: 'Megnyitás Spotify-ban ↗',
|
||||
},
|
||||
store: {
|
||||
label: 'Játssz a saját gépeden',
|
||||
allStores: 'Összes bolt',
|
||||
title: 'WarpEngine Client',
|
||||
desc: 'A katalógusunk kártyákban, az asztalodon: egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első futáskor magát is beállítja.',
|
||||
point1: 'Semmit nem kell előtte feltenni — se értelmező, se rendszergazdai jog',
|
||||
point2: 'A feltelepített játék rendes menüelem lesz, tehát az app nélkül is indul',
|
||||
point3: 'Böngészős buildek is, ha egy címhez nincs letöltés a gépedre',
|
||||
download: 'Letöltés',
|
||||
more: 'Hogyan működik',
|
||||
platforms: 'Linux, macOS és Windows',
|
||||
},
|
||||
},
|
||||
contact: {
|
||||
title: 'Kapcsolat',
|
||||
@@ -99,6 +102,8 @@ export default {
|
||||
},
|
||||
},
|
||||
blog: {
|
||||
back: 'Vissza a bloghoz',
|
||||
noContent: 'Ehhez a bejegyzéshez nincs tartalom.',
|
||||
badge: 'Közösségi történetek',
|
||||
title: 'Fejlesztői Blog',
|
||||
subtitle: 'Hírek, frissítések és kulisszák mögötti történetek a Teletype Games közösségből.',
|
||||
@@ -109,6 +114,7 @@ export default {
|
||||
readMore: 'Tovább olvasom',
|
||||
},
|
||||
catalog: {
|
||||
getTheStore: 'Töltsd le a boltot',
|
||||
title: 'Játékaink',
|
||||
subtitle: 'Fedezd fel a TIC-80-ra és más platformokra készített játékainkat, amelyeknek közvetlenül a böngésződben is kipróbálhatsz verzióit!',
|
||||
filter: {
|
||||
@@ -120,12 +126,15 @@ export default {
|
||||
},
|
||||
author: 'Szerző:',
|
||||
platform: 'Platform:',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
moreInfo: 'Részletek',
|
||||
downloads: 'letöltés',
|
||||
},
|
||||
catalogShow: {
|
||||
back: '← Vissza a katalógushoz',
|
||||
close: 'Bezárás',
|
||||
prevImage: 'Előző kép',
|
||||
nextImage: 'Következő kép',
|
||||
projectInfo: 'Projekt infó',
|
||||
author: 'Szerző',
|
||||
platform: 'Platform',
|
||||
@@ -133,7 +142,7 @@ export default {
|
||||
about: 'Leírás',
|
||||
latestStable: 'Legújabb stabil',
|
||||
released: 'Kiadva:',
|
||||
playNow: '▶ Játék most',
|
||||
playNow: 'Játék most',
|
||||
download: 'Letöltés',
|
||||
source: 'Forráskód',
|
||||
docs: 'Dokumentáció',
|
||||
@@ -144,7 +153,7 @@ export default {
|
||||
notFound: 'A játék nem található',
|
||||
failedToLoad: 'Nem sikerült betölteni:',
|
||||
loading: 'Betöltés...',
|
||||
play: '▶ Játék',
|
||||
play: 'Játék',
|
||||
downloads: 'letöltés',
|
||||
links: 'Linkek',
|
||||
platformLinks: 'Platform linkek',
|
||||
@@ -160,6 +169,7 @@ export default {
|
||||
win_x64: 'Windows (64 bit)',
|
||||
linux_x86: 'Linux (32 bit)',
|
||||
linux_x64: 'Linux (64 bit)',
|
||||
linux_arm64: 'Linux (ARM 64 bit)',
|
||||
mac_x64: 'macOS (Intel)',
|
||||
mac_arm64: 'macOS (Apple Silicon)',
|
||||
mac_universal: 'macOS',
|
||||
@@ -197,23 +207,36 @@ export default {
|
||||
title: 'Build mátrix',
|
||||
subtitle: 'Melyik engine melyik platformra fordít.',
|
||||
},
|
||||
batocera: {
|
||||
title: 'Batocera Store',
|
||||
subtitle: 'A katalógusunk közvetlenül a retró gépeden.',
|
||||
lead: 'A ttg-batocera-store a katalógusunkat teszi fel a Batocerára, a plug-and-play retró gamer disztribúcióra. A játékainkat a gép ROM mappáiba tölti le, borítóképpel és EmulationStation metaadatokkal együtt, és a Ports menüből bármikor újrafuttatható az új kiadásokért. Csak a Python 3 alapkönyvtárát használja — nem kell mellé semmit telepíteni.',
|
||||
repo: 'Git tároló',
|
||||
stores: {
|
||||
title: 'Store-ok',
|
||||
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
|
||||
viewClient: 'WarpEngine Client',
|
||||
viewOthers: 'Egyéb',
|
||||
|
||||
clientDesc: 'Az alkalmazás: a katalógus kártyákban, egy kattintás a telepítés az alkalmazásmenübe, egy az indítás. Első indításkor magát a store-t is beállítja, Windowson pedig ez az út.',
|
||||
clientDownload: 'Letöltés',
|
||||
clientPlatforms: 'Linux, macOS és Windows · semmit nem kell előtte feltenni',
|
||||
clientShotAlt: 'A WarpEngine Client ablaka: balra a store-választó és a kategóriaszűrők, jobbra a katalógus játékkártyákban, telepítés és indítás gombokkal.',
|
||||
|
||||
docs: 'Dokumentáció',
|
||||
batoceraProject: 'Batocera projekt',
|
||||
repo: 'Git repó',
|
||||
installTitle: 'Telepítés',
|
||||
installDesc: 'Futtasd ezt SSH-n a Batocera gépen. Telepíti a klienst, kiírja a konfigurációt, létrehozza a Ports bejegyzést és lefuttatja az első szinkront.',
|
||||
installNote: 'Utána indítsd újra az EmulationStationt, hogy megjelenjenek a játékok:',
|
||||
installThen: 'Utána',
|
||||
useTitle: 'Használat',
|
||||
useDesc: 'A gépen: Ports ▸ „Teletype Games Store”. SSH-n keresztül egy egyszerű CLI áll rendelkezésre:',
|
||||
platformsTitle: 'Mit telepít',
|
||||
platformsDesc: 'Egy katalógus-platform akkor telepíthető, ha a kiadás fájlja közvetlenül elindul egy Batocera rendszeren. A többi platform webes és asztali buildeket ad, ezért nincsenek leképezve.',
|
||||
engineTitle: 'Csinálj saját store-t',
|
||||
engineDesc: 'Itt semmi sem ránk van szabva. A mi store-unk csak egy konfigurációs fájl a warp-engine-batocera-store fölött, ami bármilyen WarpEngine alapú oldallal működik: állítsd a saját katalógusodra, adj neki nevet és saját ROM almappát, és a játékaid ugyanígy kerülnek fel egy Batocera gépre. Egy gépen több store is megfér egymás mellett anélkül, hogy egymás játékaihoz nyúlnának.',
|
||||
uninstallTitle: 'Eltávolítás',
|
||||
platformsTitle: 'Mi kerül fel',
|
||||
copy: 'Másolás',
|
||||
|
||||
batocera: {
|
||||
name: 'Batocera',
|
||||
site: 'Batocera projekt',
|
||||
tagline: 'A dugd-be-és-megy retro disztró',
|
||||
},
|
||||
retroarch: {
|
||||
name: 'RetroArch',
|
||||
site: 'RetroArch',
|
||||
tagline: 'Bármi, amin fut a RetroArch',
|
||||
},
|
||||
},
|
||||
code: {
|
||||
title: 'Kódbázis',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -29,3 +29,14 @@ export function filterDevReleases(releases: Release[]): Release[] {
|
||||
export function getLatestStable(releases: Release[]): Release | undefined {
|
||||
return filterStableReleases(releases)[0]
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASS: Record<string, string> = {
|
||||
released: 'status-badge status-released',
|
||||
demo: 'status-badge status-demo',
|
||||
development: 'status-badge status-development',
|
||||
archived: 'status-badge',
|
||||
}
|
||||
|
||||
export function statusBadgeClass(status: string): string {
|
||||
return STATUS_BADGE_CLASS[status] ?? 'status-badge'
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import { router } from './router/index.router'
|
||||
import { i18n } from './i18n'
|
||||
import App from './App.vue'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import 'tuicss/dist/tuicss.min.css'
|
||||
import './styles/tokens.css'
|
||||
import './styles/global.css'
|
||||
import './styles/retro.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-16">
|
||||
<div class="hero-container">
|
||||
<h1 class="hero-title">{{ t('batocera.title') }}</h1>
|
||||
<p class="hero-subtitle text-emerald-50">{{ t('batocera.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-container py-12 px-4 mt-0">
|
||||
<div class="bat-panel">
|
||||
<p class="bat-lead">{{ t('batocera.lead') }}</p>
|
||||
|
||||
<div class="bat-links">
|
||||
<a :href="REPO_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-dark">
|
||||
<i class="fa-solid fa-code-branch"></i> {{ t('batocera.repo') }}
|
||||
</a>
|
||||
<a :href="WIKI_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-indigo">
|
||||
<i class="fa-solid fa-book"></i> {{ t('batocera.docs') }}
|
||||
</a>
|
||||
<a :href="BATOCERA_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-emerald">
|
||||
<i class="fa-solid fa-tv"></i> {{ t('batocera.batoceraProject') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="bat-section">
|
||||
<h2 class="bat-section-title">
|
||||
<span class="bat-step">1</span> {{ t('batocera.installTitle') }}
|
||||
</h2>
|
||||
<p class="bat-section-desc">{{ t('batocera.installDesc') }}</p>
|
||||
<div class="bat-code">
|
||||
<pre><code>{{ INSTALL_CMD }}</code></pre>
|
||||
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(INSTALL_CMD)">
|
||||
<i :class="copied === INSTALL_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="bat-note">{{ t('batocera.installNote') }}</p>
|
||||
<div class="bat-code">
|
||||
<pre><code>{{ RESTART_CMD }}</code></pre>
|
||||
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(RESTART_CMD)">
|
||||
<i :class="copied === RESTART_CMD ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bat-section">
|
||||
<h2 class="bat-section-title">
|
||||
<span class="bat-step">2</span> {{ t('batocera.useTitle') }}
|
||||
</h2>
|
||||
<p class="bat-section-desc">{{ t('batocera.useDesc') }}</p>
|
||||
<div class="bat-code">
|
||||
<pre><code>{{ CLI_SNIPPET }}</code></pre>
|
||||
<button class="bat-copy" :title="t('batocera.copy')" @click="copy(CLI_SNIPPET)">
|
||||
<i :class="copied === CLI_SNIPPET ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bat-section">
|
||||
<h2 class="bat-section-title">{{ t('batocera.platformsTitle') }}</h2>
|
||||
<p class="bat-section-desc">{{ t('batocera.platformsDesc') }}</p>
|
||||
<ul class="bat-platforms">
|
||||
<li v-for="p in platforms" :key="p.platform" class="bat-platform">
|
||||
<i :class="p.icon" class="bat-platform-icon"></i>
|
||||
<span class="bat-platform-name">{{ p.label }}</span>
|
||||
<code class="bat-platform-ext">{{ p.ext }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="bat-section bat-engine">
|
||||
<h2 class="bat-section-title">
|
||||
<i class="fa-solid fa-cubes bat-engine-icon"></i> {{ t('batocera.engineTitle') }}
|
||||
</h2>
|
||||
<p class="bat-section-desc">{{ t('batocera.engineDesc') }}</p>
|
||||
<a :href="ENGINE_URL" target="_blank" rel="noopener noreferrer" class="bat-link bat-link-slate">
|
||||
<i class="fa-solid fa-code-branch"></i> warp-engine-batocera-store
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<div class="bat-back">
|
||||
<RouterLink to="/catalog" class="bat-back-link">{{ t('catalogShow.back') }}</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CONFIG } from '../../lib/config'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const FORGE = 'https://git.teletypegames.org/tools'
|
||||
const REPO_URL = `${FORGE}/ttg-batocera-store`
|
||||
// The store is only a config; the client itself is a reusable engine that runs
|
||||
// against any WarpEngine site, so it gets its own repository and its own link.
|
||||
const ENGINE_URL = `${FORGE}/warp-engine-batocera-store`
|
||||
const WIKI_URL = `${CONFIG.wikiBase}/others/ttg-batocera-store`
|
||||
const BATOCERA_URL = 'https://batocera.org'
|
||||
|
||||
// The installer is served straight from the forge, so this one line is the
|
||||
// whole install on a Batocera box.
|
||||
const INSTALL_CMD = `curl -fsSL ${REPO_URL}/raw/branch/master/install.sh | bash`
|
||||
const RESTART_CMD = 'batocera-es-swissknife --restart'
|
||||
const STORE_CLI = '/userdata/system/batocera-store/ttg-store'
|
||||
const CLI_SNIPPET = [
|
||||
`${STORE_CLI} list # compatible catalog entries`,
|
||||
`${STORE_CLI} sync # download everything new`,
|
||||
`${STORE_CLI} remove c64demo`,
|
||||
].join('\n')
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
const copied = ref('')
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = text
|
||||
setTimeout(() => { if (copied.value === text) copied.value = '' }, 2000)
|
||||
} catch {
|
||||
// Clipboard API needs a secure context — the command stays selectable anyway.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bat-panel {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8;
|
||||
}
|
||||
.bat-lead {
|
||||
@apply text-gray-600 leading-relaxed mb-6;
|
||||
}
|
||||
.bat-links {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
.bat-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;
|
||||
}
|
||||
.bat-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
|
||||
.bat-link-slate { @apply bg-slate-600 hover:bg-slate-700 shadow-lg shadow-slate-600/20; }
|
||||
.bat-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
|
||||
.bat-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
|
||||
|
||||
.bat-section {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
|
||||
}
|
||||
.bat-section-title {
|
||||
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
|
||||
}
|
||||
.bat-step {
|
||||
@apply inline-flex items-center justify-center w-7 h-7 rounded-full bg-emerald-100 text-emerald-700 text-sm font-bold;
|
||||
}
|
||||
.bat-section-desc {
|
||||
@apply text-gray-600 mb-4;
|
||||
}
|
||||
.bat-note {
|
||||
@apply text-gray-600 mt-4 mb-3 text-sm;
|
||||
}
|
||||
.bat-code {
|
||||
@apply relative bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
|
||||
}
|
||||
.bat-code pre {
|
||||
@apply p-4 pr-14 font-mono text-sm leading-relaxed;
|
||||
}
|
||||
.bat-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;
|
||||
}
|
||||
.bat-engine {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.bat-engine-icon {
|
||||
@apply text-gray-400 text-lg;
|
||||
}
|
||||
.bat-platforms {
|
||||
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
|
||||
}
|
||||
.bat-platform {
|
||||
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
|
||||
}
|
||||
.bat-platform-icon {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
.bat-platform-name {
|
||||
@apply font-medium text-gray-800 flex-grow;
|
||||
}
|
||||
.bat-platform-ext {
|
||||
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
|
||||
}
|
||||
.bat-back {
|
||||
@apply mt-8 text-center;
|
||||
}
|
||||
.bat-back-link {
|
||||
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
<template>
|
||||
<header class="hero-section-gradient from-slate-700 to-gray-800 py-16">
|
||||
<div class="hero-container">
|
||||
<header class="hero-section">
|
||||
<div class="hero-container hero-centered">
|
||||
<h1 class="hero-title">{{ t('builds.title') }}</h1>
|
||||
<p class="hero-subtitle text-slate-300">{{ t('builds.subtitle') }}</p>
|
||||
<p class="hero-subtitle">{{ t('builds.subtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-container py-12 px-4 mt-0">
|
||||
<div v-if="error" class="builds-error">{{ error }}</div>
|
||||
<main class="main-container">
|
||||
<div v-if="error" class="error-banner">
|
||||
<h2 class="error-banner-title">{{ t('common.error') }}</h2>
|
||||
<p class="error-banner-desc">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<SkeletonCard v-else-if="!data" :count="3" />
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div v-else class="builds-desktop">
|
||||
<div class="builds-table-wrap">
|
||||
<div class="table-wrap">
|
||||
<table class="builds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" class="builds-th-engine">Engine</th>
|
||||
<th v-for="group in columnGroups" :key="group.label" :colspan="group.kinds.length" :class="['builds-th-group', group.color]">
|
||||
<i :class="group.icon" class="builds-group-icon"></i>
|
||||
<span class="builds-group-label">{{ group.label }}</span>
|
||||
<th
|
||||
v-for="group in columnGroups" :key="group.label"
|
||||
:colspan="group.kinds.length" class="builds-th-group builds-group-start"
|
||||
>
|
||||
{{ group.label }}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<template v-for="group in columnGroups" :key="group.label">
|
||||
<th v-for="(col, colIdx) in group.kinds" :key="col.kind" :class="['builds-th-sub', group.color, { 'builds-group-start': colIdx === 0 }]">
|
||||
<i :class="group.icon" class="builds-sub-icon"></i>
|
||||
<th
|
||||
v-for="(col, colIdx) in group.kinds" :key="col.kind"
|
||||
:class="['builds-th-sub', { 'builds-group-start': colIdx === 0 }]"
|
||||
>
|
||||
{{ col.sub }}
|
||||
</th>
|
||||
</template>
|
||||
@@ -35,13 +41,15 @@
|
||||
<tbody>
|
||||
<tr v-for="(info, platform) in data.platforms" :key="platform" class="builds-row">
|
||||
<td class="builds-td-engine">
|
||||
<i :class="engineIcons[platform as string] || defaultEngineIcon" class="builds-engine-icon"></i>
|
||||
{{ info.label }}
|
||||
<span class="builds-engine"><PlatformIcon :platform="platform as string" />{{ info.label }}</span>
|
||||
</td>
|
||||
<template v-for="group in columnGroups" :key="group.label">
|
||||
<td v-for="(col, colIdx) in group.kinds" :key="col.kind" :class="['builds-td-kind', group.color, { 'builds-group-start': colIdx === 0 }]">
|
||||
<i v-if="info.kinds.includes(col.kind)" class="fa-solid fa-check builds-check"></i>
|
||||
<i v-else class="fa-solid fa-xmark builds-xmark"></i>
|
||||
<td
|
||||
v-for="(col, colIdx) in group.kinds" :key="col.kind"
|
||||
:class="['builds-td-kind', { 'builds-group-start': colIdx === 0 }]"
|
||||
>
|
||||
<Check v-if="info.kinds.includes(col.kind)" class="builds-check" :size="18" :stroke-width="2" />
|
||||
<X v-else class="builds-xmark" :size="16" :stroke-width="1.75" />
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
@@ -50,21 +58,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-if="data" class="builds-mobile">
|
||||
<div v-for="(info, platform) in data.platforms" :key="platform" class="builds-card">
|
||||
<h3 class="builds-card-title">
|
||||
<i :class="engineIcons[platform as string] || defaultEngineIcon" class="builds-engine-icon"></i>
|
||||
{{ info.label }}
|
||||
</h3>
|
||||
<div v-for="group in columnGroups" :key="group.label" :class="['builds-card-group', group.color]">
|
||||
<div class="builds-card-group-header">
|
||||
<i :class="group.icon" class="builds-card-group-icon"></i>
|
||||
<span>{{ group.label }}</span>
|
||||
</div>
|
||||
<h2 class="builds-card-title">
|
||||
<PlatformIcon :platform="platform as string" />{{ info.label }}
|
||||
</h2>
|
||||
<div v-for="group in columnGroups" :key="group.label" class="builds-card-group">
|
||||
<h3 class="builds-card-group-header">{{ group.label }}</h3>
|
||||
<div class="builds-card-kinds">
|
||||
<div v-for="col in group.kinds" :key="col.kind" class="builds-card-kind">
|
||||
<i :class="info.kinds.includes(col.kind) ? 'fa-solid fa-check builds-check' : 'fa-solid fa-xmark builds-xmark'"></i>
|
||||
<Check v-if="info.kinds.includes(col.kind)" class="builds-check" :size="16" :stroke-width="2" />
|
||||
<X v-else class="builds-xmark" :size="14" :stroke-width="1.75" />
|
||||
<span class="builds-card-kind-label">{{ col.sub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,10 +76,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="builds-back">
|
||||
<RouterLink to="/catalog" class="builds-back-link">
|
||||
{{ t('catalogShow.back') }}
|
||||
</RouterLink>
|
||||
<div class="focus-row">
|
||||
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -85,30 +87,19 @@ import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, X } from 'lucide-vue-next'
|
||||
import { useBuildsStore } from '../../stores/builds.store'
|
||||
import SkeletonCard from '../../components/SkeletonCard.vue'
|
||||
import PlatformIcon from '../../components/PlatformIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const store = useBuildsStore()
|
||||
const { data, error } = storeToRefs(store)
|
||||
|
||||
const engineIcons: Record<string, string> = {
|
||||
tic80: 'fa-solid fa-tv',
|
||||
ebitengine: 'fa-brands fa-golang',
|
||||
love: 'fa-solid fa-heart',
|
||||
godot: 'fa-solid fa-robot',
|
||||
c64: 'fa-solid fa-floppy-disk',
|
||||
bevy: 'fa-solid fa-cubes',
|
||||
phaser: 'fa-solid fa-bolt',
|
||||
}
|
||||
const defaultEngineIcon = 'fa-solid fa-cube'
|
||||
|
||||
const columnGroups = [
|
||||
{
|
||||
label: 'Windows',
|
||||
icon: 'fa-brands fa-windows',
|
||||
color: 'group-blue',
|
||||
kinds: [
|
||||
{ kind: 'win_x64', sub: '64 bit' },
|
||||
{ kind: 'win_x86', sub: '32 bit' },
|
||||
@@ -116,16 +107,13 @@ const columnGroups = [
|
||||
},
|
||||
{
|
||||
label: 'Linux',
|
||||
icon: 'fa-brands fa-linux',
|
||||
color: 'group-amber',
|
||||
kinds: [
|
||||
{ kind: 'linux_x64', sub: '64 bit' },
|
||||
{ kind: 'linux_arm64', sub: 'ARM 64 bit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Mac',
|
||||
icon: 'fa-brands fa-apple',
|
||||
color: 'group-slate',
|
||||
kinds: [
|
||||
{ kind: 'mac_x64', sub: 'Intel' },
|
||||
{ kind: 'mac_arm64', sub: 'ARM' },
|
||||
@@ -134,24 +122,18 @@ const columnGroups = [
|
||||
},
|
||||
{
|
||||
label: 'Web',
|
||||
icon: 'fa-solid fa-globe',
|
||||
color: 'group-green',
|
||||
kinds: [
|
||||
{ kind: 'html', sub: 'HTML5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cartridge',
|
||||
icon: 'fa-solid fa-gamepad',
|
||||
color: 'group-purple',
|
||||
kinds: [
|
||||
{ kind: 'cartridge', sub: 'Native' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Others',
|
||||
icon: 'fa-solid fa-folder-open',
|
||||
color: 'group-gray',
|
||||
kinds: [
|
||||
{ kind: 'source', sub: 'Source' },
|
||||
{ kind: 'docs', sub: 'Docs' },
|
||||
@@ -163,150 +145,70 @@ onMounted(() => store.fetch())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.builds-error {
|
||||
@apply text-center text-red-500 font-bold py-12;
|
||||
}
|
||||
.builds-loading {
|
||||
@apply text-center text-gray-400 py-12;
|
||||
}
|
||||
.builds-table-wrap {
|
||||
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-x-auto;
|
||||
}
|
||||
.builds-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
.builds-th-engine {
|
||||
@apply px-6 py-4 text-left text-xs font-bold text-gray-400 uppercase tracking-widest bg-gray-50 border-b border-gray-200;
|
||||
}
|
||||
.builds-th-group {
|
||||
@apply px-2 py-3 text-center border-b border-gray-200;
|
||||
}
|
||||
.builds-group-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
.builds-group-label {
|
||||
@apply ml-1.5 text-xs font-bold uppercase tracking-wider;
|
||||
}
|
||||
.builds-th-sub {
|
||||
@apply px-3 py-2 text-center text-[10px] font-bold uppercase tracking-wider;
|
||||
}
|
||||
.builds-sub-icon {
|
||||
@apply text-[9px] mr-0.5 opacity-60;
|
||||
}
|
||||
.builds-group-start {
|
||||
@apply border-l-2;
|
||||
}
|
||||
|
||||
/* Group color themes */
|
||||
.group-blue .builds-group-icon,
|
||||
.group-blue .builds-group-label { @apply text-blue-500; }
|
||||
.th-group.group-blue, th.group-blue { background-color: rgb(239 246 255); }
|
||||
td.group-blue { background-color: rgb(239 246 255 / 0.4); }
|
||||
th.group-blue.builds-group-start, td.group-blue.builds-group-start { @apply border-l-blue-300; }
|
||||
|
||||
.group-amber .builds-group-icon,
|
||||
.group-amber .builds-group-label { @apply text-amber-600; }
|
||||
th.group-amber { background-color: rgb(255 251 235); }
|
||||
td.group-amber { background-color: rgb(255 251 235 / 0.4); }
|
||||
th.group-amber.builds-group-start, td.group-amber.builds-group-start { @apply border-l-amber-300; }
|
||||
|
||||
.group-slate .builds-group-icon,
|
||||
.group-slate .builds-group-label { @apply text-slate-500; }
|
||||
th.group-slate { background-color: rgb(248 250 252); }
|
||||
td.group-slate { background-color: rgb(248 250 252 / 0.4); }
|
||||
th.group-slate.builds-group-start, td.group-slate.builds-group-start { @apply border-l-slate-300; }
|
||||
|
||||
.group-green .builds-group-icon,
|
||||
.group-green .builds-group-label { @apply text-emerald-500; }
|
||||
th.group-green { background-color: rgb(236 253 245); }
|
||||
td.group-green { background-color: rgb(236 253 245 / 0.4); }
|
||||
th.group-green.builds-group-start, td.group-green.builds-group-start { @apply border-l-emerald-300; }
|
||||
|
||||
.group-purple .builds-group-icon,
|
||||
.group-purple .builds-group-label { @apply text-purple-500; }
|
||||
th.group-purple { background-color: rgb(250 245 255); }
|
||||
td.group-purple { background-color: rgb(250 245 255 / 0.4); }
|
||||
th.group-purple.builds-group-start, td.group-purple.builds-group-start { @apply border-l-purple-300; }
|
||||
|
||||
.group-gray .builds-group-icon,
|
||||
.group-gray .builds-group-label { @apply text-gray-500; }
|
||||
th.group-gray { background-color: rgb(249 250 251); }
|
||||
td.group-gray { background-color: rgb(249 250 251 / 0.4); }
|
||||
th.group-gray.builds-group-start, td.group-gray.builds-group-start { @apply border-l-gray-300; }
|
||||
|
||||
/* Sub-header inherits group tint */
|
||||
.builds-th-sub.group-blue { @apply text-blue-400; }
|
||||
.builds-th-sub.group-amber { @apply text-amber-500; }
|
||||
.builds-th-sub.group-slate { @apply text-slate-400; }
|
||||
.builds-th-sub.group-green { @apply text-emerald-400; }
|
||||
.builds-th-sub.group-purple { @apply text-purple-400; }
|
||||
.builds-th-sub.group-gray { @apply text-gray-400; }
|
||||
.builds-row {
|
||||
@apply border-t border-gray-100 transition-colors;
|
||||
}
|
||||
.builds-row:hover td {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
.builds-td-engine {
|
||||
@apply px-6 py-4 font-bold text-gray-900 whitespace-nowrap;
|
||||
}
|
||||
.builds-engine-icon {
|
||||
@apply text-gray-400 text-sm mr-2;
|
||||
}
|
||||
.builds-td-kind {
|
||||
@apply px-3 py-4 text-center;
|
||||
}
|
||||
.builds-check {
|
||||
@apply text-green-500 text-lg;
|
||||
}
|
||||
.builds-xmark {
|
||||
@apply text-gray-200 text-lg;
|
||||
}
|
||||
/* Responsive visibility */
|
||||
.builds-desktop {
|
||||
@apply hidden md:block;
|
||||
}
|
||||
.builds-mobile {
|
||||
@apply md:hidden space-y-4;
|
||||
.builds-table {
|
||||
@apply w-full border-collapse text-sm;
|
||||
}
|
||||
.builds-th-engine,
|
||||
.builds-th-group,
|
||||
.builds-th-sub {
|
||||
@apply bg-raised px-3 py-2.5 font-mono text-xs font-medium uppercase tracking-[0.1em] text-fg-subtle;
|
||||
}
|
||||
.builds-th-engine {
|
||||
@apply px-5 text-left;
|
||||
}
|
||||
.builds-th-group {
|
||||
@apply text-center text-fg;
|
||||
}
|
||||
.builds-th-sub {
|
||||
@apply text-center text-[10px];
|
||||
}
|
||||
|
||||
/* Mobile cards */
|
||||
.builds-group-start {
|
||||
@apply border-l border-line;
|
||||
}
|
||||
.builds-row {
|
||||
@apply border-t border-line transition-colors hover:bg-surface;
|
||||
}
|
||||
.builds-td-engine {
|
||||
@apply whitespace-nowrap px-5 py-3 font-medium text-fg;
|
||||
}
|
||||
.builds-engine,
|
||||
.builds-card-title {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
.builds-td-kind {
|
||||
@apply px-3 py-3 text-center;
|
||||
}
|
||||
.builds-check {
|
||||
@apply mx-auto text-accent;
|
||||
}
|
||||
|
||||
.builds-xmark {
|
||||
@apply mx-auto text-fg-subtle opacity-50;
|
||||
}
|
||||
|
||||
.builds-mobile {
|
||||
@apply flex flex-col gap-4 md:hidden;
|
||||
}
|
||||
.builds-card {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-gray-100 p-5;
|
||||
@apply rounded-card border border-line bg-surface p-5;
|
||||
}
|
||||
.builds-card-title {
|
||||
@apply text-lg font-bold text-gray-900 mb-4 pb-3 border-b border-gray-100;
|
||||
@apply mb-3 border-b border-line pb-3 text-base font-semibold text-fg;
|
||||
}
|
||||
.builds-card-group {
|
||||
@apply py-2 px-3 rounded-lg my-1;
|
||||
@apply py-1.5;
|
||||
}
|
||||
.builds-card-group.group-blue { background-color: rgb(239 246 255 / 0.5); }
|
||||
.builds-card-group.group-amber { background-color: rgb(255 251 235 / 0.5); }
|
||||
.builds-card-group.group-slate { background-color: rgb(248 250 252 / 0.5); }
|
||||
.builds-card-group.group-green { background-color: rgb(236 253 245 / 0.5); }
|
||||
.builds-card-group.group-purple { background-color: rgb(250 245 255 / 0.5); }
|
||||
.builds-card-group.group-gray { background-color: rgb(249 250 251 / 0.5); }
|
||||
|
||||
.builds-card-group-header {
|
||||
@apply flex items-center gap-2 text-xs font-bold uppercase tracking-wider mb-2;
|
||||
}
|
||||
.builds-card-group-icon {
|
||||
@apply text-sm;
|
||||
@apply mb-1 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle;
|
||||
}
|
||||
.builds-card-kinds {
|
||||
@apply flex flex-wrap gap-x-5 gap-y-1 pl-5 mb-2;
|
||||
@apply flex flex-wrap gap-x-4 gap-y-1;
|
||||
}
|
||||
.builds-card-kind {
|
||||
@apply flex items-center gap-1.5 text-sm;
|
||||
}
|
||||
.builds-card-kind-label {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.builds-back {
|
||||
@apply mt-8 text-center;
|
||||
}
|
||||
.builds-back-link {
|
||||
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
|
||||
@apply flex items-center gap-1.5 text-sm text-fg-muted;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<template>
|
||||
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
|
||||
<div class="hero-container">
|
||||
<header class="hero-section">
|
||||
<div class="hero-container hero-centered">
|
||||
<h1 class="hero-title">{{ t('catalog.title') }}</h1>
|
||||
<p class="hero-subtitle text-purple-50">{{ t('catalog.subtitle') }}</p>
|
||||
<p class="hero-subtitle">{{ t('catalog.subtitle') }}</p>
|
||||
<div class="hero-actions">
|
||||
<RouterLink to="/stores" class="btn-accent">{{ t('catalog.getTheStore') }}</RouterLink>
|
||||
<RouterLink to="/builds" class="link text-sm">{{ t('builds.title') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-container py-12 px-4 mt-0">
|
||||
<div class="filter-buttons">
|
||||
<main class="main-container">
|
||||
<div class="filter-bar" role="tablist">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f"
|
||||
:class="['filter-btn', { active: activeFilter === f }]"
|
||||
role="tab"
|
||||
:aria-selected="activeFilter === f"
|
||||
@click="activeFilter = f"
|
||||
>
|
||||
{{ t(`catalog.filter.${f}`) }}
|
||||
@@ -20,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="/batocera" class="builds-link">
|
||||
<i class="fa-solid fa-gamepad"></i> {{ t('batocera.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>
|
||||
|
||||
@@ -1,250 +1,139 @@
|
||||
<template>
|
||||
<div class="catalog-show-page">
|
||||
<header class="hero-section-gradient from-purple-700 to-indigo-800 py-12 md:py-20">
|
||||
<header class="hero-section">
|
||||
<div class="hero-container">
|
||||
<nav class="mb-8">
|
||||
<RouterLink to="/catalog" class="catalog-back-link">
|
||||
<i class="fa-solid fa-arrow-left"></i> {{ t('catalogShow.back') }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
|
||||
|
||||
<div v-if="software" class="catalog-header-content">
|
||||
<div class="flex-grow">
|
||||
<div class="catalog-badges-group">
|
||||
<span :class="`status-badge status-${software.status} px-3 py-1`">{{ t(`common.status.${software.status}`) }}</span>
|
||||
<PlatformBadge :platform="software.platform" />
|
||||
</div>
|
||||
<h1 class="catalog-title">{{ software.title }}</h1>
|
||||
<p class="catalog-subtitle">{{ software.desc }}</p>
|
||||
<template v-if="software">
|
||||
<div class="catalog-badges">
|
||||
<span :class="statusBadgeClass(software.status)">{{ t(`common.status.${software.status}`) }}</span>
|
||||
<PlatformBadge :platform="software.platform" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="hero-title">{{ software.title }}</h1>
|
||||
<p class="hero-subtitle">{{ software.desc }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main v-if="software" class="main-container -mt-10">
|
||||
<main v-if="software" class="main-container">
|
||||
<div class="catalog-layout">
|
||||
<!-- Sidebar Info -->
|
||||
<div class="catalog-sidebar">
|
||||
<div class="catalog-card">
|
||||
<div v-if="defaultImageUrl" class="mb-6">
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="defaultImageUrl"
|
||||
:alt="software.title"
|
||||
:class="['catalog-featured-image', softwareImages.length > 0 ? 'cursor-pointer hover:opacity-80 transition-opacity' : '']"
|
||||
@click="openLightbox(defaultImageIndex)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="softwareImages.length > 1" class="catalog-thumbs">
|
||||
<img
|
||||
v-for="(img, idx) in softwareImages"
|
||||
:key="idx"
|
||||
:src="img.url"
|
||||
:alt="`${software.title} ${idx + 1}`"
|
||||
:class="['catalog-thumb', img.isDefault ? 'border-purple-400' : 'border-gray-200 hover:border-purple-300']"
|
||||
@click="openLightbox(idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="catalog-section-heading">
|
||||
<i class="fa-solid fa-circle-info text-purple-600"></i> {{ t('catalogShow.projectInfo') }}
|
||||
</h2>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="catalog-info-label">{{ t('catalogShow.author') }}</dt>
|
||||
<dd class="catalog-info-value">{{ software.author }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="catalog-info-label">{{ t('catalogShow.platform') }}</dt>
|
||||
<dd><PlatformBadge :platform="software.platform" /></dd>
|
||||
</div>
|
||||
<div v-if="software.license">
|
||||
<dt class="catalog-info-label">{{ t('catalogShow.license') }}</dt>
|
||||
<dd class="catalog-info-value">{{ software.license }}</dd>
|
||||
</div>
|
||||
<div v-if="software.externalLinks?.length">
|
||||
<dt class="catalog-info-label">{{ t('catalogShow.links') }}</dt>
|
||||
<dd class="flex flex-col gap-1.5">
|
||||
<a v-for="link in software.externalLinks" :key="link.url" :href="link.url" target="_blank" class="catalog-external-link">
|
||||
{{ link.label }} <i class="fa-solid fa-up-right-from-square text-xs"></i>
|
||||
<aside class="catalog-sidebar">
|
||||
<div v-if="defaultImageUrl" class="catalog-image-panel">
|
||||
<img
|
||||
:src="defaultImageUrl"
|
||||
:alt="software.title"
|
||||
class="catalog-featured-image"
|
||||
:class="softwareImages.length > 0 ? 'cursor-pointer' : ''"
|
||||
@click="openLightbox(defaultImageIndex)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="softwareImages.length > 1" class="catalog-thumbs">
|
||||
<img
|
||||
v-for="(img, idx) in softwareImages"
|
||||
:key="idx"
|
||||
:src="img.url"
|
||||
:alt="`${software.title} ${idx + 1}`"
|
||||
class="catalog-thumb"
|
||||
:class="img.isDefault ? 'border-accent' : 'border-line'"
|
||||
@click="openLightbox(idx)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="catalog-section-heading">{{ t('catalogShow.projectInfo') }}</h2>
|
||||
<dl class="catalog-facts">
|
||||
<dt>{{ t('catalogShow.author') }}</dt>
|
||||
<dd>{{ software.author }}</dd>
|
||||
|
||||
<dt>{{ t('catalogShow.platform') }}</dt>
|
||||
<dd>{{ software.platform }}</dd>
|
||||
|
||||
<template v-if="software.license">
|
||||
<dt>{{ t('catalogShow.license') }}</dt>
|
||||
<dd>{{ software.license }}</dd>
|
||||
</template>
|
||||
|
||||
<template v-if="software.externalLinks?.length">
|
||||
<dt>{{ t('catalogShow.links') }}</dt>
|
||||
<dd class="flex flex-col items-start gap-1">
|
||||
<a v-for="link in software.externalLinks" :key="link.url" :href="link.url" target="_blank" rel="noopener" class="link">
|
||||
{{ link.label }}<ExternalLink v-bind="ICON" class="ml-1 inline" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="software.platformLinks?.length">
|
||||
<dt class="catalog-info-label">{{ t('catalogShow.platformLinks') }}</dt>
|
||||
<dd class="flex flex-col gap-1.5">
|
||||
<a v-for="pl in software.platformLinks" :key="pl.url" :href="pl.url" target="_blank" class="catalog-external-link">
|
||||
{{ pl.name }} <i class="fa-solid fa-up-right-from-square text-xs"></i>
|
||||
</template>
|
||||
|
||||
<template v-if="software.platformLinks?.length">
|
||||
<dt>{{ t('catalogShow.platformLinks') }}</dt>
|
||||
<dd class="flex flex-col items-start gap-1">
|
||||
<a v-for="pl in software.platformLinks" :key="pl.url" :href="pl.url" target="_blank" rel="noopener" class="link">
|
||||
{{ pl.name }}<ExternalLink v-bind="ICON" class="ml-1 inline" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="catalog-main">
|
||||
<div v-if="software.story" class="catalog-card">
|
||||
<h2 class="catalog-about-title">{{ t('catalogShow.about') }}</h2>
|
||||
<section v-if="software.story" class="card">
|
||||
<h2 class="catalog-section-heading">{{ t('catalogShow.about') }}</h2>
|
||||
<p class="catalog-about-text">{{ software.story }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="latestStable" class="latest-release-card">
|
||||
<div class="latest-release-header">
|
||||
<section v-if="latestStable" class="card">
|
||||
<div class="release-headline">
|
||||
<div>
|
||||
<div class="latest-release-label-row">
|
||||
<span class="latest-release-badge">{{ t('catalogShow.latestStable') }}</span>
|
||||
</div>
|
||||
<h2 class="latest-release-version">{{ latestStable.version }}</h2>
|
||||
<p class="latest-release-date"><i class="fa-solid fa-calendar mr-1"></i>{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
|
||||
<span class="badge-accent">{{ t('catalogShow.latestStable') }}</span>
|
||||
<h2 class="release-version-lead mono">{{ latestStable.version }}</h2>
|
||||
<p class="release-date">{{ t('catalogShow.released') }} {{ formatDateTime(latestStable.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="latest-release-play-btn"><i class="fa-solid fa-play mr-2"></i>{{ t('catalogShow.playNow') }}</a>
|
||||
<a v-if="latestStable.htmlFolderPath" :href="latestStable.htmlFolderPath" target="_blank" class="btn-accent">
|
||||
{{ t('catalogShow.playNow') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="tableAssets(latestStable).length" class="latest-release-assets">
|
||||
<div class="assets-section-label">{{ t('catalogShow.assetsTitle') }}</div>
|
||||
<!-- Desktop asset table -->
|
||||
<table class="asset-table-desktop">
|
||||
<tbody>
|
||||
<tr v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-table-row-light">
|
||||
<td class="asset-kind-col-light"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
|
||||
<td class="asset-path-col-light">{{ assetFileName(asset.path) }}</td>
|
||||
<td class="asset-action-col">
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-link-white">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="asset-link-white">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Mobile asset list -->
|
||||
<div class="asset-mobile-list">
|
||||
<div v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-mobile-item-light">
|
||||
<div class="asset-mobile-row">
|
||||
<span class="asset-mobile-kind-light"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</span>
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-mobile-link-white">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="asset-mobile-link-white">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</div>
|
||||
<div class="asset-mobile-path-light">{{ assetFileName(asset.path) }}</div>
|
||||
<div v-if="tableAssets(latestStable).length" class="asset-list">
|
||||
<h3 class="asset-list-title">{{ t('catalogShow.assetsTitle') }}</h3>
|
||||
<div v-for="asset in tableAssets(latestStable)" :key="asset.kind" class="asset-row">
|
||||
<span class="asset-kind">{{ t('catalogShow.assetKind.' + asset.kind) }}</span>
|
||||
<span class="asset-path mono">{{ assetFileName(asset.path) }}</span>
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="link text-sm">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="link text-sm">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card-bare">
|
||||
<h2 class="releases-title">{{ t('catalogShow.allReleases') }}</h2>
|
||||
<article v-for="release in allReleases" :key="release.version" class="release-item">
|
||||
<div class="release-item-head">
|
||||
<span class="release-version mono">{{ release.version }}</span>
|
||||
<span v-if="release.version.startsWith('dev-')" class="badge">dev</span>
|
||||
<span v-if="release.downloadCount" class="release-downloads mono">
|
||||
{{ release.downloadCount }} {{ t('catalogShow.downloads') }}
|
||||
</span>
|
||||
<span class="release-date mono ml-auto">{{ formatDateTime(release.updatedAt) }}</span>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="link text-sm">
|
||||
{{ t('catalogShow.play') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="tableAssets(release).length" class="asset-list">
|
||||
<div v-for="asset in tableAssets(release)" :key="asset.kind" class="asset-row">
|
||||
<span class="asset-kind">{{ t('catalogShow.assetKind.' + asset.kind) }}</span>
|
||||
<span class="asset-path mono">{{ assetFileName(asset.path) }}</span>
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="link text-sm">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="link text-sm">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="releases-card">
|
||||
<div class="releases-header">
|
||||
<h2 class="releases-title">{{ t('catalogShow.allReleases') }}</h2>
|
||||
</div>
|
||||
<div class="p-0">
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden md:block">
|
||||
<table class="w-full text-left">
|
||||
<thead class="release-table-head">
|
||||
<tr>
|
||||
<th class="px-8 py-4">{{ t('catalogShow.version') }}</th>
|
||||
<th class="px-8 py-4">{{ t('catalogShow.releaseDate') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="release in stableReleases" :key="release.version">
|
||||
<tr class="release-table-row">
|
||||
<td class="px-8 py-4">
|
||||
<div class="release-version-row">
|
||||
<span class="release-version">{{ release.version }}</span>
|
||||
<span v-if="release.downloadCount" class="release-download-count"><i class="fa-solid fa-download text-[10px]"></i> {{ release.downloadCount }}</span>
|
||||
</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr v-for="asset in tableAssets(release)" :key="asset.kind" class="release-asset-row">
|
||||
<td class="release-asset-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
|
||||
<td class="release-asset-path">{{ assetFileName(asset.path) }}</td>
|
||||
<td class="release-asset-action">
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="release-asset-link">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="release-asset-link">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<tr v-if="devReleases.length > 0" class="dev-versions-header">
|
||||
<td colspan="2" class="dev-versions-label">{{ t('catalogShow.devVersions') }}</td>
|
||||
</tr>
|
||||
|
||||
<template v-for="release in devReleases" :key="release.version">
|
||||
<tr class="dev-release-row">
|
||||
<td class="px-8 py-4">
|
||||
<div class="dev-release-version">{{ release.version }}</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="release-play-link">{{ t('catalogShow.play') }}</a>
|
||||
</td>
|
||||
<td class="release-date-col">{{ formatDateTime(release.updatedAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="tableAssets(release).length" class="bg-yellow-50/10">
|
||||
<td colspan="2" class="px-8 pb-4 pt-0">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr v-for="asset in tableAssets(release)" :key="asset.kind" class="release-asset-row">
|
||||
<td class="release-asset-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</td>
|
||||
<td class="release-asset-path">{{ assetFileName(asset.path) }}</td>
|
||||
<td class="release-asset-action">
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="release-asset-link">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="release-asset-link">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile View -->
|
||||
<div class="md:hidden divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="release in allReleases"
|
||||
:key="release.version"
|
||||
:class="['p-6', release.version.startsWith('dev-') ? 'bg-yellow-50/20' : '']"
|
||||
>
|
||||
<div class="mobile-release-header">
|
||||
<div>
|
||||
<div class="mobile-release-version">{{ release.version }}</div>
|
||||
<div class="mobile-release-date">{{ formatDateTime(release.updatedAt) }}</div>
|
||||
</div>
|
||||
<span v-if="release.version.startsWith('dev-')" class="dev-badge">Dev</span>
|
||||
</div>
|
||||
<a v-if="release.htmlFolderPath" :href="release.htmlFolderPath" target="_blank" class="mobile-play-btn">{{ t('catalogShow.play') }}</a>
|
||||
<div v-if="tableAssets(release).length" class="mt-3 space-y-1">
|
||||
<div v-for="asset in tableAssets(release)" :key="asset.kind" class="asset-mobile-item">
|
||||
<div class="asset-mobile-row">
|
||||
<span class="asset-mobile-kind"><i :class="assetKindIcon(asset.kind)" class="w-4 text-center"></i> {{ t('catalogShow.assetKind.' + asset.kind) }}</span>
|
||||
<a v-if="asset.kind === 'docs'" :href="asset.path" target="_blank" class="asset-mobile-link">{{ t('catalogShow.assetOpen') }}</a>
|
||||
<a v-else :href="downloadUrl(asset.path)" class="asset-mobile-link">{{ t('catalogShow.assetDownload') }}</a>
|
||||
</div>
|
||||
<div class="asset-mobile-path">{{ assetFileName(asset.path) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main v-else class="main-container -mt-10">
|
||||
<div class="loading-card">
|
||||
{{ error || t('catalogShow.loading') }}
|
||||
</div>
|
||||
<main v-else class="main-container">
|
||||
<p class="state-message">{{ error || t('catalogShow.loading') }}</p>
|
||||
</main>
|
||||
|
||||
<ImageLightbox
|
||||
@@ -262,10 +151,12 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '../../lib/dateFormat'
|
||||
import { useSoftwareStore } from '../../stores/software.store'
|
||||
import { filterStableReleases, filterDevReleases } from '../../lib/softwareUtils'
|
||||
import { filterStableReleases, filterDevReleases, statusBadgeClass } from '../../lib/softwareUtils'
|
||||
import type { Release, Software, SoftwareImage } from '../../lib/interfaces/software.interface'
|
||||
import { ExternalLink } from 'lucide-vue-next'
|
||||
import ImageLightbox from './ImageLightbox.vue'
|
||||
import PlatformBadge from '../../components/PlatformBadge.vue'
|
||||
import { ICON } from '../../components/icons'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -311,10 +202,9 @@ const latestStable = computed(() => stableReleases.value[0] ?? null)
|
||||
const downloadUrl = (path: string) =>
|
||||
`/api/download?path=${encodeURIComponent(path.replace('/file/', ''))}`
|
||||
|
||||
// az asset-táblázat sorai és sorrendjük; a html nem letöltés, azt a Play gomb viszi
|
||||
const TABLE_KIND_ORDER = [
|
||||
'cartridge', 'source', 'docs',
|
||||
'win_x64', 'win_x86', 'linux_x64', 'linux_x86',
|
||||
'win_x64', 'win_x86', 'linux_x64', 'linux_arm64', 'linux_x86',
|
||||
'mac_universal', 'mac_arm64', 'mac_x64',
|
||||
]
|
||||
|
||||
@@ -325,15 +215,6 @@ const tableAssets = (release: Release) =>
|
||||
|
||||
const assetFileName = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? ''
|
||||
|
||||
const assetKindIcon = (kind: string) =>
|
||||
kind.startsWith('win_') ? 'fa-brands fa-windows'
|
||||
: kind.startsWith('linux_') ? 'fa-brands fa-linux'
|
||||
: kind.startsWith('mac_') ? 'fa-brands fa-apple'
|
||||
: kind === 'cartridge' ? 'fa-solid fa-gamepad'
|
||||
: kind === 'source' ? 'fa-solid fa-code'
|
||||
: kind === 'docs' ? 'fa-solid fa-book'
|
||||
: ''
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.fetch()
|
||||
@@ -351,105 +232,88 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Page */
|
||||
.catalog-show-page { @apply bg-gray-50 min-h-screen pb-20; }
|
||||
.catalog-back-link { @apply text-purple-200 hover:text-white transition-colors flex items-center gap-2 font-medium; }
|
||||
.catalog-header-content { @apply flex flex-col md:flex-row md:items-end justify-between gap-6; }
|
||||
.catalog-badges-group { @apply flex items-center justify-center gap-3 mb-4; }
|
||||
.catalog-title { @apply text-4xl md:text-6xl font-black text-white mb-4 leading-tight; }
|
||||
.catalog-subtitle { @apply text-xl text-purple-100 max-w-3xl mx-auto leading-relaxed; }
|
||||
.catalog-show-page {
|
||||
@apply min-h-screen bg-page;
|
||||
}
|
||||
.catalog-badges {
|
||||
@apply mb-3 mt-6 flex flex-wrap items-center gap-2;
|
||||
}
|
||||
.catalog-layout {
|
||||
@apply grid grid-cols-1 gap-6 lg:grid-cols-[320px_minmax(0,1fr)];
|
||||
}
|
||||
.catalog-sidebar {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
.catalog-main {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.catalog-layout { @apply grid grid-cols-1 lg:grid-cols-3 gap-8; }
|
||||
.catalog-sidebar { @apply lg:col-span-1 space-y-6; }
|
||||
.catalog-main { @apply lg:col-span-2 space-y-8; }
|
||||
.catalog-card { @apply bg-white rounded-3xl shadow-xl p-8 border border-gray-100; }
|
||||
.catalog-image-panel {
|
||||
@apply flex items-center justify-center rounded-card border border-line bg-raised p-6;
|
||||
}
|
||||
.catalog-featured-image {
|
||||
@apply max-h-64 w-auto object-contain transition-opacity hover:opacity-80;
|
||||
}
|
||||
.catalog-thumbs {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
.catalog-thumb {
|
||||
@apply h-14 w-14 cursor-pointer rounded-control border object-cover transition-colors hover:border-accent;
|
||||
}
|
||||
.catalog-section-heading {
|
||||
@apply mb-4 text-base font-semibold tracking-tight text-fg;
|
||||
}
|
||||
.catalog-facts {
|
||||
@apply grid grid-cols-1 gap-y-3 text-sm;
|
||||
}
|
||||
.catalog-facts dt {
|
||||
@apply text-xs uppercase tracking-wide text-fg-subtle;
|
||||
}
|
||||
.catalog-facts dd {
|
||||
@apply m-0 mb-2 text-fg;
|
||||
}
|
||||
.catalog-about-text {
|
||||
@apply max-w-[68ch] whitespace-pre-line leading-relaxed text-fg-muted;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.catalog-featured-image { @apply max-h-48 w-auto object-contain border-4 border-gray-100 shadow-sm; }
|
||||
.catalog-thumbs { @apply flex gap-2 mt-3 justify-center; }
|
||||
.catalog-thumb { @apply w-12 h-12 object-cover rounded border-2 cursor-pointer transition-all; }
|
||||
.catalog-section-heading { @apply text-xl font-bold text-gray-900 mb-6 flex items-center gap-2; }
|
||||
.catalog-info-label { @apply text-xs font-bold text-gray-400 uppercase tracking-widest mb-1; }
|
||||
.catalog-info-value { @apply text-gray-900 font-medium; }
|
||||
.catalog-external-link { @apply text-purple-600 hover:text-purple-800 font-bold inline-flex items-center gap-1; }
|
||||
.release-headline {
|
||||
@apply flex flex-wrap items-start justify-between gap-4;
|
||||
}
|
||||
.release-version-lead {
|
||||
@apply mt-2 text-2xl font-semibold text-fg;
|
||||
}
|
||||
.release-date {
|
||||
@apply text-sm text-fg-subtle;
|
||||
}
|
||||
.releases-title {
|
||||
@apply border-b border-line px-5 py-3 text-base font-semibold tracking-tight text-fg md:px-6;
|
||||
}
|
||||
.release-item {
|
||||
@apply border-b border-line px-5 py-4 last:border-b-0 md:px-6;
|
||||
}
|
||||
.release-item-head {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
.release-version {
|
||||
@apply text-sm font-medium text-fg;
|
||||
}
|
||||
.release-downloads {
|
||||
@apply text-xs text-fg-subtle;
|
||||
}
|
||||
|
||||
/* About */
|
||||
.catalog-about-title { @apply text-xl font-bold text-gray-900 mb-4; }
|
||||
.catalog-about-text { @apply text-gray-700 leading-relaxed whitespace-pre-line; }
|
||||
|
||||
/* Latest Release */
|
||||
.latest-release-card { @apply bg-gradient-to-br from-indigo-600 to-purple-700 rounded-3xl shadow-xl p-8 text-white; }
|
||||
.latest-release-header { @apply flex flex-col md:flex-row justify-between items-start md:items-center gap-6; }
|
||||
.latest-release-label-row { @apply flex items-center gap-2 mb-2; }
|
||||
.latest-release-badge { @apply px-2 py-0.5 bg-white/20 text-white text-[10px] font-black uppercase rounded tracking-widest; }
|
||||
.latest-release-version { @apply text-4xl font-black mb-1; }
|
||||
.latest-release-date { @apply text-indigo-100 text-sm; }
|
||||
.latest-release-play-btn { @apply flex items-center justify-center py-3 px-6 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap; }
|
||||
.latest-release-assets { @apply mt-6 pt-6 border-t border-white/15; }
|
||||
.assets-section-label { @apply text-[10px] font-black uppercase tracking-widest text-indigo-200 mb-3; }
|
||||
|
||||
/* Asset table - light (hero) */
|
||||
.asset-table-desktop { @apply hidden md:table w-full text-sm; }
|
||||
.asset-table-row-light { @apply border-b border-white/10 last:border-0; }
|
||||
.asset-kind-col-light { @apply py-1.5 pr-6 text-indigo-100 whitespace-nowrap; }
|
||||
.asset-path-col-light { @apply py-1.5 pr-6 w-full font-mono text-xs text-indigo-200/80 break-all; }
|
||||
.asset-action-col { @apply py-1.5 text-right whitespace-nowrap; }
|
||||
.asset-link-white { @apply text-white font-bold hover:underline; }
|
||||
|
||||
/* Asset mobile - light (hero) */
|
||||
.asset-mobile-list { @apply md:hidden space-y-2; }
|
||||
.asset-mobile-item-light { @apply flex flex-col gap-0.5 py-2 border-b border-white/10 last:border-0; }
|
||||
.asset-mobile-row { @apply flex justify-between items-center; }
|
||||
.asset-mobile-kind-light { @apply text-indigo-100 text-sm whitespace-nowrap; }
|
||||
.asset-mobile-link-white { @apply text-white font-bold text-sm hover:underline ml-4 shrink-0; }
|
||||
.asset-mobile-path-light { @apply font-mono text-xs text-indigo-200/80 truncate; }
|
||||
|
||||
/* Releases section */
|
||||
.releases-card { @apply bg-white rounded-3xl shadow-xl overflow-hidden border border-gray-100; }
|
||||
.releases-header { @apply p-8 border-b border-gray-100; }
|
||||
.releases-title { @apply text-2xl font-bold text-gray-900; }
|
||||
.release-table-head { @apply bg-gray-50 text-gray-500 text-xs font-bold uppercase tracking-widest; }
|
||||
.release-table-row { @apply border-t border-gray-100 first:border-t-0 hover:bg-gray-50/50 transition-colors; }
|
||||
.release-version-row { @apply flex items-center gap-2 mb-2; }
|
||||
.release-version { @apply font-bold text-gray-900 text-lg; }
|
||||
.release-download-count { @apply text-xs text-gray-400 font-medium; }
|
||||
.release-play-link { @apply text-purple-600 hover:text-purple-900 font-bold text-sm; }
|
||||
.release-date-col { @apply px-8 py-4 text-gray-500 align-top pt-5; }
|
||||
|
||||
/* Release asset table */
|
||||
.release-asset-row { @apply border-b border-gray-100 last:border-0; }
|
||||
.release-asset-kind { @apply py-1 pr-6 text-gray-500 whitespace-nowrap; }
|
||||
.release-asset-path { @apply py-1 pr-6 w-full font-mono text-xs text-gray-400 break-all; }
|
||||
.release-asset-action { @apply py-1 text-right whitespace-nowrap; }
|
||||
.release-asset-link { @apply text-blue-600 hover:text-blue-900 font-bold; }
|
||||
|
||||
/* Dev versions */
|
||||
.dev-versions-header { @apply border-t border-gray-100 bg-yellow-50/30; }
|
||||
.dev-versions-label { @apply px-8 py-3 text-[10px] font-black text-yellow-700 uppercase tracking-tighter; }
|
||||
.dev-release-row { @apply border-t border-gray-100 bg-yellow-50/10 hover:bg-yellow-50/30 transition-colors; }
|
||||
.dev-release-version { @apply font-bold text-yellow-800 text-lg mb-2; }
|
||||
|
||||
/* Mobile releases */
|
||||
.mobile-release-header { @apply flex justify-between items-start mb-4; }
|
||||
.mobile-release-version { @apply font-bold text-gray-900 text-lg; }
|
||||
.mobile-release-date { @apply text-gray-500 text-xs mt-1; }
|
||||
.dev-badge { @apply px-2 py-0.5 bg-yellow-100 text-yellow-800 text-[10px] font-black uppercase rounded; }
|
||||
.mobile-play-btn { @apply flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm; }
|
||||
|
||||
/* Asset mobile - dark (releases) */
|
||||
.asset-mobile-item { @apply flex flex-col gap-0.5 py-1.5 border-b border-gray-100 last:border-0; }
|
||||
.asset-mobile-kind { @apply text-gray-500 text-sm whitespace-nowrap; }
|
||||
.asset-mobile-link { @apply text-blue-600 font-bold text-sm ml-4 shrink-0; }
|
||||
.asset-mobile-path { @apply font-mono text-xs text-gray-400 truncate; }
|
||||
|
||||
/* Status badges */
|
||||
.status-badge { @apply rounded text-[10px] font-black uppercase tracking-wider; }
|
||||
.status-released { @apply bg-green-500 text-white shadow-lg shadow-green-500/20; }
|
||||
.status-demo { @apply bg-blue-500 text-white shadow-lg shadow-blue-500/20; }
|
||||
.status-development { @apply bg-yellow-500 text-white shadow-lg shadow-yellow-500/20; }
|
||||
.status-archived { @apply bg-gray-500 text-white shadow-lg shadow-gray-500/20; }
|
||||
|
||||
/* Loading */
|
||||
.loading-card { @apply bg-white rounded-3xl shadow-xl p-12 text-center text-gray-400; }
|
||||
.asset-list {
|
||||
@apply mt-4 flex flex-col;
|
||||
}
|
||||
.asset-list-title {
|
||||
@apply mb-1 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle;
|
||||
}
|
||||
.asset-row {
|
||||
@apply flex flex-wrap items-baseline gap-x-4 gap-y-1 border-t border-line py-2 text-sm;
|
||||
}
|
||||
.asset-kind {
|
||||
@apply min-w-[9rem] text-fg;
|
||||
}
|
||||
.asset-path {
|
||||
@apply flex-grow truncate text-xs text-fg-subtle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
@keydown.left="prev"
|
||||
@keydown.right="next"
|
||||
>
|
||||
<button class="lightbox-close-btn" @click="close"><i class="fa-solid fa-xmark"></i></button>
|
||||
<button class="lightbox-close-btn" :aria-label="t('catalogShow.close')" @click="close"><X :size="24" :stroke-width="1.75" /></button>
|
||||
|
||||
<button
|
||||
v-if="images.length > 1" class="lightbox-nav-btn left-4"
|
||||
@click="prev"
|
||||
:aria-label="t('catalogShow.prevImage')" @click="prev"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
<ChevronLeft :size="28" :stroke-width="1.75" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
<button
|
||||
v-if="images.length > 1" class="lightbox-nav-btn right-4"
|
||||
@click="next"
|
||||
:aria-label="t('catalogShow.nextImage')" @click="next"
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
<ChevronRight :size="28" :stroke-width="1.75" />
|
||||
</button>
|
||||
|
||||
<div v-if="images.length > 1" class="lightbox-counter">
|
||||
@@ -41,8 +41,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-vue-next'
|
||||
import type { SoftwareImage } from '../../lib/interfaces/software.interface'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
images: SoftwareImage[]
|
||||
isOpen: boolean
|
||||
@@ -77,15 +81,15 @@ function prev() { currentIndex.value = (currentIndex.value - 1 + props.images.le
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm;
|
||||
}
|
||||
.lightbox-close-btn {
|
||||
@apply absolute top-4 right-4 text-white/70 hover:text-white text-3xl font-bold z-10;
|
||||
@apply absolute right-4 top-4 z-10 rounded-control p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white;
|
||||
}
|
||||
.lightbox-nav-btn {
|
||||
@apply absolute text-white/70 hover:text-white text-5xl font-bold z-10 select-none;
|
||||
@apply absolute z-10 select-none rounded-control p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white;
|
||||
}
|
||||
.lightbox-image {
|
||||
@apply max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl;
|
||||
@apply max-h-[85vh] max-w-[90vw] rounded-card object-contain;
|
||||
}
|
||||
.lightbox-counter {
|
||||
@apply absolute bottom-6 text-white/60 text-sm font-mono;
|
||||
@apply absolute bottom-6 font-mono text-sm text-white/60;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||