Compare commits

..

3 Commits

Author SHA1 Message Date
149ed13c5f chore: update gitignore 2026-03-23 12:45:13 +01:00
fd6edf9d09 feat: create app base 2026-03-23 12:44:50 +01:00
e70a8a2e25 chore: update gitignore to reflect vercel preferences 2026-03-23 12:43:48 +01:00
18 changed files with 35 additions and 490 deletions

9
.gitignore vendored
View File

@@ -7,18 +7,12 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
bun.lock
node_modules node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
# Runtime
bun.lock
# Vercel
.vercel
*.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -29,3 +23,4 @@ bun.lock
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.vercel

View File

@@ -6,18 +6,16 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
## buildování ## buildování
0. Pro buildování je potřeba software třetích stran: 0. Pro buildování je potřeba software třetích stran:
- Bun -> JavaScriptový/TypeScriptový runtime. Stáhnout lze z více zdrojů. Po instalaci doporučuji přidat Bun executable do PATH. Je možno použít Node.JS a npm. - Bun -> JavaScriptový/TypeScriptový runtime. Stáhnout lze z více zdrojů. Po instalaci doporučuji přidat Bun executable do PATH. Je možno použít Node.JS a npm.
- Univerzálně: - Univerzálně: pomocí npm příkazem `npm i -g bun`
1. pomocí npm příkazem `npm i -g bun`
2. z oficiální stránky https://bun.sh/
- Win NT: - Win NT:
1. z powershellu příkazem `powershell -c "irm bun.sh/install.ps1 | iex"` 1. ze stránky https://bun.sh/
2. přes winget powershellovým příkazem `winget install --id Oven-sh.Bun` 2. přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
3. pomocí chocolatey příkazem `choco install bun` 3. pomocí chocolatey příkazem `choco install bun`
- Arch Linux: - Arch Linux:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash` 1. ze stránky https://bun.sh/
2. z `extra` repozitáře příkazem `pacman -S bun` 2. z `extra` repozitáře příkazem `pacman -S bun`
- MacOS: - MacOS:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash` 1. ze stránky https://bun.sh/
2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun` 2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun`
- Vite -> Builder a packer pro webové aplikace - Vite -> Builder a packer pro webové aplikace
- Již definováno jako závislost v projektovém `package.json` - Již definováno jako závislost v projektovém `package.json`
@@ -31,12 +29,22 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
4. Stránka je nyní lokálně dostupná na http://localhost:3000/ 4. Stránka je nyní lokálně dostupná na http://localhost:3000/
5. (volitelné) Apliakci je možné buildnout pomocí `<bun/npm> run build`. Statická verze stránky je nyní dostupná v adresáři `webshell/dist/` 5. (volitelné) Apliakci je možné buildnout pomocí `<bun/npm> run build`. Statická verze stránky je nyní dostupná v adresáři `webshell/dist/`
## testování
Aplikace je testována v následujících prohlížečích:
- `Chromium 146.0.7680.153 (Official Build) Arch Linux (64-bit)`
- `Zen Browser 1.19.3b (Firefox 148.0.2) (64-bit)`
## licence ## licence
Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`), jejíž celé znění je dostupné v LICENSE.md. Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`). Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell
```md
Copyright 2026 Vendelín Mžik
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```

View File

@@ -1,30 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- google fonts --> <title>webshell</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> </head>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <body>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet" /> <div id="app">
<h1>hmmm</h1>
<!-- import main stylesheet --> </div>
<link rel="stylesheet" href="/src/styles/app.scss" /> <script type="module" src="/src/app.ts"></script>
</body>
<title>webshell</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app">
<div id="cursor"></div>
<div id="terminal">
<div id="lines"></div>
</div>
</div>
<!-- run main script -->
<script type="module" src="/src/app.ts"></script>
</body>
</html> </html>

View File

@@ -11,8 +11,5 @@
"devDependencies": { "devDependencies": {
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^8.0.1" "vite": "^8.0.1"
},
"dependencies": {
"sass": "^1.98.0"
} }
} }

View File

@@ -1,20 +0,0 @@
import { CreateKeyboardListeners } from './input/keyboard'
import { Wush } from './shell/Wush'
import { Terminal } from './terminal/Terminal'
import { EventBroadcaster } from './utils/EventBroadcaster'
// Initializes the app
const init = () => {
const localBroadcaster = new EventBroadcaster()
// creates keyboard listeners for the local event broadcaster
CreateKeyboardListeners(
(key: string, isCharacter: boolean) => localBroadcaster.emit('keydown', key, isCharacter),
(key: string, isCharacter: boolean) => localBroadcaster.emit('keyup', key, isCharacter),
)
const terminal = new Terminal()
terminal.LoadShell(new Wush(localBroadcaster, terminal))
}
init()

View File

View File

