ai generated solutions to our ai generated problems

This commit is contained in:
2026-06-22 19:51:13 +01:00
parent a3947f3662
commit 237a9a69fc
9 changed files with 688 additions and 101 deletions
-3
View File
@@ -25,9 +25,6 @@ npm run dev
The frontend development server runs on <http://localhost:3001>. The frontend development server runs on <http://localhost:3001>.
Set `comingsoon=TRUE` in `.env` to serve a temporary coming soon page instead
of the app. Omit it, or set any other value, to serve the regular site.
By default, `/api/*` and `/health` requests are proxied to `http://localhost:6000`. Override By default, `/api/*` and `/health` requests are proxied to `http://localhost:6000`. Override
that with `VITE_API_TARGET`: that with `VITE_API_TARGET`:
-1
View File
@@ -1,5 +1,4 @@
NODE_ENV=production NODE_ENV=production
comingsoon=TRUE
PORT=3010 PORT=3010
API_UPSTREAM=http://127.0.0.1:6000 API_UPSTREAM=http://127.0.0.1:6000
Binary file not shown.
+541 -1
View File
@@ -39,6 +39,7 @@ const primaryNavItems = [
{ path: '/players', label: 'Players' }, { path: '/players', label: 'Players' },
{ path: '/battle-logs', label: 'Battles' }, { path: '/battle-logs', label: 'Battles' },
{ path: '/tournaments', label: 'Tournaments' }, { path: '/tournaments', label: 'Tournaments' },
{ path: '/blog', label: 'Blog' },
] ]
const utilityNavItems = [ const utilityNavItems = [
@@ -61,6 +62,11 @@ const siteGateEnabled = String(import.meta.env.VITE_SITE_GATE || 'false').toLowe
const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '') const staticDataBase = (import.meta.env.VITE_STATIC_DATA_BASE || '/data').replace(/\/+$/, '')
const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true' const staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true'
const missingStaticDataPaths = new Set() const missingStaticDataPaths = new Set()
const blogPostFiles = import.meta.glob('./blog/posts/*.md', {
eager: true,
import: 'default',
query: '?raw',
})
// Rendered inline (not via <img src="data:...">) so the SVGs live in the page's // Rendered inline (not via <img src="data:...">) so the SVGs live in the page's
// CSS scope and can resolve currentColor and the --sprite-* / --color-* theme // CSS scope and can resolve currentColor and the --sprite-* / --color-* theme
// variables. An external SVG image renders in an isolated document and would // variables. An external SVG image renders in an isolated document and would
@@ -183,6 +189,11 @@ function parseRoute(pathname = window.location.pathname) {
if (pathname === '/') return { page: 'home', teamName: '' } if (pathname === '/') return { page: 'home', teamName: '' }
if (pathname === '/teams') return { page: 'teams', teamName: '' } if (pathname === '/teams') return { page: 'teams', teamName: '' }
if (pathname === '/players') return { page: 'players', teamName: '' } if (pathname === '/players') return { page: 'players', teamName: '' }
if (pathname === '/blog') return { page: 'blog', teamName: '' }
if (pathname.startsWith('/blog/')) {
const slug = decodeURIComponent(pathname.slice('/blog/'.length))
return { page: 'blog-post', teamName: '', slug }
}
if (pathname === '/uptime') return { page: 'uptime', teamName: '' } if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
if (pathname === '/viewers') return { page: 'viewers', teamName: '' } if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
if (pathname === '/privacy') return { page: 'privacy', teamName: '' } if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
@@ -224,6 +235,10 @@ function tournamentPath(tournamentId) {
return `/tournaments/${encodeURIComponent(tournamentId)}` return `/tournaments/${encodeURIComponent(tournamentId)}`
} }
function blogPostPath(slug) {
return `/blog/${encodeURIComponent(slug)}`
}
function formatNumber(value) { function formatNumber(value) {
return numberFormat.format(Number(value || 0)) return numberFormat.format(Number(value || 0))
} }
@@ -247,6 +262,81 @@ function formatDuration(seconds) {
return `${m}m ${s}s` return `${m}m ${s}s`
} }
function slugify(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function parseBlogFrontMatter(raw) {
const text = String(raw || '').replace(/^\uFEFF/, '')
if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
return { attributes: {}, content: text.trim() }
}
const normalized = text.replace(/\r\n/g, '\n')
const end = normalized.indexOf('\n---', 4)
if (end === -1) return { attributes: {}, content: normalized.trim() }
const frontMatter = normalized.slice(4, end).trim()
const content = normalized.slice(end + 4).trim()
const attributes = {}
frontMatter.split('\n').forEach((line) => {
const separator = line.indexOf(':')
if (separator === -1) return
const key = line.slice(0, separator).trim()
const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')
if (key) attributes[key] = value
})
return { attributes, content }
}
function excerptFromMarkdown(markdown) {
return String(markdown || '')
.replace(/```[\s\S]*?```/g, '')
.split('\n')
.map((line) => line.replace(/^#{1,6}\s+/, '').trim())
.find((line) => line && !line.startsWith('![') && !line.startsWith('>')) || ''
}
function createBlogPost(path, raw) {
const { attributes, content } = parseBlogFrontMatter(raw)
const filename = path.split('/').pop()?.replace(/\.md$/i, '') || ''
const slug = slugify(attributes.slug || filename)
const title = attributes.title || filename.replace(/[-_]+/g, ' ') || 'Untitled post'
const dateValue = attributes.date || ''
const timestamp = dateValue ? Date.parse(dateValue) : 0
return {
author: attributes.author || '',
content,
date: dateValue,
excerpt: attributes.excerpt || excerptFromMarkdown(content),
path: blogPostPath(slug),
slug,
timestamp: Number.isFinite(timestamp) ? timestamp : 0,
title,
}
}
const blogPosts = Object.entries(blogPostFiles)
.map(([path, raw]) => createBlogPost(path, raw))
.filter((post) => post.slug && post.title)
.sort((a, b) => b.timestamp - a.timestamp || a.title.localeCompare(b.title))
const blogPostsBySlug = new Map(blogPosts.map((post) => [post.slug, post]))
function formatBlogDate(dateValue) {
if (!dateValue) return 'Undated'
const timestamp = Date.parse(dateValue)
if (!Number.isFinite(timestamp)) return dateValue
return new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(new Date(timestamp))
}
function gameParticipants(game) { function gameParticipants(game) {
const winner = displayTeamName(game?.winning_team) const winner = displayTeamName(game?.winning_team)
const loser = displayTeamName(game?.losing_team) const loser = displayTeamName(game?.losing_team)
@@ -617,6 +707,8 @@ function routeLabel(route) {
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}` if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
if (route.page === 'teams') return 'Team Leaderboard' if (route.page === 'teams') return 'Team Leaderboard'
if (route.page === 'players') return 'Player Leaderboard' if (route.page === 'players') return 'Player Leaderboard'
if (route.page === 'blog') return 'Blog'
if (route.page === 'blog-post') return blogPostsBySlug.get(route.slug)?.title || 'Blog post'
if (route.page === 'battle-logs') return 'Battle Logs' if (route.page === 'battle-logs') return 'Battle Logs'
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game' if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
if (route.page === 'tournaments-list') return 'Tournaments' if (route.page === 'tournaments-list') return 'Tournaments'
@@ -637,6 +729,8 @@ function canonicalPathForRoute(route) {
if (route.page === 'team' && route.teamName) return teamPath(route.teamName) if (route.page === 'team' && route.teamName) return teamPath(route.teamName)
if (route.page === 'teams') return '/teams' if (route.page === 'teams') return '/teams'
if (route.page === 'players') return '/players' if (route.page === 'players') return '/players'
if (route.page === 'blog') return '/blog'
if (route.page === 'blog-post' && route.slug) return blogPostPath(route.slug)
if (route.page === 'battle-logs') return '/battle-logs' if (route.page === 'battle-logs') return '/battle-logs'
if (route.page === 'game' && route.gameId) return gamePath(route.gameId) if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
if (route.page === 'tournaments-list') return '/tournaments' if (route.page === 'tournaments-list') return '/tournaments'
@@ -725,6 +819,18 @@ function seoForRoute(route, profileDetail = null) {
robots: 'index, follow', robots: 'index, follow',
path: '/tournaments', path: '/tournaments',
}, },
blog: {
title: "TSS Bot Blog | Toothless' TSS Bot",
description: 'News, updates, and announcements from Toothless TSS Bot.',
robots: 'index, follow',
path: '/blog',
},
'blog-post': {
title: `${blogPostsBySlug.get(route.slug)?.title || 'Blog post'} | Toothless' TSS Bot`,
description: blogPostsBySlug.get(route.slug)?.excerpt || 'News and updates from Toothless TSS Bot.',
robots: blogPostsBySlug.has(route.slug) ? 'index, follow' : 'noindex, follow',
path: route.slug ? blogPostPath(route.slug) : '/blog',
},
uptime: { uptime: {
title: "TSS Bot Uptime Status | Toothless' TSS Bot", title: "TSS Bot Uptime Status | Toothless' TSS Bot",
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.', description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
@@ -1828,6 +1934,8 @@ function AppContent() {
? '/battle-logs' ? '/battle-logs'
: route.page === 'tournaments-list' || route.page === 'tournament' : route.page === 'tournaments-list' || route.page === 'tournament'
? '/tournaments' ? '/tournaments'
: route.page === 'blog' || route.page === 'blog-post'
? '/blog'
: route.page === 'viewers' : route.page === 'viewers'
? '/viewers' ? '/viewers'
: window.location.pathname : window.location.pathname
@@ -1978,6 +2086,8 @@ function AppContent() {
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null} {route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
{route.page === 'privacy' ? <PrivacyPage /> : null} {route.page === 'privacy' ? <PrivacyPage /> : null}
{route.page === 'docs' ? <DocsPage /> : null} {route.page === 'docs' ? <DocsPage /> : null}
{route.page === 'blog' ? <BlogPage navigate={navigate} posts={blogPosts} /> : null}
{route.page === 'blog-post' ? <BlogPostPage navigate={navigate} post={blogPostsBySlug.get(route.slug)} /> : null}
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null} {route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null} {route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null} {route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
@@ -2023,6 +2133,13 @@ function Footer({ navigate }) {
> >
Documentation Documentation
</button> </button>
<button
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/blog')}
type="button"
>
Blog
</button>
<a <a
className="w-fit font-semibold text-fury-cyan transition hover:text-text" className="w-fit font-semibold text-fury-cyan transition hover:text-text"
href="https://github.com/Clippii/tssbot.web" href="https://github.com/Clippii/tssbot.web"
@@ -2437,13 +2554,414 @@ function DocsPage() {
</p> </p>
<h1 className="mt-2 text-4xl font-bold">Documentation</h1> <h1 className="mt-2 text-4xl font-bold">Documentation</h1>
<p className="mt-3 text-text-soft"> <p className="mt-3 text-text-soft">
Coming soon. Documentation is being written.
</p> </p>
</div> </div>
</section> </section>
) )
} }
function safeMarkdownLink(href) {
const value = String(href || '').trim()
if (/^(https?:|mailto:|\/)/i.test(value)) return value
return '#'
}
function safeMediaSource(src) {
const value = String(src || '').trim()
if (/^(https?:|\/)/i.test(value)) return value
return ''
}
function youtubeEmbedUrl(value) {
try {
const url = new URL(value)
const host = url.hostname.replace(/^www\./, '')
let id = ''
if (host === 'youtu.be') {
id = url.pathname.split('/').filter(Boolean)[0] || ''
} else if (host === 'youtube.com' || host === 'm.youtube.com') {
if (url.pathname.startsWith('/watch')) id = url.searchParams.get('v') || ''
if (url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')) {
id = url.pathname.split('/').filter(Boolean)[1] || ''
}
}
if (!/^[A-Za-z0-9_-]{6,32}$/.test(id)) return ''
return `https://www.youtube-nocookie.com/embed/${id}`
} catch {
return ''
}
}
function vimeoEmbedUrl(value) {
try {
const url = new URL(value)
const host = url.hostname.replace(/^www\./, '')
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return ''
const id = url.pathname.split('/').filter(Boolean).find((part) => /^\d+$/.test(part)) || ''
return id ? `https://player.vimeo.com/video/${id}` : ''
} catch {
return ''
}
}
function mediaEmbedFor(url) {
const src = safeMediaSource(url)
if (!src) return { kind: 'invalid', src: '' }
const youtube = youtubeEmbedUrl(src)
if (youtube) return { kind: 'iframe', src: youtube, title: 'YouTube video' }
const vimeo = vimeoEmbedUrl(src)
if (vimeo) return { kind: 'iframe', src: vimeo, title: 'Vimeo video' }
if (/\.(mp4|webm|ogg)(?:[?#].*)?$/i.test(src)) return { kind: 'video', src }
return { kind: 'link', src }
}
function MarkdownInline({ text }) {
const parts = []
const pattern = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g
let lastIndex = 0
let match = pattern.exec(text)
while (match) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index))
if (match[2] && match[3]) {
const href = safeMarkdownLink(match[3])
parts.push(
<a
className="font-semibold text-fury-cyan underline-offset-4 hover:underline"
href={href}
key={`link-${match.index}`}
rel={href.startsWith('http') ? 'noopener noreferrer' : undefined}
target={href.startsWith('http') ? '_blank' : undefined}
>
{match[2]}
</a>,
)
} else if (match[5]) {
parts.push(
<code className="rounded bg-surface px-1.5 py-0.5 text-[0.9em] text-text" key={`code-${match.index}`}>
{match[5]}
</code>,
)
} else if (match[7]) {
parts.push(<strong key={`strong-${match.index}`}>{match[7]}</strong>)
} else if (match[9]) {
parts.push(<em key={`em-${match.index}`}>{match[9]}</em>)
}
lastIndex = pattern.lastIndex
match = pattern.exec(text)
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts
}
function MarkdownContent({ markdown }) {
const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
if (!line.trim()) {
index += 1
continue
}
if (line.startsWith('```')) {
const language = line.slice(3).trim()
const code = []
index += 1
while (index < lines.length && !lines[index].startsWith('```')) {
code.push(lines[index])
index += 1
}
index += 1
blocks.push({ type: 'code', language, text: code.join('\n') })
continue
}
const mediaEmbed = line.match(/^@\[([^\]]*)\]\(([^)]+)\)$/)
if (mediaEmbed) {
blocks.push({ type: 'media', label: mediaEmbed[1].trim(), url: mediaEmbed[2].trim() })
index += 1
continue
}
const image = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
if (image) {
blocks.push({ type: 'image', alt: image[1].trim(), src: image[2].trim() })
index += 1
continue
}
const heading = line.match(/^(#{1,3})\s+(.+)$/)
if (heading) {
blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] })
index += 1
continue
}
if (/^>\s?/.test(line)) {
const quote = []
while (index < lines.length && /^>\s?/.test(lines[index])) {
quote.push(lines[index].replace(/^>\s?/, ''))
index += 1
}
blocks.push({ type: 'quote', text: quote.join(' ') })
continue
}
if (/^[-*]\s+/.test(line)) {
const items = []
while (index < lines.length && /^[-*]\s+/.test(lines[index])) {
items.push(lines[index].replace(/^[-*]\s+/, ''))
index += 1
}
blocks.push({ type: 'list', ordered: false, items })
continue
}
if (/^\d+\.\s+/.test(line)) {
const items = []
while (index < lines.length && /^\d+\.\s+/.test(lines[index])) {
items.push(lines[index].replace(/^\d+\.\s+/, ''))
index += 1
}
blocks.push({ type: 'list', ordered: true, items })
continue
}
const paragraph = [line.trim()]
index += 1
while (
index < lines.length &&
lines[index].trim() &&
!lines[index].startsWith('```') &&
!/^@\[([^\]]*)\]\(([^)]+)\)$/.test(lines[index]) &&
!/^!\[([^\]]*)\]\(([^)]+)\)$/.test(lines[index]) &&
!/^(#{1,3})\s+/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) &&
!/^[-*]\s+/.test(lines[index]) &&
!/^\d+\.\s+/.test(lines[index])
) {
paragraph.push(lines[index].trim())
index += 1
}
blocks.push({ type: 'paragraph', text: paragraph.join(' ') })
}
return (
<div className="blog-content mt-8 grid gap-5 text-base leading-8 text-text-soft">
{blocks.map((block, blockIndex) => {
if (block.type === 'heading') {
const className = block.level === 1
? 'mt-4 text-3xl font-bold leading-tight text-text'
: block.level === 2
? 'mt-3 text-2xl font-bold leading-tight text-text'
: 'mt-2 text-xl font-semibold leading-tight text-text'
const HeadingTag = `h${Math.min(block.level + 1, 4)}`
return (
<HeadingTag className={className} key={`heading-${blockIndex}`}>
<MarkdownInline text={block.text} />
</HeadingTag>
)
}
if (block.type === 'code') {
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-surface p-4 text-sm leading-6 text-text" key={`code-${blockIndex}`}>
<code>{block.text}</code>
</pre>
)
}
if (block.type === 'image') {
const src = safeMediaSource(block.src)
if (!src) return null
return (
<figure className="overflow-hidden rounded-lg border border-border bg-surface" key={`image-${blockIndex}`}>
<img
alt={block.alt}
className="block max-h-[560px] w-full object-contain"
loading="lazy"
src={src}
/>
{block.alt ? (
<figcaption className="border-t border-border px-4 py-2 text-sm text-text-soft">
{block.alt}
</figcaption>
) : null}
</figure>
)
}
if (block.type === 'media') {
const embed = mediaEmbedFor(block.url)
if (embed.kind === 'iframe') {
return (
<div className="aspect-video overflow-hidden rounded-lg border border-border bg-surface shadow-sm" key={`media-${blockIndex}`}>
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-full w-full"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
src={embed.src}
title={block.label || embed.title}
/>
</div>
)
}
if (embed.kind === 'video') {
return (
<video
className="aspect-video w-full rounded-lg border border-border bg-surface shadow-sm"
controls
key={`media-${blockIndex}`}
preload="metadata"
src={embed.src}
>
<a href={embed.src}>Open video</a>
</video>
)
}
if (embed.kind === 'link') {
return (
<p className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-text-soft" key={`media-${blockIndex}`}>
Media link:{' '}
<a
className="font-semibold text-fury-cyan underline-offset-4 hover:underline"
href={embed.src}
rel="noopener noreferrer"
target="_blank"
>
{block.label || embed.src}
</a>
</p>
)
}
return null
}
if (block.type === 'quote') {
return (
<blockquote className="border-l-4 border-fury-cyan bg-surface px-4 py-3 font-semibold text-text" key={`quote-${blockIndex}`}>
<MarkdownInline text={block.text} />
</blockquote>
)
}
if (block.type === 'list') {
const ListTag = block.ordered ? 'ol' : 'ul'
return (
<ListTag className={`${block.ordered ? 'list-decimal' : 'list-disc'} grid gap-2 pl-6`} key={`list-${blockIndex}`}>
{block.items.map((item, itemIndex) => (
<li key={`${blockIndex}-${itemIndex}`}>
<MarkdownInline text={item} />
</li>
))}
</ListTag>
)
}
return (
<p key={`paragraph-${blockIndex}`}>
<MarkdownInline text={block.text} />
</p>
)
})}
</div>
)
}
function BlogPage({ navigate, posts }) {
return (
<section className="mx-auto max-w-5xl pb-12 pt-24 sm:pt-28">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
Blog
</p>
<h1 className="mt-2 text-4xl font-bold">News and updates</h1>
<p className="mt-3 max-w-2xl text-text-soft">
Updates from the TSS bot, posted from Markdown files in the repo.
</p>
</div>
<div className="mt-8 grid gap-4">
{posts.map((post) => (
<article className="rounded-lg border border-border bg-fury-white p-5 shadow-sm" key={post.slug}>
<button
className="block w-full text-left"
onClick={() => navigate(post.path)}
type="button"
>
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
{formatBlogDate(post.date)}
</p>
<h2 className="mt-2 text-2xl font-bold text-text">{post.title}</h2>
{post.excerpt ? (
<p className="mt-3 text-sm leading-6 text-text-soft">{post.excerpt}</p>
) : null}
<span className="mt-4 inline-block text-sm font-semibold text-fury-cyan">
Read post
</span>
</button>
</article>
))}
{!posts.length ? (
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm">
No posts yet. Add a Markdown file to frontend/src/blog and rebuild.
</p>
) : null}
</div>
</section>
)
}
function BlogPostPage({ navigate, post }) {
if (!post) {
return (
<section className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
<div className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Blog</p>
<h1 className="mt-2 text-4xl font-bold">Post not found</h1>
<p className="mt-3 text-text-soft">That blog post does not exist.</p>
</div>
<button
className="mt-8 rounded-lg bg-text px-4 py-2 text-sm font-semibold text-bg apricot-button-text"
onClick={() => navigate('/blog')}
type="button"
>
Back to blog
</button>
</section>
)
}
return (
<article className="mx-auto max-w-4xl pb-12 pt-24 sm:pt-28">
<button
className="mb-6 text-sm font-semibold text-fury-cyan transition hover:text-text"
onClick={() => navigate('/blog')}
type="button"
>
Back to blog
</button>
<header className="border-b border-border pb-6">
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
{formatBlogDate(post.date)}
</p>
<h1 className="mt-2 text-4xl font-bold leading-tight">{post.title}</h1>
{post.author ? <p className="mt-3 text-text-soft">By {post.author}</p> : null}
</header>
<MarkdownContent markdown={post.content} />
</article>
)
}
function PrivacySection({ children, title }) { function PrivacySection({ children, title }) {
return ( return (
<article className="border-l border-border pl-4"> <article className="border-l border-border pl-4">
@@ -2466,6 +2984,7 @@ function Landing({
teamQuery, teamQuery,
}) { }) {
const treeRef = useRef(null) const treeRef = useRef(null)
const latestBlogPost = blogPosts[0]
return ( return (
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg"> <div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
@@ -2535,6 +3054,27 @@ function Landing({
Search Search
</button> </button>
</form> </form>
<button
className="mt-4 grid w-full gap-3 border-l-2 border-fury-cyan bg-fury-white/80 px-4 py-3 text-left shadow-sm backdrop-blur transition hover:bg-surface sm:grid-cols-[1fr_auto] sm:items-center"
onClick={() => navigate(latestBlogPost?.path || '/blog')}
type="button"
>
<span className="min-w-0">
<span className="block text-xs font-semibold uppercase tracking-wide text-fury-cyan">
Latest news
</span>
<span className="mt-1 block truncate text-sm font-semibold text-text">
{latestBlogPost?.title || 'News and updates'}
</span>
<span className="mt-1 block truncate text-xs text-text-soft">
{latestBlogPost?.excerpt || 'Read the latest notes from Toothless TSS Bot.'}
</span>
</span>
<span className="text-sm font-semibold text-fury-cyan">
{latestBlogPost ? 'Read post' : 'Open blog'}
</span>
</button>
</div> </div>
</div> </div>
+112
View File
@@ -0,0 +1,112 @@
# Blog posts
Add a `.md` file in `frontend/src/blog/posts` and it will appear on `/blog` after the Vite dev server refreshes or the site is rebuilt.
Use this front matter at the top:
```md
---
title: Your post title
date: 2026-06-22
author: Heidi
excerpt: Short one-line summary for the blog list.
---
Write the post here.
```
The URL slug comes from the filename, so `new-feature.md` becomes `/blog/new-feature`. You can override it with `slug: custom-url`.
## Markdown examples
Headings:
```md
# Huge heading
## Section heading
### Small heading
```
Paragraphs:
```md
This is a normal paragraph.
Leave a blank line to start a new paragraph.
```
Bullet points:
```md
- First bullet
- Second bullet
- Third bullet
```
Numbered lists:
```md
1. First step
2. Second step
3. Third step
```
Blockquotes:
```md
> This text gets shown as a quote/callout.
```
Links:
```md
[The homepage](/)
[Discord](https://discord.com/)
```
Inline code:
```md
Use `frontend/src/blog/posts` for blog posts.
```
Code blocks:
````md
```js
console.log('hello from the blog')
```
````
## Images and videos
Put local images and videos in `frontend/public`. Files in there are served from the site root.
Example local file paths:
```txt
frontend/public/images/news/screenshot.png
frontend/public/videos/news/launch.mp4
```
Embed an image:
```md
![Image caption or alt text](/images/news/screenshot.png)
```
Embed a local video:
```md
@[video](/videos/news/launch.mp4)
```
Embed YouTube or Vimeo:
```md
@[youtube](https://www.youtube.com/watch?v=VIDEO_ID)
@[vimeo](https://vimeo.com/123456789)
```
YouTube embeds use the privacy-friendly `youtube-nocookie.com` player. Direct video files support `.mp4`, `.webm`, and `.ogg`.
+28
View File
@@ -0,0 +1,28 @@
---
title: Our Launch & The Future
date: 2026-06-22
author: Heidi
excerpt: A brief discussion about the site and the future.
---
# ♥ Awruff :3
Hi all, glad to see you all (mostly) like the page :3 (over 1000 page views in 24hrs (allegedly) + nothing but positive feedback)
Just a brief overview of what we're going to be adding in the future
## stuff:
- 3D maps for all played maps (in progress, should be done within the next few weeks)
@[video](/videos/news/3d_map_preview.mp4)
- The ability to predict where a player might go on a map, basically just showing all their prior paths as either a heatmap or a couple lines with % probability, based on their historical games
- Missile timings to the viewer (time to impact, etc)
- Generally just more 3D viewer stuff, like chaff deployment, flare deployment
- accurate 3d models for vehicles, player cones of vision, etc.
Honestly, I dont really know what to add.. lol, if you have any suggestions for stuff you'd like to see, feel free to suggest them on our [Discord](https://discord.gg/kP45rcU2Jx), basically any suggestion will be reviewed :3
Have fun!!!!!!!! ♥♥♥
+4 -4
View File
@@ -149,11 +149,11 @@ TSS_BATTLES_DB="$WD/tss_battles.db" TSS_TEAMS_DB="$WD/tss_teams.db" \
VEHICLE_TRANSLATIONS_JSON="$WD/vt.json" VEHICLE_DATA_CACHE_JSON="$WD/vc.json" \ VEHICLE_TRANSLATIONS_JSON="$WD/vt.json" VEHICLE_DATA_CACHE_JSON="$WD/vc.json" \
BACKEND_PORT="$BE_PORT" "$BIN" >/tmp/vgd_be.log 2>&1 & BACKEND_PORT="$BE_PORT" "$BIN" >/tmp/vgd_be.log 2>&1 &
BE_PID=$! BE_PID=$!
# Override PUBLIC_ORIGIN/comingsoon so the server's same-origin guard accepts the # Override PUBLIC_ORIGIN so the server's same-origin guard accepts the localhost
# localhost test origin and serves normally. server.cjs's loadEnvFile only fills # test origin. server.cjs's loadEnvFile only fills unset/empty vars, so these
# unset/empty vars, so these non-empty values win over the prod .env. # non-empty values win over the prod .env.
PORT="$WEB_PORT" API_UPSTREAM="http://127.0.0.1:$BE_PORT" VEHICLE_ICONS_DIR="$ICONS" \ PORT="$WEB_PORT" API_UPSTREAM="http://127.0.0.1:$BE_PORT" VEHICLE_ICONS_DIR="$ICONS" \
UPTIME_STORAGE_DIR="$STORE" PUBLIC_ORIGIN="http://localhost:$WEB_PORT" comingsoon="FALSE" \ UPTIME_STORAGE_DIR="$STORE" PUBLIC_ORIGIN="http://localhost:$WEB_PORT" \
node "$WEB_REPO/server.cjs" >/tmp/vgd_web.log 2>&1 & node "$WEB_REPO/server.cjs" >/tmp/vgd_web.log 2>&1 &
WEB_PID=$! WEB_PID=$!
# wait for both to listen # wait for both to listen
-35
View File
@@ -60,7 +60,6 @@ const PUBLIC_DATA_STALE_REVALIDATE_SECONDS = Math.max(0, Math.floor(PUBLIC_DATA_
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || '' const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''
const COMING_SOON = String(process.env.comingsoon || process.env.COMINGSOON || '').toLowerCase() === 'true'
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000) const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
const TURNSTILE_MAX_TOKEN_LENGTH = 2048 const TURNSTILE_MAX_TOKEN_LENGTH = 2048
@@ -2371,36 +2370,6 @@ function sendHtml(req, res, data, status = 200) {
}) })
} }
function comingSoonHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#fefde7">
<title>Toothless' TSS Bot | Coming soon</title>
</head>
<body style="margin:0;min-width:320px;min-height:100vh;background:#fefde7;color:#000;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Arial,sans-serif;">
<main style="min-height:100vh;display:grid;place-items:center;padding:32px;">
<section style="width:min(100%,720px);border-left:4px solid #e82517;padding:8px 0 8px 28px;">
<p style="margin:0 0 14px;color:#e82517;font-size:13px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;">Toothless' TSS Bot</p>
<h1 style="margin:0;color:#000;font-size:clamp(48px,12vw,112px);line-height:.92;font-weight:900;letter-spacing:0;">Coming soon</h1>
<p style="margin:24px 0 0;max-width:520px;color:#555;font-size:18px;line-height:1.6;font-weight:600;">TSS analytics are getting tucked away for a little bit. Check back soon.</p>
</section>
</main>
</body>
</html>`
}
function sendComingSoonPage(req, res) {
send(res, 200, comingSoonHtml(), {
...securityHeaders(req, { html: true }),
'content-type': mimeTypes['.html'],
'cache-control': 'no-store',
})
}
function sendRobotsTxt(req, res) { function sendRobotsTxt(req, res) {
const origin = pagePublicOrigin(req) const origin = pagePublicOrigin(req)
const body = [ const body = [
@@ -2452,10 +2421,6 @@ function serveStatic(req, res) {
} catch { } catch {
return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' }) return send(res, 400, 'Bad request', { 'content-type': 'text/plain; charset=utf-8' })
} }
if (COMING_SOON) {
return sendComingSoonPage(req, res)
}
const relativePath = requestPath === '/' ? '/index.html' : requestPath const relativePath = requestPath === '/' ? '/index.html' : requestPath
const filePath = path.resolve(DIST_DIR, `.${relativePath}`) const filePath = path.resolve(DIST_DIR, `.${relativePath}`)
const relativeToDist = path.relative(DIST_DIR, filePath) const relativeToDist = path.relative(DIST_DIR, filePath)
+3 -57
View File
@@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
import { defineConfig, loadEnv } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@@ -130,65 +130,11 @@ function apiGuard() {
} }
} }
function comingSoonHtml() { export default defineConfig(() => {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<meta name="theme-color" content="#fefde7">
<title>Toothless' TSS Bot | Coming soon</title>
</head>
<body style="margin:0;min-width:320px;min-height:100vh;background:#fefde7;color:#000;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Arial,sans-serif;">
<main style="min-height:100vh;display:grid;place-items:center;padding:32px;">
<section style="width:min(100%,720px);border-left:4px solid #e82517;padding:8px 0 8px 28px;">
<p style="margin:0 0 14px;color:#e82517;font-size:13px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;">Toothless' TSS Bot</p>
<h1 style="margin:0;color:#000;font-size:clamp(48px,12vw,112px);line-height:.92;font-weight:900;letter-spacing:0;">Coming soon</h1>
<p style="margin:24px 0 0;max-width:520px;color:#555;font-size:18px;line-height:1.6;font-weight:600;">TSS analytics are getting tucked away for a little bit. Check back soon.</p>
</section>
</main>
</body>
</html>`
}
function comingSoonDev(enabled) {
return {
name: 'coming-soon-dev',
configureServer(server) {
if (!enabled) return
server.middlewares.use((req, res, next) => {
if (req.url?.startsWith('/api/') || req.url === '/health') {
next()
return
}
const acceptsHtml = String(req.headers.accept || '').includes('text/html')
if (!acceptsHtml && req.url !== '/') {
next()
return
}
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
})
res.end(comingSoonHtml())
})
},
}
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const comingSoon = String(env.comingsoon || env.COMINGSOON || '').toLowerCase() === 'true'
return { return {
root: path.resolve(__dirname, 'frontend'), root: path.resolve(__dirname, 'frontend'),
publicDir: path.resolve(__dirname, 'frontend/public'), publicDir: path.resolve(__dirname, 'frontend/public'),
plugins: [comingSoonDev(comingSoon), apiGuard(), react(), tailwindcss()], plugins: [apiGuard(), react(), tailwindcss()],
define: { define: {
'import.meta.env.VITE_SITE_VERSION': JSON.stringify(siteVersion()), 'import.meta.env.VITE_SITE_VERSION': JSON.stringify(siteVersion()),
}, },