initial commit

This commit is contained in:
2026-08-15 18:51:43 +02:00
commit 8a6a1fcf07
119 changed files with 11326 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
export interface Props { html: string; }
const { html } = Astro.props;
---
<Fragment set:html={html} />
+8
View File
@@ -0,0 +1,8 @@
---
const letters = ['t', 'H', 'E', ' ', 't', 'E', 'M', 'P', 'L', 'E', ' ', 'b', 'B', 'S'];
---
<div class="letter-footer" aria-hidden="true">
{letters.map((l) => l === ' ' ? <span class="letter-space">{'\u00A0'}</span> : <span>{l}</span>)}
<a href="/" onclick="localStorage.removeItem('hasConnected');">[Q] Disconnect</a>
<a href="/bbs/legal-notice">[I] Legal Notice</a>
</div>
+142
View File
@@ -0,0 +1,142 @@
import { useState, useRef, useCallback } from 'react';
export interface ModEntry {
name: string;
title: string;
size_human: string;
channels: number;
sample_count: number;
modified: string;
url: string;
}
declare global {
interface Window {
ChiptuneJsPlayer: any;
}
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
async function getPlayer(): Promise<any> {
await loadScript('/js/chiptune3.js');
return new Promise<any>((resolve) => {
const p = new window.ChiptuneJsPlayer({ repeatCount: 0 });
p.onInitialized(() => resolve(p));
});
}
export default function ModPlayer({ mods }: { mods: ModEntry[] }) {
const [currentIdx, setCurrentIdx] = useState(-1);
const [playing, setPlaying] = useState(false);
const [ready, setReady] = useState(false);
const playerRef = useRef<any>(null);
const loadingRef = useRef(false);
const ensurePlayer = useCallback(async () => {
if (playerRef.current) return playerRef.current;
if (loadingRef.current) return null;
loadingRef.current = true;
try {
const p = await getPlayer();
p.onEnded(() => {
setCurrentIdx((prev) => {
const next = prev + 1;
if (next < mods.length) {
p.setRepeatCount(0);
p.load(mods[next].url);
return next;
}
setPlaying(false);
return -1;
});
});
playerRef.current = p;
setReady(true);
return p;
} catch (e) {
console.error('MOD player init failed:', e);
loadingRef.current = false;
return null;
}
}, [mods]);
const playIdx = useCallback(async (idx: number) => {
if (idx < 0 || idx >= mods.length) return;
const p = await ensurePlayer();
if (!p) return;
p.setRepeatCount(0);
p.load(mods[idx].url);
setCurrentIdx(idx);
setPlaying(true);
}, [mods, ensurePlayer]);
async function togglePlay(idx: number) {
const p = playerRef.current;
if (currentIdx === idx && playing) {
p?.pause();
setPlaying(false);
} else if (currentIdx === idx && !playing) {
p?.unpause();
setPlaying(true);
} else {
playIdx(idx);
}
}
function prev() {
if (currentIdx > 0) playIdx(currentIdx - 1);
}
function next() {
if (currentIdx < mods.length - 1) playIdx(currentIdx + 1);
}
const current = currentIdx >= 0 ? mods[currentIdx] : null;
return (
<div className="mod-player">
<div className="mod-player-nowplaying">
<span className="mod-player-label">NOW PLAYING:</span>{' '}
{current
? <span className="mod-player-track">{current.title} <span className="mod-player-file">({current.name})</span></span>
: <span className="mod-player-idle">-- nothing selected --</span>
}
<span className="mod-player-controls">
<button onClick={prev} disabled={currentIdx <= 0} className="mod-btn" title="Previous">|&lt;&lt;</button>
<button onClick={() => currentIdx >= 0 ? togglePlay(currentIdx) : playIdx(0)} className="mod-btn mod-btn-play" title={playing ? 'Pause' : 'Play'}>
{playing ? '⏸' : '▶'}
</button>
<button onClick={next} disabled={currentIdx >= mods.length - 1} className="mod-btn" title="Next">&gt;&gt;|</button>
</span>
</div>
<table className="mod-list">
<tbody>
{mods.map((m, i) => (
<tr key={m.name} className={currentIdx === i ? 'mod-active' : ''}>
<td className="mod-col-play">
<button onClick={() => togglePlay(i)} className="mod-btn-inline" title={currentIdx === i && playing ? 'Pause' : 'Play'}>
{currentIdx === i && playing ? '⏸' : '▶'}
</button>
</td>
<td className="mod-col-file">[F] {m.name}</td>
<td className="mod-col-title">{m.title}</td>
<td className="mod-col-ch">{m.channels}ch</td>
<td className="mod-col-size">{m.size_human}</td>
<td><a href={m.url}>[D]ownload</a></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useState } from 'react';
import { playLine1Sequence, markConnected } from '../lib/sound';
type DialState = 'idle' | 'dialing';
export default function ModemIntro() {
const [state, setState] = useState<DialState>('idle');
function selectLine(line: 1 | 2) {
if (line === 2) { markConnected(); window.location.href = '/bbs/'; return; }
setState('dialing');
playLine1Sequence(() => { markConnected(); window.location.href = '/bbs/'; });
}
return (
<div className="ansi-text welcome-modem" role="region" aria-label="Modem connect intro">
<pre style={{ textAlign: 'center' }}>{`+------------------------------------------+
| 28k8 [MODEM] ATDT +49-821-2191-038 |
+------------------------------------------+`}</pre>
{state === 'idle' && (
<pre style={{ textAlign: 'center' }}><a href="#" onClick={(e) => { e.preventDefault(); selectLine(1); }}>{`> Line 1: +49-821-2191-038 [VFC V34] 28800 <`}</a>{'\n\n'}<a href="#" onClick={(e) => { e.preventDefault(); selectLine(2); }}>{`> Line 2: +49-821-2191-036 [X75] 64000 <`}</a></pre>
)}
{state === 'dialing' && <p style={{ textAlign: 'center' }}>Dialing... please wait.</p>}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
---
export interface Props { showBack?: boolean; }
const { showBack = true } = Astro.props;
---
<div class="nav-bar">
{showBack && <a href="#" onclick="event.preventDefault(); history.back();">[B] Back</a>}
</div>
+5
View File
@@ -0,0 +1,5 @@
---
const now = new Date();
const dateStr = `${String(now.getDate()).padStart(2, '0')}.${String(now.getMonth() + 1).padStart(2, '0')}.1994`;
---
<div class="status-bar" style="display: flex; justify-content: space-between;"><span>Status: 28800 bps | Line: V34 | 28k8.moonweb.org</span><span>{dateStr}</span></div>
+18
View File
@@ -0,0 +1,18 @@
import { useEffect } from 'react';
import { findKeyEntry } from '../lib/keymap';
import { resetConnected } from '../lib/sound';
export default function TerminalShell({ children }: { children?: React.ReactNode }) {
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const entry = findKeyEntry(e.key);
if (!entry) return;
if (entry.href === 'back') { window.history.back(); return; }
if (entry.href === 'disconnect') { resetConnected(); window.location.href = '/'; return; }
window.location.href = entry.href;
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
return <>{children}</>;
}
+27
View File
@@ -0,0 +1,27 @@
---
interface Props {
title: string;
content: string;
}
const { title, content } = Astro.props;
const id = `overlay-${title.toLowerCase().replace(/[^a-z0-9]/gi, '-')}`;
---
<button class="text-overlay-btn" data-overlay={id}>
{title}
</button>
<style>
.text-overlay-btn {
background: none;
border: 1px solid var(--red-dark);
color: var(--fg);
font-family: var(--font-ansi);
padding: 0.25rem 0.6rem;
cursor: pointer;
font-size: inherit;
display: inline;
}
.text-overlay-btn:hover {
border-color: var(--red-bright);
color: var(--red-bright);
}
</style>
+125
View File
@@ -0,0 +1,125 @@
---
interface OverlayEntry {
id: string;
title: string;
content: string;
}
interface Props {
overlays: OverlayEntry[];
}
const { overlays } = Astro.props;
const data = JSON.stringify(overlays);
---
<script define:vars={{ data }}>
window.__textOverlays = JSON.parse(data);
</script>
<script>
function initOverlays() {
document.querySelectorAll('.text-overlay-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-overlay');
let dialog = document.getElementById(id) as HTMLDialogElement;
if (!dialog) {
const entry = window.__textOverlays.find((o) => o.id === id);
if (!entry) return;
dialog = document.createElement('dialog');
dialog.className = 'text-overlay';
dialog.id = id;
const titleHtml = document.createElement('span');
titleHtml.textContent = entry.title;
const contentPre = document.createElement('pre');
contentPre.className = 'text-overlay-content';
contentPre.textContent = entry.content;
const headerDiv = document.createElement('div');
headerDiv.className = 'text-overlay-header';
headerDiv.appendChild(titleHtml);
const closeBtn = document.createElement('button');
closeBtn.className = 'text-overlay-close';
closeBtn.textContent = '[X]';
headerDiv.appendChild(closeBtn);
const innerDiv = document.createElement('div');
innerDiv.className = 'text-overlay-inner';
innerDiv.appendChild(headerDiv);
innerDiv.appendChild(contentPre);
dialog.appendChild(innerDiv);
document.body.appendChild(dialog);
closeBtn.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
}
dialog.showModal();
});
});
}
initOverlays();
document.addEventListener('astro:page-load', initOverlays);
</script>
<style is:global>
.text-overlay {
background: #000;
color: #ffb000;
border: 2px solid var(--red-dark);
padding: 0;
max-width: 90vw;
height: 80vh;
width: 80ch;
overflow: hidden;
}
.text-overlay::backdrop {
background: rgba(0, 0, 0, 0.85);
}
.text-overlay-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.text-overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--red-dark);
font-family: var(--font-ansi);
color: var(--red-bright);
flex-shrink: 0;
}
.text-overlay-close {
background: none;
border: none;
color: var(--red-bright);
font-family: var(--font-ansi);
cursor: pointer;
padding: 0;
font-size: inherit;
}
.text-overlay-close:hover {
color: #fff;
}
.text-overlay-content {
margin: 0;
padding: 0.75rem;
font-family: var(--font-ansi);
font-size: 14px;
line-height: 1.15;
overflow-y: auto;
white-space: pre;
color: #ffb000;
flex: 1;
scrollbar-width: thin;
scrollbar-color: var(--red-dark) #000;
}
.text-overlay-content::-webkit-scrollbar {
width: 8px;
}
.text-overlay-content::-webkit-scrollbar-track {
background: #000;
}
.text-overlay-content::-webkit-scrollbar-thumb {
background: var(--red-dark);
border-radius: 0;
}
.text-overlay-content::-webkit-scrollbar-thumb:hover {
background: var(--red-bright);
}
</style>
+19
View File
@@ -0,0 +1,19 @@
---
export interface MenuItem { key: string; label: string; href: string; }
export interface Section { title: string; hardware: string; items: MenuItem[]; }
export interface Props { sections: Section[]; }
const { sections } = Astro.props;
---
<div>
{sections.map((section, i) => (
<div class="tile-section">
<h3>{section.title}</h3>
<p>{section.hardware}</p>
<ul style="list-style:none; padding:0; margin:0;">
{section.items.map((item) => (
<li><a class="tile" href={item.href} title={item.label} aria-label={`[${item.key}] ${item.label}`}>[{item.key}] {item.label}</a></li>
))}
</ul>
</div>
))}
</div>
+80
View File
@@ -0,0 +1,80 @@
---
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const raw = readFileSync(join(process.cwd(), 'source-assets/welcome-utf8.txt'), 'utf-8');
const text = raw.replace(/\r\n/g, '\n').trimEnd();
const escaped = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
---
<pre class="welcome-banner" id="welcome-banner" aria-hidden="true">{escaped}</pre>
<script>
(function () {
const el = document.getElementById('welcome-banner');
if (!el) return;
const full = el.textContent || '';
el.textContent = '';
let i = 0;
const BASE = 8;
const FAST = 4;
const HARD_PAUSES: [number, number][] = [[34, 400], [98, 350]];
function tick() {
if (i >= full.length) {
el.removeAttribute('aria-hidden');
return;
}
const ch = full[i];
el.textContent += ch;
i++;
let delay = FAST;
const hp = HARD_PAUSES.find(p => p[0] === i);
if (hp) {
delay = hp[1];
} else if (ch === '\n') {
delay = BASE * 4;
} else {
delay = BASE + Math.random() * 6;
}
setTimeout(tick, delay);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(tick, 300));
} else {
setTimeout(tick, 300);
}
})();
</script>
<style>
.welcome-banner {
font-family: var(--font-ansi);
color: var(--red-bright);
text-align: left;
width: 500px;
margin: 50px auto 0.5rem;
line-height: 1.1;
font-size: clamp(0.6rem, 1.5vw, 0.85rem);
letter-spacing: 0;
white-space: pre;
overflow: hidden;
min-height: 9.5em;
}
.welcome-banner::after {
content: '\2588';
animation: blink 1s step-end infinite;
color: var(--red-bright);
}
@keyframes blink {
50% { opacity: 0; }
}
</style>