A site megtanul témát váltani, és leszokik az ikonokról

Nyolc akcentszín volt, oldalanként másik hero-gradienssel, tizenegy
blur-blobbal és 146 ikonhasználattal — ettől nézett ki réginek. Most egy
vizuális nyelv van: semleges zinc-skála, egyetlen akcens (a saját
foszforzöldünk), 1px keretek árnyék helyett, és ikon csak ott, ahol nincs
mellette szöveg.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:24:42 +02:00
co-authored by Claude Opus 5
parent e040763454
commit e431e2b85b
47 changed files with 2157 additions and 3322 deletions
+12
View File
@@ -7,6 +7,18 @@
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Teletype Games</title>
<script>
/* A témát a bundle betöltése ELŐTT tesszük a gyökérre, különben minden
sötét témás oldalbetöltés villan egy világosat. A sütit ezután az
ui.store tartja karban; süti nélkül az alapértelmezés a világos. */
(function () {
var m = document.cookie.match(/(?:^|; )theme=([^;]*)/)
var theme = m && decodeURIComponent(m[1]) === 'dark' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme)
var r = document.cookie.match(/(?:^|; )retro-mode=([^;]*)/)
document.documentElement.setAttribute('data-retro', r && r[1] === '1' ? '1' : '0')
})()
</script>
<link rel="alternate" type="application/rss+xml" title="Teletype Games Blog" href="/api/rss/blog" />
<link rel="alternate" type="application/rss+xml" title="Teletype Games Releases" href="/api/rss/releases" />
<link rel="alternate" type="application/rss+xml" title="Teletype Games HowTos" href="/api/rss/howtos" />
+222 -277
View File
@@ -8,8 +8,8 @@
"name": "ttglanding",
"version": "0.0.1",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.1",
"dompurify": "^3.4.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.0",
"tuicss": "^2.1.2",
"vue": "^3.5.0",
@@ -25,7 +25,7 @@
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.28.0",
"happy-dom": "^20.11.1",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
@@ -49,56 +49,19 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -145,23 +108,10 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
@@ -175,13 +125,13 @@
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
@@ -195,17 +145,17 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
@@ -219,21 +169,21 @@
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.3.0"
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
@@ -247,41 +197,16 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
@@ -295,7 +220,7 @@
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -887,33 +812,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2472,6 +2370,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2627,16 +2535,6 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2904,20 +2802,6 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2931,6 +2815,20 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2938,27 +2836,27 @@
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/data-urls/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/de-indent": {
@@ -3706,17 +3604,45 @@
"he": "bin/he"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
@@ -3928,36 +3854,35 @@
}
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -3968,14 +3893,27 @@
}
}
},
"node_modules/jsdom/node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/jsdom/node_modules/xml-name-validator": {
@@ -4345,13 +4283,20 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
"license": "ISC"
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"deprecated": "Package deprecated. Please use @lucide/vue instead.",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
@@ -4363,13 +4308,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4517,6 +4455,13 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nwsapi": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4622,26 +4567,26 @@
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -4698,13 +4643,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -5006,16 +4944,6 @@
"node": ">=8.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5138,6 +5066,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5162,6 +5097,13 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -5574,22 +5516,22 @@
}
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -5607,29 +5549,29 @@
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
@@ -5722,16 +5664,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -6734,13 +6666,27 @@
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
@@ -6754,18 +6700,17 @@
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
"node": ">=18"
}
},
"node_modules/which": {
+2 -2
View File
@@ -12,8 +12,8 @@
"test:watch": "vitest"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.1",
"dompurify": "^3.4.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.0",
"tuicss": "^2.1.2",
"vue": "^3.5.0",
@@ -29,7 +29,7 @@
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.28.0",
"happy-dom": "^20.11.1",
"jsdom": "^29.1.1",
"jsdom": "^26.1.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,34 @@
<!-- A főoldal három beágyazott blokkja bolt, YouTube, Spotify ugyanaz a
forma volt három külön osztálykészlettel megírva. Ez az egy komponens
váltja ki mindhármat: bal oldalt a címke, jobbra egy kivezető link, alatta
a tartalom. -->
<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>
+4 -33
View File
@@ -1,38 +1,9 @@
<template>
<span :class="badgeClass"><i :class="iconClass"></i> {{ platform }}</span>
<span class="badge">{{ platform }}</span>
</template>
<script setup lang="ts">
import { computed } from '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])
// A platform neve önmagában elmond mindent, amit egy ikon is elmondana, ezért a
// jelölő itt szöveg. Ikon ott marad, ahol nincs mellette szó — a build-mátrixban.
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>
+8 -13
View File
@@ -1,12 +1,10 @@
<template>
<div class="animate-pulse">
<div class="animate-pulse space-y-4">
<div v-for="n in count" :key="n" class="skeleton-card">
<div class="skeleton-card-inner">
<div class="skeleton-line w-1/4 mb-4"></div>
<div class="skeleton-line-lg w-3/4 mb-3"></div>
<div class="skeleton-line w-full mb-2"></div>
<div class="skeleton-line w-5/6"></div>
</div>
<div class="skeleton-line w-1/4"></div>
<div class="skeleton-line-lg w-3/4"></div>
<div class="skeleton-line w-full"></div>
<div class="skeleton-line w-5/6"></div>
</div>
</div>
</template>
@@ -17,15 +15,12 @@ withDefaults(defineProps<{ count?: number }>(), { count: 3 })
<style scoped>
.skeleton-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden mb-6;
}
.skeleton-card-inner {
@apply p-6 md:p-8;
@apply flex flex-col gap-3 rounded-card border border-line bg-surface p-5 md:p-6;
}
.skeleton-line {
@apply h-4 bg-gray-200 rounded;
@apply h-4 rounded bg-raised;
}
.skeleton-line-lg {
@apply h-6 bg-gray-200 rounded;
@apply h-6 rounded bg-raised;
}
</style>
@@ -0,0 +1,29 @@
<!-- Márkajel (Discord, Windows, Linux, ). A Lucide készletében nincsenek
brand-ikonok, ezért ez a tíz saját inline SVG. currentColor-ral rajzol,
tehát a témát külön beállítás nélkül követi.
Címke nélkül dekoratív (aria-hidden); ha az ikon önmagában áll szöveg
nélkül, adj neki `label`-t. -->
<template>
<svg
:width="size" :height="size" viewBox="0 0 24 24"
fill="currentColor" :aria-hidden="label ? undefined : 'true'"
:role="label ? 'img' : undefined" :aria-label="label || undefined"
class="inline-block shrink-0 align-[-0.125em]"
>
<path v-for="(d, i) in paths" :key="i" :d="d" />
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BRAND_PATHS } from './brandPaths'
const props = withDefaults(defineProps<{
name: string
size?: number | string
label?: string
}>(), { size: 16, label: undefined })
const paths = computed(() => BRAND_PATHS[props.name] ?? [])
</script>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
// Ikonok a faceliftben: a szöveg mellett álló, díszítő ikonok elmaradnak —
// ikon csak akkor kerül a felületre, ha nincs mellette szöveg, vagy ha maga az
// ikon az adat (build-mátrix pipa, platformjelölő).
//
// A készlet a Lucide (`lucide-vue-next`), névre szóló importtal a tree-shake
// miatt; a márkajelek a BrandIcon-ban vannak, mert a Lucide-ban nincsenek.
//
// import { Download } from 'lucide-vue-next'
// import { ICON } from '../components/icons'
// <Download v-bind="ICON" />
//
export const ICON = { size: 16, strokeWidth: 1.75 } as const
// Önállóan álló vezérlők (hamburger, bezárás, téma-kapcsoló).
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'
+16 -20
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retro Mode',
exitRetro: 'Exit Retro Mode',
rss: 'RSS',
stores: 'Stores',
builds: 'Builds',
theme: 'Switch theme',
menu: 'Menu',
},
footer: {
wiki: 'Wiki',
git: 'Git repositories',
releases: 'Releases',
license: 'All content is available under the',
tagline: 'Built in the open — we love open source, and the freedom to learn, fork and share.',
},
common: {
status: {
@@ -37,26 +48,6 @@ export default {
newRelease: 'New Release',
watchOnYoutube: 'Watch on YouTube',
note: 'Our HowTo-s and blog posts are written by AI with human supervision.',
catalog: {
title: 'Catalog',
desc: 'Here you can find our games. Discover our creations for TIC-80 and other platforms, many of which you can try directly in your browser.',
btn: 'Browse Games',
},
wiki: {
title: 'Wiki',
desc: 'We gather our public interest development knowledge here. Read through technical descriptions and development logs.',
btn: 'Knowledge Base',
},
git: {
title: 'Git Repos',
desc: 'All our projects are open source. Browse our code, study our solutions, or even participate in the development.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.',
btn: 'Watch on YouTube',
},
spotify: {
openInSpotify: 'Open in Spotify ↗',
},
@@ -111,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.',
@@ -139,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',
+16 -20
View File
@@ -11,6 +11,17 @@ export default {
retro: 'Retró mód',
exitRetro: 'Retró mód kikapcsolása',
rss: 'RSS',
stores: 'Boltok',
builds: 'Buildek',
theme: 'Téma váltása',
menu: 'Menü',
},
footer: {
wiki: 'Wiki',
git: 'Git tárolók',
releases: 'Kiadások',
license: 'A tartalom elérhető',
tagline: 'Nyíltan fejlesztünk — szeretjük a nyílt forráskódot, és a szabadságot, hogy tanulni, forkolni és megosztani lehessen.',
},
common: {
status: {
@@ -37,26 +48,6 @@ export default {
newRelease: 'Új megjelenés',
watchOnYoutube: 'Nézd meg YouTube-on',
note: 'A HowTo-k és blogbejegyzések mesterséges intelligenciával íródtak, emberi felügyelet mellett.',
catalog: {
title: 'Katalógus',
desc: 'Itt megtalálod a játékainkat. Fedezd fel alkotásainkat TIC-80-ra és más platformokra, amelyek közül sokat közvetlenül a böngésződben is kipróbálhatsz.',
btn: 'Játékok böngészése',
},
wiki: {
title: 'Wiki',
desc: 'Itt gyűjtjük össze a közérdekű fejlesztési tudásunkat. Olvasd át a technikai leírásokat és fejlesztési naplókat.',
btn: 'Tudásbázis',
},
git: {
title: 'Git tárolók',
desc: 'Minden projektünk nyílt forráskódú. Böngészd a kódunkat, tanulmányozd a megoldásainkat, vagy akár részt is vehetsz a fejlesztésben.',
btn: 'Gitea',
},
youtube: {
title: 'YouTube',
desc: 'Nézd meg fejlesztési vlogjainkat, gameplay videóinkat és tutorialainkat a hivatalos YouTube-csatornánkon.',
btn: 'Nézd meg YouTube-on',
},
spotify: {
openInSpotify: 'Megnyitás Spotify-ban ↗',
},
@@ -111,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.',
@@ -139,6 +132,9 @@ export default {
},
catalogShow: {
back: '← Vissza a katalógushoz',
close: 'Bezárás',
prevImage: 'Előző kép',
nextImage: 'Következő kép',
projectInfo: 'Projekt infó',
author: 'Szerző',
platform: 'Platform',
+153 -93
View File
@@ -1,76 +1,84 @@
<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>
<!-- Ikonok nélkül: a nyolc menüpont szövege megmondja, hova visz.
Az aktív pontot aláhúzás jelöli, nem külön szín. -->
<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 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 +87,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 +122,107 @@ 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;
}
/* ---- Fejléc ---- */
.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;
}
/* ---- Mobil menü ---- */
.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;
/* ---- Lábléc ---- */
.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;
}
/* A retro-kapcsoló stílusa itt él, nem a retro.css-ben: az a fájl már csak
akkor töltődik be, amikor valaki bekapcsolja a retro módot. */
.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,73 @@
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'
/**
* A váz az egyetlen darab, ami minden oldalon ott van, és a facelift éppen ezt
* írta át: ikonok nélküli navigáció, valódi lábléc, téma-kapcsoló. Ezek olyan
* dolgok, amiket kézzel könnyű elrontani és nehéz észrevenni — a nyelvváltó
* mellől eltűnő kapcsoló nem dob hibát, csak nincs ott.
*/
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('light')
// Az első ikongomb a téma-kapcsoló, a második a hamburger.
await wrapper.findAll('.icon-btn')[0].trigger('click')
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
expect(document.cookie).toContain('theme=dark')
})
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,4 +1,8 @@
/**
* jsdom, és nem a projekt alapértelmezett happy-dom-ja: a DOMPurify happy-dom
* alatt nem ismeri fel a környezetet, és változatlanul visszaadja a bemenetet —
* a teszt így zöld lenne úgy, hogy közben semmit nem tisztít.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest'
+15
View File
@@ -29,3 +29,18 @@ export function filterDevReleases(releases: Release[]): Release[] {
export function getLatestStable(releases: Release[]): Release | undefined {
return filterStableReleases(releases)[0]
}
// A négy státusz közül csak a megjelent kap kiemelést, a többi semleges jelölő.
// Az osztálynevek szándékosan teljes szövegként állnak itt: ha a sablon
// `status-${status}`-t interpolálna, a Tailwind nem látná őket, és kiszedné a
// bundle-ből.
const STATUS_BADGE_CLASS: Record<string, string> = {
released: 'status-badge status-released',
demo: 'status-badge',
development: 'status-badge',
archived: 'status-badge',
}
export function statusBadgeClass(status: string): string {
return STATUS_BADGE_CLASS[status] ?? 'status-badge'
}
+4 -3
View File
@@ -3,9 +3,10 @@ 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'
// A retro mód stílusai (tuicss + retro.css) az ui.store-ból töltődnek be,
// akkor és csak akkor, ha valaki bekapcsolja.
createApp(App).use(createPinia()).use(router).use(i18n).mount('#app')
+31 -100
View File
@@ -1,70 +1,41 @@
<template>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<header class="hero-section">
<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>
<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;
/* Olvasnivaló, nem kártyafal: egy hasáb, vonallal elválasztott bejegyzésekkel. */
.blog-list {
@apply flex max-w-[68ch] flex-col;
}
.blog-header-decor {
@apply absolute inset-0 opacity-30;
}
.blog-main {
@apply max-w-5xl mx-auto px-4 md:px-8 -mt-12 relative z-10;
}
.banner-base {
@apply max-w-7xl mx-auto border-l-4 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner { @apply banner-base bg-red-50 border-red-500; }
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
.empty-state {
@apply max-w-7xl mx-auto bg-white rounded-2xl shadow-xl p-12 text-center border border-gray-100;
}
.empty-state-icon { @apply text-6xl mb-4; }
.empty-state-title { @apply text-2xl font-bold text-gray-900 mb-2; }
.empty-state-desc { @apply text-gray-500 max-w-md mx-auto; }
.blog-posts-list {
@apply flex flex-col gap-12;
}
.blog-post-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden;
}
.blog-post-content {
@apply p-6 md:p-10;
.blog-post {
@apply flex flex-col items-start gap-2 border-b border-line py-8 first:pt-0 last:border-b-0;
}
.blog-post-meta {
@apply flex items-center justify-between mb-4;
}
.blog-post-timestamp {
@apply flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400;
}
.new-post-badge {
@apply flex items-center gap-1 text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-100;
}
.new-post-dot {
@apply w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse;
@apply flex flex-wrap items-center gap-3 text-xs text-fg-subtle;
}
.blog-post-title {
@apply text-2xl md:text-3xl font-black text-gray-900 mb-4 leading-tight;
@apply text-xl font-semibold leading-snug tracking-tight text-fg md:text-2xl;
}
.blog-post-preview {
@apply relative overflow-hidden;
max-height: 8rem;
}
.blog-post-preview::after {
content: "";
@apply absolute bottom-0 left-0 w-full h-16 bg-gradient-to-b from-transparent to-white pointer-events-none;
.blog-post-title a {
@apply transition-colors hover:text-accent;
}
.blog-post-description {
@apply text-lg text-gray-800 font-bold mb-2 leading-relaxed;
@apply text-base leading-relaxed text-fg;
}
.blog-post-body {
@apply text-base text-gray-500 font-medium leading-relaxed;
}
.read-more-btn {
@apply inline-flex items-center text-indigo-600 font-bold hover:text-indigo-800 transition-colors;
@apply line-clamp-3 text-sm leading-relaxed text-fg-muted;
}
</style>
+16 -56
View File
@@ -1,35 +1,25 @@
<template>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="post-hero-container">
<RouterLink to="/blog" class="back-link"><i class="fa-solid fa-arrow-left mr-1"></i> Back to Blog</RouterLink>
<header class="hero-section">
<div class="hero-container">
<RouterLink to="/blog" class="link text-sm">{{ t('blog.back') }}</RouterLink>
<template v-if="pageContent">
<h1 class="hero-title mt-4">{{ pageContent.title }}</h1>
<p class="hero-subtitle mb-4">{{ pageContent.description }}</p>
<div class="blog-post-timestamp text-indigo-200"><i class="fa-solid fa-calendar mr-1"></i>{{ formatDateTime(pageContent.createdAt) }}</div>
<p class="hero-subtitle">{{ pageContent.description }}</p>
<p class="post-timestamp mono">{{ formatDateTime(pageContent.createdAt) }}</p>
</template>
</div>
</header>
<main class="blog-main">
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<i class="fa-solid fa-triangle-exclamation text-2xl text-yellow-500"></i>
<div>
<h3 class="error-banner-title">Error</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
<h2 class="error-banner-title">{{ t('common.error') }}</h2>
<p class="error-banner-desc">{{ error }}</p>
</div>
<article v-else-if="pageContent" class="blog-post-card">
<div class="blog-post-content">
<div v-if="pageContent.render" class="wiki-content max-w-none text-gray-700 leading-relaxed" v-html="sanitize(pageContent.render)" />
<div v-else class="blog-post-fallback">No content available for this post.</div>
</div>
<article v-else-if="pageContent">
<div v-if="pageContent.render" class="wiki-content" v-html="sanitize(pageContent.render)" />
<p v-else class="text-fg-muted">{{ t('blog.noContent') }}</p>
</article>
<SkeletonCard v-else-if="loading" :count="1" />
@@ -41,11 +31,13 @@
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '../../lib/dateFormat'
import { sanitize } from '../../lib/sanitize'
import { useBlogStore } from '../../stores/blog.store'
import SkeletonCard from '../../components/SkeletonCard.vue'
const { t } = useI18n()
const route = useRoute()
const store = useBlogStore()
const { currentPage: pageContent, pageLoading: loading, pageError: error } = storeToRefs(store)
@@ -55,41 +47,9 @@ onMounted(() => store.fetchPage(route.params.slug as string))
<style scoped>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
@apply min-h-screen bg-page;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
.post-timestamp {
@apply mt-4 text-xs text-fg-subtle;
}
.blog-header-decor {
@apply absolute inset-0 opacity-30;
}
.post-hero-container {
@apply relative z-10 max-w-4xl mx-auto px-4;
}
.back-link {
@apply inline-block text-indigo-300 hover:text-white transition-colors font-medium;
}
.hero-title {
@apply text-4xl md:text-5xl font-black mb-4 leading-tight;
}
.hero-subtitle {
@apply text-xl text-indigo-100 font-medium;
}
.blog-post-timestamp {
@apply text-sm font-semibold uppercase tracking-wider;
}
.blog-main {
@apply max-w-4xl mx-auto px-4 md:px-8 -mt-12 relative z-10;
}
.blog-post-card {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 overflow-hidden;
}
.blog-post-content {
@apply p-6 md:p-12;
}
.error-banner {
@apply max-w-7xl mx-auto bg-red-50 border-l-4 border-red-500 p-6 rounded-r-xl shadow-lg mb-12 flex items-start gap-4;
}
.error-banner-title { @apply text-red-800 font-bold text-lg; }
.error-banner-desc { @apply text-red-700 mt-1; }
</style>
+80 -181
View File
@@ -1,32 +1,40 @@
<template>
<header class="hero-section-gradient from-slate-700 to-gray-800 py-16">
<header class="hero-section">
<div class="hero-container">
<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 -->
<!-- Desktop table a pipa és az ikssz maga az adat, ezért marad ikon.
A csoportokat vonal választja el, nem hat különböző háttérszín. -->
<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>
@@ -34,14 +42,14 @@
</thead>
<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 }}
</td>
<td class="builds-td-engine">{{ info.label }}</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>
@@ -53,18 +61,13 @@
<!-- 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">{{ 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>
@@ -73,9 +76,7 @@
</div>
<div class="builds-back">
<RouterLink to="/catalog" class="builds-back-link">
{{ t('catalogShow.back') }}
</RouterLink>
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
@@ -85,6 +86,7 @@ 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'
@@ -93,22 +95,9 @@ const { t } = useI18n()
const store = useBuildsStore()
const { data, error } = storeToRefs(store)
const engineIcons: Record<string, string> = {
tic80: 'fa-solid fa-tv',
ebitengine: 'fa-brands fa-golang',
love: 'fa-solid fa-heart',
godot: 'fa-solid fa-robot',
c64: 'fa-solid fa-floppy-disk',
bevy: 'fa-solid fa-cubes',
phaser: 'fa-solid fa-bolt',
}
const defaultEngineIcon = 'fa-solid fa-cube'
const columnGroups = [
{
label: 'Windows',
icon: 'fa-brands fa-windows',
color: 'group-blue',
kinds: [
{ kind: 'win_x64', sub: '64 bit' },
{ kind: 'win_x86', sub: '32 bit' },
@@ -116,8 +105,6 @@ const columnGroups = [
},
{
label: 'Linux',
icon: 'fa-brands fa-linux',
color: 'group-amber',
kinds: [
{ kind: 'linux_x64', sub: '64 bit' },
{ kind: 'linux_arm64', sub: 'ARM 64 bit' },
@@ -125,8 +112,6 @@ const columnGroups = [
},
{
label: 'Mac',
icon: 'fa-brands fa-apple',
color: 'group-slate',
kinds: [
{ kind: 'mac_x64', sub: 'Intel' },
{ kind: 'mac_arm64', sub: 'ARM' },
@@ -135,24 +120,18 @@ const columnGroups = [
},
{
label: 'Web',
icon: 'fa-solid fa-globe',
color: 'group-green',
kinds: [
{ kind: 'html', sub: 'HTML5' },
],
},
{
label: 'Cartridge',
icon: 'fa-solid fa-gamepad',
color: 'group-purple',
kinds: [
{ kind: 'cartridge', sub: 'Native' },
],
},
{
label: 'Others',
icon: 'fa-solid fa-folder-open',
color: 'group-gray',
kinds: [
{ kind: 'source', sub: 'Source' },
{ kind: 'docs', sub: 'Docs' },
@@ -164,150 +143,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];
}
/* A csoportokat egyetlen vonal választja el — a hat háttérszín helyett. */
.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-td-kind {
@apply px-3 py-3 text-center;
}
.builds-check {
@apply mx-auto text-accent;
}
/* A hiányzó build nem hiba, ezért nem piros. */
.builds-xmark {
@apply mx-auto text-fg-subtle opacity-50;
}
/* Mobile cards */
/* ---- Mobil ---- */
.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;
@apply flex items-center gap-1.5 text-sm text-fg-muted;
}
.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 mt-8;
}
</style>
@@ -1,23 +1,27 @@
<template>
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
<header class="hero-section">
<div class="hero-container">
<h1 class="hero-title">{{ t('catalog.title') }}</h1>
<!-- Playing a game needs the store, so the way to it belongs where somebody
arrives rather than under the list they have to scroll past first. Same
shape as the Code and HowTo heroes: subtitle, breathing room, one button. -->
<p class="hero-subtitle text-purple-50 mb-10">{{ t('catalog.subtitle') }}</p>
<RouterLink to="/stores" class="btn-hero-purple">
<i class="fa-solid fa-download mr-2"></i>{{ t('catalog.getTheStore') }}
</RouterLink>
arrives rather than under the list they have to scroll past first. -->
<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">
<!-- Szegmentált szűrő: egy sáv, egy kiemelt állapottal nem hat különböző
színű pirula. -->
<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}`) }}
@@ -26,55 +30,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>
<!-- Stores moved up into the hero; the build matrix stays down here, which is
where somebody who has already read the catalog goes looking for it. -->
<div class="builds-link-row">
<RouterLink to="/builds" class="builds-link">
<i class="fa-solid fa-table-cells"></i> {{ t('builds.title') }}
</RouterLink>
</div>
</main>
</template>
@@ -84,7 +83,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'
@@ -115,64 +114,53 @@ 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;
/* A borítók egy része fehér hátterű; a semleges alap és a keret miatt sötét
témában sem ütnek lyukat az oldalba. */
.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;
@apply mt-5 flex flex-wrap items-center gap-2 pt-1;
}
</style>
+198 -327
View File
@@ -1,250 +1,143 @@
<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>
<!-- Oldalsáv: kép és tények -->
<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 -->
<!-- oszlop -->
<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>
<!-- Egy lista minden kiadásnak; a fejlesztői verziók jelölve. Korábban
ugyanez két külön táblázatban élt, asztali és mobil változatban. -->
<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="text-fg-muted">{{ error || t('catalogShow.loading') }}</p>
</main>
<ImageLightbox
@@ -262,10 +155,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()
@@ -325,15 +220,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 +237,90 @@ 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;
}
/* A borítók egy része fehér hátterű, ezért semleges alap és keret kerül alájuk. */
.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;
}
/* 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; }
/* ---- Kiadások ---- */
.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;
}
/* 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; }
/* 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; }
/* ---- Letöltések ---- */
.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>
+62 -67
View File
@@ -1,60 +1,62 @@
<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">
<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>
<!-- Kártyafal helyett lista: a tárolók neve és leírása sorokban olvasható. -->
<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 +68,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 +79,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>
+109 -193
View File
@@ -1,227 +1,143 @@
<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">
<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">
<!-- E-mail -->
<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>
<!-- Közösség -->
<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">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">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>
<!-- Közösségi média itt a márkajel az, ami felismerhetővé tesz, de a
márkaszínek nem: azok egyik témában sem viselkednek jól. -->
<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>
<!-- Spotify -->
<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 { BrandIcon } 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>
<header class="hero-section">
<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>
<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>
@@ -104,83 +88,51 @@ 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. */
/* Kevés motor van, ezért mindegyik teljes szélességű kártyát kap. A sorszám az
oldal karaktere — csak halványabban, hogy ne vigye el a szöveg elől a helyet. */
.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>
+201 -481
View File
@@ -1,272 +1,158 @@
<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>
<header class="hero-section">
<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>
<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">
<!-- Visszaszámláló vékony sáv, nem féloldalnyi kártya: egy dátum és egy
szám annyi információ, amennyi elfér egy sorban. -->
<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" />
<!-- Kiemelt játék -->
<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>
<!-- The store. Placed under the featured game on purpose: somebody who has just
read about a title is one step from wanting to run it, and this is that step.
Same dark band as the two media sections below, so the page keeps one rhythm. -->
<section class="mb-16">
<div class="store-wrapper group">
<div class="store-glow"></div>
<!-- A bolt a kiemelt játék alatt: aki épp olvasott egy címről, egy lépésre
van attól, hogy futtatni akarja, és ez az a lépés. -->
<MediaBand :label="t('home.store.label')">
<template #action>
<RouterLink to="/stores" class="link text-sm">{{ t('home.store.allStores') }}</RouterLink>
</template>
<div class="store-header">
<div class="store-header-inner">
<div class="store-status-blob">
<div class="store-status-dot"></div>
</div>
<span class="store-label">{{ t('home.store.label') }}</span>
<div class="store-grid">
<figure class="store-shot">
<img
:src="clientScreenshot" :alt="t('stores.clientShotAlt')"
width="1536" height="1110" loading="lazy"
>
</figure>
<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>
<RouterLink to="/stores" class="store-all-link">{{ t('home.store.allStores') }}</RouterLink>
</div>
<div class="store-grid">
<figure class="store-shot">
<img
:src="clientScreenshot" :alt="t('stores.clientShotAlt')"
width="2304" height="1664" loading="lazy"
>
</figure>
<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><i class="fa-solid fa-check"></i> {{ t('home.store.point1') }}</li>
<li><i class="fa-solid fa-check"></i> {{ t('home.store.point2') }}</li>
<li><i class="fa-solid fa-check"></i> {{ t('home.store.point3') }}</li>
</ul>
<div class="store-actions">
<a :href="CLIENT_RELEASES_URL" target="_blank" rel="noopener noreferrer" class="store-btn-primary">
<i class="fa-solid fa-download"></i> {{ t('home.store.download') }}
</a>
<RouterLink to="/stores" class="store-btn-secondary">
<i class="fa-solid fa-circle-info"></i> {{ t('home.store.more') }}
</RouterLink>
</div>
<p class="store-platforms">
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
<i class="fa-brands fa-windows"></i> {{ t('home.store.platforms') }}
</p>
</div>
<p class="store-platforms">
<BrandIcon name="linux" /><BrandIcon name="apple" /><BrandIcon name="windows" />
{{ t('home.store.platforms') }}
</p>
</div>
</div>
</section>
</MediaBand>
<!-- Latest YouTube Video -->
<section class="mb-16">
<div class="yt-featured-wrapper group">
<div class="yt-glow"></div>
<!-- Legutóbbi YouTube-videó -->
<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="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="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>
<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>
</div>
</div>
</section>
<!-- Spotify Playlist -->
<section class="mb-16">
<div class="spotify-wrapper group">
<div class="spotify-glow"></div>
<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') }}
<!-- Spotify-lejátszási lista -->
<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>
@@ -279,12 +165,15 @@ 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 clientScreenshot from '../../assets/warpengine-client.png'
import { getDefaultImageUrl, statusBadgeClass } from '../../lib/softwareUtils'
import MediaBand from '../../components/MediaBand.vue'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
// The releases page, not a versioned file: whoever follows it should get whatever is
// newest, and this page would otherwise need a release every time the client has one.
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()
@@ -314,286 +203,117 @@ 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];
/* ---- Visszaszámláló ---- */
.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;
/* ---- Kiemelt játék ---- */
.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;
}
.yt-glow {
@apply absolute -top-24 -right-24 w-96 h-96 bg-indigo-500/10 rounded-full blur-[100px] pointer-events-none;
}
.yt-header-area {
@apply flex items-center justify-between mb-6 relative z-10;
}
.yt-status-blob {
@apply w-3 h-3 bg-red-500/20 rounded-full flex items-center justify-center;
}
.yt-status-dot {
@apply w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse;
}
.yt-label-text {
@apply text-xs font-bold uppercase tracking-widest text-slate-400;
}
.yt-channel-link {
@apply text-xs font-semibold text-indigo-400 hover:text-indigo-300 transition-colors;
}
.yt-content-grid {
@apply flex flex-col lg:flex-row gap-8 relative z-10;
}
.yt-player-container {
@apply flex-[1.6] overflow-hidden rounded-2xl border border-white/5 shadow-inner bg-black;
}
.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-iframe {
@apply w-full h-full border-none block;
}
.yt-header-inner {
@apply flex items-center gap-3;
}
.yt-info-sidebar {
@apply flex-1 flex flex-col justify-center;
}
.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-video-title {
@apply text-2xl font-black text-white mb-4 leading-tight group-hover:text-indigo-200 transition-colors;
}
.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;
}
/* 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;
}
/* Store Promo */
.store-wrapper {
@apply relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-900 to-violet-950 rounded-3xl shadow-2xl border border-slate-800 p-6 md:p-8;
}
.store-glow {
@apply absolute -bottom-24 -right-24 w-96 h-96 bg-violet-500/10 rounded-full blur-[100px] pointer-events-none;
}
.store-header {
@apply flex items-center justify-between mb-6 relative z-10;
}
.store-header-inner {
@apply flex items-center gap-3;
}
.store-status-blob {
@apply w-3 h-3 bg-violet-500/20 rounded-full flex items-center justify-center;
}
.store-status-dot {
@apply w-1.5 h-1.5 bg-violet-400 rounded-full animate-pulse;
}
.store-label {
@apply text-xs font-bold uppercase tracking-widest text-slate-400;
}
.store-all-link {
@apply text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors;
}
/* ---- Bolt ---- */
.store-grid {
@apply flex flex-col lg:flex-row gap-8 relative z-10;
@apply grid grid-cols-1 gap-6 md:grid-cols-2 md:items-center;
}
/* The screenshot leads on a wide screen and follows on a narrow one: it is what makes
the section legible at a glance, but it is not what somebody reads first on a phone. */
.store-shot {
@apply flex-[1.4] order-last lg:order-first overflow-hidden rounded-2xl border border-white/5 shadow-inner bg-black;
@apply m-0 overflow-hidden rounded-card border border-line;
}
.store-shot img {
@apply w-full h-auto block;
}
.store-info {
@apply flex-1 flex flex-col justify-center;
@apply block h-auto w-full;
}
.store-title {
@apply text-2xl font-black text-white mb-3 leading-tight group-hover:text-violet-200 transition-colors;
@apply text-xl font-semibold tracking-tight text-fg;
}
.store-desc {
@apply text-slate-300 leading-relaxed mb-5;
@apply mt-2 leading-relaxed text-fg-muted;
}
.store-points {
@apply space-y-2 mb-6 text-sm text-slate-400;
}
.store-points li {
@apply flex items-start gap-2;
}
.store-points i {
@apply text-violet-400 mt-1 text-xs;
@apply mt-4 flex list-disc flex-col gap-1.5 pl-5 text-sm text-fg-muted;
}
.store-actions {
@apply flex flex-wrap gap-3;
}
.store-btn-primary {
@apply inline-flex items-center gap-2 px-6 py-3 bg-white text-slate-900 font-black rounded-xl transition-all hover:scale-[1.02] hover:bg-violet-50 active:scale-95 shadow-xl;
}
.store-btn-secondary {
@apply inline-flex items-center gap-2 px-6 py-3 bg-violet-500/10 text-white border border-violet-400/30 font-bold rounded-xl transition-all hover:bg-violet-500/20;
@apply mt-5 flex flex-wrap gap-3;
}
.store-platforms {
@apply flex items-center gap-2 mt-5 text-xs text-slate-500;
@apply mt-4 flex items-center gap-2 text-xs text-fg-subtle;
}
/* Nav Grid */
.nav-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6;
/* ---- YouTube ---- */
.yt-grid {
@apply grid grid-cols-1 gap-6 lg:grid-cols-[1.6fr_1fr];
}
.yt-player {
@apply relative aspect-video overflow-hidden rounded-card border border-line bg-raised;
}
.yt-iframe {
@apply absolute inset-0 h-full w-full;
}
.yt-state {
@apply absolute inset-0 flex items-center justify-center p-4 text-center text-sm text-fg-muted;
}
.yt-spinner {
@apply h-8 w-8 animate-spin rounded-full border-2 border-line border-t-accent;
}
.yt-info {
@apply flex flex-col items-start gap-3;
}
.yt-title {
@apply text-lg font-semibold leading-snug tracking-tight text-fg;
}
.yt-stats {
@apply flex flex-wrap gap-4 text-xs text-fg-subtle;
}
/* 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 ----
A beágyazás a saját sötét felületét hozza; keretbe tesszük, hogy világos
témában is szándékosnak látsszon, ne foltnak. */
.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>
<header class="hero-section">
<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>
<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">
<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,30 @@ 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;
@apply mt-auto truncate border-t border-line pt-3 font-mono text-xs text-fg-subtle;
}
.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-10;
}
</style>
@@ -1,8 +1,9 @@
<template>
<div class="cb">
<pre><code>{{ command }}</code></pre>
<button class="cb-copy" :title="t('stores.copy')" @click="copy">
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
<button class="cb-copy" :aria-label="t('stores.copy')" :title="t('stores.copy')" @click="copy">
<Check v-if="copied" v-bind="ICON" />
<Copy v-else v-bind="ICON" />
</button>
</div>
</template>
@@ -12,6 +13,8 @@
// block and its copy button live in one place rather than being repeated.
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Copy } from 'lucide-vue-next'
import { ICON } from '../../components/icons'
const { command } = defineProps<{ command: string }>()
const { t } = useI18n()
@@ -30,7 +33,7 @@ async function copy() {
<style scoped>
.cb {
@apply relative block bg-gray-900 text-gray-100 rounded-xl overflow-x-auto;
@apply relative block overflow-x-auto rounded-card border border-line bg-raised text-fg;
}
/* Explicitly margin-free: a `pre` carries a browser margin of its own, and the block is
spaced by whatever contains it — otherwise what follows ends up against it. */
@@ -38,6 +41,8 @@ async function copy() {
@apply m-0 p-4 pr-14 font-mono text-sm leading-relaxed;
}
.cb-copy {
@apply absolute top-2 right-2 w-9 h-9 rounded-lg bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors;
@apply absolute right-2 top-2 rounded-control border border-line bg-surface p-2 text-fg-muted
transition-colors hover:text-fg
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
</style>
@@ -1,12 +1,12 @@
<template>
<header class="hero-section-gradient from-emerald-700 to-teal-800 py-10">
<header class="hero-section">
<div class="hero-container">
<h1 class="hero-title">{{ t('stores.title') }}</h1>
<p class="hero-subtitle text-emerald-50">{{ t('stores.subtitle') }}</p>
<p class="hero-subtitle">{{ t('stores.subtitle') }}</p>
</div>
</header>
<main class="main-container py-8 px-4 mt-0">
<main class="main-container">
<!-- Two ways in, and almost everybody wants the first one. A thin switcher rather
than a page of prose: whoever needs the detail follows a link to the wiki. -->
<nav class="st-views" role="tablist" :aria-label="t('stores.title')">
@@ -14,44 +14,41 @@
class="st-view" :class="{ 'st-view-active': view === 'client' }"
role="tab" :aria-selected="view === 'client'" @click="selectView('client')"
>
<i class="fa-solid fa-window-maximize"></i> {{ t('stores.viewClient') }}
{{ t('stores.viewClient') }}
</button>
<button
class="st-view" :class="{ 'st-view-active': view === 'others' }"
role="tab" :aria-selected="view === 'others'" @click="selectView('others')"
>
<i class="fa-solid fa-terminal"></i> {{ t('stores.viewOthers') }}
{{ t('stores.viewOthers') }}
</button>
</nav>
<section v-if="view === 'client'" class="st-section st-app">
<section v-if="view === 'client'" class="st-section">
<div class="st-app-grid">
<div>
<p class="st-app-lead">{{ t('stores.clientDesc') }}</p>
<div class="st-links">
<a
:href="client.releasesUrl" target="_blank" rel="noopener noreferrer"
class="st-link st-link-cta"
>
<i class="fa-solid fa-download"></i> {{ t('stores.clientDownload') }}
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer" class="btn-accent">
{{ t('stores.clientDownload') }}
</a>
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.repo') }}
</a>
</div>
<p class="st-app-platforms">
<i class="fa-brands fa-linux"></i> <i class="fa-brands fa-apple"></i>
<i class="fa-brands fa-windows"></i> {{ t('stores.clientPlatforms') }}
<BrandIcon name="linux" /><BrandIcon name="apple" /><BrandIcon name="windows" />
{{ t('stores.clientPlatforms') }}
</p>
</div>
<!-- Eager, with its dimensions given: it sits in the first screen, so lazy
loading it would only buy a reflow. -->
<figure class="st-app-shot">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="2304" height="1664">
<img :src="clientScreenshot" :alt="t('stores.clientShotAlt')" width="1536" height="1110">
</figure>
</div>
</section>
@@ -67,7 +64,6 @@
:aria-selected="d.id === device"
@click="select(d.id)"
>
<i :class="d.icon" class="st-device-icon"></i>
<span class="st-device-name">{{ t(`stores.${d.id}.name`) }}</span>
<span class="st-device-tagline">{{ t(`stores.${d.id}.tagline`) }}</span>
</button>
@@ -92,14 +88,14 @@
</div>
<div class="st-links st-links-after">
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
<a :href="current.wikiUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.docs') }}
</a>
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
<a :href="current.repoUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t('stores.repo') }}
</a>
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-emerald">
<i :class="current.icon"></i> {{ t(`stores.${device}.site`) }}
<a :href="current.projectUrl" target="_blank" rel="noopener noreferrer" class="btn-ghost">
{{ t(`stores.${device}.site`) }}
</a>
</div>
</section>
@@ -111,15 +107,15 @@
<h2 class="st-section-title">{{ t('stores.platformsTitle') }}</h2>
<ul class="st-platforms">
<li v-for="p in platforms" :key="p.platform" class="st-platform">
<i :class="p.icon" class="st-platform-icon"></i>
<span class="st-platform-name">{{ p.label }}</span>
<code class="st-platform-ext">{{ p.ext }}</code>
<code class="st-platform-ext mono">{{ p.ext }}</code>
</li>
</ul>
</section>
</template>
<div class="st-back">
<RouterLink to="/catalog" class="st-back-link">{{ t('catalogShow.back') }}</RouterLink>
<RouterLink to="/catalog" class="link text-sm">{{ t('catalogShow.back') }}</RouterLink>
</div>
</main>
</template>
@@ -130,7 +126,8 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CONFIG } from '../../lib/config'
import CommandBlock from './CommandBlock.vue'
import clientScreenshot from '../../assets/warpengine-client.png'
import { BrandIcon } from '../../components/icons'
import clientScreenshot from '../../assets/warpengine-client.webp'
const { t } = useI18n()
const route = useRoute()
@@ -158,7 +155,6 @@ const RETROARCH_CLI = '~/.local/bin/ttg-retroarch-store'
const devices = [
{
id: 'batocera' as DeviceId,
icon: 'fa-solid fa-tv',
projectUrl: 'https://batocera.org',
repoUrl: `${FORGE}/ttg-batocera-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-batocera-store`,
@@ -175,7 +171,6 @@ const devices = [
},
{
id: 'retroarch' as DeviceId,
icon: 'fa-solid fa-gamepad',
projectUrl: 'https://www.retroarch.com',
repoUrl: `${FORGE}/ttg-retroarch-store`,
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-retroarch-store`,
@@ -224,8 +219,8 @@ function selectView(id: ViewId) {
}
const platforms = [
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg', icon: 'fa-solid fa-floppy-disk' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic', icon: 'fa-solid fa-tv' },
{ platform: 'c64', label: 'Commodore 64 (VICE)', ext: '.prg' },
{ platform: 'tic80', label: 'TIC-80', ext: '.tic' },
]
</script>
@@ -233,51 +228,43 @@ const platforms = [
.st-links {
@apply flex flex-wrap gap-3;
}
.st-link {
@apply inline-flex items-center gap-2 font-bold py-2.5 px-5 rounded-xl text-sm text-white transition-all active:scale-95;
}
.st-link-dark { @apply bg-slate-800 hover:bg-slate-900 shadow-lg shadow-slate-800/20; }
.st-link-indigo { @apply bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-600/20; }
.st-link-emerald { @apply bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-600/20; }
.st-section {
@apply bg-white rounded-2xl shadow-xl border border-gray-100 p-6 md:p-8 mt-6;
@apply mt-6 rounded-card border border-line bg-surface p-5 md:p-8;
}
.st-section-title {
@apply flex items-center gap-3 text-xl font-bold text-gray-900 mb-2;
@apply mb-3 text-lg font-semibold tracking-tight text-fg;
}
.st-devices {
@apply grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6;
@apply mb-6 grid grid-cols-1 gap-3 sm:grid-cols-3;
}
.st-device {
@apply flex flex-col items-start gap-1 text-left bg-gray-50 border-2 border-gray-100 rounded-xl px-4 py-3 transition-all hover:border-emerald-200 active:scale-95;
@apply flex flex-col items-start gap-0.5 rounded-card border border-line bg-surface px-4 py-3 text-left transition-colors hover:border-line-strong
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.st-device-active {
@apply bg-emerald-50 border-emerald-500;
}
.st-device-icon {
@apply text-gray-400;
}
.st-device-active .st-device-icon {
@apply text-emerald-600;
@apply border-accent bg-accent-quiet;
}
.st-device-name {
@apply font-bold text-gray-900;
@apply font-medium text-fg;
}
.st-device-tagline {
@apply text-sm text-gray-500;
@apply text-sm text-fg-muted;
}
.st-device-active .st-device-tagline {
@apply text-accent;
}
/* The switcher is meant to be almost nothing: two words and a line under the active one. */
.st-views {
@apply flex gap-1 border-b border-gray-200 mb-6;
@apply mb-6 flex gap-1 border-b border-line;
}
.st-view {
@apply flex items-center gap-2 px-4 py-2 -mb-px text-sm font-semibold text-gray-500 border-b-2 border-transparent transition-colors hover:text-gray-800;
@apply -mb-px border-b-2 border-transparent px-4 py-2 text-sm font-medium text-fg-muted transition-colors hover:text-fg
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.st-view-active {
@apply text-emerald-700 border-emerald-500;
@apply border-accent text-fg;
}
/* One rhythm for the whole command list, so nothing depends on a margin someone
remembered to add. */
@@ -285,59 +272,39 @@ const platforms = [
@apply space-y-2;
}
.st-cmd-label {
@apply text-xs font-semibold uppercase tracking-wide text-gray-400 pt-3;
}
.st-cmd-label:first-child {
@apply pt-0;
@apply pt-3 font-mono text-xs uppercase tracking-[0.1em] text-fg-subtle first:pt-0;
}
.st-links-after {
@apply mt-6;
}
.st-engine-link {
@apply text-indigo-600 hover:underline font-mono text-xs;
}
/* The app leads the page, so it is the one section that looks like an offer rather
than a paragraph. */
.st-app {
@apply bg-gradient-to-br from-emerald-50 to-teal-50 border-emerald-200;
}
.st-app-lead {
@apply text-gray-700 text-lg leading-relaxed mb-4;
@apply mb-4 text-base leading-relaxed text-fg-muted;
}
.st-app-platforms {
@apply flex items-center gap-2 text-sm text-gray-500 mt-4;
@apply mt-4 flex items-center gap-2 text-sm text-fg-subtle;
}
.st-app-grid {
@apply grid gap-6 items-start lg:grid-cols-2;
@apply grid items-start gap-6 lg:grid-cols-2;
}
.st-app-shot {
@apply rounded-xl overflow-hidden border border-emerald-200/70 shadow-lg bg-gray-900;
@apply m-0 overflow-hidden rounded-card border border-line;
}
.st-app-shot img {
@apply block w-full h-auto;
}
.st-link-cta {
@apply bg-emerald-600 text-white hover:bg-emerald-700 text-base px-5 py-2.5 font-semibold;
@apply block h-auto w-full;
}
.st-platforms {
@apply grid grid-cols-1 sm:grid-cols-2 gap-3;
@apply grid grid-cols-1 gap-3 sm:grid-cols-2;
}
.st-platform {
@apply flex items-center gap-3 bg-gray-50 border border-gray-100 rounded-xl px-4 py-3;
}
.st-platform-icon {
@apply text-gray-400;
@apply flex items-center gap-3 rounded-card border border-line bg-raised px-4 py-3;
}
.st-platform-name {
@apply font-medium text-gray-800 flex-grow;
@apply flex-grow text-sm text-fg;
}
.st-platform-ext {
@apply font-mono text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded;
@apply rounded border border-line bg-surface px-2 py-0.5 text-xs text-accent;
}
.st-back {
@apply mt-8 text-center;
}
.st-back-link {
@apply text-gray-400 hover:text-gray-600 text-sm font-medium transition-colors;
@apply mt-8;
}
</style>
+18 -28
View File
@@ -1,24 +1,20 @@
<template>
<header class="hero-section-gradient from-purple-600 to-blue-700 py-20 text-white">
<div class="hero-container text-center">
<h1 class="hero-title mb-6">{{ t('team.title') }}</h1>
<p class="hero-subtitle mb-10 text-purple-100">{{ t('team.subtitle') }}</p>
<header class="hero-section">
<div class="hero-container">
<h1 class="hero-title">{{ t('team.title') }}</h1>
<p class="hero-subtitle">{{ t('team.subtitle') }}</p>
</div>
</header>
<main class="main-container py-16 px-4">
<main class="main-container">
<SkeletonCard v-if="loading" :count="4" />
<div v-else class="team-grid">
<div v-for="member in members" :key="member.nick" class="team-card">
<div class="team-card-image-container">
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-card-image" />
</div>
<div class="team-card-content">
<h2 class="team-card-title">{{ member.nick }}: {{ member.realNick }}</h2>
<p class="team-card-desc">{{ member.motto }}</p>
</div>
</div>
<article v-for="member in members" :key="member.nick" class="team-card">
<img :src="resolvedAvatarUrl(member)" :alt="member.nick" class="team-avatar" width="512" height="512" loading="lazy" />
<h2 class="team-name">{{ member.nick }}: {{ member.realNick }}</h2>
<p class="team-motto">{{ member.motto }}</p>
</article>
</div>
</main>
</template>
@@ -41,24 +37,18 @@ onMounted(() => store.fetch())
<style scoped>
.team-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto;
@apply grid grid-cols-2 gap-8 lg:grid-cols-4;
}
.team-card {
@apply bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 transition-all hover:scale-105;
@apply flex flex-col items-center text-center;
}
.team-card-image-container {
@apply aspect-square overflow-hidden bg-gray-200;
.team-avatar {
@apply mb-4 h-28 w-28 rounded-full border border-line bg-raised object-cover;
}
.team-card-image {
@apply w-full h-full object-cover;
.team-name {
@apply text-base font-semibold tracking-tight text-fg;
}
.team-card-content {
@apply p-6;
}
.team-card-title {
@apply text-xl font-black text-gray-900 mb-2;
}
.team-card-desc {
@apply text-gray-600 leading-relaxed;
.team-motto {
@apply mt-1 text-sm leading-relaxed text-fg-muted;
}
</style>
@@ -23,6 +23,30 @@ describe('useUiStore', () => {
expect(store.isRetroMode).toBe(false)
})
it('starts in the light theme when no cookie is set', () => {
const store = useUiStore()
expect(store.theme).toBe('light')
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
})
it('reads the theme back from the cookie', () => {
document.cookie = 'theme=dark;path=/'
const store = useUiStore()
expect(store.theme).toBe('dark')
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
})
it('toggling the theme writes the cookie and the root attribute', () => {
const store = useUiStore()
store.toggleTheme()
expect(store.theme).toBe('dark')
expect(document.cookie).toContain('theme=dark')
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
store.toggleTheme()
expect(store.theme).toBe('light')
expect(document.cookie).toContain('theme=light')
})
it('toggles menu', () => {
const store = useUiStore()
expect(store.menuOpen).toBe(false)
+5 -1
View File
@@ -14,9 +14,13 @@ export const useMemberStore = defineStore('member', () => {
})
}
// A tartalék avatarok a bundle-ben WebP-ként élnek (a PNG-k fejenként ~1,6 MB-ot
// vittek egy ~300 px-es képért), a fájlnév viszont az adatbázisból jön, még
// .png végződéssel — ezért itt cseréljük a kiterjesztést.
function resolvedAvatarUrl(member: Member): string {
if (member.imageUrl) return member.imageUrl
return new URL(`../assets/team/${member.avatarFilename}`, import.meta.url).href
const file = member.avatarFilename.replace(/\.(png|jpe?g)$/i, '.webp')
return new URL(`../assets/team/${file}`, import.meta.url).href
}
return { members, loading, error, fetch, resolvedAvatarUrl, invalidate }
+56 -1
View File
@@ -2,13 +2,68 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getCookie, setCookie } from '../lib/cookie'
export type Theme = 'light' | 'dark'
const THEME_COOKIE = 'theme'
// Süti nélkül világos: az alapértelmezés legyen kiszámítható, ezért a
// prefers-color-scheme-et szándékosan nem nézzük.
function readTheme(): Theme {
return getCookie(THEME_COOKIE) === 'dark' ? 'dark' : 'light'
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme)
}
// A retro mód a gyökéren is jelöli magát: a body háttere a tokenekből jön, és a
// retro.css ott írja felül őket. A .retro-mode osztály az app gyökerén marad,
// az viszi a formát (betűtípus, keretek).
function applyRetro(on: boolean) {
document.documentElement.setAttribute('data-retro', on ? '1' : '0')
}
// A TuiCSS és a retro.css együtt ~500 KB, és a látogatók töredéke kapcsolja be
// a retro módot — ezért csak akkor töltjük be, amikor tényleg kell.
let retroStyles: Promise<unknown> | null = null
function loadRetroStyles(): Promise<unknown> {
if (!retroStyles) {
retroStyles = Promise.all([
import('tuicss/dist/tuicss.min.css'),
import('../styles/retro.css'),
])
}
return retroStyles
}
export const useUiStore = defineStore('ui', () => {
const isRetroMode = ref(getCookie('retro-mode') === '1')
const theme = ref<Theme>(readTheme())
const menuOpen = ref(false)
// Az index.html inline scriptje már beállította ugyanezt; itt csak azért
// ismételjük meg, hogy a store maradjon az állapot egyetlen gazdája.
applyTheme(theme.value)
applyRetro(isRetroMode.value)
if (isRetroMode.value) loadRetroStyles()
function setTheme(next: Theme) {
theme.value = next
setCookie(THEME_COOKIE, next)
applyTheme(next)
}
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function toggleRetroMode() {
isRetroMode.value = !isRetroMode.value
setCookie('retro-mode', isRetroMode.value ? '1' : '0')
applyRetro(isRetroMode.value)
if (isRetroMode.value) loadRetroStyles()
window.scrollTo({ top: 0, behavior: 'instant' })
}
@@ -20,5 +75,5 @@ export const useUiStore = defineStore('ui', () => {
menuOpen.value = false
}
return { isRetroMode, menuOpen, toggleRetroMode, toggleMenu, closeMenu }
return { isRetroMode, theme, menuOpen, setTheme, toggleTheme, toggleRetroMode, toggleMenu, closeMenu }
})
+175 -86
View File
@@ -9,95 +9,184 @@ body {
height: 100%;
}
@layer components {
/* Hero Section */
.hero-section {
@apply relative overflow-hidden py-24 text-white text-center;
}
/* A felület alapja tokenből jön, hogy a témaváltás egyetlen attribútumon
múljon — lásd src/styles/tokens.css. */
body {
background-color: rgb(var(--c-page));
color: rgb(var(--c-fg));
}
.hero-section-gradient {
@apply hero-section bg-gradient-to-r;
}
.hero-section-slate {
@apply hero-section bg-slate-900;
}
.hero-container {
@apply relative max-w-5xl mx-auto px-4 z-10;
}
.hero-badge {
@apply inline-block px-3 py-1 rounded-full bg-indigo-500/20 border border-indigo-400/30 text-indigo-300 text-sm font-medium mb-6 backdrop-blur-sm;
}
.hero-title {
@apply text-5xl md:text-6xl font-extrabold tracking-tight mb-6;
}
.hero-subtitle {
@apply text-lg md:text-xl text-slate-300 max-w-2xl mx-auto leading-relaxed;
}
/* Buttons */
.btn-hero {
@apply inline-flex items-center gap-3 bg-white px-8 py-4 rounded-xl transition-all font-bold shadow-xl hover:scale-105 active:scale-95;
}
.btn-hero-teal {
@apply btn-hero text-teal-700 hover:bg-teal-50;
}
.btn-hero-indigo {
@apply btn-hero text-indigo-900 hover:bg-indigo-50;
}
.btn-hero-purple {
@apply btn-hero text-purple-700 hover:bg-purple-50;
}
.btn-primary {
@apply inline-flex items-center justify-center font-bold py-3 px-8 rounded-xl transition-all shadow-lg;
}
/* Grid and Containers */
.main-container {
@apply max-w-7xl mx-auto px-4 -mt-12 relative z-10;
}
.card-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
}
/* Cards */
.card-base {
@apply bg-white rounded-2xl border border-gray-200 p-6 shadow-sm;
}
.card-interactive {
@apply bg-white rounded-2xl border border-gray-200 p-6 shadow-sm hover:shadow-xl transition-all duration-300 ease-out hover:-translate-y-1;
}
/* Decorative Background Elements */
.hero-decor-blob {
@apply absolute rounded-full blur-3xl opacity-30;
/* A témaváltás nem animált: a keresztfade olcsó trükknek látszik, és a
sok elem egyszerre úszó színe zajos. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Wiki content styles (used with v-html) */
.wiki-content h1 { @apply text-2xl font-bold mt-8 mb-4 text-gray-900; }
.wiki-content h2 { @apply text-xl font-bold mt-6 mb-3 text-gray-800; }
.wiki-content h3 { @apply text-lg font-bold mt-4 mb-2 text-gray-800; }
.wiki-content p { @apply mb-4 text-gray-700 leading-relaxed; }
.wiki-content ul { @apply list-disc ml-6 mb-4 text-gray-700; }
.wiki-content ol { @apply list-decimal ml-6 mb-4 text-gray-700; }
/* A billentyűzetes fókusz mindenhol látszik — a linkeknek nincs saját
komponensosztályuk, ezért az alapot itt kapják. */
a:focus-visible,
button:focus-visible {
outline: 2px solid rgb(var(--c-accent));
outline-offset: 2px;
border-radius: 2px;
}
@layer components {
/* ---- Elrendezés ---------------------------------------------------- */
.main-container {
@apply mx-auto w-full max-w-6xl px-4 py-10 md:px-6 md:py-14;
}
.card-grid {
@apply grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3;
}
/* ---- Hero ----------------------------------------------------------
Egy hero minden oldalon: cím, egy alcím, legfeljebb egy gomb. A
gradiensek, a blur-blobok és az oldalanként külön akcentszín kiestek. */
.hero-section {
@apply border-b border-line bg-surface py-10 md:py-14;
}
.hero-container {
@apply mx-auto w-full max-w-6xl px-4 md:px-6;
}
.hero-badge {
@apply mb-3 inline-block font-mono text-xs uppercase tracking-[0.14em] text-accent;
}
.hero-title {
@apply text-3xl font-semibold tracking-tight text-fg md:text-5xl;
}
.hero-subtitle {
@apply mt-3 max-w-2xl text-base leading-relaxed text-fg-muted md:text-lg;
}
.hero-actions {
@apply mt-6 flex flex-wrap items-center gap-3;
}
/* ---- Felületek — a kártyát keret határolja, nem árnyék -------------- */
.card {
@apply rounded-card border border-line bg-surface p-5 md:p-6;
}
.card-bare {
@apply rounded-card border border-line bg-surface;
}
/* ---- Vezérlők ------------------------------------------------------- */
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-control px-4 py-2 text-sm font-medium transition-colors
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent;
}
.btn-accent {
@apply btn bg-accent text-accent-fg hover:bg-accent-hover;
}
.btn-ghost {
@apply btn border border-line text-fg hover:border-line-strong hover:bg-raised;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.link {
@apply text-accent underline-offset-4 transition-colors hover:underline;
}
/* ---- Jelölők -------------------------------------------------------- */
.badge {
@apply inline-flex items-center rounded-control border border-line bg-raised px-2 py-0.5 text-xs font-medium text-fg-muted;
}
.badge-accent {
@apply badge border-accent/40 bg-accent-quiet text-accent;
}
/* A négy státusz nem kap négy színcsaládot: a megjelent cím az egyetlen,
ami kiemelést érdemel, a többi semleges. */
.status-badge {
@apply badge uppercase tracking-wide;
}
.status-released {
@apply border-accent/40 bg-accent-quiet text-accent;
}
.mono {
@apply font-mono tabular-nums;
}
/* ---- Üres és hibaállapotok ------------------------------------------ */
.empty-state {
@apply rounded-card border border-line bg-surface px-6 py-16 text-center;
}
.empty-state-title {
@apply text-lg font-semibold text-fg;
}
.empty-state-desc {
@apply mx-auto mt-2 max-w-md text-sm text-fg-muted;
}
.error-banner {
@apply rounded-card border border-line border-l-2 border-l-warn bg-surface px-5 py-4;
}
.error-banner-title {
@apply font-semibold text-fg;
}
.error-banner-desc {
@apply mt-1 text-sm text-fg-muted;
}
/* ---- Táblázat ------------------------------------------------------- */
.table-wrap {
@apply overflow-x-auto rounded-card border border-line;
}
}
/* ==========================================================================
Wiki-tartalom (v-html-lel érkezik a wikiből, ezért nem lehet utility-kkel
megcímezni — minden szabály tokenre hivatkozik, hogy mindkét témában álljon)
========================================================================== */
.wiki-content {
@apply max-w-[68ch] text-fg-muted;
}
.wiki-content h1 { @apply mb-4 mt-10 text-2xl font-semibold tracking-tight text-fg; }
.wiki-content h2 { @apply mb-3 mt-8 text-xl font-semibold tracking-tight text-fg; }
.wiki-content h3 { @apply mb-2 mt-6 text-lg font-semibold text-fg; }
.wiki-content p { @apply mb-4 leading-relaxed; }
.wiki-content ul { @apply mb-4 ml-5 list-disc; }
.wiki-content ol { @apply mb-4 ml-5 list-decimal; }
.wiki-content li { @apply mb-1; }
.wiki-content a { @apply text-indigo-600 hover:underline; }
.wiki-content img { @apply max-w-full h-auto rounded-lg my-6 shadow-md; }
.wiki-content pre { @apply bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-6 font-mono text-sm; }
.wiki-content code { @apply bg-gray-100 text-pink-600 px-1 rounded font-mono text-sm; }
.wiki-content blockquote { @apply border-l-4 border-gray-300 pl-4 italic my-6 text-gray-600; }
.wiki-content table { @apply w-full border-collapse my-6; }
.wiki-content a { @apply text-accent underline-offset-4 hover:underline; }
.wiki-content strong { @apply font-semibold text-fg; }
.wiki-content img { @apply my-6 h-auto max-w-full rounded-card border border-line; }
.wiki-content hr { @apply my-8 border-line; }
.wiki-content pre {
@apply my-6 overflow-x-auto rounded-card border border-line bg-raised p-4 font-mono text-sm text-fg;
}
.wiki-content code { @apply rounded border border-line bg-raised px-1 py-0.5 font-mono text-[0.9em] text-fg; }
.wiki-content pre code { @apply border-0 bg-transparent p-0; }
.wiki-content blockquote { @apply my-6 border-l-2 border-accent pl-4 italic; }
.wiki-content table { @apply my-6 w-full border-collapse text-sm; }
.wiki-content th,
.wiki-content td { @apply border border-gray-200 p-3 text-left; }
.wiki-content th { @apply bg-gray-50 font-bold; }
.wiki-content td { @apply border border-line px-3 py-2 text-left; }
.wiki-content th { @apply bg-raised font-semibold text-fg; }
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
/* ===================================================================
Design tokenek — a világos és a sötét téma ugyanazokat a szerepeket
tölti ki, csak más értékkel. A komponensek soha nem hivatkoznak nyers
palettanévre, csak ezekre a szerepekre (Tailwindből: bg-surface,
text-fg-muted, border-line, bg-accent, …).
A témát a <html data-theme="light|dark"> attribútuma választja; azt az
index.html inline scriptje állítja be a bundle betöltése előtt, és
utána az ui.store tartja karban. Alapértelmezés (süti nélkül): világos.
A prefers-color-scheme-et szándékosan nem követjük.
Az értékek szóközzel elválasztott RGB-csatornák, hogy a Tailwind
alfa-variánsai (bg-accent/15) is működjenek.
=================================================================== */
:root {
/* felületek */
--c-page: 255 255 255;
--c-surface: 250 250 250; /* zinc-50 */
--c-raised: 244 244 245; /* zinc-100 */
/* keretek */
--c-line: 228 228 231; /* zinc-200 */
--c-line-strong: 212 212 216; /* zinc-300 */
/* szöveg */
--c-fg: 24 24 27; /* zinc-900 · 17.7:1 */
--c-fg-muted: 82 82 91; /* zinc-600 · 7.7:1 */
--c-fg-subtle: 113 113 122; /* zinc-500 · 4.8:1 — csak metaadatra */
/* akcens: a foszforzöld világos alapon olvasható változata.
A green-600 fehéren csak 3.3:1, ezért green-700. */
--c-accent: 21 128 61; /* green-700 · 5.0:1 fehéren */
--c-accent-hover: 22 101 52; /* green-800 */
--c-accent-fg: 255 255 255; /* szöveg akcens-kitöltésen */
--c-accent-quiet: 220 252 231; /* green-100 — halvány alap, badge */
/* állapotok */
--c-warn: 180 83 9; /* amber-700 · 5.0:1 */
--c-danger: 185 28 28; /* red-700 */
}
[data-theme='dark'] {
--c-page: 9 9 11; /* zinc-950 */
--c-surface: 24 24 27; /* zinc-900 */
--c-raised: 39 39 42; /* zinc-800 */
--c-line: 39 39 42; /* zinc-800 */
--c-line-strong: 63 63 70; /* zinc-700 */
--c-fg: 250 250 250; /* 19.1:1 */
--c-fg-muted: 161 161 170; /* zinc-400 · 7.8:1 */
--c-fg-subtle: 113 113 122; /* zinc-500 · 4.1:1 — csak metaadatra */
--c-accent: 74 222 128; /* green-400 · 11.4:1 feketén */
--c-accent-hover: 134 239 172; /* green-300 */
--c-accent-fg: 9 9 11; /* akcens-kitöltésen sötét szöveg kell */
--c-accent-quiet: 20 83 45; /* green-900 */
--c-warn: 255 176 0; /* retro.amber · 10.9:1 */
--c-danger: 248 113 113; /* red-400 */
}
+33
View File
@@ -1,10 +1,33 @@
// A szemantikus színek a src/styles/tokens.css változóira mutatnak, hogy a
// témaváltás (világos / sötét) egyetlen attribútumon múljon, és az
// alfa-variánsok (bg-accent/15) is működjenek.
const token = (name) => `rgb(var(${name}) / <alpha-value>)`
module.exports = {
content: [
"./src/**/*.{astro,html,js,ts,jsx,tsx,vue,svelte}"
],
// A témát a <html data-theme="dark"> választja, nem a prefers-color-scheme.
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
// Szerepek — ezeket használja a markup.
page: token('--c-page'),
surface: token('--c-surface'),
raised: token('--c-raised'),
line: token('--c-line'),
'line-strong': token('--c-line-strong'),
fg: token('--c-fg'),
'fg-muted': token('--c-fg-muted'),
'fg-subtle': token('--c-fg-subtle'),
accent: token('--c-accent'),
'accent-hover': token('--c-accent-hover'),
'accent-fg': token('--c-accent-fg'),
'accent-quiet': token('--c-accent-quiet'),
warn: token('--c-warn'),
danger: token('--c-danger'),
primary: {
50: '#f0fdf4',
100: '#dcfce7',
@@ -23,6 +46,16 @@ module.exports = {
amber: '#ffb000',
},
},
// Egy sugárskála: kártya, vezérlő. A kör (rounded-full) marad a Tailwindé.
borderRadius: {
card: '8px',
control: '6px',
},
// Árnyék csak lebegő elemre; a kártyát keret határolja, nem árnyék.
boxShadow: {
pop: '0 1px 2px rgb(0 0 0 / 0.06), 0 1px 1px rgb(0 0 0 / 0.04)',
float: '0 8px 24px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.08)',
},
},
},
plugins: [],