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>
373 lines
13 KiB
Ruby
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
|