new window dsl
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
GIT
|
GIT
|
||||||
remote: https://git.teletype.hu/tools/rubbs.git
|
remote: https://git.teletype.hu/tools/rubbs.git
|
||||||
revision: 5bf754f38cbd41c7e81a6990db8139ce170e0bc2
|
revision: 18e77c14b5e0d48599bc18a1f55cf9c72b64fe47
|
||||||
specs:
|
specs:
|
||||||
bbs (0.4.0)
|
bbs (0.4.0)
|
||||||
artii (~> 2.1)
|
artii (~> 2.1)
|
||||||
|
|||||||
244
bbs.rb
244
bbs.rb
@@ -16,15 +16,7 @@ require_relative 'lib/service/wiki_service'
|
|||||||
require_relative 'lib/service/games_service'
|
require_relative 'lib/service/games_service'
|
||||||
require_relative 'lib/service/chat_service'
|
require_relative 'lib/service/chat_service'
|
||||||
require_relative 'lib/service/last_callers_service'
|
require_relative 'lib/service/last_callers_service'
|
||||||
require_relative 'lib/ui/windows/system_info_window'
|
require_relative 'lib/ui/formatters'
|
||||||
require_relative 'lib/ui/windows/messages_window'
|
|
||||||
require_relative 'lib/ui/windows/wiki_window'
|
|
||||||
require_relative 'lib/ui/windows/games_window'
|
|
||||||
require_relative 'lib/ui/windows/users_window'
|
|
||||||
require_relative 'lib/ui/windows/chat_window'
|
|
||||||
require_relative 'lib/ui/windows/bulletin_window'
|
|
||||||
require_relative 'lib/ui/windows/profile_window'
|
|
||||||
require_relative 'lib/ui/windows/sysop_window'
|
|
||||||
|
|
||||||
ONLINE = OnlineService.new(OnlineUsersRepository.new)
|
ONLINE = OnlineService.new(OnlineUsersRepository.new)
|
||||||
BOARDS = BoardService.new(BoardRepository.new(
|
BOARDS = BoardService.new(BoardRepository.new(
|
||||||
@@ -50,11 +42,12 @@ SERVICES = {
|
|||||||
chat: CHAT,
|
chat: CHAT,
|
||||||
last_callers: LAST_CALLERS,
|
last_callers: LAST_CALLERS,
|
||||||
profile: PROFILE,
|
profile: PROFILE,
|
||||||
uptime: STARTED_AT,
|
uptime: STARTED_AT
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
MAIN_APP = BBS::Application.define do
|
MAIN_APP = BBS::Application.define do
|
||||||
theme BBS::Theme.neumanntronics
|
theme BBS::Theme.neumanntronics
|
||||||
|
services SERVICES
|
||||||
|
|
||||||
helpers do
|
helpers do
|
||||||
def sysop?
|
def sysop?
|
||||||
@@ -63,43 +56,236 @@ MAIN_APP = BBS::Application.define do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Windows ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window :bulletin, type: :info do |w|
|
||||||
|
w.title ' Welcome '
|
||||||
|
w.width 70
|
||||||
|
w.height 17
|
||||||
|
w.body do |ctx|
|
||||||
|
UI::Formatters::Bulletin.body(
|
||||||
|
username: ctx[:username] || 'Anonymous',
|
||||||
|
online_count: ctx[:online].count,
|
||||||
|
total_messages: ctx[:boards].total_count,
|
||||||
|
recent_callers: ctx[:last_callers].recent(3)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
w.button label: '&Continue', primary: true, cancel: true
|
||||||
|
end
|
||||||
|
|
||||||
|
window :system_info, type: :info do |w|
|
||||||
|
w.title ' System Info '
|
||||||
|
w.width 60
|
||||||
|
w.height 14
|
||||||
|
w.body do |ctx|
|
||||||
|
rows = [
|
||||||
|
['Online users', ctx[:online].count.to_s],
|
||||||
|
['Messages', ctx[:boards].total_count.to_s],
|
||||||
|
['Boards', ctx[:boards].boards.size.to_s],
|
||||||
|
['Wiki', 'https://wiki.teletypegames.org'],
|
||||||
|
['Games API', 'https://teletypegames.org'],
|
||||||
|
['Platform', RUBY_PLATFORM],
|
||||||
|
['Ruby', RUBY_VERSION],
|
||||||
|
['Uptime', UI::Formatters::SystemInfo.format_uptime(ctx[:uptime])]
|
||||||
|
]
|
||||||
|
[{ kv: rows, label_width: 15 }]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :messages, type: :master_detail do |w|
|
||||||
|
w.title ' Message Boards '
|
||||||
|
w.list_width 20
|
||||||
|
w.empty_message ' (no boards configured)'
|
||||||
|
w.items { |ctx| ctx[:boards].boards }
|
||||||
|
w.item_label { |board, _ctx| board.title }
|
||||||
|
w.render_detail do |board, width, ctx|
|
||||||
|
UI::Formatters::Board.lines(ctx[:boards].messages(board.id, 100), board.title, width)
|
||||||
|
end
|
||||||
|
w.action label: '&New', align: :left do |window, ctx|
|
||||||
|
board = window.list_widget.selected_item
|
||||||
|
ctx[:app].open_window(:compose_message, board: board,
|
||||||
|
on_posted: -> { window.reload_detail })
|
||||||
|
:keep
|
||||||
|
end
|
||||||
|
w.action label: '&Refresh', align: :left do |window, _ctx|
|
||||||
|
window.reload_detail
|
||||||
|
:keep
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :compose_message, type: :form do |w|
|
||||||
|
w.title { |ctx| ctx[:board] ? " Post to #{ctx[:board].title} " : ' Compose ' }
|
||||||
|
w.width { |ctx| [ctx[:app].cols - 8, 70].min }
|
||||||
|
w.height 14
|
||||||
|
w.hint { |ctx| "From: #{ctx[:username] || 'Anonymous'} — Ctrl-Enter sends" }
|
||||||
|
w.submit_label '&Send'
|
||||||
|
w.cancel_label '&Cancel'
|
||||||
|
w.field :body, type: :textarea, max_length: 1000
|
||||||
|
w.on_submit do |values, form, ctx|
|
||||||
|
if ctx[:board].nil?
|
||||||
|
form.error('Select a board first.')
|
||||||
|
:keep
|
||||||
|
else
|
||||||
|
case ctx[:boards].post(ctx[:board].id, ctx[:username] || 'Anonymous', values[:body])
|
||||||
|
when :empty
|
||||||
|
form.error('Message cannot be empty.')
|
||||||
|
:keep
|
||||||
|
else
|
||||||
|
ctx[:on_posted]&.call
|
||||||
|
:close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :wiki, type: :master_detail do |w|
|
||||||
|
w.title { |ctx| " #{ctx[:title]} " }
|
||||||
|
w.list_width { |ctx| [32, ctx[:app].cols / 3].max }
|
||||||
|
w.empty_message ' (no pages found)'
|
||||||
|
w.close_button false
|
||||||
|
w.items { |ctx| ctx[:wiki].list(ctx[:category]) }
|
||||||
|
w.item_label { |page, _ctx| page.title }
|
||||||
|
w.render_detail do |page, width, ctx|
|
||||||
|
text = ctx[:wiki].content(page.id)
|
||||||
|
BBS::Markdown.to_lines(text, width: width - 1, theme: ctx[:app].theme)
|
||||||
|
end
|
||||||
|
w.bottom_meta do |page, ctx|
|
||||||
|
"#{page.created_at.to_s[0, 10]} #{ctx[:wiki].page_url(page.locale, page.path)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :games, type: :master_detail do |w|
|
||||||
|
w.title ' Game Catalog '
|
||||||
|
w.list_width 30
|
||||||
|
w.empty_message ' (no games)'
|
||||||
|
w.items { |ctx| ctx[:games].all }
|
||||||
|
w.item_label { |game, _ctx| game.title }
|
||||||
|
w.render_detail { |game, width, _ctx| UI::Formatters::Game.card(game, width) }
|
||||||
|
end
|
||||||
|
|
||||||
|
window :online_users, type: :info do |w|
|
||||||
|
w.title ' Online Users '
|
||||||
|
w.width 50
|
||||||
|
w.scroll true
|
||||||
|
w.height { |ctx| [ctx[:online].user_list.size + 6, 10].max }
|
||||||
|
w.body { |ctx| UI::Formatters::Users.online_list(ctx[:online].user_list, ctx[:username]) }
|
||||||
|
end
|
||||||
|
|
||||||
|
window :last_callers, type: :info do |w|
|
||||||
|
w.title ' Last Callers '
|
||||||
|
w.width 64
|
||||||
|
w.scroll true
|
||||||
|
w.height { |ctx| [ctx[:last_callers].recent(15).size + 6, 10].max }
|
||||||
|
w.body { |ctx| UI::Formatters::Users.last_callers_list(ctx[:last_callers].recent(15)) }
|
||||||
|
end
|
||||||
|
|
||||||
|
window :chat, type: :stream do |w|
|
||||||
|
w.title ' Live Chat '
|
||||||
|
w.placeholder 'Type a message and press Enter…'
|
||||||
|
w.initial_lines do |ctx|
|
||||||
|
ctx[:chat].history.map { |line| UI::Formatters::Chat.line(line) }
|
||||||
|
end
|
||||||
|
w.on_submit do |text, _app, ctx|
|
||||||
|
ctx[:chat].broadcast(ctx[:username] || 'Anonymous', text.strip)
|
||||||
|
end
|
||||||
|
w.on_poll do |app_, ctx|
|
||||||
|
ctx[:chat].drain(app_.session_id).map { |line| UI::Formatters::Chat.line(line) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :profile, type: :form do |w|
|
||||||
|
w.title { |ctx| " Profile — #{ctx[:username] || 'Anonymous'} " }
|
||||||
|
w.width 64
|
||||||
|
w.height 14
|
||||||
|
w.prefill do |ctx|
|
||||||
|
existing = ctx[:profile].find(ctx[:username] || 'Anonymous') || {}
|
||||||
|
{
|
||||||
|
signature: existing['signature'].to_s,
|
||||||
|
location: existing['location'].to_s,
|
||||||
|
homepage: existing['homepage'].to_s,
|
||||||
|
notes: existing['notes'].to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
w.field :signature, label: 'Signature'
|
||||||
|
w.field :location, label: 'Location'
|
||||||
|
w.field :homepage, label: 'Homepage'
|
||||||
|
w.field :notes, label: 'Notes'
|
||||||
|
w.on_submit do |values, _form, ctx|
|
||||||
|
ctx[:profile].update(ctx[:username] || 'Anonymous', **values)
|
||||||
|
BBS::Dialogs.message(ctx[:app], 'Profile saved.', title: ' Saved ')
|
||||||
|
:close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
window :sysop_console, type: :info do |w|
|
||||||
|
w.title ' Sysop Console '
|
||||||
|
w.width 70
|
||||||
|
w.height 18
|
||||||
|
w.scroll true
|
||||||
|
w.body do |ctx|
|
||||||
|
UI::Formatters::Sysop.console_body(
|
||||||
|
username: ctx[:username],
|
||||||
|
online_count: ctx[:online].count,
|
||||||
|
total_messages: ctx[:boards].total_count,
|
||||||
|
chat_subscribers: ctx[:chat].subscriber_count,
|
||||||
|
online_users: ctx[:online].user_list,
|
||||||
|
recent_callers: ctx[:last_callers].recent(5)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
w.button label: '&Broadcast', align: :left do |_window, ctx|
|
||||||
|
BBS::Dialogs.input(ctx[:app], prompt: 'Sysop broadcast message:',
|
||||||
|
title: ' Broadcast ', width: 64,
|
||||||
|
on_submit: lambda do |text|
|
||||||
|
next if text.strip.empty?
|
||||||
|
ctx[:chat].broadcast("[SYSOP] #{ctx[:username]}", text.strip)
|
||||||
|
end)
|
||||||
|
:keep
|
||||||
|
end
|
||||||
|
w.button label: '&Close', cancel: true
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Menubar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
menubar do
|
menubar do
|
||||||
menu '&File' do
|
menu '&File' do
|
||||||
item '&Bulletin' do UI::Windows::Bulletin.open(self, services: SERVICES) end
|
item '&Bulletin' do open_window(:bulletin) end
|
||||||
item '&System Info' do UI::Windows::SystemInfo.open(self, services: SERVICES) end
|
item '&System Info' do open_window(:system_info) end
|
||||||
separator
|
separator
|
||||||
item 'E&xit', shortcut: 'Alt-X' do :halt end
|
item 'E&xit', shortcut: 'Alt-X' do :halt end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu '&Messages' do
|
menu '&Messages' do
|
||||||
item '&Boards…', shortcut: 'F2' do UI::Windows::Messages.open(self, services: SERVICES) end
|
item '&Boards…', shortcut: 'F2' do open_window(:messages) end
|
||||||
item '&New Post…', shortcut: 'F3' do
|
item '&New Post…', shortcut: 'F3' do
|
||||||
board = SERVICES[:boards].boards.first
|
board = SERVICES[:boards].boards.first
|
||||||
UI::Windows::ComposeMessage.open(self, services: SERVICES, board: board)
|
if board
|
||||||
|
open_window(:compose_message, board: board)
|
||||||
|
else
|
||||||
|
BBS::Dialogs.message(self, 'Select a board first.', title: ' Compose ')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu '&Files' do
|
menu '&Files' do
|
||||||
item '&Blog Posts' do UI::Windows::Wiki.open(self, services: SERVICES, category: 'blog', title: 'Blog Posts') end
|
item '&Blog Posts' do open_window(:wiki, category: 'blog', title: 'Blog Posts') end
|
||||||
item '&HowTo Guides' do UI::Windows::Wiki.open(self, services: SERVICES, category: 'howto', title: 'HowTo Guides') end
|
item '&HowTo Guides' do open_window(:wiki, category: 'howto', title: 'HowTo Guides') end
|
||||||
item '&Game Catalog' do UI::Windows::Games.open(self, services: SERVICES) end
|
item '&Game Catalog' do open_window(:games) end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu '&Users' do
|
menu '&Users' do
|
||||||
item '&Online' do UI::Windows::OnlineUsers.open(self, services: SERVICES) end
|
item '&Online' do open_window(:online_users) end
|
||||||
item '&Last Callers' do UI::Windows::LastCallers.open(self, services: SERVICES) end
|
item '&Last Callers' do open_window(:last_callers) end
|
||||||
item '&Profile…' do UI::Windows::Profile.open(self, services: SERVICES) end
|
item '&Profile…' do open_window(:profile) end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu '&Chat' do
|
menu '&Chat' do
|
||||||
item '&Open chat', shortcut: 'F4' do UI::Windows::Chat.open(self, services: SERVICES) end
|
item '&Open chat', shortcut: 'F4' do open_window(:chat) end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu 'S&ysop' do
|
menu 'S&ysop' do
|
||||||
item '&Console', shortcut: 'F10' do
|
item '&Console', shortcut: 'F10' do
|
||||||
name = (context[:username] || '').downcase
|
if sysop?
|
||||||
if SYSOPS.include?(name)
|
open_window(:sysop_console)
|
||||||
UI::Windows::SysopConsole.open(self, services: SERVICES)
|
|
||||||
else
|
else
|
||||||
BBS::Dialogs.message(self, 'Sysop access denied.', title: ' Sysop ')
|
BBS::Dialogs.message(self, 'Sysop access denied.', title: ' Sysop ')
|
||||||
end
|
end
|
||||||
@@ -123,12 +309,16 @@ MAIN_APP = BBS::Application.define do
|
|||||||
end
|
end
|
||||||
|
|
||||||
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Top menu: Alt+letter — Windows: Tab/Shift-Tab — Close: Esc') }
|
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Top menu: Alt+letter — Windows: Tab/Shift-Tab — Close: Esc') }
|
||||||
status_hint('F2', 'Boards') { UI::Windows::Messages.open(self, services: SERVICES) }
|
status_hint('F2', 'Boards') { open_window(:messages) }
|
||||||
status_hint('F3', 'Post') {
|
status_hint('F3', 'Post') {
|
||||||
board = SERVICES[:boards].boards.first
|
board = SERVICES[:boards].boards.first
|
||||||
UI::Windows::ComposeMessage.open(self, services: SERVICES, board: board)
|
if board
|
||||||
|
open_window(:compose_message, board: board)
|
||||||
|
else
|
||||||
|
BBS::Dialogs.message(self, 'Select a board first.', title: ' Compose ')
|
||||||
|
end
|
||||||
}
|
}
|
||||||
status_hint('F4', 'Chat') { UI::Windows::Chat.open(self, services: SERVICES) }
|
status_hint('F4', 'Chat') { open_window(:chat) }
|
||||||
status_hint('F10', 'Menu') { menubar.open(0) }
|
status_hint('F10', 'Menu') { menubar.open(0) }
|
||||||
status_hint('Alt-X', 'Exit') { :halt }
|
status_hint('Alt-X', 'Exit') { :halt }
|
||||||
|
|
||||||
|
|||||||
118
lib/ui/formatters.rb
Normal file
118
lib/ui/formatters.rb
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Pure view-layer helpers used by the window DSL in bbs.rb. No business
|
||||||
|
# logic here — each method takes data + width and returns lines/strings.
|
||||||
|
module UI
|
||||||
|
module Formatters
|
||||||
|
module Bulletin
|
||||||
|
BANNER = [
|
||||||
|
" \e[1;33m _____ _ _ ____ ____ _____\e[0m",
|
||||||
|
" \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m",
|
||||||
|
" \e[1;33m | |/ _ \\ | | | __|/ _` | '_/ _ \\ | _ \\| _ \\\\___ \\\e[0m",
|
||||||
|
" \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m",
|
||||||
|
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m"
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def self.body(username:, online_count:, total_messages:, recent_callers:)
|
||||||
|
callers = recent_callers.map do |c|
|
||||||
|
" · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
|
||||||
|
end
|
||||||
|
[
|
||||||
|
*BANNER.map { |l| { text: l, style: :accent } },
|
||||||
|
:spacer,
|
||||||
|
"Logged in as \e[1;33m#{username}\e[0m",
|
||||||
|
:spacer,
|
||||||
|
"Online now: #{online_count}",
|
||||||
|
"Total messages: #{total_messages}",
|
||||||
|
:spacer,
|
||||||
|
'Last callers:',
|
||||||
|
*callers
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module SystemInfo
|
||||||
|
def self.format_uptime(start)
|
||||||
|
return '–' unless start
|
||||||
|
seconds = (Time.now - start).to_i
|
||||||
|
h = seconds / 3600
|
||||||
|
m = (seconds % 3600) / 60
|
||||||
|
s = seconds % 60
|
||||||
|
"#{h}h #{m}m #{s}s"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Board
|
||||||
|
def self.lines(messages, board_title, width)
|
||||||
|
return [" (no messages on #{board_title} yet — press 'n' to start one)"] if messages.empty?
|
||||||
|
lines = []
|
||||||
|
messages.each do |m|
|
||||||
|
head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: "
|
||||||
|
BBS::FrameBuffer.wrap_ansi(head + m.text.to_s, width - 1).each { |l| lines << l }
|
||||||
|
lines << ''
|
||||||
|
end
|
||||||
|
lines
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Game
|
||||||
|
def self.card(game, width)
|
||||||
|
lines = []
|
||||||
|
lines << " \e[1;33m#{game.title}\e[0m"
|
||||||
|
lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m"
|
||||||
|
lines << ''
|
||||||
|
BBS::FrameBuffer.wrap_ansi(game.desc.to_s, width - 4).each { |l| lines << " #{l}" }
|
||||||
|
lines << ''
|
||||||
|
BBS::FrameBuffer.wrap_ansi(game.story.to_s, width - 4).each { |l| lines << " #{l}" }
|
||||||
|
lines << ''
|
||||||
|
game.external_links.each do |link|
|
||||||
|
lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m"
|
||||||
|
end
|
||||||
|
lines
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Chat
|
||||||
|
def self.line(entry)
|
||||||
|
" \e[2;37m#{entry.timestamp}\e[0m \e[1;33m#{entry.username}\e[0m: #{entry.text}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Users
|
||||||
|
def self.online_list(usernames, current_user)
|
||||||
|
return [' (no one else here yet)'] if usernames.empty?
|
||||||
|
usernames.map { |u| u == current_user ? " #{u} ← you" : " #{u}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.last_callers_list(callers)
|
||||||
|
return [' (no callers recorded yet)'] if callers.empty?
|
||||||
|
callers.map do |c|
|
||||||
|
ts = c.timestamp.to_s[0, 19].sub('T', ' ')
|
||||||
|
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module Sysop
|
||||||
|
def self.console_body(username:, online_count:, total_messages:,
|
||||||
|
chat_subscribers:, online_users:, recent_callers:)
|
||||||
|
body = []
|
||||||
|
body << " Sysop: \e[1;33m#{username}\e[0m"
|
||||||
|
body << :spacer
|
||||||
|
body << ' Stats'
|
||||||
|
body << " · Online users: #{online_count}"
|
||||||
|
body << " · Total messages: #{total_messages}"
|
||||||
|
body << " · Chat subscribers: #{chat_subscribers}"
|
||||||
|
body << :spacer
|
||||||
|
body << ' Online users:'
|
||||||
|
online_users.each { |u| body << " · #{u}" }
|
||||||
|
body << :spacer
|
||||||
|
body << ' Last 5 callers:'
|
||||||
|
recent_callers.each do |c|
|
||||||
|
body << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}"
|
||||||
|
end
|
||||||
|
body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
# Login splash: ASCII banner + last callers + new message counts. Closed
|
|
||||||
# with Enter / Esc / clicking the OK button.
|
|
||||||
class Bulletin
|
|
||||||
def self.open(app, services:)
|
|
||||||
banner_lines = [
|
|
||||||
" \e[1;33m _____ _ _ ____ ____ _____\e[0m",
|
|
||||||
" \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m",
|
|
||||||
" \e[1;33m | |/ _ \\ | | | __|/ _` | '_/ _ \\ | _ \\| _ \\\\___ \\\e[0m",
|
|
||||||
" \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m",
|
|
||||||
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m",
|
|
||||||
]
|
|
||||||
caller_lines = services[:last_callers].recent(3).map do |c|
|
|
||||||
" · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
|
|
||||||
end
|
|
||||||
BBS::Windows::Info.open(app,
|
|
||||||
title: ' Welcome ', width: 70, height: 17,
|
|
||||||
body: [
|
|
||||||
*banner_lines.map { |l| { text: l, style: :accent } },
|
|
||||||
:spacer,
|
|
||||||
"Logged in as \e[1;33m#{app.context[:username] || 'Anonymous'}\e[0m",
|
|
||||||
:spacer,
|
|
||||||
"Online now: #{services[:online].count}",
|
|
||||||
"Total messages: #{services[:boards].total_count}",
|
|
||||||
:spacer,
|
|
||||||
'Last callers:',
|
|
||||||
*caller_lines,
|
|
||||||
],
|
|
||||||
buttons: [{ label: '&Continue', primary: true, cancel: true }]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
class Chat
|
|
||||||
def self.open(app, services:)
|
|
||||||
username = app.context[:username] || 'Anonymous'
|
|
||||||
BBS::Windows::Stream.open(app,
|
|
||||||
title: ' Live Chat ',
|
|
||||||
initial_lines: services[:chat].history.map { |l| format_chat_line(l) },
|
|
||||||
placeholder: 'Type a message and press Enter…',
|
|
||||||
on_submit: ->(text, _a) { services[:chat].broadcast(username, text.strip) },
|
|
||||||
on_poll: ->(a) { services[:chat].drain(a.session_id).map { |l| format_chat_line(l) } }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.format_chat_line(line)
|
|
||||||
" \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}"
|
|
||||||
end
|
|
||||||
private_class_method :format_chat_line
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
# Game catalog browser. Left=titles, right=card with story + links.
|
|
||||||
class Games
|
|
||||||
def self.open(app, services:)
|
|
||||||
BBS::Windows::MasterDetail.open(app,
|
|
||||||
title: ' Game Catalog ',
|
|
||||||
items: services[:games].all,
|
|
||||||
item_label: ->(g) { g.title },
|
|
||||||
list_width: 30,
|
|
||||||
empty_message: ' (no games)',
|
|
||||||
render_detail: ->(game, width) { game_card_lines(game, width) }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.game_card_lines(game, width)
|
|
||||||
lines = []
|
|
||||||
lines << " \e[1;33m#{game.title}\e[0m"
|
|
||||||
lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m"
|
|
||||||
lines << ''
|
|
||||||
BBS::FrameBuffer.wrap_ansi(game.desc.to_s, width - 4).each { |l| lines << " #{l}" }
|
|
||||||
lines << ''
|
|
||||||
BBS::FrameBuffer.wrap_ansi(game.story.to_s, width - 4).each { |l| lines << " #{l}" }
|
|
||||||
lines << ''
|
|
||||||
game.external_links.each do |link|
|
|
||||||
lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m"
|
|
||||||
end
|
|
||||||
lines
|
|
||||||
end
|
|
||||||
private_class_method :game_card_lines
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
# Two-pane message reader: left=board list, right=messages of the selected
|
|
||||||
# board. Press &New to compose, &Refresh to reload.
|
|
||||||
class Messages
|
|
||||||
def self.open(app, services:)
|
|
||||||
BBS::Windows::MasterDetail.open(app,
|
|
||||||
title: ' Message Boards ',
|
|
||||||
items: services[:boards].boards,
|
|
||||||
item_label: ->(b) { b.title },
|
|
||||||
list_width: 20,
|
|
||||||
empty_message: ' (no boards configured)',
|
|
||||||
render_detail: ->(board, width) { board_lines(services, board, width) },
|
|
||||||
actions: [
|
|
||||||
{ label: '&New', align: :left, on_click: ->(window) {
|
|
||||||
board = window.list_widget.selected_item
|
|
||||||
ComposeMessage.open(app, services: services, board: board,
|
|
||||||
on_posted: -> { window.reload_detail })
|
|
||||||
:keep
|
|
||||||
} },
|
|
||||||
{ label: '&Refresh', align: :left, on_click: ->(window) {
|
|
||||||
window.reload_detail
|
|
||||||
:keep
|
|
||||||
} }
|
|
||||||
]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.board_lines(services, board, width)
|
|
||||||
msgs = services[:boards].messages(board.id, 100)
|
|
||||||
return [" (no messages on #{board.title} yet — press 'n' to start one)"] if msgs.empty?
|
|
||||||
lines = []
|
|
||||||
msgs.each do |m|
|
|
||||||
head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: "
|
|
||||||
BBS::FrameBuffer.wrap_ansi(head + m.text.to_s, width - 1).each { |l| lines << l }
|
|
||||||
lines << ''
|
|
||||||
end
|
|
||||||
lines
|
|
||||||
end
|
|
||||||
private_class_method :board_lines
|
|
||||||
end
|
|
||||||
|
|
||||||
class ComposeMessage
|
|
||||||
def self.open(app, services:, board:, on_posted: nil)
|
|
||||||
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board
|
|
||||||
|
|
||||||
username = app.context[:username] || 'Anonymous'
|
|
||||||
width = [app.cols - 8, 70].min
|
|
||||||
BBS::Windows::Form.open(app,
|
|
||||||
title: " Post to #{board.title} ",
|
|
||||||
width: width, height: 14,
|
|
||||||
hint: "From: #{username} — Ctrl-Enter sends",
|
|
||||||
submit_label: '&Send',
|
|
||||||
cancel_label: '&Cancel',
|
|
||||||
fields: [
|
|
||||||
{ key: :body, type: :textarea, value: '', max_length: 1000 }
|
|
||||||
],
|
|
||||||
on_submit: ->(values, form) {
|
|
||||||
result = services[:boards].post(board.id, username, values[:body])
|
|
||||||
if result == :empty
|
|
||||||
form.error('Message cannot be empty.')
|
|
||||||
:keep
|
|
||||||
else
|
|
||||||
on_posted&.call
|
|
||||||
:close
|
|
||||||
end
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
class Profile
|
|
||||||
def self.open(app, services:)
|
|
||||||
username = app.context[:username] || 'Anonymous'
|
|
||||||
existing = services[:profile].find(username) || {}
|
|
||||||
BBS::Windows::Form.open(app,
|
|
||||||
title: " Profile — #{username} ",
|
|
||||||
width: 64, height: 14,
|
|
||||||
fields: [
|
|
||||||
{ key: :signature, label: 'Signature', value: existing['signature'].to_s },
|
|
||||||
{ key: :location, label: 'Location', value: existing['location'].to_s },
|
|
||||||
{ key: :homepage, label: 'Homepage', value: existing['homepage'].to_s },
|
|
||||||
{ key: :notes, label: 'Notes', value: existing['notes'].to_s }
|
|
||||||
],
|
|
||||||
on_submit: ->(values, _form) {
|
|
||||||
services[:profile].update(username,
|
|
||||||
signature: values[:signature],
|
|
||||||
location: values[:location],
|
|
||||||
homepage: values[:homepage],
|
|
||||||
notes: values[:notes]
|
|
||||||
)
|
|
||||||
BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
class SysopConsole
|
|
||||||
def self.open(app, services:)
|
|
||||||
body = []
|
|
||||||
body << " Sysop: \e[1;33m#{app.context[:username]}\e[0m"
|
|
||||||
body << :spacer
|
|
||||||
body << ' Stats'
|
|
||||||
body << " · Online users: #{services[:online].count}"
|
|
||||||
body << " · Total messages: #{services[:boards].total_count}"
|
|
||||||
body << " · Chat subscribers: #{services[:chat].subscriber_count}"
|
|
||||||
body << :spacer
|
|
||||||
body << ' Online users:'
|
|
||||||
services[:online].user_list.each { |u| body << " · #{u}" }
|
|
||||||
body << :spacer
|
|
||||||
body << ' Last 5 callers:'
|
|
||||||
services[:last_callers].recent(5).each do |c|
|
|
||||||
body << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}"
|
|
||||||
end
|
|
||||||
|
|
||||||
BBS::Windows::Info.open(app,
|
|
||||||
title: ' Sysop Console ', width: 70, height: 18, scroll: true,
|
|
||||||
body: body,
|
|
||||||
buttons: [
|
|
||||||
{ label: '&Broadcast', align: :left, on_click: ->(_window) {
|
|
||||||
BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:',
|
|
||||||
title: ' Broadcast ', width: 64,
|
|
||||||
on_submit: lambda do |text|
|
|
||||||
next if text.strip.empty?
|
|
||||||
services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip)
|
|
||||||
end)
|
|
||||||
:keep
|
|
||||||
} },
|
|
||||||
{ label: '&Close', cancel: true }
|
|
||||||
]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
class SystemInfo
|
|
||||||
def self.open(app, services:)
|
|
||||||
rows = [
|
|
||||||
['Online users', services[:online].count.to_s],
|
|
||||||
['Messages', services[:boards].total_count.to_s],
|
|
||||||
['Boards', services[:boards].boards.size.to_s],
|
|
||||||
['Wiki', 'https://wiki.teletypegames.org'],
|
|
||||||
['Games API', 'https://teletypegames.org'],
|
|
||||||
['Platform', RUBY_PLATFORM],
|
|
||||||
['Ruby', RUBY_VERSION],
|
|
||||||
['Uptime', format_uptime(services[:uptime])],
|
|
||||||
]
|
|
||||||
BBS::Windows::Info.open(app,
|
|
||||||
title: ' System Info ', width: 60, height: 14,
|
|
||||||
body: [{ kv: rows, label_width: 15 }]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.format_uptime(start)
|
|
||||||
return '–' unless start
|
|
||||||
seconds = (Time.now - start).to_i
|
|
||||||
h = seconds / 3600
|
|
||||||
m = (seconds % 3600) / 60
|
|
||||||
s = seconds % 60
|
|
||||||
"#{h}h #{m}m #{s}s"
|
|
||||||
end
|
|
||||||
private_class_method :format_uptime
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
class OnlineUsers
|
|
||||||
def self.open(app, services:)
|
|
||||||
users = services[:online].user_list
|
|
||||||
username = app.context[:username]
|
|
||||||
body = if users.empty?
|
|
||||||
[' (no one else here yet)']
|
|
||||||
else
|
|
||||||
users.map { |u| u == username ? " #{u} ← you" : " #{u}" }
|
|
||||||
end
|
|
||||||
BBS::Windows::Info.open(app,
|
|
||||||
title: ' Online Users ',
|
|
||||||
width: 50, height: [users.size + 6, 10].max,
|
|
||||||
scroll: true, body: body
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class LastCallers
|
|
||||||
def self.open(app, services:)
|
|
||||||
callers = services[:last_callers].recent(15)
|
|
||||||
body = if callers.empty?
|
|
||||||
[' (no callers recorded yet)']
|
|
||||||
else
|
|
||||||
callers.map do |c|
|
|
||||||
ts = c.timestamp.to_s[0, 19].sub('T', ' ')
|
|
||||||
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
BBS::Windows::Info.open(app,
|
|
||||||
title: ' Last Callers ',
|
|
||||||
width: 64, height: [callers.size + 6, 10].max,
|
|
||||||
scroll: true, body: body
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module UI
|
|
||||||
module Windows
|
|
||||||
# Split-view wiki browser. Left=titles, right=selected page content
|
|
||||||
# (rendered with markdown-aware formatting).
|
|
||||||
class Wiki
|
|
||||||
def self.open(app, services:, category:, title:)
|
|
||||||
items = services[:wiki].list(category)
|
|
||||||
BBS::Windows::MasterDetail.open(app,
|
|
||||||
title: " #{title} ",
|
|
||||||
items: items,
|
|
||||||
item_label: ->(p) { p.title },
|
|
||||||
list_width: [32, app.cols / 3].max,
|
|
||||||
empty_message: ' (no pages found)',
|
|
||||||
close_button: false,
|
|
||||||
render_detail: ->(page, width) {
|
|
||||||
text = services[:wiki].content(page.id)
|
|
||||||
BBS::Markdown.to_lines(text, width: width - 1, theme: app.theme)
|
|
||||||
},
|
|
||||||
bottom_meta: ->(page) {
|
|
||||||
"#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Reference in New Issue
Block a user