@@ -1,15 +0,0 @@
// creates keyboard listeners
export const CreateKeyboardListeners = (
onKeyDown: (key: string, isCharacter: boolean) => void,
onKeyUp: (key: string, isCharacter: boolean) => void,
) => {
document.addEventListener('keydown', event => {
onKeyDown(event.key, event.key.length === 1)
event.preventDefault()
})
document.addEventListener('keyup', event => {
onKeyUp(event.key, event.key.length === 1)
event.preventDefault()
})
}

View File

@@ -1,15 +0,0 @@
import type { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
export abstract class Shell {
broadcaster: EventBroadcaster
terminal: Terminal
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
this.broadcaster = broadcaster
this.terminal = terminal
}
abstract Init(): void
abstract HandleKeyInput(key: string, isCharacter: boolean): void
}

View File

@@ -1,87 +0,0 @@
// Web-Uno Shell
// the best name I could come up with lmao
import { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
import { Shell } from './Shell'
export class Wush extends Shell {
private buffer: string[] = []
private bufferPos: number = 0
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
super(broadcaster, terminal)
}
Init() {
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter),
)
this.Prompt()
}
Prompt() {
this.terminal.Write('hi -> ')
}
PushToBuffer(text: string) {
text.split('').forEach(char => {
this.buffer.splice(this.bufferPos, 0, char)
this.bufferPos++
})
console.log(this.buffer)
}
RemoveLastCharsFromBuffer(amount: number) {
this.buffer.splice(this.buffer.length - amount, amount)
this.bufferPos -= amount
console.log(this.buffer)
}
MoveBufferPos(index: number, absolute: boolean = false) {
this.bufferPos = Math.max(absolute ? index : this.bufferPos + index, 0)
}
FlushBuffer() {
this.buffer = []
this.bufferPos = 0
}
ExecuteBuffer() {
const args = this.buffer.join('').split(' ')
this.FlushBuffer()
console.log(`Executing ${args[0]} with args '${args}'`)
}
HandleKeyInput(key: string, isCharacter: boolean) {
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
this.terminal.MoveCursor(-1, 0)
this.MoveBufferPos(-1)
break
case 'ArrowRight':
this.terminal.MoveCursor(1, 0)
this.MoveBufferPos(1)
break
case 'Backspace':
this.terminal.RemoveCell()
this.RemoveLastCharsFromBuffer(1)
this.terminal.MoveCursor(-1, 0)
break
case 'Enter':
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.ExecuteBuffer()
this.Prompt()
break
}
} else {
this.terminal.InsertCell(key)
this.PushToBuffer(key)
this.terminal.MoveCursor(1, 0)
}
}
}

View File

@@ -1,9 +0,0 @@
@forward "terminal.scss";
body {
margin: 0;
}
* {
box-sizing: border-box;
}

View File

@@ -1,2 +0,0 @@
$terminal-background: #141414;
$terminal-white: #fff;

View File

@@ -1,12 +0,0 @@
@use "colors.scss" as colors;
#cursor {
top: 0;
left: 0;
width: 1px;
height: 20px;
position: absolute;
background: colors.$terminal-white;
transition: .2s cubic-bezier(0, 1, 0.3, 1);
}

View File

@@ -1,29 +0,0 @@
@use "colors.scss" as colors;
@forward "cursor.scss";
#terminal {
background: colors.$terminal-background;
color: colors.$terminal-white;
font-family: "JetBrains Mono", "Cascadia Mono", "Consolas", monospace;
::-moz-selection {
background: colors.$terminal-white;
color: colors.$terminal-background;
}
p {
margin: 0;
height: fit-content;
word-wrap: break-word;
text-wrap: nowrap;
span {
display: inline-block;
}
}
.line {
width: fit-content
}
}

View File

@@ -1,6 +0,0 @@
export type CursorPosition = {
col: number,
row: number,
}
export type CursorStyle = 'bar' | 'underline' | 'cell' | 'cell_hollow'

View File

