Files
bbs-server/bbs.rb
Zsolt Tasnadi 5c9cb269fb Redesign TUI with DOS Navigator look and two-panel layout
Full blue background (screen_fill), double-line bordered panels side
by side: left panel for the menu, right panel for content. The right
panel's title bar shows the current page name, username and online count.

Color theme (THEME constant) maps to combined fg+bg ANSI codes so all
elements look native on the blue background: bright cyan borders, white
normal text, yellow labels, black-on-cyan selection highlights.

Status bar at the bottom mimics the NC function key bar with bright key
labels on cyan background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:53:18 +02:00

373 lines
13 KiB
Ruby

# frozen_string_literal: true
require 'bbs'
require 'time'
require_relative 'lib/repository/online_users_repository'
require_relative 'lib/repository/message_board_repository'
require_relative 'lib/repository/wiki_repository'
require_relative 'lib/repository/catalog_repository'
ONLINE = OnlineUsersRepository.new
MESSAGES = MessageBoardRepository.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat'))
WIKI = WikiRepository.new(token: ENV['WEBAPP_WIKIJS_TOKEN'])
CATALOG = CatalogRepository.new
MENU_ITEMS = [
'Messages', 'Post Message', 'Blog Posts',
'HowTo Guides', 'Game Catalog', 'Online Users',
'System Info', 'Exit'
].freeze
# ── Layout ──────────────────────────────────────────────────────────────────
LEFT_W = 21 # left panel outer width
CONT_X = 23 # content start x inside right panel (LEFT_W + 2)
CONT_Y = 2 # content start y inside panels (top border + 1)
# ── DOS Navigator color theme ────────────────────────────────────────────────
THEME = {
bg: "\e[0;44m", # blue background
normal: "\e[0;37;44m", # white on blue
bright: "\e[1;37;44m", # bright white on blue
border: "\e[1;36;44m", # bright cyan on blue
selected: "\e[0;30;46m", # black on cyan (highlighted menu item)
header: "\e[1;36;44m", # bright cyan on blue (section titles)
label: "\e[1;33;44m", # yellow on blue (timestamps, labels)
status: "\e[0;30;46m", # black on cyan (bottom status bar)
input: "\e[1;33;44m", # yellow on blue (text input)
success: "\e[1;32;44m", # green on blue
error: "\e[1;31;44m", # red on blue
reset: "\e[0m",
}.freeze
# ── TUI definition ───────────────────────────────────────────────────────────
MAIN_TUI = BBS::TUI.define do
helpers do
def tc(style, str) = "#{THEME.fetch(style, THEME[:normal])}#{str}#{THEME[:reset]}"
def cont_w = term_cols - CONT_X - 1
def cont_h = term_rows - 4
def fmt_date(iso)
Time.parse(iso.to_s).strftime('%Y-%m-%d')
rescue
iso.to_s
end
def wordwrap(text, width)
words = text.to_s
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
.gsub(/[#*_`~>|\\]/, '')
.gsub(/\r?\n+/, ' ')
.strip
.split
lines, line = [], +''
words.each do |w|
if line.empty? then line << w
elsif line.length + 1 + w.length <= width then line << ' ' << w
else lines << line.dup; line = +w
end
end
lines << line unless line.empty?
lines
end
def wiki_list_render(title)
text title, x: CONT_X, y: CONT_Y, style: THEME[:header]
if @items.empty?
text 'No items found.', x: CONT_X, y: CONT_Y + 2, style: THEME[:normal]
return
end
list_h = cont_h - 5
visible = @items[@scroll, list_h] || []
list visible.map { |p| p.title[0, cont_w - 2] },
x: CONT_X, y: CONT_Y + 2,
selected: @item_sel - @scroll,
style: THEME[:normal], highlight: THEME[:selected]
if (page = @items[@item_sel])
hint = page.description.to_s.strip[0, cont_w]
text hint, x: CONT_X, y: CONT_Y + cont_h - 2, style: THEME[:normal] unless hint.empty?
end
text '↑↓ navigate Enter read q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
def scroll_list_up
return if @item_sel <= 0
@item_sel -= 1
@scroll = @item_sel if @item_sel < @scroll
end
def scroll_list_down
return if @item_sel >= @items.size - 1
@item_sel += 1
list_h = cont_h - 5
@scroll = @item_sel - list_h + 1 if @item_sel >= @scroll + list_h
end
def open_wiki_page(back:)
return unless (page = @items[@item_sel])
@detail = wordwrap(WIKI.content(page.id), cont_w)
@page_header = page.title
@page_meta = "#{fmt_date(page.created_at)} #{WIKI.page_url(page.locale, page.path)}"
@prev_page = back
@scroll = 0
go :page_view
end
end
init do
@menu_sel = 0
@page_title = 'TELETYPE BBS'
@items = []
@item_sel = 0
@scroll = 0
@detail = []
@page_header = ''
@page_meta = ''
@input = ''
@status = ''
@prev_page = nil
end
chrome do
screen_fill bg: :blue
panel_h = term_rows - 1
box x: 1, y: 1, w: LEFT_W, h: panel_h,
style: THEME[:border], bg: :blue
box x: LEFT_W + 1, y: 1, w: term_cols - LEFT_W, h: panel_h,
title: " #{@page_title}#{@username}#{ONLINE.count} online ",
style: THEME[:border], bg: :blue
list MENU_ITEMS.map { |i| i[0, LEFT_W - 4] },
x: 2, y: CONT_Y,
selected: @menu_sel,
style: THEME[:normal], highlight: THEME[:selected]
bar y: term_rows,
content: " \e[1;37;46m↑↓\e[0;30;46m Navigate \e[1;37;46mEnter\e[0;30;46m Select " \
"\e[1;37;46mQ\e[0;30;46m Back \e[1;37;46mR\e[0;30;46m Refresh",
style: THEME[:status]
end
# ── Pages ───────────────────────────────────────────────────────────────────
page :idle do
enter { @page_title = 'TELETYPE BBS' }
render do
text 'Navigate the menu with ↑↓, select with Enter.',
x: CONT_X, y: CONT_Y + cont_h / 2, style: THEME[:normal]
end
key(:up) { @menu_sel = (@menu_sel - 1) % MENU_ITEMS.size }
key(:down) { @menu_sel = (@menu_sel + 1) % MENU_ITEMS.size }
key(:enter) do
case MENU_ITEMS[@menu_sel]
when 'Messages' then go :messages
when 'Post Message' then go :new_msg
when 'Blog Posts' then go :blog
when 'HowTo Guides' then go :howto
when 'Game Catalog' then go :games
when 'Online Users' then go :online
when 'System Info' then go :sysinfo
when 'Exit' then :halt
end
end
key('q') { :halt }
key(:escape) { :halt }
end
page :messages do
enter do
@page_title = 'MESSAGES'
@items = MESSAGES.last(30).map { |m| "#{tc(:label, m.timestamp)} #{tc(:bright, m.username)}: #{m.text}" }
@scroll = 0
end
render do
text '── Message Board ──', x: CONT_X, y: CONT_Y, style: THEME[:header]
(@items[@scroll, cont_h - 2] || []).each_with_index do |line, i|
text line[0, cont_w], x: CONT_X, y: CONT_Y + 2 + i
end
text "#{@items.size} msg(s) ↑↓ scroll q back",
x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
key(:up) { @scroll = [@scroll - 1, 0].max }
key(:down) { @scroll = [@scroll + 1, [@items.size - (cont_h - 2), 0].max].min }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :new_msg do
enter { @page_title = 'POST MESSAGE'; @input = ''; @status = '' }
render do
text '── Post Message ──', x: CONT_X, y: CONT_Y, style: THEME[:header]
text 'Type your message and press Enter. Esc cancels.',
x: CONT_X, y: CONT_Y + 2, style: THEME[:normal]
text "#{tc(:bright, @username)}: #{tc(:input, @input)}_",
x: CONT_X, y: CONT_Y + 4
text @status, x: CONT_X, y: CONT_Y + 6,
style: @status.start_with?('Sent') ? THEME[:success] : THEME[:error] unless @status.empty?
end
key(:enter) do
if @input.strip.empty?
@status = 'Message cannot be empty.'
else
MESSAGES.append(@username, @input.strip[0...200])
@status = 'Sent!'
@input = ''
end
end
key(:backspace) { @input.chop! unless @input.empty? }
key(:escape) { go :idle }
printable { |ch| @input << ch if @input.length < 200 }
end
page :blog do
enter { @page_title = 'BLOG'; @items = WIKI.list('blog'); @item_sel = 0; @scroll = 0 }
render { wiki_list_render('── Blog Posts ──') }
key(:up) { scroll_list_up }
key(:down) { scroll_list_down }
key(:enter) { open_wiki_page(back: :blog) }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :howto do
enter { @page_title = 'HOWTO'; @items = WIKI.list('howto'); @item_sel = 0; @scroll = 0 }
render { wiki_list_render('── HowTo Guides ──') }
key(:up) { scroll_list_up }
key(:down) { scroll_list_down }
key(:enter) { open_wiki_page(back: :howto) }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :page_view do
render do
text @page_header[0, cont_w], x: CONT_X, y: CONT_Y, style: THEME[:bright]
text @page_meta[0, cont_w], x: CONT_X, y: CONT_Y + 1, style: THEME[:normal]
(@detail[@scroll, cont_h - 3] || []).each_with_index do |l, i|
text l[0, cont_w], x: CONT_X, y: CONT_Y + 3 + i, style: THEME[:normal]
end
text "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back",
x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
key(:up) { @scroll = [@scroll - 1, 0].max }
key(:down) { @scroll = [@scroll + 1, [@detail.size - (cont_h - 3), 0].max].min }
key('q') { @scroll = 0; go(@prev_page || :idle) }
key(:escape) { @scroll = 0; go(@prev_page || :idle) }
end
page :games do
enter { @page_title = 'GAME CATALOG'; @items = CATALOG.fetch; @item_sel = 0 }
render do
text '── Game Catalog ──', x: CONT_X, y: CONT_Y, style: THEME[:header]
if @items.empty?
text 'No games found.', x: CONT_X, y: CONT_Y + 2, style: THEME[:normal]
else
game = @items[@item_sel]
text "#{@item_sel + 1}/#{@items.size} #{game.title}"[0, cont_w],
x: CONT_X, y: CONT_Y + 1, style: THEME[:bright]
text "#{game.platform} #{game.author}"[0, cont_w],
x: CONT_X, y: CONT_Y + 2, style: THEME[:normal]
wordwrap(game.desc.to_s, cont_w).first(cont_h - 10).each_with_index do |l, i|
text l, x: CONT_X, y: CONT_Y + 4 + i, style: THEME[:normal]
end
badges = []
badges << tc(:success, '[▶ Play]') unless game.play_path.empty?
badges << tc(:label, '[⬇ Download]') unless game.download_path.empty?
badges << tc(:bright, '[Source]') unless game.source_path.empty?
badges << tc(:header, '[Docs]') unless game.docs_path.empty?
text badges.join(' '), x: CONT_X, y: CONT_Y + cont_h - 5
text '↑↓ navigate q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
end
key(:up) { @item_sel = (@item_sel - 1) % @items.size if @items.any? }
key(:down) { @item_sel = (@item_sel + 1) % @items.size if @items.any? }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :online do
enter { @page_title = 'ONLINE USERS'; @items = ONLINE.snapshot.sort.map { |_, name| name } }
render do
text '── Online Users ──', x: CONT_X, y: CONT_Y, style: THEME[:header]
@items.each_with_index do |uname, i|
you = uname == @username ? tc(:normal, ' ← you') : ''
text tc(:bright, uname) + you, x: CONT_X, y: CONT_Y + 2 + i
end
text "#{@items.size} user(s) online q back",
x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :sysinfo do
enter do
@page_title = 'SYSTEM INFO'
@items = [
"Online users #{ONLINE.count}",
"Messages #{MESSAGES.count}",
"Wiki https://wiki.teletypegames.org",
"Games API https://teletypegames.org",
"Platform #{RUBY_PLATFORM}",
"Ruby #{RUBY_VERSION}"
]
end
render do
text '── System Info ──', x: CONT_X, y: CONT_Y, style: THEME[:header]
@items.each_with_index do |row, i|
text row[0, cont_w], x: CONT_X, y: CONT_Y + 2 + i, style: THEME[:normal]
end
text 'q back', x: CONT_X, y: CONT_Y + cont_h, style: THEME[:normal]
end
key('q') { go :idle }
key(:escape) { go :idle }
end
start :idle
end
# ── App config ───────────────────────────────────────────────────────────────
BBS.configure do |c|
c.on_session_end = ->(session) { ONLINE.remove(session.session_id) }
c.flow = BBS::Flow.define do
big_banner 'TELETYPE BBS', style: :success
ask :username, prompt: 'Name (blank for Anonymous)',
transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] }
call { |ctx| ONLINE.add(ctx[:session_id], ctx[:username]) }
tui MAIN_TUI
say 'Goodbye!', style: :muted
end
end
BBS.start