vue3 version of frontend
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_WIKI_BASE=/proxy/wiki
|
||||
VITE_GIT_BASE=/proxy/git/api/v1
|
||||
@@ -7,6 +7,6 @@ RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4321
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
Generated
+567
-4517
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user