Compare commits

...

2 Commits

Author SHA1 Message Date
Marc Robin Richter e3ea4772d6 add ts 2026-03-17 14:38:39 +01:00
Marc Robin Richter 8acde1f9ad add boot themes 2026-03-17 13:55:45 +01:00
8 changed files with 180 additions and 63 deletions
+8
View File
@@ -0,0 +1,8 @@
______ _
| ____| | |
| |__ _ __ __ _ _ __ | | __
| __| '__/ _` | '_ \| |/ /
| | | | | (_| | | | | <
|_| |_| \__,_|_| |_|_|\_\
was here. sorry about the server.
+4
View File
@@ -0,0 +1,4 @@
___ ___
/ __| / __|
| (_ | | (_ |
\___| \___|
+6
View File
@@ -0,0 +1,6 @@
_____
/ \
| () () |
\ ^ /
|||||
|||||
+36
View File
@@ -0,0 +1,36 @@
/\
//\\
// \\
// \\
|\ /|
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
__ | | | | __
/ \____| | | |____/ \
) ___/ ) ( \___ (
\__/ \__/ \__/ \__/
\____/
) (
/ /\ \
) )( (
| || |
| || |
| || |
| || |
| )( |
) \/ (
\____/
(____)
+26
View File
@@ -1,4 +1,5 @@
import type { LineColor } from '../components/BootSequence';
import { BOOT_SCREENS } from '../components/MatrixRain';
export interface OutputLine {
text: string;
@@ -101,6 +102,7 @@ export const handlers: Record<string, CommandHandler> = {
green(' ping <target> - Ping a target'),
green(' rm <file> - Remove a file'),
green(' sudo <cmd> - Run as root'),
green(' ts - Check TeamSpeak server status'),
green(' reboot - Reboot the server'),
],
@@ -211,6 +213,30 @@ export const handlers: Record<string, CommandHandler> = {
return [red(`rm: cannot remove '${args[args.length - 1]}': Permission denied`)];
},
theme: (args) => {
if (args.length === 0) {
const current = localStorage.getItem('stift15-bootscreen') || 'logo';
return [
amber('Boot screen themes:'),
...BOOT_SCREENS.map((s) =>
green(` ${s === current ? '▸ ' : ' '}${s}`)
),
dim(''),
dim(`Usage: theme <name>`),
dim('Run "reboot" to see the new boot screen.'),
];
}
const name = args[0].toLowerCase();
if (!BOOT_SCREENS.includes(name)) {
return [red(`theme: '${name}' not found. Available: ${BOOT_SCREENS.join(', ')}`)];
}
localStorage.setItem('stift15-bootscreen', name);
return [
green(`Boot screen set to '${name}'.`),
dim('Run "reboot" to see it.'),
];
},
sudo: (args) => {
if (args.length === 0) return [red('sudo: missing command')];
return [
+11
View File
@@ -91,12 +91,23 @@ export function Bricked() {
</div>
))}
{visibleCount >= LINES.length && (
<>
<div className="text-term-dim whitespace-pre leading-[1.4] mt-2">
<span
className="inline-block w-[0.6em] h-[1em] bg-term-dim ml-0.5 align-middle"
style={{ animation: 'blink 1s step-end infinite' }}
/>
</div>
<button
className="mt-6 px-4 py-2 border border-term-dim text-term-dim font-mono text-sm hover:text-term-green hover:border-term-green transition-colors cursor-pointer bg-transparent"
onClick={() => {
localStorage.removeItem('stift15-bricked');
setUnbricked(true);
}}
>
[ F ]
</button>
</>
)}
</div>
);
+60 -30
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { handlers, type OutputLine } from '../commands/handlers';
import { useState, useEffect, useRef, useCallback } from "react";
import { handlers, type OutputLine } from "../commands/handlers";
const PROMPT = 'stift15@server:~$ ';
const PROMPT = "stift15@server:~$ ";
const WELCOME: OutputLine[] = [
{ text: '> Connection restored.', color: 'green' },
{ text: '> Welcome to Stift15 Terminal v1.0', color: 'green' },
{ text: '> Type \'help\' for available commands.', color: 'dim' },
{ text: '', color: 'dim' },
{ text: "> Connection restored.", color: "green" },
{ text: "> Welcome to Stift15 Terminal v1.0", color: "green" },
{ text: "> Type 'help' for available commands.", color: "dim" },
{ text: "", color: "dim" },
];
interface CommandPromptProps {
@@ -17,14 +17,14 @@ interface CommandPromptProps {
export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
const [outputLines, setOutputLines] = useState<OutputLine[]>(WELCOME);
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(scrollToBottom, [outputLines.length, scrollToBottom]);
@@ -40,7 +40,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
const executeCommand = useCallback(
(cmdLine: string) => {
const echoLine: OutputLine = { text: PROMPT + cmdLine, color: 'green' };
const echoLine: OutputLine = { text: PROMPT + cmdLine, color: "green" };
const trimmed = cmdLine.trim();
if (!trimmed) {
@@ -52,22 +52,39 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
const cmd = parts[0].toLowerCase();
const args = parts.slice(1);
if (cmd === 'clear') {
if (cmd === "clear") {
setOutputLines([]);
return;
}
if (cmd === 'rm' && trimmed.includes('-rf') && trimmed.includes('/')) {
if (cmd === "rm" && trimmed.includes("-rf") && trimmed.includes("/")) {
setOutputLines((prev) => [...prev, echoLine]);
setTimeout(() => onBrick?.(), 300);
return;
}
if (cmd === 'reboot') {
if (cmd === "ts" || cmd === "teamspeak") {
const msg = encodeURIComponent("Frank, warum ist der ts down?");
window.open(`https://wa.me/?text=${msg}`, "_blank");
setOutputLines((prev) => [
...prev,
echoLine,
{ text: 'Rebooting server...', color: 'amber' },
{ text: "Checking TeamSpeak server stift15.de...", color: "amber" },
{ text: "", color: "dim" },
{ text: " ✗ NOT IMPLEMENTED", color: "red" },
{ text: " Opening WhatsApp to yell at Frank...", color: "dim" },
{ text: "", color: "dim" },
]);
setHistory((prev) => [...prev, trimmed]);
setHistoryIndex(-1);
return;
}
if (cmd === "reboot") {
setOutputLines((prev) => [
...prev,
echoLine,
{ text: "Rebooting server...", color: "amber" },
]);
setTimeout(() => onReboot?.(), 800);
return;
@@ -76,48 +93,61 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
const handler = handlers[cmd];
const result = handler
? handler(args)
: [{ text: `${cmd}: command not found. Type 'help' for available commands.`, color: 'red' as const }];
: [
{
text: `${cmd}: command not found. Type 'help' for available commands.`,
color: "red" as const,
},
];
setOutputLines((prev) => [...prev, echoLine, ...result, { text: '', color: 'dim' }]);
setOutputLines((prev) => [
...prev,
echoLine,
...result,
{ text: "", color: "dim" },
]);
setHistory((prev) => [...prev, trimmed]);
setHistoryIndex(-1);
},
[]
[onBrick, onReboot],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (e.key === "Enter") {
executeCommand(input);
setInput('');
setInput("");
setHistoryIndex(-1);
} else if (e.key === 'ArrowUp') {
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (history.length === 0) return;
const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
const newIndex =
historyIndex === -1
? history.length - 1
: Math.max(0, historyIndex - 1);
setHistoryIndex(newIndex);
setInput(history[newIndex]);
} else if (e.key === 'ArrowDown') {
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex === -1) return;
const newIndex = historyIndex + 1;
if (newIndex >= history.length) {
setHistoryIndex(-1);
setInput('');
setInput("");
} else {
setHistoryIndex(newIndex);
setInput(history[newIndex]);
}
}
},
[input, executeCommand, history, historyIndex]
[input, executeCommand, history, historyIndex],
);
const colorClass: Record<string, string> = {
green: 'text-term-green',
amber: 'text-term-amber',
red: 'text-term-red',
dim: 'text-term-dim',
green: "text-term-green",
amber: "text-term-amber",
red: "text-term-red",
dim: "text-term-dim",
};
return (
@@ -130,7 +160,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
key={i}
className={`${colorClass[line.color]} whitespace-pre leading-[1.4]`}
>
{line.text || '\u00A0'}
{line.text || "\u00A0"}
</div>
))}
@@ -140,7 +170,7 @@ export function CommandPrompt({ onReboot, onBrick }: CommandPromptProps) {
<span>{input}</span>
<span
className="inline-block w-[0.6em] h-[1em] bg-term-green ml-0.5"
style={{ animation: 'blink 1s step-end infinite' }}
style={{ animation: "blink 1s step-end infinite" }}
/>
{/* Hidden native input for reliable focus and mobile keyboard support */}
<input
+23 -27
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { loadAsciiArt } from '../utils/loadAsciiArt';
import { useEffect, useRef, useState, useCallback } from "react";
import { loadAsciiArt } from "../utils/loadAsciiArt";
interface MatrixRainProps {
onComplete?: () => void;
@@ -11,11 +11,14 @@ interface Column {
chars: string[];
}
export const BOOT_SCREENS = ["logo", "skull", "sword", "frank", "gg"];
const FONT_SIZE = 14;
const CHAR_WIDTH = FONT_SIZE * 0.6;
const CHAR_HEIGHT = FONT_SIZE * 1.2;
const FADE_ALPHA = 0.05;
const RAIN_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789';
const RAIN_CHARS =
"アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789";
function randomChar(): string {
return RAIN_CHARS[Math.floor(Math.random() * RAIN_CHARS.length)];
@@ -35,12 +38,14 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
const completedRef = useRef(false);
useEffect(() => {
loadAsciiArt('/ascii/logo.txt').then(setArtLines);
const stored = localStorage.getItem("stift15-bootscreen");
const artFile = stored && BOOT_SCREENS.includes(stored) ? stored : "logo";
loadAsciiArt(`/ascii/${artFile}.txt`).then(setArtLines);
}, []);
const startAnimation = useCallback(
(canvas: HTMLCanvasElement, art: string[]) => {
const ctx = canvas.getContext('2d')!;
const ctx = canvas.getContext("2d")!;
let animId: number;
function resize() {
@@ -67,7 +72,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
artGrid[r] = [];
for (let c = 0; c < artWidth; c++) {
const ch = art[r]?.[c];
if (ch && ch !== ' ') {
if (ch && ch !== " ") {
artGrid[r][c] = ch;
}
}
@@ -75,10 +80,10 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
// Track which art cells have been revealed and when (for glow decay)
const revealed: boolean[][] = Array.from({ length: artHeight }, () =>
Array(artWidth).fill(false)
Array(artWidth).fill(false),
);
const revealTimeMap: number[][] = Array.from({ length: artHeight }, () =>
Array(artWidth).fill(0)
Array(artWidth).fill(0),
);
const GLOW_DURATION = 1200; // ms for glow to fade out
let totalArtChars = 0;
@@ -92,11 +97,11 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
// Initialize columns
const columns: Column[] = Array.from({ length: totalCols }, () =>
createColumn(totalRows)
createColumn(totalRows),
);
// Clear to black initially
ctx.fillStyle = '#0a0a0a';
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
function draw() {
@@ -105,7 +110,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = `${FONT_SIZE}px "IBM Plex Mono", "Fira Code", "Courier New", monospace`;
ctx.textBaseline = 'top';
ctx.textBaseline = "top";
for (let col = 0; col < totalCols; col++) {
const column = columns[col];
@@ -143,7 +148,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
if (t === 0) {
// Head character — bright white-green
ctx.fillStyle = '#ccffcc';
ctx.fillStyle = "#ccffcc";
} else {
// Trail fades from bright to dim
const brightness = Math.max(0, 1 - t / trailLen);
@@ -156,11 +161,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
column.chars[row % column.chars.length] = randomChar();
}
ctx.fillText(
column.chars[row % column.chars.length],
x,
y
);
ctx.fillText(column.chars[row % column.chars.length], x, y);
}
// Reset column when it falls off screen
@@ -188,9 +189,9 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
ctx.fillStyle = `rgb(${white + 51}, 255, ${white + 51})`;
} else {
// Settled: normal bright green, no shadow
ctx.shadowColor = 'transparent';
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.fillStyle = '#33ff33';
ctx.fillStyle = "#33ff33";
}
ctx.fillText(artGrid[r][c]!, x, y);
@@ -198,7 +199,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
}
}
// Reset shadow state for next frame's rain drawing
ctx.shadowColor = 'transparent';
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
// Check completion
@@ -223,7 +224,7 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
resizeObserver.disconnect();
};
},
[onComplete]
[onComplete],
);
useEffect(() => {
@@ -231,10 +232,5 @@ export function MatrixRain({ onComplete }: MatrixRainProps) {
return startAnimation(canvasRef.current, artLines);
}, [artLines, startAnimation]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 z-0"
/>
);
return <canvas ref={canvasRef} className="absolute inset-0 z-0" />;
}