@@ -1,184 +0,0 @@
import type { Shell } from '../shell/Shell'
import sqs from '../utils/sqs'
import type { CursorPosition, CursorStyle } from './CursorProperties'
export class Terminal {
private terminal: HTMLElement
private cursor: HTMLElement
private cellHeight = 16
private cellWidth = 8
private cursorPosition: CursorPosition = {
col: 0,
row: 0,
}
private shell?: Shell
constructor() {
this.terminal = sqs('#terminal')
this.cursor = sqs('#cursor')
this.ResetCellSize()
this.SetCursorStyle('bar')
this.NewPage()
}
LoadShell(shell: Shell) {
this.shell = shell
this.shell.Init()
}
NewPage() {
this.terminal.innerHTML = ''
this.SetCursorPosition(0, 0)
}
AppendLine() {
const paragraph = document.createElement('p')
paragraph.style.height = `${this.cellHeight}px`
paragraph.style.lineHeight = `${this.cellHeight}px`
paragraph.id = `line-${this.cursorPosition.row}`
paragraph.className = 'line'
this.terminal.appendChild(paragraph)
paragraph.scrollIntoView({behavior: 'smooth'})
}
UpdateLines() {
const lines = new Array(...this.terminal.children)
lines.forEach((line, i) => line.id = `line-${i}`)
}
/**
* @returns index of the last line on the page. -1 if there are no lines
*/
GetLastLineIndex(): number {
return this.terminal.children.length - 1
}
Write(text: string) {
text.split('').forEach((char) => {
this.SetCell(char)
this.MoveCursor(1, 0)
})
}
SetCell(char: string) {
const selector = `#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`
// adjust for the cursor
console.log(`going from ${this.terminal.children.length - 1} to ${this.cursorPosition.row}`)
if (!document.querySelector(`#line-${this.cursorPosition.row}`)) {
for (let i = this.terminal.children.length - 1; i < this.cursorPosition.row; i++) {
console.log(i)
this.AppendLine()
}
}
if (!document.querySelector(selector)) {
const line = sqs(`#line-${this.cursorPosition.row}`)
for (let i = line.children.length; i < this.cursorPosition.col + 1; i++) {
const cell = document.createElement('span')
cell.className = `cell-${i}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.style.lineHeight = `${this.cellHeight}px`
line.appendChild(cell)
}
}
sqs(selector).innerText = char[0]
}
UpdateCells() {
const cells = new Array(...sqs(`#line-${this.cursorPosition.row}`).children)
cells.forEach((cell, i) => cell.className = `cell-${i}`)
}
InsertCell(char: string) {
const cell = document.createElement('span')
cell.className = `cell-${this.cursorPosition.col}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.style.lineHeight = `${this.cellHeight}px`
cell.innerText = char[0]
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`).insertAdjacentElement('beforebegin', cell)
this.UpdateCells()
}
RemoveCell() {
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col - 1}`).remove()
} catch (_) {
} finally {
this.UpdateCells()
}
}
ResetCellSize() {
// dynamically determine cell size using dom
const cell = document.createElement('span')
cell.textContent = 'A'
this.terminal.appendChild(cell)
this.cellWidth = cell.scrollWidth
this.cellHeight = cell.scrollHeight
cell.remove()
}
GetHeightCells(): number {
return this.terminal.clientHeight / this.cellHeight
}
GetWidthCells(): number {
return this.terminal.clientWidth / this.cellWidth
}
UpdateCursor() {
this.cursor.style.left = `${this.cursorPosition.col * this.cellWidth}px`
this.cursor.style.top = `${this.cursorPosition.row * this.cellHeight}px`
}
GetCursorPosition(): CursorPosition {
return this.cursorPosition
}
SetCursorPosition(col: number, row: number) {
this.cursorPosition.col = Math.max(col, 0)
this.cursorPosition.row = Math.max(row, 0)
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`)
} catch (_) {
this.SetCell(' ')
} finally {
this.UpdateCursor()
}
}
MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) {
this.SetCursorPosition(
!absolute.x ? this.cursorPosition.col + col : col,
!absolute.y ? this.cursorPosition.row + row : row,
)
}
SetCursorStyle(style: CursorStyle) {
switch (style) {
default:
this.cursor.style.width = `${this.cellWidth}px`
this.cursor.style.height = `${this.cellHeight}px`
}
}
}

View File

@@ -1,43 +0,0 @@
export class EventBroadcaster {
private listeners: Map<string, Set<Function>> = new Map()
/**
* Assigns a listener for the specified event
* @param event event name
* @param listener function to call when the event is emitted
*/
on(event: string, listener: Function) {
if (!this.listeners.has(event))
this.listeners.set(event, new Set())
this.listeners.get(event)!.add(listener)
}
/**
* Removes a listener from the specified event
* @param event event name
* @param listener function to remove
*/
off(event: string, listener: Function) {
if (this.listeners.has(event))
this.listeners.get(event)!.delete(listener)
}
/**
* Removes all listeners for the specified event
* @param event event name
*/
clear(event: string) {
if (this.listeners.has(event))
this.listeners.get(event)!.clear()
}
/**
* Emits an event with the given arguments
* @param event event name
* @param args arguments to pass to the listeners
*/
emit(event: string, ...args: any[]) {
if (this.listeners.has(event))
this.listeners.get(event)!.forEach((listener) => listener(...args))
}
}

View File

@@ -1,13 +0,0 @@
/**
* a "safe" wrapper for querySelector. ugly as hell but whatever
* @param query css selector query
* @returns first element matching the query
*/
export default function sqs<T extends HTMLElement>(query: string): T {
const element = document.querySelector(query)
if (!(element instanceof HTMLElement))
throw new Error(`Failed to process a short element query: ${query}`)
return element as T
}

View File

@@ -1,5 +0,0 @@
export default {
server: {
port: 3000,
},
}