mirror of
https://github.com/skoelle/28k8-moonweb-org.git
synced 2026-09-18 02:40:24 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
export interface Props { html: string; }
|
||||
const { html } = Astro.props;
|
||||
---
|
||||
<Fragment set:html={html} />
|
||||
@@ -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>
|
||||
@@ -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">|<<</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">>>|</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
---
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user