ai generated solutions to our ai generated problems
This commit is contained in:
Binary file not shown.
+541
-1
@@ -39,6 +39,7 @@ const primaryNavItems = [
|
||||
{ path: '/players', label: 'Players' },
|
||||
{ path: '/battle-logs', label: 'Battles' },
|
||||
{ path: '/tournaments', label: 'Tournaments' },
|
||||
{ path: '/blog', label: 'Blog' },
|
||||
]
|
||||
|
||||
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 staticDataEnabled = String(import.meta.env.VITE_STATIC_DATA || 'false').toLowerCase() === 'true'
|
||||
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
|
||||
// CSS scope and can resolve currentColor and the --sprite-* / --color-* theme
|
||||
// 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 === '/teams') return { page: 'teams', 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 === '/viewers') return { page: 'viewers', teamName: '' }
|
||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||
@@ -224,6 +235,10 @@ function tournamentPath(tournamentId) {
|
||||
return `/tournaments/${encodeURIComponent(tournamentId)}`
|
||||
}
|
||||
|
||||
function blogPostPath(slug) {
|
||||
return `/blog/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormat.format(Number(value || 0))
|
||||
}
|
||||
@@ -247,6 +262,81 @@ function formatDuration(seconds) {
|
||||
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) {
|
||||
const winner = displayTeamName(game?.winning_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 === 'teams') return 'Team 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 === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
||||
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 === 'teams') return '/teams'
|
||||
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 === 'game' && route.gameId) return gamePath(route.gameId)
|
||||
if (route.page === 'tournaments-list') return '/tournaments'
|
||||
@@ -725,6 +819,18 @@ function seoForRoute(route, profileDetail = null) {
|
||||
robots: 'index, follow',
|
||||
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: {
|
||||
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
||||
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
||||
@@ -1828,6 +1934,8 @@ function AppContent() {
|
||||
? '/battle-logs'
|
||||
: route.page === 'tournaments-list' || route.page === 'tournament'
|
||||
? '/tournaments'
|
||||
: route.page === 'blog' || route.page === 'blog-post'
|
||||
? '/blog'
|
||||
: route.page === 'viewers'
|
||||
? '/viewers'
|
||||
: window.location.pathname
|
||||
@@ -1978,6 +2086,8 @@ function AppContent() {
|
||||
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||
{route.page === 'privacy' ? <PrivacyPage /> : 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 === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||||
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
|
||||
@@ -2023,6 +2133,13 @@ function Footer({ navigate }) {
|
||||
>
|
||||
Documentation
|
||||
</button>
|
||||
<button
|
||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||
onClick={() => navigate('/blog')}
|
||||
type="button"
|
||||
>
|
||||
Blog
|
||||
</button>
|
||||
<a
|
||||
className="w-fit font-semibold text-fury-cyan transition hover:text-text"
|
||||
href="https://github.com/Clippii/tssbot.web"
|
||||
@@ -2437,13 +2554,414 @@ function DocsPage() {
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-bold">Documentation</h1>
|
||||
<p className="mt-3 text-text-soft">
|
||||
Coming soon.
|
||||
Documentation is being written.
|
||||
</p>
|
||||
</div>
|
||||
</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 }) {
|
||||
return (
|
||||
<article className="border-l border-border pl-4">
|
||||
@@ -2466,6 +2984,7 @@ function Landing({
|
||||
teamQuery,
|
||||
}) {
|
||||
const treeRef = useRef(null)
|
||||
const latestBlogPost = blogPosts[0]
|
||||
|
||||
return (
|
||||
<div className="relative left-1/2 w-screen -translate-x-1/2 bg-bg">
|
||||
@@ -2535,6 +3054,27 @@ function Landing({
|
||||
Search
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -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!!!!!!!! ♥♥♥
|
||||
Reference in New Issue
Block a user