diff --git a/README.md b/README.md index 3497eee..4b64962 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ npm run dev The frontend development server runs on . -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 that with `VITE_API_TARGET`: diff --git a/example.env b/example.env index e588dbe..25bee70 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,4 @@ NODE_ENV=production -comingsoon=TRUE PORT=3010 API_UPSTREAM=http://127.0.0.1:6000 diff --git a/frontend/public/videos/news/3d_map_preview.mp4 b/frontend/public/videos/news/3d_map_preview.mp4 new file mode 100644 index 0000000..ae0babe Binary files /dev/null and b/frontend/public/videos/news/3d_map_preview.mp4 differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9f4bb31..f482bbd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ) 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' ? : null} {route.page === 'privacy' ? : null} {route.page === 'docs' ? : null} + {route.page === 'blog' ? : null} + {route.page === 'blog-post' ? : null} {route.page === 'player' ? : null} {route.page === 'game' ? : null} {route.page === 'tournaments-list' ? : null} @@ -2023,6 +2133,13 @@ function Footer({ navigate }) { > Documentation +

Documentation

- Coming soon. + Documentation is being written.

) } +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( +
+ {match[2]} + , + ) + } else if (match[5]) { + parts.push( + + {match[5]} + , + ) + } else if (match[7]) { + parts.push({match[7]}) + } else if (match[9]) { + parts.push({match[9]}) + } + + 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 ( +
+ {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 ( + + + + ) + } + if (block.type === 'code') { + return ( +
+              {block.text}
+            
+ ) + } + if (block.type === 'image') { + const src = safeMediaSource(block.src) + if (!src) return null + return ( +
+ {block.alt} + {block.alt ? ( +
+ {block.alt} +
+ ) : null} +
+ ) + } + if (block.type === 'media') { + const embed = mediaEmbedFor(block.url) + if (embed.kind === 'iframe') { + return ( +
+