vue3 version of frontend

This commit is contained in:
2026-05-05 22:03:56 +02:00
parent 9891a03982
commit 71d341c9f3
37 changed files with 2592 additions and 6815 deletions
+6 -1
View File
@@ -5,7 +5,12 @@
"Bash(go vet *)",
"Bash(chmod +x *)",
"Bash(xargs ruby *)",
"Bash(docker compose exec *)"
"Bash(docker compose exec *)",
"Bash(rm /Users/tasi/Work/TTG/teletypegames/apps/frontend/src/pages/blog/\\\\[...slug\\\\].astro)",
"Bash(rm /Users/tasi/Work/TTG/teletypegames/apps/frontend/src/pages/catalog/\\\\[name\\\\].astro)",
"Bash(npm install *)",
"Bash(npx vue-tsc *)",
"Bash(npx vite *)"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
VITE_WIKI_BASE=/proxy/wiki
VITE_GIT_BASE=/proxy/git/api/v1
+2 -2
View File
@@ -7,6 +7,6 @@ RUN npm install
COPY . .
EXPOSE 4321
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
CMD ["npm", "run", "dev"]
-14
View File
@@ -1,14 +0,0 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
devToolbar: {
enabled: false
},
integrations: [tailwind()],
server: {
host: true,
allowedHosts: ['teletypegames.org']
}
});
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<title>Teletype Games</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+567 -4517
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -3,17 +3,21 @@
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@astrojs/tailwind": "^6.0.2",
"astro": "^5.17.1",
"js-yaml": "^4.1.1"
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
"@vitejs/plugin-vue": "^5.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
<template>
<AppLayout>
<RouterView />
</AppLayout>
</template>
<script setup lang="ts">
import AppLayout from './components/AppLayout.vue'
import { RouterView } from 'vue-router'
</script>
@@ -0,0 +1,53 @@
<template>
<header class="bg-gray-800 text-white p-4 flex justify-between items-center relative">
<RouterLink to="/" class="text-xl font-bold">Teletype Games</RouterLink>
<!-- Desktop Navigation -->
<nav class="hidden md:flex space-x-4">
<RouterLink to="/" class="p-2 hover:text-purple-300 transition-colors duration-200">Home</RouterLink>
<RouterLink to="/catalog" class="p-2 hover:text-purple-300 transition-colors duration-200">Catalog</RouterLink>
<RouterLink to="/blog" class="p-2 hover:text-purple-300 transition-colors duration-200">Blog</RouterLink>
<RouterLink to="/howtos" class="p-2 hover:text-purple-300 transition-colors duration-200">How-tos</RouterLink>
<RouterLink to="/code" class="p-2 hover:text-purple-300 transition-colors duration-200">Code</RouterLink>
<RouterLink to="/team" class="p-2 hover:text-purple-300 transition-colors duration-200">Team</RouterLink>
<RouterLink to="/contact" class="p-2 hover:text-purple-300 transition-colors duration-200">Contact us</RouterLink>
</nav>
<!-- Hamburger Button (Mobile only) -->
<button @click="toggleMenu" class="md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- Mobile Navigation -->
<nav v-show="menuOpen" class="md:hidden absolute top-full left-0 w-full bg-gray-800 flex flex-col items-center py-4 space-y-2 z-50">
<RouterLink to="/" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Home</RouterLink>
<RouterLink to="/catalog" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Catalog</RouterLink>
<RouterLink to="/blog" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Blog</RouterLink>
<RouterLink to="/howtos" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">How-tos</RouterLink>
<RouterLink to="/code" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Code</RouterLink>
<RouterLink to="/team" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Team</RouterLink>
<RouterLink to="/contact" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Contact us</RouterLink>
</nav>
</header>
<main>
<slot />
</main>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const menuOpen = ref(false)
const route = useRoute()
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
watch(() => route.path, () => {
menuOpen.value = false
})
</script>
+3
View File
@@ -0,0 +1,3 @@
export const GAMES_BASE = ''
export const WIKI_BASE = import.meta.env.VITE_WIKI_BASE || 'https://wiki.teletype.hu'
export const GIT_BASE = import.meta.env.VITE_GIT_BASE || 'https://git.teletype.hu/api/v1'
-82
View File
@@ -1,82 +0,0 @@
---
import '../styles/global.css';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>Teletype Games</title>
</head>
<body>
<header class="bg-gray-800 text-white p-4 flex justify-between items-center relative">
<a href="/" class="text-xl font-bold">Teletype Games</a>
<!-- Desktop Navigation -->
<nav class="hidden md:flex space-x-4">
<a href="/" class="p-2 hover:text-purple-300 transition-colors duration-200">Home</a>
<a href="/catalog" class="p-2 hover:text-purple-300 transition-colors duration-200">Catalog</a>
<a href="/blog" class="p-2 hover:text-purple-300 transition-colors duration-200">Blog</a>
<a href="/howtos" class="p-2 hover:text-purple-300 transition-colors duration-200">How-tos</a>
<a href="/code" class="p-2 hover:text-purple-300 transition-colors duration-200">Code</a>
<a href="/team" class="p-2 hover:text-purple-300 transition-colors duration-200">Team</a>
<a href="/contact" class="p-2 hover:text-purple-300 transition-colors duration-200">Contact us</a>
</nav>
<!-- Hamburger Button (Mobile only) -->
<button id="menu-toggle" class="md:hidden p-2 focus:outline-none focus:ring-2 focus:ring-purple-500 rounded-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- Mobile Navigation -->
<nav id="mobile-menu" class="hidden md:hidden absolute top-full left-0 w-full bg-gray-800 flex-col items-center py-4 space-y-2 z-50">
<a href="/" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Home</a>
<a href="/catalog" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Catalog</a>
<a href="/blog" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Blog</a>
<a href="/howtos" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">How-tos</a>
<a href="/code" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Code</a>
<a href="/team" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Team</a>
<a href="/contact" class="block p-2 w-full text-center hover:bg-gray-700 transition-colors duration-200">Contact us</a>
</nav>
</header>
<main>
<slot />
</main>
</body>
<script is:inline>
document.getElementById('menu-toggle').addEventListener('click', () => {
document.getElementById('mobile-menu').classList.toggle('hidden');
});
</script>
<!-- Matomo -->
<script is:inline>
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//matomo.vps.teletype.hu/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '1']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import { router } from './router'
import App from './App.vue'
import './styles/global.css'
createApp(App).use(router).mount('#app')
-323
View File
@@ -1,323 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import { formatDateTime } from '../utils/dateFormat';
const WIKIJS_BASE_URL = 'https://wiki.teletype.hu';
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN;
interface WikiPage {
id: number;
path: string;
title: string;
description: string;
content: string;
render?: string;
updatedAt: string;
createdAt: string;
locale: string;
}
let blogPages: WikiPage[] = [];
let error: string | null = null;
let permissionWarning = false;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`;
}
// 1. Get the list of pages with the "blog" tag
const LIST_QUERY = `
{
pages {
list(orderBy: CREATED, orderByDirection: DESC, tags: ["blog"]) {
id
path
title
description
updatedAt
createdAt
locale
}
}
}
`;
const listResponse = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: LIST_QUERY }),
});
const listJson = await listResponse.json();
if (listJson.errors) {
throw new Error(`GraphQL error: ${listJson.errors.map((e: any) => e.message).join(', ')}`);
}
const pages = listJson?.data?.pages?.list ?? [];
// 2. Fetch content for each page (WikiJS list doesn't include content)
// To keep it efficient, we only fetch the first few blog posts' content if needed,
// or we can fetch all if the list is short.
const pagesWithContent = await Promise.all(pages.map(async (p: any) => {
try {
const CONTENT_QUERY = `
{
pages {
single(id: ${p.id}) {
content
}
}
}
`;
const contentRes = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: CONTENT_QUERY }),
});
const contentJson = await contentRes.json();
return {
...p,
content: contentJson?.data?.pages?.single?.content ?? ''
};
} catch (e) {
console.error(`Failed to fetch content for page ${p.id}`, e);
return { ...p, content: '' };
}
}));
blogPages = pagesWithContent.map((p: any) => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
content: p.content ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
}));
} catch (e: any) {
console.error('Failed to fetch WikiJS data:', e);
error = `Failed to fetch wiki data: ${e.message}`;
}
function getPermalink(path: string): string {
const slug = path.startsWith('blog/') ? path.replace('blog/', '') : path;
return `/blog/${slug}`;
}
function getCleanPreview(content: string): string {
if (!content) return '';
return content
.replace(/[#*`_\[\]()]/g, '')
.trim()
.slice(0, 300) + '...';
}
---
<Layout>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">
Community Stories
</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">
News, updates, and behind-the-scenes stories from the Teletype Games community.
</p>
</div>
</header>
<main class="blog-main">
{error && (
<div class="error-banner" role="alert">
<span class="text-2xl">⚠️</span>
<div>
<h3 class="error-banner-title">Failed to connect to Wiki</h3>
<p class="error-banner-desc">{error}</p>
</div>
</div>
)}
{!error && blogPages.length === 0 && (
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h2 class="empty-state-title">No blog posts found</h2>
<p class="empty-state-desc">We haven't published any blog posts yet. Check back soon!</p>
</div>
)}
{!error && blogPages.length > 0 && (
<div class="blog-posts-list">
{blogPages.map((page) => {
const isNew = (new Date().getTime() - new Date(page.createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000;
const permalink = getPermalink(page.path);
return (
<article 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">
{isNew && (
<span class="new-post-badge">
<span class="new-post-dot"></span>
New Post
</span>
)}
<span>{formatDateTime(page.createdAt)}</span>
</div>
</div>
</div>
<h2 class="blog-post-title">
<a href={permalink} class="hover:text-indigo-600 transition-colors">
{page.title}
</a>
</h2>
<div class="blog-post-preview">
{page.description && (
<p class="blog-post-description">
{page.description}
</p>
)}
{page.content && (
<p class="blog-post-body">
{getCleanPreview(page.content)}
</p>
)}
</div>
<div class="mt-6">
<a href={permalink} class="read-more-btn">
Read more
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
</div>
</div>
</article>
);
})}
</div>
)}
</main>
</div>
</Layout>
<style>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
}
.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;
}
/* Banners */
.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 */
.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; }
/* Posts */
.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-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;
}
.blog-post-title {
@apply text-2xl md:text-3xl font-black text-gray-900 mb-4 leading-tight;
}
.blog-post-preview {
@apply relative overflow-hidden;
max-height: 8rem; /* kb 4-5 sor */
}
.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-description {
@apply text-lg text-gray-800 font-bold mb-2 leading-relaxed;
}
.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;
}
</style>
<style is:global>
.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; }
.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 th, .wiki-content td { @apply border border-gray-200 p-3 text-left; }
.wiki-content th { @apply bg-gray-50 font-bold; }
</style>
@@ -1,231 +0,0 @@
---
import Layout from '../../layouts/Layout.astro';
import { formatDateTime } from '../../utils/dateFormat';
export async function getStaticPaths() {
const WIKIJS_BASE_URL = 'https://wiki.teletype.hu';
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`;
}
const LIST_QUERY = `
{
pages {
list(orderBy: CREATED, orderByDirection: DESC, tags: ["blog"]) {
id
path
title
description
updatedAt
createdAt
locale
}
}
}
`;
try {
const listResponse = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: LIST_QUERY }),
});
const listJson = await listResponse.json();
const pages = listJson?.data?.pages?.list ?? [];
return pages.map((page: any) => {
// WikiJS paths usually look like "blog/my-post"
// We want to match it against /blog/[...slug]
// If path is "blog/post-1", slug should be "post-1"
const slug = page.path.startsWith('blog/')
? page.path.replace('blog/', '')
: page.path;
return {
params: { slug },
props: { pageId: page.id },
};
});
} catch (e) {
console.error('Error fetching paths for blog posts:', e);
return [];
}
}
const { slug } = Astro.params;
const { pageId } = Astro.props;
const WIKIJS_BASE_URL = 'https://wiki.teletype.hu';
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN;
let pageContent = null;
let error = null;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`;
}
const SINGLE_PAGE_QUERY = `
{
pages {
single(id: ${pageId}) {
id
path
title
description
render
updatedAt
createdAt
}
}
}
`;
const response = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: SINGLE_PAGE_QUERY }),
});
const json = await response.json();
pageContent = json?.data?.pages?.single;
if (!pageContent) {
error = "Post not found";
}
} catch (e: any) {
error = e.message;
}
---
<Layout>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="hero-container">
<a href="/blog" class="back-link">
← Back to Blog
</a>
{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">
{formatDateTime(pageContent.createdAt)}
</div>
</>
)}
</div>
</header>
<main class="blog-main">
{error && (
<div class="error-banner" role="alert">
<span class="text-2xl">⚠️</span>
<div>
<h3 class="error-banner-title">Error</h3>
<p class="error-banner-desc">{error}</p>
</div>
</div>
)}
{pageContent && (
<article class="blog-post-card">
<div class="blog-post-content">
{pageContent.render ? (
<div class="wiki-content max-w-none text-gray-700 leading-relaxed" set:html={pageContent.render} />
) : (
<div class="blog-post-fallback">
No content available for this post.
</div>
)}
</div>
</article>
)}
</main>
</div>
</Layout>
<style>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
}
.blog-header-decor {
@apply absolute inset-0 opacity-30;
}
.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-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;
}
.blog-post-timestamp {
@apply text-sm font-semibold uppercase tracking-wider;
}
.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>
<style is:global>
.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; }
.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 th, .wiki-content td { @apply border border-gray-200 p-3 text-left; }
.wiki-content th { @apply bg-gray-50 font-bold; }
</style>
-134
View File
@@ -1,134 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import { formatDateTime } from '../utils/dateFormat';
const BASE = "https://games.teletype.hu";
const API = BASE + "/api/software";
const res = await fetch(API);
const json = await res.json();
const softwares = json.softwares;
---
<Layout>
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
<div class="hero-container">
<h1 class="hero-title">Our Games</h1>
<p class="hero-subtitle text-purple-50">Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!</p>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div class="flex flex-wrap gap-4 mb-8 justify-center filter-buttons pb-4">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="released">Released</button>
<button class="filter-btn" data-filter="demo">Demo</button>
<button class="filter-btn" data-filter="development">Development</button>
<button class="filter-btn" data-filter="archived">Archived</button>
</div>
<div class="grid grid-cols-1 gap-8 software-list">
{softwares.map(({ software, releases }) => {
const latestStable = releases
.filter((r: any) => !r.version.startsWith('dev-'))
.sort((a: any, b: any) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())[0];
return (
<div class="software-card group" data-status={software.status}>
<div class="software-card-content">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center gap-3 mb-2">
<a href={`/catalog/${software.name}`} class="hover:text-purple-600 transition-colors">
<h2 class="software-title mb-0">{software.title}</h2>
</a>
<span class={`status-badge status-${software.status}`}>
{software.status}
</span>
</div>
<p class="software-desc max-w-2xl">{software.desc}</p>
<div class="software-meta">
<span>Author: <span class="text-gray-900 font-medium">{software.author}</span></span>
<span class="mx-2 text-gray-300">•</span>
<span>Platform: <span class="text-gray-900 font-medium">{software.platform}</span></span>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
{latestStable?.htmlFolderPath && (
<a href={BASE + latestStable.htmlFolderPath} target="_blank" class="btn-play-sm">▶ Play</a>
)}
<a href={`/catalog/${software.name}`} class="btn-info-sm">
More Info
</a>
</div>
</div>
</div>
</div>
)})}
</div>
</main>
</Layout>
<script>
const filterButtons = document.querySelectorAll('.filter-btn');
const softwareCards = document.querySelectorAll('.software-card');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
// Update active button
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const filter = button.getAttribute('data-filter');
softwareCards.forEach(card => {
const status = (card as HTMLElement).getAttribute('data-status');
if (filter === 'all' || status === filter) {
(card as HTMLElement).style.display = 'block';
} else {
(card as HTMLElement).style.display = 'none';
}
});
});
});
</script>
<style>
.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;
}
.filter-btn.active {
@apply bg-purple-600 text-white;
}
.status-badge {
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
}
.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;
}
.software-card-content {
@apply p-6;
}
.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;
}
/* Small Action Buttons */
.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; }
</style>
@@ -1,236 +0,0 @@
---
import Layout from '../../layouts/Layout.astro';
import { formatDateTime } from '../../utils/dateFormat';
export async function getStaticPaths() {
const BASE = "https://games.teletype.hu";
const API = BASE + "/api/software";
const res = await fetch(API);
const json = await res.json();
const softwares = json.softwares;
return softwares.map((item: any) => ({
params: { name: item.software.name },
props: { project: item },
}));
}
const { project } = Astro.props;
const { software, releases } = project;
const BASE = "https://games.teletype.hu";
const stableReleases = releases
.filter((r: any) => !r.version.startsWith('dev-'))
.sort((a: any, b: any) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime());
const devReleases = releases
.filter((r: any) => r.version.startsWith('dev-'))
.sort((a: any, b: any) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime());
const latestStable = stableReleases[0];
---
<Layout>
<div class="bg-gray-50 min-h-screen pb-20">
<header class="hero-section-gradient from-purple-700 to-indigo-800 py-12 md:py-20">
<div class="hero-container">
<nav class="mb-8">
<a href="/catalog" class="text-purple-200 hover:text-white transition-colors flex items-center gap-2 font-medium">
← Back to Catalog
</a>
</nav>
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div class="flex-grow">
<div class="flex items-center gap-3 mb-4">
<span class={`status-badge status-${software.status} px-3 py-1`}>
{software.status}
</span>
<span class="text-purple-300 font-mono text-sm tracking-widest uppercase">{software.platform}</span>
</div>
<h1 class="text-4xl md:text-6xl font-black text-white mb-4 leading-tight">{software.title}</h1>
<p class="text-xl text-purple-100 max-w-3xl leading-relaxed">{software.desc}</p>
</div>
</div>
</div>
</header>
<main class="main-container -mt-10">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Sidebar Info -->
<div class="lg:col-span-1 space-y-6">
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
<h2 class="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="text-purple-600"></span> Project Info
</h2>
<dl class="space-y-4">
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Author</dt>
<dd class="text-gray-900 font-medium">{software.author}</dd>
</div>
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Platform</dt>
<dd class="text-gray-900 font-medium">{software.platform}</dd>
</div>
{software.license && (
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">License</dt>
<dd class="text-gray-900 font-medium">{software.license}</dd>
</div>
)}
{software.externalLinks && software.externalLinks.map((link: any) => (
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">{link.label}</dt>
<dd>
<a href={link.url} target="_blank" class="text-purple-600 hover:text-purple-800 font-bold break-all">
{link.url} ↗
</a>
</dd>
</div>
))}
</dl>
</div>
</div>
<!-- Main Content Area -->
<div class="lg:col-span-2 space-y-8">
<!-- Story -->
{software.story && (
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
<h2 class="text-xl font-bold text-gray-900 mb-4">About</h2>
<p class="text-gray-700 leading-relaxed whitespace-pre-line">{software.story}</p>
</div>
)}
<!-- Latest Release (Prominent Section) -->
{latestStable && (
<div class="bg-gradient-to-br from-indigo-600 to-purple-700 rounded-3xl shadow-xl p-8 text-white">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 bg-white/20 text-white text-[10px] font-black uppercase rounded tracking-widest">Latest Stable</span>
</div>
<h2 class="text-4xl font-black mb-1">{latestStable.version}</h2>
<p class="text-indigo-100 text-sm">Released: {formatDateTime(latestStable.UpdatedAt)}</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-2 xl:grid-cols-4 gap-2 w-full md:w-auto">
{latestStable.htmlFolderPath && (
<a href={BASE + latestStable.htmlFolderPath} target="_blank" class="flex items-center justify-center py-3 px-4 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap">
▶ Play Now
</a>
)}
{latestStable.cartridgePath && (
<a href={BASE + latestStable.cartridgePath} target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">
💾 Download
</a>
)}
{latestStable.sourcePath && (
<a href={BASE + latestStable.sourcePath} target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">
📄 Source
</a>
)}
{latestStable.docsFolderPath && (
<a href={BASE + latestStable.docsFolderPath} target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">
📖 Docs
</a>
)}
</div>
</div>
</div>
)}
<!-- Releases List Box -->
<div class="bg-white rounded-3xl shadow-xl overflow-hidden border border-gray-100">
<div class="p-8 border-b border-gray-100">
<h2 class="text-2xl font-bold text-gray-900">All Releases</h2>
</div>
<div class="p-0">
{/* Desktop Table */}
<div class="hidden md:block">
<table class="w-full text-left">
<thead class="bg-gray-50 text-gray-500 text-xs font-bold uppercase tracking-widest">
<tr>
<th class="px-8 py-4">Version</th>
<th class="px-8 py-4">Release Date</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{stableReleases.map((release: any) => (
<tr class="hover:bg-gray-50/50 transition-colors">
<td class="px-8 py-4">
<div class="font-bold text-gray-900 text-lg mb-2">{release.version}</div>
<div class="flex flex-wrap gap-4">
{release.htmlFolderPath && <a href={BASE + release.htmlFolderPath} target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">▶ Play</a>}
{release.cartridgePath && <a href={BASE + release.cartridgePath} target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">💾 Download</a>}
{release.sourcePath && <a href={BASE + release.sourcePath} target="_blank" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">📄 Source</a>}
{release.docsFolderPath && <a href={BASE + release.docsFolderPath} target="_blank" class="text-yellow-600 hover:text-yellow-900 font-bold text-sm flex items-center gap-1">📖 Docs</a>}
</div>
</td>
<td class="px-8 py-4 text-gray-500 align-top pt-5">{formatDateTime(release.UpdatedAt)}</td>
</tr>
))}
{devReleases.length > 0 && (
<tr class="bg-yellow-50/30">
<td colspan="2" class="px-8 py-3 text-[10px] font-black text-yellow-700 uppercase tracking-tighter">Development Versions</td>
</tr>
)}
{devReleases.map((release: any) => (
<tr class="bg-yellow-50/10 hover:bg-yellow-50/30 transition-colors">
<td class="px-8 py-4">
<div class="font-bold text-yellow-800 text-lg mb-2">{release.version}</div>
<div class="flex flex-wrap gap-4">
{release.htmlFolderPath && <a href={BASE + release.htmlFolderPath} target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1">▶ Play</a>}
{release.cartridgePath && <a href={BASE + release.cartridgePath} target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">💾 Download</a>}
</div>
</td>
<td class="px-8 py-4 text-gray-500 align-top pt-5">{formatDateTime(release.UpdatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile View */}
<div class="md:hidden divide-y divide-gray-100">
{releases.map((release: any) => (
<div class={`p-6 ${release.version.startsWith('dev-') ? 'bg-yellow-50/20' : ''}`}>
<div class="flex justify-between items-start mb-4">
<div>
<div class="font-bold text-gray-900 text-lg">{release.version}</div>
<div class="text-gray-500 text-xs mt-1">{formatDateTime(release.UpdatedAt)}</div>
</div>
{release.version.startsWith('dev-') && (
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 text-[10px] font-black uppercase rounded">Dev</span>
)}
</div>
<div class="grid grid-cols-2 gap-2">
{release.htmlFolderPath && <a href={BASE + release.htmlFolderPath} target="_blank" class="flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm">Play</a>}
{release.cartridgePath && <a href={BASE + release.cartridgePath} target="_blank" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">Download</a>}
{release.sourcePath && <a href={BASE + release.sourcePath} target="_blank" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">Source</a>}
{release.docsFolderPath && <a href={BASE + release.docsFolderPath} target="_blank" class="flex items-center justify-center py-2 bg-yellow-100 text-yellow-700 font-bold rounded-lg text-sm">Docs</a>}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</Layout>
<style>
.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; }
</style>
-244
View File
@@ -1,244 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import { formatDateTime } from '../utils/dateFormat';
const GITEA_API_BASE_URL = 'https://git.teletype.hu/api/v1';
const GITEA_TOKEN = import.meta.env.WEBAPP_GITEA_TOKEN;
interface GiteaRepo {
owner: {
login: string;
};
name: string;
description: string;
language?: string;
html_url: string;
updated_at: string;
}
interface Commit {
sha: string;
message: string;
author: { name: string; email: string; };
date: string;
url: string;
repo: {
owner: string;
name: string;
html_url: string;
};
}
let allCommits: Commit[] = [];
let error: string | null = null;
let publicRepos: GiteaRepo[] = [];
if (!GITEA_TOKEN) {
error = "Gitea API token (WEBAPP_GITEA_TOKEN) is not configured. Please set it in your .env file and docker-compose.yml.";
} else {
try {
// Step 1: Fetch public repositories
const reposResponse = await fetch(`${GITEA_API_BASE_URL}/repos/search?q=&private=false&limit=50`, {
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Accept': 'application/json'
}
});
if (!reposResponse.ok) {
const errorText = await reposResponse.text();
throw new Error(`Gitea API (repos/search) responded with status ${reposResponse.status}: ${errorText}`);
}
const reposData = await reposResponse.json();
publicRepos = reposData.data || [];
// Step 2: Fetch recent commits directly for each repository
const commitPromises = publicRepos.map(async (repo) => {
try {
const commitsResponse = await fetch(
`${GITEA_API_BASE_URL}/repos/${repo.owner.login}/${repo.name}/commits?limit=10&page=1`,
{
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Accept': 'application/json'
}
}
);
if (!commitsResponse.ok) {
console.warn(`Failed to fetch commits for ${repo.owner.login}/${repo.name}: ${commitsResponse.status}`);
return [];
}
const commitsData = await commitsResponse.json();
if (!Array.isArray(commitsData)) {
console.warn(`Unexpected response for ${repo.owner.login}/${repo.name}:`, commitsData);
return [];
}
const commits: Commit[] = commitsData.map((c: any) => ({
sha: c.sha,
message: c.commit?.message ?? '',
author: {
name: c.commit?.author?.name ?? 'Unknown',
email: c.commit?.author?.email ?? '',
},
date: c.commit?.author?.date ?? c.created,
url: c.html_url,
repo: {
owner: repo.owner.login,
name: repo.name,
html_url: repo.html_url,
}
}));
return commits;
} catch (repoCommitError: any) {
console.warn(`Error fetching commits for ${repo.owner.login}/${repo.name}: ${repoCommitError.message}`);
return [];
}
});
const allCommitsArrays = await Promise.all(commitPromises);
allCommits = allCommitsArrays.flat();
// Step 3: Sort all commits by date descending
allCommits.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} catch (e: any) {
console.error('Failed to fetch Gitea data:', e);
error = `Failed to fetch Gitea data: ${e.message}`;
}
}
// Limit to top 20 commits for display
const recentCommits = allCommits.slice(0, 20);
---
<Layout>
<header class="hero-section-gradient from-teal-600 to-green-600 py-20">
<div class="hero-container max-w-4xl">
<h1 class="hero-title">Codebase</h1>
<p class="hero-subtitle text-teal-50 mb-10">
Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.
</p>
<a
href="https://git.teletype.hu"
target="_blank"
class="btn-hero-teal"
>
Open Gitea
</a>
</div>
</header>
<main class="main-container">
{error && (
<div class="error-alert" role="alert">
<strong class="font-bold">Error!</strong>
<span class="block sm:inline"> {error}</span>
</div>
)}
{!error && publicRepos.length > 0 && (
<div class="card-grid mb-16">
{publicRepos.map((repo) => (
<div class="card-base">
<h3 class="repo-card-title">
<a href={repo.html_url} target="_blank" class="repo-link">
{repo.owner.login}/{repo.name}
</a>
</h3>
{repo.description && <p class="repo-card-desc">{repo.description}</p>}
{repo.language && <p class="repo-card-meta">Language: {repo.language}</p>}
<p class="repo-card-meta">Updated: {formatDateTime(repo.updated_at)}</p>
</div>
))}
</div>
)}
<h2 class="commits-section-title">Recent Commits</h2>
{!error && recentCommits.length === 0 && (
<p class="empty-commits-msg">No recent commits found or accessible from public repositories.</p>
)}
<div class="commits-list">
{recentCommits.map((commit) => (
<div class="commit-item">
<div class="commit-icon">
🚀
</div>
<div class="commit-content">
<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>
: <span class="text-gray-700">{commit.message.split('\n')[0]}</span>
</p>
<p class="commit-meta">
Authored by <span class="font-bold">{commit.author.name}</span> 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>
</a>
</p>
</div>
</div>
))}
</div>
</main>
</Layout>
<style>
.error-alert {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-8;
}
/* Repo Cards */
.repo-card-title {
@apply text-xl font-semibold mb-2;
}
.repo-link {
@apply text-blue-600 hover:text-blue-800;
}
.repo-card-desc {
@apply text-gray-700 text-sm;
}
.repo-card-meta {
@apply text-gray-500 text-xs mt-1;
}
/* Commits */
.commits-section-title {
@apply text-3xl font-bold mb-8 text-center text-gray-800;
}
.empty-commits-msg {
@apply text-center text-gray-600 text-lg;
}
.commits-list {
@apply space-y-4 mb-12;
}
.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;
}
.commit-message {
@apply text-gray-800 font-semibold;
}
.commit-repo-link {
@apply text-blue-600 hover:text-blue-800;
}
.commit-meta {
@apply text-gray-600 text-sm mt-1;
}
.commit-sha-link {
@apply ml-2 text-blue-500 hover:text-blue-700;
}
</style>
-262
View File
@@ -1,262 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import { formatDateTime } from '../utils/dateFormat';
const WIKIJS_BASE_URL = 'https://wiki.teletype.hu';
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN;
interface WikiPage {
id: number;
path: string;
title: string;
description: string;
updatedAt: string;
createdAt: string;
locale: string;
}
let recentPages: WikiPage[] = [];
let error: string | null = null;
const GRAPHQL_QUERY = `
{
pages {
list(orderBy: UPDATED, orderByDirection: DESC, tags: ["howto"]) {
id
path
title
description
updatedAt
createdAt
locale
}
}
}
`;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`;
}
const response = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: GRAPHQL_QUERY }),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`WikiJS GraphQL responded with status ${response.status}: ${text}`);
}
const json = await response.json();
if (json.errors) {
throw new Error(`GraphQL error: ${json.errors.map((e: any) => e.message).join(', ')}`);
}
const pages: any[] = json?.data?.pages?.list ?? [];
recentPages = pages.map((p: any) => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
})).slice(0, 30);
} catch (e: any) {
console.error('Failed to fetch WikiJS data:', e);
error = `Failed to fetch wiki data: ${e.message}`;
}
---
<Layout>
<div class="howtos-container">
<header class="hero-section-slate">
<!-- Decorative background elements -->
<div class="howtos-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">
Knowledge Base
</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">
The latest documentation, technical guides, and development logs from our knowledge base.
</p>
<a
href={WIKIJS_BASE_URL}
target="_blank"
class="btn-hero-indigo"
>
Knowledge Base
</a>
</div>
</header>
<main class="main-container">
{error && (
<div class="error-banner" role="alert">
<span class="text-2xl">⚠️</span>
<div>
<h3 class="error-banner-title">Failed to connect to Wiki</h3>
<p class="error-banner-desc">{error}</p>
</div>
</div>
)}
{!error && recentPages.length === 0 && (
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h2 class="empty-state-title">No pages found</h2>
<p class="empty-state-desc">It seems like there aren't any public pages available on the wiki at the moment.</p>
</div>
)}
{!error && recentPages.length > 0 && (
<div class="card-grid">
{recentPages.map((page) => {
const pageUrl = `${WIKIJS_BASE_URL}/${page.locale}/${page.path}`;
const isNew = (new Date().getTime() - new Date(page.createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000;
return (
<a
href={pageUrl}
target="_blank"
class="group card-interactive flex flex-col"
>
<div class="howto-card-meta">
<div class="howto-card-timestamp">
{isNew && (
<span class="new-badge">
<span class="new-dot"></span>
New
</span>
)}
<span>{formatDateTime(page.updatedAt)}</span>
</div>
</div>
<h2 class="howto-card-title">
{page.title}
</h2>
{page.description ? (
<p class="howto-card-desc">
{page.description}
</p>
) : (
<div class="howto-card-no-desc">No description provided.</div>
)}
<div class="howto-card-footer">
<span class="howto-card-path">
{page.path}
</span>
<span class="howto-card-read-link">
Read <span class="text-lg">→</span>
</span>
</div>
</a>
);
})}
</div>
)}
{!error && recentPages.length > 0 && (
<div class="howtos-footer">
<a
href={WIKIJS_BASE_URL}
target="_blank"
class="browse-wiki-btn"
>
Browse Wiki Home
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
</div>
)}
</main>
</div>
</Layout>
<style>
.howtos-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.howtos-header-decor {
@apply absolute inset-0 opacity-30;
}
/* Banners */
.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 */
.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; }
/* Cards */
.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;
}
.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;
}
.howto-card-desc {
@apply text-gray-600 text-sm mb-4 line-clamp-3 leading-relaxed flex-grow;
}
.howto-card-no-desc {
@apply flex-grow mb-4 italic text-gray-400 text-sm;
}
.howto-card-footer {
@apply pt-4 border-t border-gray-100 mt-auto flex items-center justify-between;
}
.howto-card-path {
@apply text-xs font-mono text-gray-400 truncate max-w-[180px];
}
.howto-card-read-link {
@apply text-indigo-600 text-sm font-bold flex items-center gap-1 group-hover:translate-x-1 transition-transform;
}
.howtos-footer {
@apply mt-16 text-center;
}
.browse-wiki-btn {
@apply inline-flex items-center gap-2 bg-slate-900 text-white px-8 py-4 rounded-xl hover:bg-slate-800 transition-all font-bold shadow-lg hover:shadow-indigo-200/50;
}
</style>
-592
View File
@@ -1,592 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import { formatDateTime } from '../utils/dateFormat';
interface Event {
name: string;
date: string;
}
const BASE = "https://games.teletype.hu";
let nextEvent: (Event & { dateISO: string; dateText: string }) | null = null;
let followingEvents: (Event & { dateText: string })[] = [];
try {
const res = await fetch(BASE + "/api/events");
if (res.ok) {
const events: Event[] = await res.json();
if (events.length > 0) {
const first = events[0];
const firstDate = new Date(first.date);
nextEvent = {
name: first.name,
date: first.date,
dateISO: firstDate.toISOString(),
dateText: formatDateTime(firstDate)
};
followingEvents = events.slice(1).map(e => ({
name: e.name,
date: e.date,
dateText: formatDateTime(new Date(e.date))
}));
}
}
} catch (e) {
console.error('Failed to load events:', e);
}
// Fetch highlighted software
const API_HIGHLIGHTED = BASE + "/api/software/highlighted";
let highlightedSoftware = null;
let highlightedStableRelease = null;
try {
const res = await fetch(API_HIGHLIGHTED);
if (res.ok) {
highlightedSoftware = await res.json();
if (highlightedSoftware?.releases) {
const stableReleases = highlightedSoftware.releases
.filter((r: any) => !r.version.startsWith('dev-'))
.sort((a: any, b: any) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime());
highlightedStableRelease = stableReleases[0] ?? null;
}
}
} catch (e) {
console.error('Failed to fetch highlighted software:', e);
}
// YouTube Config from Env
const YOUTUBE_API_KEY = import.meta.env.YOUTUBE_API_KEY;
const YOUTUBE_CHANNEL_ID = import.meta.env.YOUTUBE_CHANNEL_ID;
---
<Layout>
<div class="home-container">
<!-- Header Section -->
<header class="hero-section-slate">
<div class="home-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">
Welcome to
</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-subtitle">
Teletype Games is an independent game development community built on creative freedom, equality, and an open-source mindset. Our goal is to create experimental and full-fledged games with short development cycles.
</p>
</div>
</header>
<main class="main-container max-w-6xl">
<!-- Countdown Section -->
{nextEvent && (
<section class="mb-12">
<div class="countdown-card">
<h2 class="countdown-event-name">{nextEvent.name}</h2>
<div id="countdown" data-date={nextEvent.dateISO} class="countdown-timer">
-- : -- : -- : --
</div>
<p class="countdown-date">Starts on: <span class="font-semibold">{nextEvent.dateText}</span></p>
{followingEvents.length > 0 && (
<div class="upcoming-events-section">
<h3 class="upcoming-events-title">Upcoming Events</h3>
<div class="space-y-4">
{followingEvents.map(event => (
<div 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>
)}
</div>
</section>
)}
<!-- Featured Software Section -->
{highlightedSoftware && (
<section class="mb-16">
<div class="featured-card group">
<div class="featured-content">
<div class="flex items-center gap-2 mb-4">
<span class="featured-badge">Featured Game</span>
<span class="status-badge status-released">{highlightedSoftware.software.status}</span>
{highlightedStableRelease && (
<span class="featured-version-badge">{highlightedStableRelease.version}</span>
)}
</div>
<h2 class="featured-title">{highlightedSoftware.software.title}</h2>
<p class="featured-desc">{highlightedSoftware.software.desc}</p>
<div class="flex flex-wrap gap-4 mt-8">
{highlightedStableRelease?.htmlFolderPath && (
<a href={BASE + highlightedStableRelease.htmlFolderPath} target="_blank" class="featured-btn-play">
▶ Play in Browser
</a>
)}
<a href={`/catalog/${highlightedSoftware.software.name}`} class="featured-btn-secondary">
View Project Details
</a>
</div>
</div>
</div>
</section>
)}
<!-- Latest YouTube Video -->
<section class="mb-16">
<div
class="yt-featured-wrapper group"
id="youtubeWidget"
data-api-key={YOUTUBE_API_KEY}
data-channel-id={YOUTUBE_CHANNEL_ID}
>
<!-- Decorative Glow -->
<div class="yt-glow"></div>
<div class="yt-header-area">
<div class="flex items-center gap-3">
<div class="yt-status-blob">
<div class="yt-status-dot"></div>
</div>
<span class="yt-label-text">Latest from YouTube</span>
</div>
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="yt-channel-link">
Visit Channel ↗
</a>
</div>
<div class="yt-content-grid">
<div class="yt-player-container">
<div class="player-wrap" id="playerWrap">
<div class="state-overlay" id="stateOverlay">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="yt-info-sidebar" id="metaBox" style="display:none;">
<div class="mb-auto">
<span class="yt-new-tag">New Release</span>
<h3 class="yt-video-title" id="videoTitle"></h3>
<div class="yt-stats-row">
<span id="publishDate">📅 </span>
<span id="viewCount">👁 </span>
</div>
</div>
<div class="mt-6">
<a id="ytLink" href="#" target="_blank" class="yt-play-button">
<span class="text-xl">▶</span> Watch on YouTube
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Navigation Grid -->
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Catalog Section -->
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-purple-50 group-hover:bg-purple-100">
<span class="text-3xl">🎮</span>
</div>
<h2 class="nav-card-title">Catalog</h2>
<p class="nav-card-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.</p>
<a href="/catalog" class="btn-primary bg-purple-600 hover:bg-purple-700 text-white hover:shadow-purple-200">
Browse Games
</a>
</div>
<!-- Wiki Section -->
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-green-50 group-hover:bg-green-100">
<span class="text-3xl">📚</span>
</div>
<h2 class="nav-card-title">Wiki</h2>
<p class="nav-card-desc">We gather our public interest development knowledge here. Read through technical descriptions and development logs.</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">
Knowledge Base
</a>
</div>
<!-- Git Section -->
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-slate-50 group-hover:bg-slate-100">
<span class="text-3xl">💻</span>
</div>
<h2 class="nav-card-title">Git Repos</h2>
<p class="nav-card-desc">All our projects are open source. Browse our code, study our solutions, or even participate in the development.</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">
Gitea
</a>
</div>
<!-- YouTube Section -->
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-red-50 group-hover:bg-red-100">
<span class="text-3xl">📺</span>
</div>
<h2 class="nav-card-title">YouTube</h2>
<p class="nav-card-desc">Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.</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">
Watch on YouTube
</a>
</div>
</section>
<!-- AI Content Notice -->
<div class="mb-12 mt-12 bg-white rounded-2xl border border-indigo-100 p-6 shadow-sm flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center text-2xl flex-shrink-0">🤖</div>
<div class="flex-grow text-slate-600 text-sm md:text-base italic">
<strong>Note:</strong> Our HowTo-s and blog posts are written by AI with human supervision.
</div>
</div>
</main>
</div>
</Layout>
<style>
.home-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.home-header-decor {
@apply absolute inset-0 opacity-30;
}
/* 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];
}
.featured-content {
@apply p-8 md:p-12 flex flex-col justify-center flex-1 z-10;
}
.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-visual {
@apply flex-1 relative min-h-[300px] md:min-h-full overflow-hidden;
}
.featured-placeholder {
@apply absolute inset-0 flex items-center justify-center bg-indigo-500/5;
}
.featured-glow {
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-indigo-500/30 rounded-full blur-[100px] pointer-events-none;
}
.text-8-xl {
font-size: 8rem;
}
/* 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; }
.featured-version-badge {
@apply px-2 py-0.5 rounded text-[10px] font-bold bg-white/10 text-white/70 border border-white/20;
}
/* Countdown 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];
}
.featured-content {
@apply p-8 md:p-12 flex flex-col justify-center flex-1 z-10;
}
.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-visual {
@apply flex-1 relative min-h-[300px] md:min-h-full overflow-hidden;
}
.featured-placeholder {
@apply absolute inset-0 flex items-center justify-center bg-indigo-500/5;
}
.featured-glow {
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-indigo-500/30 rounded-full blur-[100px] pointer-events-none;
}
.text-8-xl {
font-size: 8rem;
}
/* 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-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;
}
.countdown-date {
@apply text-gray-500 text-lg;
}
/* Upcoming Events */
.upcoming-events-section {
@apply mt-12 pt-12 border-t border-gray-100 text-left max-w-2xl mx-auto;
}
.upcoming-events-title {
@apply text-xl font-bold text-gray-900 mb-6;
}
.upcoming-event-item {
@apply flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100;
}
/* Nav Cards */
.nav-card {
@apply flex flex-col h-full;
}
.nav-card-icon {
@apply w-14 h-14 rounded-xl flex items-center justify-center mb-6 transition-colors;
}
.nav-card-title {
@apply text-2xl font-bold text-gray-900 mb-3;
}
.nav-card-desc {
@apply text-gray-600 mb-6 flex-grow leading-relaxed;
}
/* 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-container .player-wrap {
@apply relative w-full aspect-video;
}
.yt-player-container .player-wrap :global(iframe) {
@apply w-full h-full border-none block;
}
.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;
}
.yt-player-container .state-overlay {
@apply absolute inset-0 flex items-center justify-center bg-slate-950;
}
.yt-player-container .state-overlay :global(.spinner) {
@apply w-10 h-10 border-4 border-white/5 border-t-indigo-500 rounded-full animate-spin;
}
</style>
<script>
function updateCountdown() {
const countdownElement = document.getElementById('countdown');
if (!countdownElement) return;
const targetDateStr = countdownElement.getAttribute('data-date');
if (!targetDateStr) return;
const targetDate = new Date(targetDateStr).getTime();
function update() {
const now = Date.now();
const distance = targetDate - now;
if (distance < 0) {
countdownElement.innerHTML = "LIVE NOW!";
return;
}
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
const d = String(days).padStart(2, '0');
const h = String(hours).padStart(2, '0');
const m = String(minutes).padStart(2, '0');
const s = String(seconds).padStart(2, '0');
countdownElement.innerHTML = `${d}d ${h}h ${m}m ${s}s`;
}
update();
setInterval(update, 1000);
}
async function loadLatestVideo() {
const youtubeWidget = document.getElementById('youtubeWidget');
if (!youtubeWidget) return;
const API_KEY = youtubeWidget.getAttribute('data-api-key');
const CHANNEL_ID = youtubeWidget.getAttribute('data-channel-id');
const playerWrap = document.getElementById('playerWrap');
const stateOverlay = document.getElementById('stateOverlay');
const metaBox = document.getElementById('metaBox');
const videoTitle = document.getElementById('videoTitle');
const publishDate = document.getElementById('publishDate');
const viewCount = document.getElementById('viewCount');
const ytLink = document.getElementById('ytLink');
if (!playerWrap || !API_KEY || !CHANNEL_ID) {
if (playerWrap && (!API_KEY || !CHANNEL_ID)) {
if (stateOverlay) {
stateOverlay.innerHTML = `<div class="text-slate-500 text-xs">Missing API Configuration</div>`;
}
}
return;
}
function showError(msg) {
if (stateOverlay) {
stateOverlay.innerHTML = `
<div class="text-xl mb-2">⚠️</div>
<div class="text-[11px] text-slate-500 text-center px-4 font-bold uppercase tracking-tighter">${msg}</div>`;
}
}
function formatDate(iso) {
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${y}-${m}-${day} ${h}:${min}`;
}
function formatViews(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M views';
if (n >= 1_000) return Math.round(n / 1_000) + 'K views';
return n + ' views';
}
try {
const searchUrl = `https://www.googleapis.com/youtube/v3/search?key=${API_KEY}&channelId=${CHANNEL_ID}&part=snippet&order=date&maxResults=1&type=video`;
const searchRes = await fetch(searchUrl);
if (!searchRes.ok) throw new Error(`API error: ${searchRes.status}`);
const searchData = await searchRes.json();
if (!searchData.items || searchData.items.length === 0) {
showError('No videos found on this channel.');
return;
}
const item = searchData.items[0];
const videoId = item.id.videoId;
const title = item.snippet.title;
const pubDate = item.snippet.publishedAt;
const statsUrl = `https://www.googleapis.com/youtube/v3/videos?key=${API_KEY}&id=${videoId}&part=statistics`;
const statsRes = await fetch(statsUrl);
const statsData = await statsRes.json();
const views = statsData.items?.[0]?.statistics?.viewCount ?? null;
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=0&rel=0&modestbranding=1`;
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
iframe.allowFullscreen = true;
if (stateOverlay) stateOverlay.remove();
playerWrap.appendChild(iframe);
if (videoTitle) videoTitle.textContent = title;
if (publishDate) publishDate.textContent = '📅 ' + formatDate(pubDate);
if (viewCount) viewCount.textContent = views ? '👁 ' + formatViews(Number(views)) : '';
if (ytLink) ytLink.href = `https://www.youtube.com/watch?v=${videoId}`;
if (metaBox) metaBox.style.display = 'block';
} catch (err) {
showError('Error loading video: ' + err.message);
console.error(err);
}
}
// Initialize
updateCountdown();
loadLatestVideo();
</script>
-81
View File
@@ -1,81 +0,0 @@
---
import Layout from '../layouts/Layout.astro';
import mrZeroImg from '../assets/team/mr.zero.png';
import mrOneImg from '../assets/team/mr.one.png';
import mrTwoImg from '../assets/team/mr.two.png';
import mrThreeImg from '../assets/team/mr.three.png';
const teamMembers = [
{
name: 'Mr. Zero: Tasi',
description: 'His dream is to become an open-source knight.',
image: mrZeroImg.src
},
{
name: 'Mr. One: Ballz',
description: 'The cheese is half-eaten. Something here went very wrong.',
image: mrOneImg.src
},
{
name: 'Mr. Two: Z',
description: 'The egg was first or the chicken? It doesn\'t matter, we eat both.',
image: mrTwoImg.src
},
{
name: 'Mr. Three: gBird',
description: 'Life is beautiful because it contains the possibility of becoming human.',
image: mrThreeImg.src
}
];
---
<Layout>
<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">Our Team</h1>
<p class="hero-subtitle mb-10 text-purple-100">
Meet the brilliant minds behind Teletype Games.
</p>
</div>
</header>
<main class="main-container py-16 px-4">
<div class="team-grid">
{teamMembers.map((member) => (
<div class="team-card">
<div class="team-card-image-container">
<img src={member.image} alt={member.name} class="team-card-image" />
</div>
<div class="team-card-content">
<h2 class="team-card-title">{member.name}</h2>
<p class="team-card-desc">{member.description}</p>
</div>
</div>
))}
</div>
</main>
</Layout>
<style>
.team-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto;
}
.team-card {
@apply bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 transition-all hover:scale-105;
}
.team-card-image-container {
@apply aspect-square overflow-hidden bg-gray-200;
}
.team-card-image {
@apply w-full h-full object-cover;
}
.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;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/blog', component: () => import('../views/BlogView.vue') },
{ path: '/blog/:slug(.*)', component: () => import('../views/BlogPostView.vue') },
{ path: '/catalog', component: () => import('../views/CatalogView.vue') },
{ path: '/catalog/:name', component: () => import('../views/CatalogItemView.vue') },
{ path: '/code', component: () => import('../views/CodeView.vue') },
{ path: '/contact', component: () => import('../views/ContactView.vue') },
{ path: '/howtos', component: () => import('../views/HowtosView.vue') },
{ path: '/team', component: () => import('../views/TeamView.vue') },
],
scrollBehavior() {
return { top: 0 }
}
})
+25
View File
@@ -2,6 +2,13 @@
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
@layer components {
/* Hero Section */
.hero-section {
@@ -72,3 +79,21 @@
@apply absolute rounded-full blur-3xl opacity-30;
}
}
/* 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; }
.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 th,
.wiki-content td { @apply border border-gray-200 p-3 text-left; }
.wiki-content th { @apply bg-gray-50 font-bold; }
+173
View File
@@ -0,0 +1,173 @@
<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"> Back to Blog</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">{{ formatDateTime(pageContent.createdAt) }}</div>
</template>
</div>
</header>
<main class="blog-main">
<div v-if="error" class="error-banner" role="alert">
<span class="text-2xl"></span>
<div>
<h3 class="error-banner-title">Error</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</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="pageContent.render" />
<div v-else class="blog-post-fallback">No content available for this post.</div>
</div>
</article>
<div v-else-if="loading" class="blog-post-card">
<div class="blog-post-content text-center text-gray-400 py-12">Loading...</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { formatDateTime } from '../utils/dateFormat'
import { WIKI_BASE } from '../config'
const WIKIJS_BASE_URL = WIKI_BASE
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN
interface PageContent {
id: number
path: string
title: string
description: string
render: string
createdAt: string
updatedAt: string
}
const route = useRoute()
const pageContent = ref<PageContent | null>(null)
const error = ref<string | null>(null)
const loading = ref(true)
onMounted(async () => {
const slug = route.params.slug as string
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`
}
try {
// Find page ID by fetching the list and matching the slug
const LIST_QUERY = `
{
pages {
list(orderBy: CREATED, orderByDirection: DESC, tags: ["blog"]) {
id path
}
}
}
`
const listRes = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: LIST_QUERY }),
})
const listJson = await listRes.json()
const pages: any[] = listJson?.data?.pages?.list ?? []
const matchedPage = pages.find((p: any) => {
const pageSlug = p.path.startsWith('blog/') ? p.path.replace('blog/', '') : p.path
return pageSlug === slug
})
if (!matchedPage) {
error.value = 'Post not found'
return
}
const SINGLE_QUERY = `
{
pages {
single(id: ${matchedPage.id}) {
id path title description render updatedAt createdAt
}
}
}
`
const singleRes = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: SINGLE_QUERY }),
})
const singleJson = await singleRes.json()
pageContent.value = singleJson?.data?.pages?.single ?? null
if (!pageContent.value) {
error.value = 'Post not found'
}
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
})
</script>
<style scoped>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
}
.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>
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="blog-container">
<header class="blog-header">
<div class="blog-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-purple-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-blue-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">Community Stories</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">
News, updates, and behind-the-scenes stories from the Teletype Games community.
</p>
</div>
</header>
<main class="blog-main">
<div v-if="error" class="error-banner" role="alert">
<span class="text-2xl"></span>
<div>
<h3 class="error-banner-title">Failed to connect to Wiki</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</div>
<div v-else-if="!loading && blogPages.length === 0" class="empty-state">
<div class="empty-state-icon">📭</div>
<h2 class="empty-state-title">No blog posts found</h2>
<p class="empty-state-desc">We haven't published any blog posts yet. Check back soon!</p>
</div>
<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>
New Post
</span>
<span>{{ formatDateTime(page.createdAt) }}</span>
</div>
</div>
</div>
<h2 class="blog-post-title">
<RouterLink :to="getPermalink(page.path)" class="hover:text-indigo-600 transition-colors">
{{ 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>
<div class="mt-6">
<RouterLink :to="getPermalink(page.path)" class="read-more-btn">
Read more
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</RouterLink>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { formatDateTime } from '../utils/dateFormat'
import { WIKI_BASE } from '../config'
const WIKIJS_BASE_URL = WIKI_BASE
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN
interface WikiPage {
id: number
path: string
title: string
description: string
content: string
updatedAt: string
createdAt: string
locale: string
}
const blogPages = ref<WikiPage[]>([])
const error = ref<string | null>(null)
const loading = ref(true)
function isNew(createdAt: string): boolean {
return (new Date().getTime() - new Date(createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000
}
function getPermalink(path: string): string {
const slug = path.startsWith('blog/') ? path.replace('blog/', '') : path
return `/blog/${slug}`
}
function getCleanPreview(content: string): string {
if (!content) return ''
return content.replace(/[#*`_\[\]()]/g, '').trim().slice(0, 300) + '...'
}
onMounted(async () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`
}
const LIST_QUERY = `
{
pages {
list(orderBy: CREATED, orderByDirection: DESC, tags: ["blog"]) {
id path title description updatedAt createdAt locale
}
}
}
`
try {
const listRes = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: LIST_QUERY }),
})
const listJson = await listRes.json()
if (listJson.errors) throw new Error(`GraphQL error: ${listJson.errors.map((e: any) => e.message).join(', ')}`)
const pages = listJson?.data?.pages?.list ?? []
const pagesWithContent = await Promise.all(pages.map(async (p: any) => {
try {
const CONTENT_QUERY = `{ pages { single(id: ${p.id}) { content } } }`
const contentRes = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: CONTENT_QUERY }),
})
const contentJson = await contentRes.json()
return { ...p, content: contentJson?.data?.pages?.single?.content ?? '' }
} catch {
return { ...p, content: '' }
}
}))
blogPages.value = pagesWithContent.map((p: any) => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
content: p.content ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
}))
} catch (e: any) {
error.value = `Failed to fetch wiki data: ${e.message}`
} finally {
loading.value = false
}
})
</script>
<style scoped>
.blog-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.blog-header {
@apply relative overflow-hidden py-24 text-white text-center bg-indigo-900;
}
.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-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;
}
.blog-post-title {
@apply text-2xl md:text-3xl font-black text-gray-900 mb-4 leading-tight;
}
.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-description {
@apply text-lg text-gray-800 font-bold mb-2 leading-relaxed;
}
.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;
}
</style>
+241
View File
@@ -0,0 +1,241 @@
<template>
<div class="bg-gray-50 min-h-screen pb-20">
<header class="hero-section-gradient from-purple-700 to-indigo-800 py-12 md:py-20">
<div class="hero-container">
<nav class="mb-8">
<RouterLink to="/catalog" class="text-purple-200 hover:text-white transition-colors flex items-center gap-2 font-medium">
Back to Catalog
</RouterLink>
</nav>
<div v-if="software" class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div class="flex-grow">
<div class="flex items-center gap-3 mb-4">
<span :class="`status-badge status-${software.status} px-3 py-1`">{{ software.status }}</span>
<span class="text-purple-300 font-mono text-sm tracking-widest uppercase">{{ software.platform }}</span>
</div>
<h1 class="text-4xl md:text-6xl font-black text-white mb-4 leading-tight">{{ software.title }}</h1>
<p class="text-xl text-purple-100 max-w-3xl leading-relaxed">{{ software.desc }}</p>
</div>
</div>
</div>
</header>
<main v-if="software" class="main-container -mt-10">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Sidebar Info -->
<div class="lg:col-span-1 space-y-6">
<div class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
<h2 class="text-xl font-bold text-gray-900 mb-6 flex items-center gap-2">
<span class="text-purple-600"></span> Project Info
</h2>
<dl class="space-y-4">
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Author</dt>
<dd class="text-gray-900 font-medium">{{ software.author }}</dd>
</div>
<div>
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">Platform</dt>
<dd class="text-gray-900 font-medium">{{ software.platform }}</dd>
</div>
<div v-if="software.license">
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">License</dt>
<dd class="text-gray-900 font-medium">{{ software.license }}</dd>
</div>
<div v-for="link in software.externalLinks" :key="link.url">
<dt class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-1">{{ link.label }}</dt>
<dd>
<a :href="link.url" target="_blank" class="text-purple-600 hover:text-purple-800 font-bold break-all">
{{ link.url }}
</a>
</dd>
</div>
</dl>
</div>
</div>
<!-- Main Content Area -->
<div class="lg:col-span-2 space-y-8">
<div v-if="software.story" class="bg-white rounded-3xl shadow-xl p-8 border border-gray-100">
<h2 class="text-xl font-bold text-gray-900 mb-4">About</h2>
<p class="text-gray-700 leading-relaxed whitespace-pre-line">{{ software.story }}</p>
</div>
<div v-if="latestStable" class="bg-gradient-to-br from-indigo-600 to-purple-700 rounded-3xl shadow-xl p-8 text-white">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 bg-white/20 text-white text-[10px] font-black uppercase rounded tracking-widest">Latest Stable</span>
</div>
<h2 class="text-4xl font-black mb-1">{{ latestStable.version }}</h2>
<p class="text-indigo-100 text-sm">Released: {{ formatDateTime(latestStable.UpdatedAt) }}</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-2 xl:grid-cols-4 gap-2 w-full md:w-auto">
<a v-if="latestStable.htmlFolderPath" :href="BASE + latestStable.htmlFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-white text-indigo-900 font-bold rounded-xl transition-all hover:bg-indigo-50 shadow-lg shadow-black/10 text-sm whitespace-nowrap"> Play Now</a>
<a v-if="latestStable.cartridgePath" :href="BASE + latestStable.cartridgePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">💾 Download</a>
<a v-if="latestStable.sourcePath" :href="BASE + latestStable.sourcePath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">📄 Source</a>
<a v-if="latestStable.docsFolderPath" :href="BASE + latestStable.docsFolderPath" target="_blank" class="flex items-center justify-center py-3 px-4 bg-indigo-500/30 border border-indigo-400/30 text-white font-bold rounded-xl transition-all hover:bg-indigo-500/40 text-sm whitespace-nowrap">📖 Docs</a>
</div>
</div>
</div>
<div class="bg-white rounded-3xl shadow-xl overflow-hidden border border-gray-100">
<div class="p-8 border-b border-gray-100">
<h2 class="text-2xl font-bold text-gray-900">All Releases</h2>
</div>
<div class="p-0">
<!-- Desktop Table -->
<div class="hidden md:block">
<table class="w-full text-left">
<thead class="bg-gray-50 text-gray-500 text-xs font-bold uppercase tracking-widest">
<tr>
<th class="px-8 py-4">Version</th>
<th class="px-8 py-4">Release Date</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="release in stableReleases" :key="release.version" class="hover:bg-gray-50/50 transition-colors">
<td class="px-8 py-4">
<div class="font-bold text-gray-900 text-lg mb-2">{{ release.version }}</div>
<div class="flex flex-wrap gap-4">
<a v-if="release.htmlFolderPath" :href="BASE + release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1"> Play</a>
<a v-if="release.cartridgePath" :href="BASE + release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">💾 Download</a>
<a v-if="release.sourcePath" :href="BASE + release.sourcePath" target="_blank" class="text-green-600 hover:text-green-900 font-bold text-sm flex items-center gap-1">📄 Source</a>
<a v-if="release.docsFolderPath" :href="BASE + release.docsFolderPath" target="_blank" class="text-yellow-600 hover:text-yellow-900 font-bold text-sm flex items-center gap-1">📖 Docs</a>
</div>
</td>
<td class="px-8 py-4 text-gray-500 align-top pt-5">{{ formatDateTime(release.UpdatedAt) }}</td>
</tr>
<tr v-if="devReleases.length > 0" class="bg-yellow-50/30">
<td colspan="2" class="px-8 py-3 text-[10px] font-black text-yellow-700 uppercase tracking-tighter">Development Versions</td>
</tr>
<tr v-for="release in devReleases" :key="release.version" class="bg-yellow-50/10 hover:bg-yellow-50/30 transition-colors">
<td class="px-8 py-4">
<div class="font-bold text-yellow-800 text-lg mb-2">{{ release.version }}</div>
<div class="flex flex-wrap gap-4">
<a v-if="release.htmlFolderPath" :href="BASE + release.htmlFolderPath" target="_blank" class="text-purple-600 hover:text-purple-900 font-bold text-sm flex items-center gap-1"> Play</a>
<a v-if="release.cartridgePath" :href="BASE + release.cartridgePath" target="_blank" class="text-blue-600 hover:text-blue-900 font-bold text-sm flex items-center gap-1">💾 Download</a>
</div>
</td>
<td class="px-8 py-4 text-gray-500 align-top pt-5">{{ formatDateTime(release.UpdatedAt) }}</td>
</tr>
</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="flex justify-between items-start mb-4">
<div>
<div class="font-bold text-gray-900 text-lg">{{ release.version }}</div>
<div class="text-gray-500 text-xs mt-1">{{ formatDateTime(release.UpdatedAt) }}</div>
</div>
<span v-if="release.version.startsWith('dev-')" class="px-2 py-0.5 bg-yellow-100 text-yellow-800 text-[10px] font-black uppercase rounded">Dev</span>
</div>
<div class="grid grid-cols-2 gap-2">
<a v-if="release.htmlFolderPath" :href="BASE + release.htmlFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-purple-100 text-purple-700 font-bold rounded-lg text-sm">Play</a>
<a v-if="release.cartridgePath" :href="BASE + release.cartridgePath" target="_blank" class="flex items-center justify-center py-2 bg-blue-100 text-blue-700 font-bold rounded-lg text-sm">Download</a>
<a v-if="release.sourcePath" :href="BASE + release.sourcePath" target="_blank" class="flex items-center justify-center py-2 bg-green-100 text-green-700 font-bold rounded-lg text-sm">Source</a>
<a v-if="release.docsFolderPath" :href="BASE + release.docsFolderPath" target="_blank" class="flex items-center justify-center py-2 bg-yellow-100 text-yellow-700 font-bold rounded-lg text-sm">Docs</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<main v-else class="main-container -mt-10">
<div class="bg-white rounded-3xl shadow-xl p-12 text-center text-gray-400">
{{ error || 'Loading...' }}
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { formatDateTime } from '../utils/dateFormat'
import { GAMES_BASE } from '../config'
const BASE = GAMES_BASE
const route = useRoute()
interface Release {
version: string
htmlFolderPath?: string
cartridgePath?: string
sourcePath?: string
docsFolderPath?: string
UpdatedAt: string
}
interface SoftwareItem {
name: string
title: string
desc: string
status: string
author: string
platform: string
license?: string
story?: string
externalLinks?: { label: string; url: string }[]
}
const software = ref<SoftwareItem | null>(null)
const releases = ref<Release[]>([])
const error = ref<string | null>(null)
const stableReleases = computed(() =>
releases.value
.filter(r => !r.version.startsWith('dev-'))
.sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())
)
const devReleases = computed(() =>
releases.value
.filter(r => r.version.startsWith('dev-'))
.sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())
)
const allReleases = computed(() => [...stableReleases.value, ...devReleases.value])
const latestStable = computed(() => stableReleases.value[0] ?? null)
onMounted(async () => {
try {
const res = await fetch(`${BASE}/api/software`)
const json = await res.json()
const item = json.softwares.find((s: any) => s.software.name === route.params.name)
if (item) {
software.value = item.software
releases.value = item.releases
} else {
error.value = 'Game not found'
}
} catch (e: any) {
error.value = `Failed to load: ${e.message}`
}
})
</script>
<style scoped>
.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; }
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<header class="hero-section-gradient from-purple-600 to-indigo-600 py-16">
<div class="hero-container">
<h1 class="hero-title">Our Games</h1>
<p class="hero-subtitle text-purple-50">Discover games made for TIC-80 and other platforms, with versions playable directly in your browser!</p>
</div>
</header>
<main class="main-container py-12 px-4 mt-0">
<div class="flex flex-wrap gap-4 mb-8 justify-center filter-buttons pb-4">
<button
v-for="f in filters"
:key="f"
:class="['filter-btn', { active: activeFilter === f }]"
@click="activeFilter = f"
>
{{ f.charAt(0).toUpperCase() + f.slice(1) }}
</button>
</div>
<div class="grid grid-cols-1 gap-8">
<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="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center gap-3 mb-2">
<RouterLink :to="`/catalog/${software.name}`" class="hover:text-purple-600 transition-colors">
<h2 class="software-title mb-0">{{ software.title }}</h2>
</RouterLink>
<span :class="`status-badge status-${software.status}`">{{ software.status }}</span>
</div>
<p class="software-desc max-w-2xl">{{ software.desc }}</p>
<div class="software-meta">
<span>Author: <span class="text-gray-900 font-medium">{{ software.author }}</span></span>
<span class="mx-2 text-gray-300"></span>
<span>Platform: <span class="text-gray-900 font-medium">{{ software.platform }}</span></span>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
<a
v-if="getLatestStable(releases)?.htmlFolderPath"
:href="BASE + getLatestStable(releases).htmlFolderPath"
target="_blank"
class="btn-play-sm"
> Play</a>
<RouterLink :to="`/catalog/${software.name}`" class="btn-info-sm">
More Info
</RouterLink>
</div>
</div>
</div>
</div>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { GAMES_BASE } from '../config'
const BASE = GAMES_BASE
const filters = ['all', 'released', 'demo', 'development', 'archived']
const activeFilter = ref('all')
interface Release {
version: string
htmlFolderPath?: string
cartridgePath?: string
sourcePath?: string
docsFolderPath?: string
UpdatedAt: string
}
interface Software {
name: string
title: string
desc: string
status: string
author: string
platform: string
}
const softwares = ref<{ software: Software; releases: Release[] }[]>([])
function getLatestStable(releases: Release[]): Release {
return releases
.filter(r => !r.version.startsWith('dev-'))
.sort((a, b) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())[0]
}
onMounted(async () => {
try {
const res = await fetch(`${BASE}/api/software`)
const json = await res.json()
softwares.value = json.softwares
} catch (e) {
console.error('Failed to load catalog:', e)
}
})
</script>
<style scoped>
.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;
}
.filter-btn.active {
@apply bg-purple-600 text-white;
}
.status-badge {
@apply px-2 py-0.5 rounded text-xs font-bold uppercase;
}
.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;
}
.software-card-content {
@apply p-6;
}
.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; }
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<header class="hero-section-gradient from-teal-600 to-green-600 py-20">
<div class="hero-container max-w-4xl">
<h1 class="hero-title">Codebase</h1>
<p class="hero-subtitle text-teal-50 mb-10">
Explore our collection of open-source projects, study our source code, and contribute to our independent game development tools.
</p>
<a href="https://git.teletype.hu" target="_blank" class="btn-hero-teal">
Open Gitea
</a>
</div>
</header>
<main class="main-container">
<div v-if="error" class="error-alert" role="alert">
<strong class="font-bold">Error!</strong>
<span class="block sm:inline"> {{ error }}</span>
</div>
<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">
{{ 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">Language: {{ repo.language }}</p>
<p class="repo-card-meta">Updated: {{ formatDateTime(repo.updated_at) }}</p>
</div>
</div>
<h2 class="commits-section-title">Recent Commits</h2>
<p v-if="!error && recentCommits.length === 0" class="empty-commits-msg">
No recent commits found or accessible from public repositories.
</p>
<div class="commits-list">
<div v-for="commit in recentCommits" :key="commit.sha" class="commit-item">
<div class="commit-icon">🚀</div>
<div class="commit-content">
<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>
: <span class="text-gray-700">{{ commit.message.split('\n')[0] }}</span>
</p>
<p class="commit-meta">
Authored by <span class="font-bold">{{ commit.author.name }}</span> 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>
</a>
</p>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { formatDateTime } from '../utils/dateFormat'
import { GIT_BASE } from '../config'
const GITEA_API_BASE_URL = GIT_BASE
const GITEA_TOKEN = import.meta.env.WEBAPP_GITEA_TOKEN
interface GiteaRepo {
owner: { login: string }
name: string
description: string
language?: string
html_url: string
updated_at: string
}
interface Commit {
sha: string
message: string
author: { name: string; email: string }
date: string
url: string
repo: { owner: string; name: string; html_url: string }
}
const publicRepos = ref<GiteaRepo[]>([])
const recentCommits = ref<Commit[]>([])
const error = ref<string | null>(null)
onMounted(async () => {
if (!GITEA_TOKEN) {
error.value = 'Gitea API token (WEBAPP_GITEA_TOKEN) is not configured.'
return
}
const headers = {
'Authorization': `token ${GITEA_TOKEN}`,
'Accept': 'application/json',
}
try {
const reposRes = await fetch(`${GITEA_API_BASE_URL}/repos/search?q=&private=false&limit=50`, { headers })
if (!reposRes.ok) throw new Error(`Gitea API responded with status ${reposRes.status}`)
const reposData = await reposRes.json()
publicRepos.value = reposData.data || []
const commitPromises = publicRepos.value.map(async (repo) => {
try {
const res = await fetch(
`${GITEA_API_BASE_URL}/repos/${repo.owner.login}/${repo.name}/commits?limit=10&page=1`,
{ headers }
)
if (!res.ok) return []
const data = await res.json()
if (!Array.isArray(data)) return []
return data.map((c: any): Commit => ({
sha: c.sha,
message: c.commit?.message ?? '',
author: { name: c.commit?.author?.name ?? 'Unknown', email: c.commit?.author?.email ?? '' },
date: c.commit?.author?.date ?? c.created,
url: c.html_url,
repo: { owner: repo.owner.login, name: repo.name, html_url: repo.html_url },
}))
} catch {
return []
}
})
const all = (await Promise.all(commitPromises)).flat()
all.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
recentCommits.value = all.slice(0, 20)
} catch (e: any) {
error.value = `Failed to fetch Gitea data: ${e.message}`
}
})
</script>
<style scoped>
.error-alert {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-8;
}
.repo-card-title {
@apply text-xl font-semibold mb-2;
}
.repo-link {
@apply text-blue-600 hover:text-blue-800;
}
.repo-card-desc {
@apply text-gray-700 text-sm;
}
.repo-card-meta {
@apply text-gray-500 text-xs mt-1;
}
.commits-section-title {
@apply text-3xl font-bold mb-8 text-center text-gray-800;
}
.empty-commits-msg {
@apply text-center text-gray-600 text-lg;
}
.commits-list {
@apply space-y-4 mb-12;
}
.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;
}
.commit-message {
@apply text-gray-800 font-semibold;
}
.commit-repo-link {
@apply text-blue-600 hover:text-blue-800;
}
.commit-meta {
@apply text-gray-600 text-sm mt-1;
}
.commit-sha-link {
@apply ml-2 text-blue-500 hover:text-blue-700;
}
</style>
@@ -1,21 +1,11 @@
---
import Layout from '../layouts/Layout.astro';
const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || "https://discord.gg/your-discord-link";
---
<Layout>
<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">Contact Us</h1>
<p class="hero-subtitle mb-10 text-blue-100">
Have questions, feedback, or just want to say hi? We'd love to hear from you!
</p>
<a
href={DISCORD_INVITE_LINK}
target="_blank"
class="discord-hero-btn"
>
<a :href="DISCORD_INVITE_LINK" target="_blank" class="discord-hero-btn">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037 19.736 19.736 0 0 0-4.885 1.515.069.069 0 0 0-.032.027C.533 9.048-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.862-1.297 1.197-1.99.051-.105.001-.23-.106-.272a13.223 13.223 0 0 1-1.878-.894.077.077 0 0 1-.007-.128c.126-.094.252-.192.372-.29a.074.074 0 0 1 .077-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .077.01c.12.098.246.196.373.29a.077.077 0 0 1-.006.128 12.299 12.299 0 0 1-1.878.894.077.077 0 0 0-.106.272c.335.693.735 1.36 1.197 1.99a.078.078 0 0 0 .084.028 19.83 19.83 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.182 0-2.156-1.085-2.156-2.419 0-1.333.956-2.419 2.156-2.419 1.21 0 2.176 1.096 2.156 2.419 0 1.334-.956 2.419-2.156 2.419zm7.974 0c-1.182 0-2.156-1.085-2.156-2.419 0-1.333.955-2.419 2.156-2.419 1.21 0 2.176 1.096 2.156 2.419 0 1.334-.946 2.419-2.156 2.419z"/>
</svg>
@@ -33,23 +23,9 @@ const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || "https://disc
<p class="contact-card-desc">
For business inquiries, support, or detailed feedback, feel free to drop us an email.
</p>
<div id="email-container" class="email-link">
<span class="opacity-50 text-sm font-normal italic">Loading email...</span>
<div @click="openEmail" class="email-link cursor-pointer">
contact@teletype.hu
</div>
<script is:inline>
// Simple obfuscation to thwart basic scrapers
const user = 'contact';
const domain = 'teletype.hu';
const element = document.getElementById('email-container');
if (element) {
element.innerHTML = `${user}@${domain}`;
element.addEventListener('click', () => {
window.location.href = `mailto:${user}@${domain}`;
});
}
</script>
</div>
<div class="contact-card">
@@ -59,12 +35,12 @@ const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || "https://disc
<p class="contact-card-desc mb-8">
Join our primarily Hungarian-speaking community (English speakers are also welcome!).
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 justify-center">
<div class="community-section border-[#5865F2]">
<h3 class="community-section-title text-[#5865F2]">Discord</h3>
<p class="community-section-desc">We have a vibrant Discord server for real-time chat and support.</p>
<a href={DISCORD_INVITE_LINK} target="_blank" class="community-link-discord">
<a :href="DISCORD_INVITE_LINK" target="_blank" class="community-link-discord">
Join Discord Server <span></span>
</a>
</div>
@@ -98,58 +74,66 @@ const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || "https://disc
</div>
</div>
</main>
</Layout>
</template>
<style>
.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;
}
.contact-grid {
@apply grid grid-cols-1 gap-12 max-w-5xl mx-auto;
}
.contact-card {
@apply bg-white p-8 rounded-2xl shadow-xl border border-gray-100;
}
.contact-card-title {
@apply text-2xl font-black text-gray-900 mb-6 flex items-center gap-3;
}
.contact-card-desc {
@apply text-gray-600 mb-6 leading-relaxed;
}
.email-link {
@apply text-indigo-600 font-bold text-xl hover:underline cursor-pointer transition-all;
}
.community-section {
@apply border-l-4 pl-4;
}
.community-section-title {
@apply text-lg font-bold text-green-700 mb-2;
}
.community-section-desc {
@apply text-gray-600 text-sm mb-3;
}
.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;
}
.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;
}
<script setup lang="ts">
const DISCORD_INVITE_LINK = import.meta.env.DISCORD_INVITE_LINK || 'https://discord.gg/your-discord-link'
function openEmail() {
window.location.href = 'mailto:contact@teletype.hu'
}
</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;
}
.contact-grid {
@apply grid grid-cols-1 gap-12 max-w-5xl mx-auto;
}
.contact-card {
@apply bg-white p-8 rounded-2xl shadow-xl border border-gray-100;
}
.contact-card-title {
@apply text-2xl font-black text-gray-900 mb-6 flex items-center gap-3;
}
.contact-card-desc {
@apply text-gray-600 mb-6 leading-relaxed;
}
.email-link {
@apply text-indigo-600 font-bold text-xl hover:underline transition-all;
}
.community-section {
@apply border-l-4 pl-4;
}
.community-section-title {
@apply text-lg font-bold mb-2;
}
.community-section-desc {
@apply text-gray-600 text-sm mb-3;
}
.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;
}
.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;
}
</style>
+458
View File
@@ -0,0 +1,458 @@
<template>
<div class="home-container">
<!-- Header Section -->
<header class="hero-section-slate">
<div class="home-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">Welcome to</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-subtitle">
Teletype Games is an independent game development community built on creative freedom, equality, and an open-source mindset. Our goal is to create experimental and full-fledged games with short development cycles.
</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">Starts on: <span class="font-semibold">{{ nextEvent.dateText }}</span></p>
<div v-if="followingEvents.length > 0" class="upcoming-events-section">
<h3 class="upcoming-events-title">Upcoming Events</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>
</div>
</section>
<!-- Featured Software Section -->
<section v-if="highlightedSoftware" class="mb-16">
<div class="featured-card group">
<div class="featured-content">
<div class="flex items-center gap-2 mb-4">
<span class="featured-badge">Featured Game</span>
<span :class="`status-badge status-${highlightedSoftware.software.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>
<div class="flex flex-wrap gap-4 mt-8">
<a
v-if="highlightedStableRelease?.htmlFolderPath"
:href="BASE + highlightedStableRelease.htmlFolderPath"
target="_blank"
class="featured-btn-play"
> Play in Browser</a>
<RouterLink :to="`/catalog/${highlightedSoftware.software.name}`" class="featured-btn-secondary">
View Project Details
</RouterLink>
</div>
</div>
</div>
</section>
<!-- Latest YouTube Video -->
<section class="mb-16">
<div class="yt-featured-wrapper group">
<div class="yt-glow"></div>
<div class="yt-header-area">
<div class="flex items-center gap-3">
<div class="yt-status-blob">
<div class="yt-status-dot"></div>
</div>
<span class="yt-label-text">Latest from YouTube</span>
</div>
<a href="https://www.youtube.com/@teletypegames" target="_blank" class="yt-channel-link">
Visit Channel
</a>
</div>
<div class="yt-content-grid">
<div class="yt-player-container">
<div class="player-wrap relative w-full aspect-video">
<div v-if="ytState === 'loading'" class="state-overlay absolute inset-0 flex items-center justify-center bg-slate-950">
<div class="w-10 h-10 border-4 border-white/5 border-t-indigo-500 rounded-full animate-spin"></div>
</div>
<div v-else-if="ytState === 'error'" class="state-overlay absolute inset-0 flex items-center justify-center bg-slate-950 flex-col gap-2">
<div class="text-xl"></div>
<div class="text-[11px] text-slate-500 text-center px-4 font-bold uppercase tracking-tighter">{{ ytError }}</div>
</div>
<div v-else-if="ytState === 'no-config'" class="state-overlay absolute inset-0 flex items-center justify-center bg-slate-950">
<div class="text-slate-500 text-xs">Missing API Configuration</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="w-full h-full border-none block"
></iframe>
</div>
</div>
<div v-if="ytVideoId" class="yt-info-sidebar">
<div class="mb-auto">
<span class="yt-new-tag">New Release</span>
<h3 class="yt-video-title">{{ ytTitle }}</h3>
<div class="yt-stats-row">
<span v-if="ytPublishDate">📅 {{ ytPublishDate }}</span>
<span v-if="ytViewCount">👁 {{ ytViewCount }}</span>
</div>
</div>
<div class="mt-6">
<a :href="`https://www.youtube.com/watch?v=${ytVideoId}`" target="_blank" class="yt-play-button">
<span class="text-xl"></span> Watch on YouTube
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Navigation Grid -->
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-purple-50 group-hover:bg-purple-100">
<span class="text-3xl">🎮</span>
</div>
<h2 class="nav-card-title">Catalog</h2>
<p class="nav-card-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.</p>
<RouterLink to="/catalog" class="btn-primary bg-purple-600 hover:bg-purple-700 text-white hover:shadow-purple-200">
Browse Games
</RouterLink>
</div>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-green-50 group-hover:bg-green-100">
<span class="text-3xl">📚</span>
</div>
<h2 class="nav-card-title">Wiki</h2>
<p class="nav-card-desc">We gather our public interest development knowledge here. Read through technical descriptions and development logs.</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">
Knowledge Base
</a>
</div>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-slate-50 group-hover:bg-slate-100">
<span class="text-3xl">💻</span>
</div>
<h2 class="nav-card-title">Git Repos</h2>
<p class="nav-card-desc">All our projects are open source. Browse our code, study our solutions, or even participate in the development.</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">
Gitea
</a>
</div>
<div class="nav-card group card-interactive">
<div class="nav-card-icon bg-red-50 group-hover:bg-red-100">
<span class="text-3xl">📺</span>
</div>
<h2 class="nav-card-title">YouTube</h2>
<p class="nav-card-desc">Watch our development vlogs, gameplay videos, and tutorials on our official YouTube channel.</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">
Watch on YouTube
</a>
</div>
</section>
<!-- AI Content Notice -->
<div class="mb-12 mt-12 bg-white rounded-2xl border border-indigo-100 p-6 shadow-sm flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-indigo-50 flex items-center justify-center text-2xl flex-shrink-0">🤖</div>
<div class="flex-grow text-slate-600 text-sm md:text-base italic">
<strong>Note:</strong> Our HowTo-s and blog posts are written by AI with human supervision.
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { formatDateTime } from '../utils/dateFormat'
import { GAMES_BASE } from '../config'
const BASE = GAMES_BASE
const YOUTUBE_API_KEY = import.meta.env.YOUTUBE_API_KEY
const YOUTUBE_CHANNEL_ID = import.meta.env.YOUTUBE_CHANNEL_ID
interface Event { name: string; date: string }
const nextEvent = ref<(Event & { dateISO: string; dateText: string }) | null>(null)
const followingEvents = ref<(Event & { dateText: string })[]>([])
const highlightedSoftware = ref<any>(null)
const highlightedStableRelease = ref<any>(null)
const countdownText = ref('-- : -- : -- : --')
const ytState = ref<'loading' | 'loaded' | 'error' | 'no-config'>('loading')
const ytVideoId = ref('')
const ytTitle = ref('')
const ytPublishDate = ref('')
const ytViewCount = ref('')
const ytError = ref('')
let countdownInterval: ReturnType<typeof setInterval> | null = null
function startCountdown(targetISO: string) {
const targetDate = new Date(targetISO).getTime()
function update() {
const distance = targetDate - Date.now()
if (distance < 0) {
countdownText.value = 'LIVE NOW!'
if (countdownInterval) clearInterval(countdownInterval)
return
}
const d = String(Math.floor(distance / 86400000)).padStart(2, '0')
const h = String(Math.floor((distance % 86400000) / 3600000)).padStart(2, '0')
const m = String(Math.floor((distance % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((distance % 60000) / 1000)).padStart(2, '0')
countdownText.value = `${d}d ${h}h ${m}m ${s}s`
}
update()
countdownInterval = setInterval(update, 1000)
}
function formatYtDate(iso: string): string {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatViews(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M views'
if (n >= 1_000) return Math.round(n / 1_000) + 'K views'
return n + ' views'
}
async function loadLatestVideo() {
if (!YOUTUBE_API_KEY || !YOUTUBE_CHANNEL_ID) {
ytState.value = 'no-config'
return
}
try {
const searchRes = await fetch(`https://www.googleapis.com/youtube/v3/search?key=${YOUTUBE_API_KEY}&channelId=${YOUTUBE_CHANNEL_ID}&part=snippet&order=date&maxResults=1&type=video`)
if (!searchRes.ok) throw new Error(`API error: ${searchRes.status}`)
const searchData = await searchRes.json()
if (!searchData.items?.length) {
ytError.value = 'No videos found on this channel.'
ytState.value = 'error'
return
}
const item = searchData.items[0]
const videoId = item.id.videoId
const title = item.snippet.title
const pubDate = item.snippet.publishedAt
const statsRes = await fetch(`https://www.googleapis.com/youtube/v3/videos?key=${YOUTUBE_API_KEY}&id=${videoId}&part=statistics`)
const statsData = await statsRes.json()
const views = statsData.items?.[0]?.statistics?.viewCount ?? null
ytVideoId.value = videoId
ytTitle.value = title
ytPublishDate.value = formatYtDate(pubDate)
ytViewCount.value = views ? formatViews(Number(views)) : ''
ytState.value = 'loaded'
} catch (err: any) {
ytError.value = 'Error loading video: ' + err.message
ytState.value = 'error'
}
}
onMounted(async () => {
// Load events
try {
const res = await fetch(BASE + '/api/events')
if (res.ok) {
const events: Event[] = await res.json()
if (events.length > 0) {
const first = events[0]
const firstDate = new Date(first.date)
nextEvent.value = {
name: first.name,
date: first.date,
dateISO: firstDate.toISOString(),
dateText: formatDateTime(firstDate),
}
followingEvents.value = events.slice(1).map(e => ({
name: e.name,
date: e.date,
dateText: formatDateTime(new Date(e.date)),
}))
startCountdown(firstDate.toISOString())
}
}
} catch (e) {
console.error('Failed to load events:', e)
}
// Load highlighted software
try {
const res = await fetch(BASE + '/api/software/highlighted')
if (res.ok) {
const data = await res.json()
highlightedSoftware.value = data
if (data?.releases) {
const stable = data.releases
.filter((r: any) => !r.version.startsWith('dev-'))
.sort((a: any, b: any) => new Date(b.UpdatedAt).getTime() - new Date(a.UpdatedAt).getTime())
highlightedStableRelease.value = stable[0] ?? null
}
}
} catch (e) {
console.error('Failed to fetch highlighted software:', e)
}
// Load YouTube
await loadLatestVideo()
})
onUnmounted(() => {
if (countdownInterval) clearInterval(countdownInterval)
})
</script>
<style scoped>
.home-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.home-header-decor {
@apply absolute inset-0 opacity-30;
}
/* 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];
}
.featured-content {
@apply p-8 md:p-12 flex flex-col justify-center flex-1 z-10;
}
.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-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-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;
}
.countdown-date {
@apply text-gray-500 text-lg;
}
.upcoming-events-section {
@apply mt-12 pt-12 border-t border-gray-100 text-left max-w-2xl mx-auto;
}
.upcoming-events-title {
@apply text-xl font-bold text-gray-900 mb-6;
}
.upcoming-event-item {
@apply flex items-center justify-between p-4 bg-gray-50 rounded-xl border border-gray-100;
}
/* Nav Cards */
.nav-card {
@apply flex flex-col h-full;
}
.nav-card-icon {
@apply w-14 h-14 rounded-xl flex items-center justify-center mb-6 transition-colors;
}
.nav-card-title {
@apply text-2xl font-bold text-gray-900 mb-3;
}
.nav-card-desc {
@apply text-gray-600 mb-6 flex-grow leading-relaxed;
}
/* 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-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;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="howtos-container">
<header class="hero-section-slate">
<div class="howtos-header-decor">
<div class="hero-decor-blob -top-24 -left-24 h-96 w-96 bg-indigo-600"></div>
<div class="hero-decor-blob -bottom-24 -right-24 h-96 w-96 bg-purple-600"></div>
</div>
<div class="hero-container">
<div class="hero-badge">Knowledge Base</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">
The latest documentation, technical guides, and development logs from our knowledge base.
</p>
<a :href="WIKIJS_BASE_URL" target="_blank" class="btn-hero-indigo">
Knowledge Base
</a>
</div>
</header>
<main class="main-container">
<div v-if="error" class="error-banner" role="alert">
<span class="text-2xl"></span>
<div>
<h3 class="error-banner-title">Failed to connect to Wiki</h3>
<p class="error-banner-desc">{{ error }}</p>
</div>
</div>
<div v-else-if="!loading && recentPages.length === 0" class="empty-state">
<div class="empty-state-icon">📭</div>
<h2 class="empty-state-title">No pages found</h2>
<p class="empty-state-desc">It seems like there aren't any public pages available on the wiki at the moment.</p>
</div>
<div v-else class="card-grid">
<a
v-for="page in recentPages"
:key="page.id"
:href="`${WIKIJS_BASE_URL}/${page.locale}/${page.path}`"
target="_blank"
class="group card-interactive flex flex-col"
>
<div class="howto-card-meta">
<div class="howto-card-timestamp">
<span v-if="isNew(page.createdAt)" class="new-badge">
<span class="new-dot"></span>
New
</span>
<span>{{ formatDateTime(page.updatedAt) }}</span>
</div>
</div>
<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">No description provided.</div>
<div class="howto-card-footer">
<span class="howto-card-path">{{ page.path }}</span>
<span class="howto-card-read-link">Read <span class="text-lg">→</span></span>
</div>
</a>
</div>
<div v-if="!error && recentPages.length > 0" class="howtos-footer">
<a :href="WIKIJS_BASE_URL" target="_blank" class="browse-wiki-btn">
Browse Wiki Home
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { formatDateTime } from '../utils/dateFormat'
import { WIKI_BASE } from '../config'
const WIKIJS_BASE_URL = WIKI_BASE
const WIKIJS_TOKEN = import.meta.env.WEBAPP_WIKIJS_TOKEN
interface WikiPage {
id: number
path: string
title: string
description: string
updatedAt: string
createdAt: string
locale: string
}
const recentPages = ref<WikiPage[]>([])
const error = ref<string | null>(null)
const loading = ref(true)
function isNew(createdAt: string): boolean {
return (new Date().getTime() - new Date(createdAt).getTime()) < 7 * 24 * 60 * 60 * 1000
}
onMounted(async () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json',
}
if (WIKIJS_TOKEN) {
headers['Authorization'] = `Bearer ${WIKIJS_TOKEN}`
}
const GRAPHQL_QUERY = `
{
pages {
list(orderBy: UPDATED, orderByDirection: DESC, tags: ["howto"]) {
id path title description updatedAt createdAt locale
}
}
}
`
try {
const response = await fetch(`${WIKIJS_BASE_URL}/graphql`, {
method: 'POST',
headers,
body: JSON.stringify({ query: GRAPHQL_QUERY }),
})
if (!response.ok) throw new Error(`WikiJS responded with status ${response.status}`)
const json = await response.json()
if (json.errors) throw new Error(`GraphQL error: ${json.errors.map((e: any) => e.message).join(', ')}`)
const pages: any[] = json?.data?.pages?.list ?? []
recentPages.value = pages.map((p: any) => ({
id: p.id,
path: p.path,
title: p.title || p.path,
description: p.description ?? '',
updatedAt: p.updatedAt,
createdAt: p.createdAt,
locale: p.locale,
})).slice(0, 30)
} catch (e: any) {
error.value = `Failed to fetch wiki data: ${e.message}`
} finally {
loading.value = false
}
})
</script>
<style scoped>
.howtos-container {
@apply bg-gray-50 min-h-screen pb-20;
}
.howtos-header-decor {
@apply absolute inset-0 opacity-30;
}
.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;
}
.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;
}
.howto-card-desc {
@apply text-gray-600 text-sm mb-4 line-clamp-3 leading-relaxed flex-grow;
}
.howto-card-no-desc {
@apply flex-grow mb-4 italic text-gray-400 text-sm;
}
.howto-card-footer {
@apply pt-4 border-t border-gray-100 mt-auto flex items-center justify-between;
}
.howto-card-path {
@apply text-xs font-mono text-gray-400 truncate max-w-[180px];
}
.howto-card-read-link {
@apply text-indigo-600 text-sm font-bold flex items-center gap-1 group-hover:translate-x-1 transition-transform;
}
.howtos-footer {
@apply mt-16 text-center;
}
.browse-wiki-btn {
@apply inline-flex items-center gap-2 bg-slate-900 text-white px-8 py-4 rounded-xl hover:bg-slate-800 transition-all font-bold shadow-lg hover:shadow-indigo-200/50;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<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">Our Team</h1>
<p class="hero-subtitle mb-10 text-purple-100">
Meet the brilliant minds behind Teletype Games.
</p>
</div>
</header>
<main class="main-container py-16 px-4">
<div class="team-grid">
<div v-for="member in teamMembers" :key="member.name" class="team-card">
<div class="team-card-image-container">
<img :src="member.image" :alt="member.name" class="team-card-image" />
</div>
<div class="team-card-content">
<h2 class="team-card-title">{{ member.name }}</h2>
<p class="team-card-desc">{{ member.description }}</p>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import mrZeroImg from '../assets/team/mr.zero.png'
import mrOneImg from '../assets/team/mr.one.png'
import mrTwoImg from '../assets/team/mr.two.png'
import mrThreeImg from '../assets/team/mr.three.png'
const teamMembers = [
{ name: 'Mr. Zero: Tasi', description: 'His dream is to become an open-source knight.', image: mrZeroImg },
{ name: 'Mr. One: Ballz', description: 'The cheese is half-eaten. Something here went very wrong.', image: mrOneImg },
{ name: 'Mr. Two: Z', description: "The egg was first or the chicken? It doesn't matter, we eat both.", image: mrTwoImg },
{ name: 'Mr. Three: gBird', description: 'Life is beautiful because it contains the possibility of becoming human.', image: mrThreeImg },
]
</script>
<style scoped>
.team-grid {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 max-w-7xl mx-auto;
}
.team-card {
@apply bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100 transition-all hover:scale-105;
}
.team-card-image-container {
@apply aspect-square overflow-hidden bg-gray-200;
}
.team-card-image {
@apply w-full h-full object-cover;
}
.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;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.png' {
const src: string
export default src
}
declare module '*.jpg' {
const src: string
export default src
}
declare module '*.svg' {
const src: string
export default src
}
+19 -3
View File
@@ -1,5 +1,21 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
envPrefix: ['VITE_', 'WEBAPP_', 'YOUTUBE_', 'DISCORD_'],
server: {
host: true,
allowedHosts: ['teletypegames.org'],
proxy: {
'/proxy/wiki': {
target: 'https://wiki.teletype.hu',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/proxy\/wiki/, '')
},
'/proxy/git': {
target: 'https://git.teletype.hu',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/proxy\/git/, '')
}
}
}
})
+17 -2
View File
@@ -28,11 +28,16 @@ services:
- ./data/gitea:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.gitea.rule=Host(`${GITEA_DOMAIN}`)"
- "traefik.http.routers.gitea.rule=Host(`${GITEA_DOMAIN}`) || Host(`${GITEA_TECHNICAL_DOMAIN}`)"
- "traefik.http.routers.gitea.entrypoints=web"
- "traefik.http.routers.gitea.middlewares=gitea-cors"
- "traefik.http.services.gitea.loadbalancer.server.port=3000"
- "traefik.docker.network=proxy"
- "traefik.http.middlewares.gitea-cors.headers.accesscontrolalloworiginlist=https://${WEBAPP_DOMAIN}"
- "traefik.http.middlewares.gitea-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.gitea-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
- "traefik.http.middlewares.gitea-cors.headers.accesscontrolmaxage=3600"
- "traefik.http.middlewares.gitea-cors.headers.addvaryheader=true"
networks:
- proxy
- interstack
@@ -132,7 +137,7 @@ services:
- "traefik.http.routers.frontend.rule=Host(`${WEBAPP_DOMAIN}`)"
- "traefik.http.routers.frontend.entrypoints=web"
- "traefik.http.routers.frontend.priority=1"
- "traefik.http.services.frontend.loadbalancer.server.port=4321"
- "traefik.http.services.frontend.loadbalancer.server.port=5173"
- "traefik.docker.network=proxy"
networks:
- proxy
@@ -164,11 +169,21 @@ services:
- api_bundle:/usr/local/bundle
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.api-cors.headers.accesscontrolalloworiginlist=https://${WEBAPP_DOMAIN}"
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.api-cors.headers.accesscontrolallowheaders=Content-Type,Authorization,Accept,X-Requested-With"
- "traefik.http.middlewares.api-cors.headers.accesscontrolmaxage=3600"
- "traefik.http.middlewares.api-cors.headers.addvaryheader=true"
- "traefik.http.routers.api.rule=Host(`${WEBAPP_DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/file`) || PathPrefix(`/update`) || PathPrefix(`/admin`))"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.routers.api.priority=10"
- "traefik.http.routers.api.middlewares=api-cors"
- "traefik.http.services.api.loadbalancer.server.port=3000"
- "traefik.docker.network=proxy"
- "traefik.http.routers.api-games.rule=Host(`${GAMES_DOMAIN:-games.teletype.hu}`)"
- "traefik.http.routers.api-games.entrypoints=web"
- "traefik.http.routers.api-games.middlewares=api-cors"
- "traefik.http.routers.api-games.service=api"
networks:
- proxy
- interstack