step: Working MQTT websocket connection, docker deploy script
This commit is contained in:
+186
-28
@@ -1,9 +1,19 @@
|
||||
import mqtt from 'mqtt'
|
||||
import './style.css'
|
||||
|
||||
type Page = 'players' | 'start' | 'info'
|
||||
type Page = 'home' | 'players' | 'start' | 'text'
|
||||
type Route = 'home' | 'maintenance' | 'players' | 'start' | 'text'
|
||||
|
||||
const storageKey = 'players'
|
||||
let players: string[] = loadPlayers()
|
||||
const mqttBrokerHost = '91.99.29.8'
|
||||
const mqttBrokerPort = 9001
|
||||
const mqttBrokerPath = '/mqtt'
|
||||
const mqttUseTls = false
|
||||
const mqttTopic = 'king-arthur/page/startup'
|
||||
const mqttMessage = 'start-hit'
|
||||
|
||||
const initialPlayers = loadPlayers()
|
||||
let players: string[] = []
|
||||
let currentName = ''
|
||||
|
||||
function loadPlayers(): string[] {
|
||||
@@ -26,40 +36,125 @@ function savePlayers(): void {
|
||||
localStorage.setItem(storageKey, JSON.stringify(players))
|
||||
}
|
||||
|
||||
function getPageFromHash(): Page {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (hash === 'start' || hash === 'info' || hash === 'players') {
|
||||
return hash
|
||||
}
|
||||
return 'players'
|
||||
function publishStartHit(): void {
|
||||
const protocol = mqttUseTls ? 'wss' : 'ws'
|
||||
const brokerUrl = `${protocol}://${mqttBrokerHost}:${mqttBrokerPort}${mqttBrokerPath}`
|
||||
const client = mqtt.connect(brokerUrl)
|
||||
|
||||
client.on('connect', () => {
|
||||
client.publish(mqttTopic, mqttMessage, { qos: 1 }, () => {
|
||||
client.end()
|
||||
})
|
||||
})
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('MQTT publish failed:', error)
|
||||
client.end(true)
|
||||
})
|
||||
}
|
||||
|
||||
function getRouteFromPath(): Route {
|
||||
const path = window.location.pathname
|
||||
|
||||
if (path === '/') return 'home'
|
||||
if (path === '/maintenance') return 'maintenance'
|
||||
if (path === '/players') return 'players'
|
||||
if (path === '/start') return 'start'
|
||||
if (path === '/text') return 'text'
|
||||
if (path.startsWith('/players')) return 'players'
|
||||
if (path.startsWith('/start')) return 'start'
|
||||
if (path.startsWith('/text')) return 'text'
|
||||
|
||||
return 'home'
|
||||
}
|
||||
|
||||
function navigateTo(path: string): void {
|
||||
window.history.pushState({}, '', path)
|
||||
render()
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')
|
||||
if (!app) return
|
||||
|
||||
const page = getPageFromHash()
|
||||
const route = getRouteFromPath()
|
||||
const path = window.location.pathname
|
||||
|
||||
app.innerHTML = `
|
||||
<main class="app">
|
||||
<h1>Simple Player Web App</h1>
|
||||
<nav class="nav">
|
||||
<a href="#players" class="nav-link ${page === 'players' ? 'active' : ''}">Page 1: Players</a>
|
||||
<a href="#start" class="nav-link ${page === 'start' ? 'active' : ''}">Page 2: Start</a>
|
||||
<a href="#info" class="nav-link ${page === 'info' ? 'active' : ''}">Page 3: Text</a>
|
||||
</nav>
|
||||
<section class="page-content">
|
||||
${renderPage(page)}
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
if (route === 'home') {
|
||||
app.innerHTML = `
|
||||
<main class="app">
|
||||
<section class="page-content">
|
||||
${renderHomePage()}
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
} else if (path === '/maintenance') {
|
||||
const maintenancePage: Page = 'players'
|
||||
app.innerHTML = `
|
||||
<main class="app">
|
||||
<h1>Simple Player Web App</h1>
|
||||
<nav class="nav">
|
||||
<a href="/players" class="nav-link active">Page 1: Players</a>
|
||||
<a href="/start" class="nav-link">Page 2: Start</a>
|
||||
<a href="/text" class="nav-link">Page 3: Text</a>
|
||||
</nav>
|
||||
<section class="page-content">
|
||||
${renderPage(maintenancePage)}
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
attachNavigationHandlers(maintenancePage)
|
||||
} else {
|
||||
const page = route as Page
|
||||
app.innerHTML = `
|
||||
<main class="app">
|
||||
<section class="page-content">
|
||||
${renderPage(page)}
|
||||
</section>
|
||||
</main>
|
||||
`
|
||||
attachHandlers(page)
|
||||
}
|
||||
}
|
||||
|
||||
attachHandlers(page)
|
||||
function renderHomePage(): string {
|
||||
return `
|
||||
<div class="home-content">
|
||||
<h1>Welcome to King Arthur</h1>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<div class="info-section">
|
||||
<div class="info-box">
|
||||
<h3>Version</h3>
|
||||
<p id="version-number">1.0.0</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h3>Service Hotline</h3>
|
||||
<p id="service-hotline">+1 800-123-4567</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="home-nav">
|
||||
<a href="/players" class="nav-button">Players</a>
|
||||
<a href="/start" class="nav-button">Start</a>
|
||||
<a href="/text" class="nav-button">Text</a>
|
||||
<a href="/maintenance" class="nav-button">Maintenance</a>
|
||||
</nav>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderPage(page: Page): string {
|
||||
|
||||
if (page === 'players') {
|
||||
const listItems = players.map((name) => `<li>${name}</li>`).join('')
|
||||
const listItems = players.map((name, index) => `
|
||||
<li class="player-item">
|
||||
<span class="player-name">${name}</span>
|
||||
<button class="delete-btn" data-index="${index}" title="Delete player">
|
||||
<svg class="trash-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-9l-1 1H5v2h14V4z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
`).join('')
|
||||
|
||||
return `
|
||||
<h2>Add players</h2>
|
||||
@@ -68,6 +163,10 @@ function renderPage(page: Page): string {
|
||||
<button id="add-player" type="button">Add</button>
|
||||
</div>
|
||||
<ul class="player-list">${listItems}</ul>
|
||||
<div class="players-actions">
|
||||
<button id="clear-all-btn" type="button" class="btn-secondary">Clear All</button>
|
||||
<button id="finished-btn" type="button" class="btn-primary">Finished</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -87,10 +186,43 @@ function renderPage(page: Page): string {
|
||||
`
|
||||
}
|
||||
|
||||
function attachNavigationHandlers(page: Page): void {
|
||||
const navLinks = document.querySelectorAll<HTMLAnchorElement>('.nav-link')
|
||||
navLinks.forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
const href = link.getAttribute('href')
|
||||
if (href) {
|
||||
navigateTo(href)
|
||||
updateActiveNavLink(page)
|
||||
}
|
||||
})
|
||||
})
|
||||
attachHandlers(page)
|
||||
}
|
||||
|
||||
function updateActiveNavLink(page: Page): void {
|
||||
const navLinks = document.querySelectorAll<HTMLAnchorElement>('.nav-link')
|
||||
navLinks.forEach((link) => {
|
||||
link.classList.remove('active')
|
||||
const href = link.getAttribute('href')
|
||||
if (
|
||||
(page === 'players' && href === '/players') ||
|
||||
(page === 'start' && href === '/start') ||
|
||||
(page === 'text' && href === '/text')
|
||||
) {
|
||||
link.classList.add('active')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function attachHandlers(page: Page): void {
|
||||
if (page === 'players') {
|
||||
const input = document.querySelector<HTMLInputElement>('#player-input')
|
||||
const button = document.querySelector<HTMLButtonElement>('#add-player')
|
||||
const deleteButtons = document.querySelectorAll<HTMLButtonElement>('.delete-btn')
|
||||
const clearAllBtn = document.querySelector<HTMLButtonElement>('#clear-all-btn')
|
||||
const finishedBtn = document.querySelector<HTMLButtonElement>('#finished-btn')
|
||||
|
||||
if (input && button) {
|
||||
const addPlayer = () => {
|
||||
@@ -110,6 +242,31 @@ function attachHandlers(page: Page): void {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deleteButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const index = parseInt(btn.getAttribute('data-index') || '0', 10)
|
||||
players.splice(index, 1)
|
||||
savePlayers()
|
||||
render()
|
||||
})
|
||||
})
|
||||
|
||||
if (clearAllBtn) {
|
||||
clearAllBtn.addEventListener('click', () => {
|
||||
if (confirm('Are you sure you want to delete all players?')) {
|
||||
players = []
|
||||
savePlayers()
|
||||
render()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (finishedBtn) {
|
||||
finishedBtn.addEventListener('click', () => {
|
||||
window.location.href = '/start'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (page === 'start') {
|
||||
@@ -118,11 +275,12 @@ function attachHandlers(page: Page): void {
|
||||
|
||||
if (button && selectedName) {
|
||||
button.addEventListener('click', () => {
|
||||
publishStartHit()
|
||||
if (players.length === 0) {
|
||||
currentName = 'Please add players on page 1 first.'
|
||||
} else {
|
||||
const randomIndex = Math.floor(Math.random() * players.length)
|
||||
currentName = players[randomIndex]
|
||||
currentName = (players[randomIndex])
|
||||
}
|
||||
selectedName.textContent = currentName
|
||||
})
|
||||
@@ -130,10 +288,10 @@ function attachHandlers(page: Page): void {
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', render)
|
||||
window.addEventListener('popstate', render)
|
||||
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = '#players'
|
||||
if (window.location.pathname === '/' || !window.location.pathname.startsWith('/')) {
|
||||
window.history.replaceState({}, '', '/')
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
+153
-5
@@ -93,13 +93,161 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.player-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
#selected-name {
|
||||
margin-top: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-content h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.home-content p {
|
||||
margin: 0 0 24px;
|
||||
line-height: 1.6;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 32px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.home-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
text-decoration: none;
|
||||
color: #ffffff;
|
||||
background: #111827;
|
||||
border: 1px solid #111827;
|
||||
border-radius: 8px;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: #374151;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.player-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
color: #ffffff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.trash-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.players-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user