Raggiunto primo stato presentabile

main
Antonio De Lucreziis 4 years ago
parent b0732c9c4f
commit bbd6b9eb7e

@ -9,77 +9,135 @@
<script type="module" src="./src/index.js"></script> <script type="module" src="./src/index.js"></script>
</head> </head>
<body> <body>
<header> <nav>
<div class="nav-cell left"> <div class="nav-group left">
<div class="nav-item" id="clock">14:23</div> <div class="nav-item" id="clock">14:23</div>
</div> </div>
<div class="nav-cell center"> <div class="nav-group center">
<div class="nav-item">Posti DM</div> <div class="nav-item">
<a href="/">Posti DM</a>
</div>
</div> </div>
<div class="nav-cell right"> <div class="nav-group right">
<div class="nav-item">Login</div> <div id="login-label" class="nav-item">
<a href="/login.html">Login</a>
</div>
<div id="logged-label" class="nav-item hidden">
<!-- @username -->
</div>
<div id="logout-label" class="nav-item hidden">
<button id="logout-button">Logout</button>
</div>
</div> </div>
</header> </nav>
<main> <main>
<div class="room-name">AulaStud</div> <h3>Aula Studenti</h3>
<div class="room-main">
<div class="panel room-diagram"> <div class="room-diagram">
<!-- <div class="options">
<input type="checkbox" id="diagram-or-list-checkbox" checked />
<label for="diagram-or-list-checkbox">Visualizzazione Posti</label>
</div> -->
<div class="room-grid">
<div <div
class="posto libero" class="posto libero"
data-index="1" data-seat-id="aula-stud/posto-1"
style="grid-column: 3 / span 4; grid-row: 1 / span 2" style="grid-column: 3 / span 4; grid-row: 1 / span 2"
></div> ></div>
<div <div
class="posto libero" class="posto libero"
data-index="2" data-seat-id="aula-stud/posto-2"
style="grid-column: 7 / span 4; grid-row: 1 / span 2" style="grid-column: 7 / span 4; grid-row: 1 / span 2"
></div> ></div>
<div <div
class="posto libero" class="posto libero"
data-index="3" data-seat-id="aula-stud/posto-3"
style="grid-column: 1 / span 2; grid-row: 3 / span 4" style="grid-column: 1 / span 2; grid-row: 3 / span 4"
></div> ></div>
<div <div
class="posto libero" class="posto libero"
data-index="4" data-seat-id="aula-stud/posto-4"
style="grid-column: 1 / span 2; grid-row: 7 / span 4" style="grid-column: 1 / span 2; grid-row: 7 / span 4"
></div> ></div>
<div <div
class="posto libero" class="posto mio"
data-index="5" data-seat-id="aula-stud/posto-5"
style="grid-column: 3 / span 4; grid-row: 11 / span 2" style="grid-column: 3 / span 4; grid-row: 11 / span 2"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="6" data-seat-id="aula-stud/posto-6"
style="grid-column: 7 / span 4; grid-row: 11 / span 2" style="grid-column: 7 / span 4; grid-row: 11 / span 2"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="7" data-seat-id="aula-stud/posto-7"
style="grid-column: 8 / span 2; grid-row: 5 / span 4" style="grid-column: 8 / span 2; grid-row: 5 / span 4"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="8" data-seat-id="aula-stud/posto-8"
style="grid-column: 14 / span 2; grid-row: 1 / span 4" style="grid-column: 14 / span 2; grid-row: 1 / span 4"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="9" data-seat-id="aula-stud/posto-9"
style="grid-column: 14 / span 2; grid-row: 5 / span 4" style="grid-column: 14 / span 2; grid-row: 5 / span 4"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="10" data-seat-id="aula-stud/posto-10"
style="grid-column: 14 / span 2; grid-row: 9 / span 4" style="grid-column: 14 / span 2; grid-row: 9 / span 4"
></div> ></div>
<div <div
class="posto occupato" class="posto occupato"
data-index="11" data-seat-id="aula-stud/posto-11"
style="grid-column: 14 / span 2; grid-row: 13 / span 4" style="grid-column: 14 / span 2; grid-row: 13 / span 4"
></div> ></div>
</div> </div>
<div class="room-seat-list hidden">
<div class="seat-item">
<div class="name">Posto 1</div>
<button>Occupa</button>
</div>
<div class="seat-item">
<div class="name">Posto 2</div>
<button>Occupa</button>
</div>
<div class="seat-item">
<div class="name">Posto 3</div>
<button>Occupa</button>
</div>
<div class="seat-item">
<div class="name">Posto 4</div>
<button>Occupa</button>
</div>
</div>
<p>
Clicca su un posto per occuparlo (i posti liberi sono
<span
style="
text-decoration: solid underline #8888 4px;
text-decoration-skip-ink: none;
"
>grigi</span
>, quelli
<span style="text-decoration: solid underline #d88 4px">rossi</span>
sono occupati dagli altri, mentre il tuo è in
<span style="text-decoration: solid underline #8d8 4px">verde</span>)
</p>
</div>
</main>
<div id="tooltip">Some text</div>
<script type="module" src="./src/pages/index.js"></script>
<!-- <main>
<div class="room-name">AulaStud</div>
<div class="room-main">
<div class="panel room-diagram">
...
</div>
<div class="panel room-bookings"> <div class="panel room-bookings">
<div class="seat-list"> <div class="seat-list">
<div class="seat"> <div class="seat">
@ -137,6 +195,6 @@
<button disabled>18:00 &mdash; 19:30</button> <button disabled>18:00 &mdash; 19:30</button>
</div> </div>
</div> </div>
</main> </main> -->
</body> </body>
</html> </html>

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Posti DM</title>
<script type="module" src="./src/index.js"></script>
</head>
<body>
<nav>
<div class="nav-group left">
<div class="nav-item" id="clock">14:23</div>
</div>
<div class="nav-group center">
<div class="nav-item">
<a href="/">Posti DM</a>
</div>
</div>
</nav>
<main>
<div class="form">
<h3 class="fill-row">Login</h3>
<p id="error-string" class="error fill-row hidden"></p>
<label for="login-username"> Username </label>
<input type="text" id="input-login-username" />
<label for="login-password"> Password </label>
<input type="password" id="input-login-password" />
<button id="button-login" class="fill-row">Login</button>
</div>
</main>
<script type="module" src="./src/pages/login.js"></script>
</body>
</html>

@ -0,0 +1,13 @@
export function createClock(elClock) {
const renderClock = () => {
const pad = s => (s + '').padStart(2, '0')
const now = new Date(),
hours = now.getHours(),
minutes = now.getMinutes(),
seconds = now.getSeconds()
elClock.innerText = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
}
setInterval(renderClock, 1000)
renderClock()
}

@ -0,0 +1,51 @@
function renderGridLinesCanvas($canvas, [rows, cols]) {
$canvas.width = $canvas.offsetWidth * 2
$canvas.height = $canvas.offsetHeight * 2
const g = $canvas.getContext('2d')
const width = $canvas.width / 2
const height = $canvas.height / 2
g.scale(2, 2)
g.strokeStyle = '#ddd'
g.lineWidth = 2
g.setLineDash([4, 4])
for (let i = 0; i < cols + 1; i++) {
const x = 2 + ((width - 4) / cols) * i
g.beginPath()
g.moveTo(x, 0)
g.lineTo(x, height)
g.stroke()
}
for (let j = 0; j < rows + 1; j++) {
const y = 2 + ((height - 4) / rows) * j
g.beginPath()
g.moveTo(0, y)
g.lineTo(width, y)
g.stroke()
}
}
export function createGridLineCanvas($roomGrid) {
const $canvas = document.createElement('canvas')
$roomGrid.append($canvas)
$canvas.style.position = 'absolute'
$canvas.style.inset = '0'
$canvas.style.width = '100%'
$canvas.style.height = '100%'
$canvas.style.zIndex = '-1'
const [rows, cols] = getComputedStyle($roomGrid)
.getPropertyValue('aspect-ratio')
.split('/')
.reverse()
.map(s => parseInt(s))
const render = () => renderGridLinesCanvas($canvas, [rows, cols])
window.addEventListener('resize', render)
render()
}

@ -0,0 +1,100 @@
import { createRoomEventStream, Database, getLoggedUser } from '../index.js'
import { addTooltipElementListener, resetTooltip, setTooltipText } from './tooltip.js'
async function renderWidget(elSeatMap, seats) {
const user = await getLoggedUser()
Object.values(seats).forEach(seat => {
const { id, occupiedBy } = seat
elSeatMap[id].classList.remove('libero')
elSeatMap[id].classList.remove('occupato')
elSeatMap[id].classList.remove('mio')
if (occupiedBy.length > 0) {
const [seatOccupant] = occupiedBy
if (user && seatOccupant === user.id) {
elSeatMap[id].classList.add('mio')
} else {
elSeatMap[id].classList.add('occupato')
}
} else {
elSeatMap[id].classList.add('libero')
}
})
}
export async function createSeatWidget($roomGrid, roomId) {
const user = await getLoggedUser()
const elSeats = [...$roomGrid.querySelectorAll('[data-seat-id]')]
const elSeatMap = {}
elSeats.forEach($seat => {
const seatId = $seat.dataset.seatId
$seat.dataset.index = seatId.split('-')[2]
elSeatMap[seatId] = $seat
})
// Full widget state
let seats = await Database.getSeats(roomId)
// First render
renderWidget(elSeatMap, seats)
// Setup event listeners
Object.entries(elSeatMap).forEach(([seatId, $seat]) => {
addTooltipElementListener($hovering => {
if ($seat.contains($hovering)) {
const occupiedBy = seats[seatId].occupiedBy
if (occupiedBy.length > 0) {
if (user && occupiedBy[0] === user.id) {
setTooltipText(`Questo è il tuo posto!`)
} else {
setTooltipText(`Occupato da @${occupiedBy[0]}`)
}
} else {
setTooltipText('Libero')
}
return true
} else {
resetTooltip()
}
})
$seat.addEventListener('click', () => {
if (!user) {
location.href = '/login.html'
return
}
const occupiedBy = seats[seatId].occupiedBy
if (occupiedBy.length === 0) {
const confirmResponse = confirm('Occupare il posto?')
if (confirmResponse) {
Database.occupySeat(seatId)
}
} else if (occupiedBy.length === 1 && occupiedBy[0] === user.id) {
const answer = confirm('Lasciare veramente il posto?')
if (answer) {
Database.leaveSeat(seatId)
}
} else {
alert('Posto già occupato!')
}
})
})
// Refresh room diagram on message from server
createRoomEventStream(roomId).addEventListener('message', async e => {
console.log(e.data)
seats = await Database.getSeats(roomId)
renderWidget(elSeatMap, seats)
})
}

@ -0,0 +1,32 @@
const LISTENERS = []
const $tooltip = document.querySelector('#tooltip')
export function attachTooltip() {
document.body.addEventListener('mousemove', e => {
$tooltip.style.setProperty('--x', 10 + e.pageX + 'px')
$tooltip.style.setProperty('--y', 10 + e.pageY + 'px')
LISTENERS.find(listener => listener(e.target))
})
}
export function addTooltipElementListener(callbackFn) {
LISTENERS.push(callbackFn)
}
export function setTooltip(mountFn) {
$tooltip.classList.remove('hidden')
mountFn($tooltip)
}
export function setTooltipText(text) {
$tooltip.classList.remove('hidden')
setTooltip($t => {
$t.innerText = text
})
}
export function resetTooltip() {
$tooltip.classList.add('hidden')
$tooltip.innerHTML = ''
}

@ -1 +1,34 @@
import './style.scss' import './style.scss'
let USER = false
export async function getLoggedUser() {
if (USER === false) {
console.log('Caching user data...')
USER = await (await fetch('/api/user')).json()
}
return USER
}
export function createRoomEventStream(roomId) {
return new EventSource(`/api/room_events?id=${roomId}`)
}
export const Database = {
async getSeats(roomId) {
const seatList = await (await fetch(`/api/room/seats?id=${roomId}`)).json()
const seats = {}
seatList.forEach(seat => {
seats[seat.id] = seat
})
return seats
},
async occupySeat(seatId) {
await (await fetch(`/api/seat/occupy?id=${seatId}`, { method: 'POST' })).json()
},
async leaveSeat(seatId) {
await (await fetch(`/api/seat/leave?id=${seatId}`, { method: 'POST' })).json()
},
}

@ -0,0 +1,52 @@
import { createGridLineCanvas } from '../components/gridlines.js'
import { getLoggedUser } from '../index.js'
import { createSeatWidget } from '../components/seats-widget.js'
import { createClock } from '../components/clock.js'
import { attachTooltip } from '../components/tooltip.js'
const elClock = document.querySelector('#clock')
const elLoggedLabel = document.querySelector('#logged-label')
const elLoginLabel = document.querySelector('#login-label')
const elLogoutLabel = document.querySelector('#logout-label')
const elLogoutButton = document.querySelector('#logout-button')
const elRoomGrid = document.querySelector('.room-grid')
async function logout() {
await fetch('/api/logout', { method: 'POST' })
location.href = '/'
}
async function main() {
const urlSearchParams = new URLSearchParams(window.location.search)
const params = Object.fromEntries(urlSearchParams.entries())
console.log(params)
elLogoutButton.addEventListener('click', () => logout())
const user = await getLoggedUser()
if (user) {
elLoginLabel.classList.add('hidden')
elLoggedLabel.innerText =
'@' + user.id + (user.permissions.length > 0 ? ` (${user.permissions.join(', ')})` : '')
elLoggedLabel.classList.remove('hidden')
elLogoutLabel.classList.remove('hidden')
}
// Widgets
// createClock(elClock)
createGridLineCanvas(elRoomGrid)
createSeatWidget(elRoomGrid, 'aula-stud')
// Use tooltips only on desktop
if (matchMedia('(pointer: fine)').matches) {
attachTooltip()
}
}
main()

@ -0,0 +1,79 @@
import { getLoggedUser } from '../index.js'
//
// Page Element
//
const elErrorString = document.querySelector('#error-string')
const elLoginUsernameInput = document.querySelector('#input-login-username')
const elLoginPasswordInput = document.querySelector('#input-login-password')
const elLoginButton = document.querySelector('#button-login')
//
// Display error message
//
let errorTimeoutHandle = null
function displayErrorString(e) {
if (errorTimeoutHandle) clearTimeout(errorTimeoutHandle)
elErrorString.classList.toggle('hidden', false)
elErrorString.innerText = e.toString()
errorTimeoutHandle = setTimeout(() => {
elErrorString.classList.toggle('hidden', true)
elErrorString.innerText = ''
}, 5 * 1000)
}
//
// Handle Login Button
//
async function login() {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: elLoginUsernameInput.value,
password: elLoginPasswordInput.value,
}),
})
if (!response.ok) {
displayErrorString(await response.text())
return
}
location.href = '/'
} catch (e) {
displayErrorString(e)
}
}
//
// Main
//
async function main() {
const user = await getLoggedUser()
console.log(user)
if (user) {
location.href = '/'
}
elLoginButton.addEventListener('click', () => login())
elLoginPasswordInput.addEventListener('keydown', e => {
if (e.key === 'Enter') login()
})
elLoginUsernameInput.focus()
}
main()

@ -35,7 +35,6 @@ html:focus-within {
// Set core body defaults // Set core body defaults
body { body {
min-height: 100vh; min-height: 100vh;
text-rendering: optimizeSpeed;
line-height: 1.5; line-height: 1.5;
} }

@ -1,249 +1,497 @@
@use './reset.scss'; @use './reset.scss';
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap');
$media-small-device-size: 750px;
body { body {
background: #f0f0f0; color: #333;
background: #f8f8f8;
// background: #fff;
font-family: 'Inter', sans-serif;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
}
header { padding-bottom: 6rem;
width: 100%; }
height: 3rem;
position: relative; //
// Utility Classes
//
@mixin panel {
background: #fff; background: #fff;
border-bottom: 1px solid #ddd; box-shadow: 0 0 8px 0px #0001, 0 0 16px 8px #0001;
.nav-item { border-radius: 0.25rem;
height: 3rem;
display: flex; @media screen and (max-width: $media-small-device-size) {
align-items: center; width: 100%;
justify-content: center; max-width: 100%;
border-radius: 0;
} }
}
.left { @mixin padded-panel {
position: absolute; @include panel;
left: 0;
padding-left: 1rem; padding: 2rem;
display: flex; @media screen and (max-width: $media-small-device-size) {
gap: 1rem; padding: 1rem;
} }
}
.center { .hidden {
position: absolute; display: none !important;
left: 50%; }
transform: translate(-50%, 0);
display: flex; //
gap: 1rem; // Typography
//
// Headings
$base-font-size: 18px;
$heading-scale: 1.33;
@function pow($number, $exponent) {
$value: 1;
@if $exponent > 0 {
@for $i from 1 through $exponent {
$value: $value * $number;
}
} }
.right { @return $value;
position: absolute; }
right: 0;
padding-right: 1rem; @for $i from 1 through 5 {
h#{$i} {
font-family: 'Inter', serif;
display: flex; $factor: pow($heading-scale, 5 - $i);
gap: 1rem; font-size: $base-font-size * $factor;
font-weight: 600;
} }
} }
.panel { //
border: 1px solid #ddd; // Base Elements
background: #fff; //
a,
a:visited {
color: royalblue;
text-decoration: none;
&:hover {
text-decoration: underline;
}
} }
main { //
display: flex; // General Page Structure
flex-direction: column; //
gap: 2rem;
align-items: center; nav {
width: 100%;
height: 4rem;
position: relative;
padding: 2rem 1rem 2rem; @include panel;
border-radius: 0;
.nav-group,
.nav-item {
height: 100%;
.room-name {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
}
font-size: 26px; .left,
font-weight: bold; .center,
.right {
display: flex;
gap: 1rem;
} }
.room-main { @media screen and (max-width: $media-small-device-size) {
display: grid; height: unset;
display: flex;
flex-direction: column;
grid-template-columns: auto 1fr; .nav-group {
grid-template-rows: auto auto; height: 4rem;
grid-template-areas: }
'diagram bookings' }
'timeline timeline';
max-width: 800px; @media screen and (min-width: $media-small-device-size) {
.left,
.center,
.right {
position: absolute;
}
.room-diagram { .left {
grid-area: diagram; left: 0;
padding-left: 1rem;
} }
.room-bookings {
grid-area: bookings; .center {
left: 50%;
transform: translate(-50%, 0);
font-size: 22px;
} }
.room-timeline {
grid-area: timeline; .right {
right: 0;
padding-right: 1rem;
} }
}
}
gap: 1rem; main {
width: 100%;
.room-diagram { @media screen and (min-width: $media-small-device-size) {
padding: 1rem; max-width: 75ch;
}
// --posto-libero-bg: #ddf; padding-top: 2rem;
// --posto-libero-bg-1: #88f;
// --posto-libero-bg-2: #66d;
--posto-libero-bg: #8d8;
--posto-occupato-bg: #d88;
display: grid; display: flex;
grid-template-columns: repeat(15, 1fr); flex-direction: column;
grid-template-rows: repeat(16, 1fr); align-items: center;
gap: 0.25rem;
aspect-ratio: 15 / 16;
width: calc(15 * 2rem);
max-width: 500px;
.posto {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
&.libero {
background: var(--posto-libero-bg);
}
&.occupato {
background: var(--posto-occupato-bg);
}
border: 2px solid #0004;
cursor: pointer;
&:hover {
border-color: #0006;
border-width: 4px;
}
line-height: 1;
&::before {
font-size: 12px;
content: 'Posto';
}
&::after {
font-size: 12px;
content: attr(data-index);
}
}
}
.room-bookings { gap: 2rem;
display: flex; }
flex-direction: column;
min-width: 15rem; .form {
width: 100%; @include padded-panel;
padding: 0.75rem 0; display: grid;
grid-template-columns: auto 1fr;
.seat-list { gap: 0.5rem 1rem;
display: flex;
flex-direction: column;
.seat { .error {
padding: 0.25rem 1rem; color: #d00;
}
display: grid; .fill-row {
grid-template-columns: 1fr auto; grid-column: 1 / span 2;
place-self: center;
}
}
height: 2.5rem; .room-diagram {
--posto-libero-bg: #ddd;
--posto-occupato-bg: #d88;
--posto-mio-occupato-bg: #8d8;
&:hover { @include padded-panel;
background: #0001;
}
.name { display: flex;
display: flex; flex-direction: column;
align-items: center; align-items: center;
}
&.selected { gap: 1rem;
background: #0002;
.name { @media screen and (min-width: $media-small-device-size) {
font-style: italic; width: 450px;
} }
}
}
}
}
.room-timeline { .room-grid {
padding: 1rem; // To absolutly position a canvas underneat and render grid lines
position: relative;
z-index: 0;
display: flex; display: grid;
flex-direction: row; grid-template-columns: repeat(15, 1fr);
gap: 0.5rem; grid-template-rows: repeat(16, 1fr);
gap: 4px;
padding: 4px;
aspect-ratio: 15 / 16;
width: 100%;
.posto {
display: flex;
align-items: center; align-items: center;
justify-content: center;
flex-direction: column;
&.mio {
background: var(--posto-mio-occupato-bg);
}
&.libero {
background: var(--posto-libero-bg);
}
&.occupato {
background: var(--posto-occupato-bg);
}
border: 4px solid #0004;
cursor: pointer;
&:hover {
border-color: #0008;
border-width: 4px;
}
overflow-x: auto; line-height: 1;
button { // @media screen and (min-width: $media-small-device-size) {
min-width: 8rem; &::before {
height: 3rem; font-size: 12px;
content: 'Posto';
} }
&::after {
font-size: 12px;
content: attr(data-index);
}
// }
} }
} }
}
@media screen and (max-width: 1000px) {
header {
height: unset;
.room-seat-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
.nav-cell { width: 100%;
position: relative;
padding: 0 1rem; .seat-item {
display: grid;
grid-template-columns: 1fr auto;
} }
} }
}
main { body {
.room-main { position: relative;
display: flex;
flex-direction: column;
.room-diagram { #tooltip {
width: 200px; position: absolute;
} left: 0;
right: 0;
padding: 0.25rem;
.room-timeline { width: fit-content;
flex-direction: column;
} font-size: 14px;
} line-height: 1;
opacity: 0.8;
background: #666;
color: #ddd;
transform: translate(var(--x, -100px), var(--y, -100px));
z-index: 100;
user-select: none;
pointer-events: none;
} }
} }
// .panel {
// border: 1px solid #ddd;
// background: #fff;
// }
// main {
// display: flex;
// flex-direction: column;
// gap: 2rem;
// align-items: center;
// padding: 2rem 1rem 2rem;
// .room-name {
// display: flex;
// align-items: center;
// justify-content: center;
// font-size: 26px;
// font-weight: bold;
// }
// .room-main {
// display: grid;
// grid-template-columns: auto 1fr;
// grid-template-rows: auto auto;
// grid-template-areas:
// 'diagram bookings'
// 'timeline timeline';
// max-width: 800px;
// .room-diagram {
// grid-area: diagram;
// }
// .room-bookings {
// grid-area: bookings;
// }
// .room-timeline {
// grid-area: timeline;
// }
// gap: 1rem;
// .room-diagram {
// padding: 1rem;
// // --posto-libero-bg: #ddf;
// // --posto-libero-bg-1: #88f;
// // --posto-libero-bg-2: #66d;
// --posto-libero-bg: #8d8;
// --posto-occupato-bg: #d88;
// display: grid;
// grid-template-columns: repeat(15, 1fr);
// grid-template-rows: repeat(16, 1fr);
// gap: 0.25rem;
// aspect-ratio: 15 / 16;
// width: calc(15 * 2rem);
// max-width: 500px;
// .posto {
// display: flex;
// align-items: center;
// justify-content: center;
// flex-direction: column;
// &.libero {
// background: var(--posto-libero-bg);
// }
// &.occupato {
// background: var(--posto-occupato-bg);
// }
// border: 2px solid #0004;
// cursor: pointer;
// &:hover {
// border-color: #0006;
// border-width: 4px;
// }
// line-height: 1;
// &::before {
// font-size: 12px;
// content: 'Posto';
// }
// &::after {
// font-size: 12px;
// content: attr(data-index);
// }
// }
// }
// .room-bookings {
// display: flex;
// flex-direction: column;
// min-width: 15rem;
// width: 100%;
// padding: 0.75rem 0;
// .seat-list {
// display: flex;
// flex-direction: column;
// .seat {
// padding: 0.25rem 1rem;
// display: grid;
// grid-template-columns: 1fr auto;
// height: 2.5rem;
// &:hover {
// background: #0001;
// }
// .name {
// display: flex;
// align-items: center;
// }
// &.selected {
// background: #0002;
// .name {
// font-style: italic;
// }
// }
// }
// }
// }
// .room-timeline {
// padding: 1rem;
// display: flex;
// flex-direction: row;
// gap: 0.5rem;
// align-items: center;
// overflow-x: auto;
// button {
// min-width: 8rem;
// height: 3rem;
// }
// }
// }
// }
// @media screen and (max-width: $media-small-device-size) {
// header {
// height: unset;
// display: flex;
// flex-direction: column;
// .nav-cell {
// position: relative;
// padding: 0 1rem;
// }
// }
// main {
// .room-main {
// display: flex;
// flex-direction: column;
// .room-diagram {
// width: 200px;
// }
// .room-timeline {
// flex-direction: column;
// }
// }
// }
// }
img { img {
transform: rotate(180deg); transform: rotate(180deg);
} }

@ -49,7 +49,7 @@ type AuthService struct {
UserFromSessionToken func(session string) (string, error) UserFromSessionToken func(session string) (string, error)
// AuthenticationFailed handles failed authentications // AuthenticationFailed handles failed authentications
AuthenticationFailed http.Handler AuthenticationFailed func(error) http.Handler
// OtherError handles other errors // OtherError handles other errors
OtherError func(error) http.Handler OtherError func(error) http.Handler
@ -57,7 +57,7 @@ type AuthService struct {
func (auth *AuthService) Login(w http.ResponseWriter, r *http.Request, userID, password string) { func (auth *AuthService) Login(w http.ResponseWriter, r *http.Request, userID, password string) {
if err := auth.CheckUserPassword(userID, password); err != nil { if err := auth.CheckUserPassword(userID, password); err != nil {
auth.AuthenticationFailed.ServeHTTP(w, r) auth.AuthenticationFailed(err).ServeHTTP(w, r)
return return
} }
@ -98,7 +98,7 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand
return return
} }
auth.AuthenticationFailed.ServeHTTP(w, r) auth.AuthenticationFailed(err).ServeHTTP(w, r)
return return
} }
if err != nil { if err != nil {
@ -139,7 +139,7 @@ func (auth *AuthService) Middleware(config *AuthMiddlewareConfig) func(http.Hand
} }
if !hasAll { if !hasAll {
auth.AuthenticationFailed.ServeHTTP(w, r) auth.AuthenticationFailed(err).ServeHTTP(w, r)
return return
} }
} }

@ -1,27 +1,33 @@
package db package db
import "fmt" import (
"errors"
"fmt"
)
var ErrAlreadyExists = errors.New(`object already exists in database`)
var ErrDoesntExist = errors.New(`object doesn't exist in database`)
// Entities // Entities
type User struct { type User struct {
ID string ID string `json:"id"`
Username string Permissions []string `json:"permissions"`
Permissions []string
} }
type Room struct { type Room struct {
ID string ID string `json:"id"`
SeatIDs []string SeatIDs []string `json:"seatIds"`
// gridRows, gridCols int // gridRows, gridCols int
} }
type Seat struct { type Seat struct {
ID string ID string `json:"id"`
RoomID string `json:"roomId"`
// OccupiedBy is an empty list or a singleton of a userID // OccupiedBy is an empty list or a singleton of a userID
OccupiedBy []string OccupiedBy []string `json:"occupiedBy"`
// x, y, w, h int // x, y, w, h int
} }
@ -29,16 +35,27 @@ type Seat struct {
// Database Interfaces // Database Interfaces
type Store interface { type Store interface {
CreateUser(user *User) error
CreateRoom(room *Room) error
CreateSeat(seat *Seat) error
DeleteUser(user *User) error
DeleteRoom(room *Room) error
DeleteSeat(seat *Seat) error
GetUser(userID string) (*User, error) GetUser(userID string) (*User, error)
GetRoom(roomID string) (*Room, error) GetRoom(roomID string) (*Room, error)
GetSeat(seatID string) (*Seat, error) GetSeat(seatID string) (*Seat, error)
GetOccupiedSeats(roomID string) ([]string, error) GetRooms() ([]string, error)
GetFreeSeats(roomID string) ([]string, error)
GetRoomOccupiedSeats(roomID string) ([]string, error)
GetRoomFreeSeats(roomID string) ([]string, error)
GetUserSeat(userID string) ([]string, error) GetUserSeat(userID string) ([]string, error)
OccupySeat(userID, roomID, seatID string) error OccupySeat(seatID, userID string) error
FreeSeat(userID, roomID, seatID string) error FreeSeat(seatID string) error
} }
// TODO: Create an SQLite implementation // TODO: Create an SQLite implementation
@ -57,47 +74,88 @@ func NewInMemoryStore() Store {
} }
db.rooms["aula-stud"] = &Room{ db.rooms["aula-stud"] = &Room{
ID: "aula-stud", ID: "aula-stud",
SeatIDs: []string{ SeatIDs: []string{},
"aula-stud/posto-1",
"aula-stud/posto-2",
"aula-stud/posto-3",
"aula-stud/posto-4",
},
} }
db.seats["aula-stud/posto-1"] = &Seat{ for i := 1; i <= 11; i++ {
ID: "aula-stud/posto-1", seatID := fmt.Sprintf(`aula-stud/posto-%d`, i)
OccupiedBy: []string{},
} db.rooms["aula-stud"].SeatIDs = append(db.rooms["aula-stud"].SeatIDs, seatID)
db.seats["aula-stud/posto-2"] = &Seat{ db.seats[seatID] = &Seat{
ID: "aula-stud/posto-2", ID: seatID,
OccupiedBy: []string{}, RoomID: "aula-stud",
} OccupiedBy: []string{},
db.seats["aula-stud/posto-3"] = &Seat{ }
ID: "aula-stud/posto-3",
OccupiedBy: []string{},
}
db.seats["aula-stud/posto-4"] = &Seat{
ID: "aula-stud/posto-4",
OccupiedBy: []string{},
} }
db.seats["aula-stud/posto-7"].OccupiedBy = []string{"aziis98"}
db.seats["aula-stud/posto-8"].OccupiedBy = []string{"jack"}
db.users["aziis98"] = &User{ db.users["aziis98"] = &User{
ID: "aziis98", ID: "aziis98",
Username: "aziis98",
Permissions: []string{"admin"}, Permissions: []string{"admin"},
} }
db.users["bachoseven"] = &User{ db.users["bachoseven"] = &User{
ID: "bachoseven", ID: "bachoseven",
Username: "bachoseven",
Permissions: []string{"admin"}, Permissions: []string{"admin"},
} }
return db return db
} }
func (db *memDB) CreateUser(user *User) error {
if _, present := db.users[user.ID]; present {
return ErrAlreadyExists
}
db.users[user.ID] = user
return nil
}
func (db *memDB) CreateRoom(room *Room) error {
if _, present := db.rooms[room.ID]; present {
return ErrAlreadyExists
}
db.rooms[room.ID] = room
return nil
}
func (db *memDB) CreateSeat(seat *Seat) error {
if _, present := db.seats[seat.ID]; present {
return ErrAlreadyExists
}
db.seats[seat.ID] = seat
return nil
}
func (db *memDB) DeleteUser(user *User) error {
if _, present := db.users[user.ID]; !present {
return ErrDoesntExist
}
delete(db.users, user.ID)
return nil
}
func (db *memDB) DeleteRoom(room *Room) error {
if _, present := db.rooms[room.ID]; !present {
return ErrDoesntExist
}
delete(db.rooms, room.ID)
return nil
}
func (db *memDB) DeleteSeat(seat *Seat) error {
if _, present := db.seats[seat.ID]; !present {
return ErrDoesntExist
}
delete(db.seats, seat.ID)
return nil
}
func (db *memDB) GetUser(userID string) (*User, error) { func (db *memDB) GetUser(userID string) (*User, error) {
user, present := db.users[userID] user, present := db.users[userID]
if !present { if !present {
@ -125,7 +183,17 @@ func (db *memDB) GetSeat(seatID string) (*Seat, error) {
return seat, nil return seat, nil
} }
func (db *memDB) GetOccupiedSeats(roomID string) ([]string, error) { func (db *memDB) GetRooms() ([]string, error) {
roomIDs := []string{}
for roomID := range db.rooms {
roomIDs = append(roomIDs, roomID)
}
return roomIDs, nil
}
func (db *memDB) GetRoomOccupiedSeats(roomID string) ([]string, error) {
room, err := db.GetRoom(roomID) room, err := db.GetRoom(roomID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -142,7 +210,7 @@ func (db *memDB) GetOccupiedSeats(roomID string) ([]string, error) {
return seats, nil return seats, nil
} }
func (db *memDB) GetFreeSeats(roomID string) ([]string, error) { func (db *memDB) GetRoomFreeSeats(roomID string) ([]string, error) {
room, err := db.GetRoom(roomID) room, err := db.GetRoom(roomID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -169,12 +237,12 @@ func (db *memDB) GetUserSeat(userID string) ([]string, error) {
return []string{}, nil return []string{}, nil
} }
func (db *memDB) OccupySeat(userID string, roomID string, seatID string) error { func (db *memDB) OccupySeat(seatID, userID string) error {
db.seats[seatID].OccupiedBy = []string{userID} db.seats[seatID].OccupiedBy = []string{userID}
return nil return nil
} }
func (db *memDB) FreeSeat(userID string, roomID string, seatID string) error { func (db *memDB) FreeSeat(seatID string) error {
db.seats[seatID].OccupiedBy = []string{} db.seats[seatID].OccupiedBy = []string{}
return nil return nil
} }

@ -7,9 +7,12 @@ import (
type H map[string]interface{} type H map[string]interface{}
func WriteJSON(w http.ResponseWriter, data interface{}) error { func WriteJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(data) err := json.NewEncoder(w).Encode(data)
if err != nil {
panic(err)
}
} }
func ReadJSON(r *http.Request, data interface{}) error { func ReadJSON(r *http.Request, data interface{}) error {

@ -0,0 +1,78 @@
package serverevents
import (
"fmt"
"log"
"net/http"
)
type Config struct {
Connected func(chan string)
Disconnected func(chan string)
}
type Handler interface {
http.Handler
Broadcast(message string)
}
type handler struct {
clients map[chan string]bool
Connected func(chan string)
Disconnected func(chan string)
}
func New(config *Config) Handler {
return &handler{
make(map[chan string]bool),
config.Connected,
config.Disconnected,
}
}
func (sse *handler) Broadcast(message string) {
for client := range sse.clients {
client <- message
}
}
func (sse *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := make(chan string)
sse.clients[client] = true
log.Printf(`New connection`)
if sse.Connected != nil {
sse.Connected(client)
}
defer func() {
log.Printf(`Connection closed`)
close(client)
delete(sse.clients, client)
if sse.Disconnected != nil {
sse.Disconnected(client)
}
}()
flusher, _ := w.(http.Flusher)
for {
select {
case message, ok := <-client:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", message)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}

@ -0,0 +1,34 @@
package serverevents_test
import (
"log"
"net/http"
"testing"
"time"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil/serverevents"
)
func TestSSE(t *testing.T) {
sse := serverevents.New(&serverevents.Config{
Connected: func(client chan string) {
go func() {
log.Printf(`New client connected callback`)
client <- "Messaggio 1 per questo client"
time.Sleep(1 * time.Second)
client <- "Messaggio 2 per questo client"
}()
},
})
go func() {
for {
log.Printf(`Broadcasting message`)
sse.Broadcast("Messaggio per tutti")
time.Sleep(1 * time.Second)
}
}()
http.Handle("/sse", sse)
http.ListenAndServe(":8000", nil)
}

@ -1,102 +0,0 @@
package httputil
import (
"fmt"
"log"
"net/http"
)
type SSEHandler struct {
clients map[chan string]bool
Connected func(chan string)
Disconnected func(chan string)
}
func (sse *SSEHandler) init() {
if sse.clients == nil {
sse.clients = make(map[chan string]bool)
}
}
func (sse *SSEHandler) Broadcast(message string) {
sse.init()
for client := range sse.clients {
client <- message
}
}
func (sse *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sse.init()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := make(chan string)
sse.clients[client] = true
log.Printf(`New connection`)
if sse.Connected != nil {
go sse.Connected(client)
}
defer func() {
log.Printf(`Connection closed`)
close(client)
delete(sse.clients, client)
if sse.Disconnected != nil {
go sse.Disconnected(client)
}
}()
flusher, _ := w.(http.Flusher)
for {
select {
case message, ok := <-client:
if !ok {
return
}
fmt.Fprintf(w, "data: %s\n\n", message)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
// func HandleSSE(handler func(broadcast, single chan<- string)) http.Handler {
// broadcast := make(chan string)
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// w.Header().Set("Content-Type", "text/event-stream")
// w.Header().Set("Cache-Control", "no-cache")
// w.Header().Set("Connection", "keep-alive")
// w.Header().Set("Access-Control-Allow-Origin", "*")
// client := make(chan string)
// defer func() {
// close(client)
// }()
// go handler(broadcast, client)
// flusher, _ := w.(http.Flusher)
// for {
// select {
// case message := <-broadcast:
// fmt.Fprintf(w, "data: %s\n\n", message)
// flusher.Flush()
// case message := <-client:
// fmt.Fprintf(w, "data: %s\n\n", message)
// flusher.Flush()
// case <-r.Context().Done():
// return
// }
// }
// })
// }

@ -1,32 +0,0 @@
package httputil_test
import (
"log"
"net/http"
"testing"
"time"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil"
)
func TestSSE(t *testing.T) {
sse := &httputil.SSEHandler{
Connected: func(client chan string) {
log.Printf(`New client connected callback`)
client <- "Messaggio 1 per questo client"
time.Sleep(1 * time.Second)
client <- "Messaggio 2 per questo client"
},
}
go func() {
for {
log.Printf(`Broadcasting message`)
sse.Broadcast("Messaggio per tutti")
time.Sleep(1 * time.Second)
}
}()
http.Handle("/sse", sse)
http.ListenAndServe(":8000", nil)
}

@ -7,71 +7,93 @@ import (
"git.phc.dm.unipi.it/aziis98/posti-dm/server/auth" "git.phc.dm.unipi.it/aziis98/posti-dm/server/auth"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/db" "git.phc.dm.unipi.it/aziis98/posti-dm/server/db"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil" "git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/httputil/serverevents"
"git.phc.dm.unipi.it/aziis98/posti-dm/server/util" "git.phc.dm.unipi.it/aziis98/posti-dm/server/util"
"github.com/alecthomas/repr" "github.com/alecthomas/repr"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Server struct { type Server struct {
sessions map[string]*db.User
authService *auth.AuthService authService *auth.AuthService
roomServerEventStreams map[string]serverevents.Handler
Database db.Store Database db.Store
ApiRoute chi.Router ApiRoute chi.Router
} }
func NewServer() *Server { func NewServer() *Server {
server := &Server{ sessions := make(map[string]*db.User)
sessions: make(map[string]*db.User), database := db.NewInMemoryStore()
Database: db.NewInMemoryStore(),
ApiRoute: chi.NewRouter(),
}
server.authService = &auth.AuthService{
CheckUserPassword: func(userID, password string) error {
repr.Println("Sessions: ", server.sessions)
if password != "phc" {
return fmt.Errorf(`invalid password`)
}
return nil
},
UserPermissions: func(userID string) ([]string, error) {
user, err := server.Database.GetUser(userID)
if err != nil {
return nil, err
}
return user.Permissions, nil server := &Server{
Database: database,
authService: &auth.AuthService{
CheckUserPassword: func(userID, password string) error {
if password != "phc" {
return fmt.Errorf(`invalid password`)
}
// FIXME: al momento quando la password è giusta creiamo tutti gli account necessari
database.CreateUser(&db.User{
ID: userID,
Permissions: []string{},
})
return nil
},
UserPermissions: func(userID string) ([]string, error) {
user, err := database.GetUser(userID)
if err != nil {
return nil, err
}
return user.Permissions, nil
},
SessionTokenFromUser: func(userID string) (string, error) {
user, err := database.GetUser(userID)
if err != nil {
return "", err
}
token := util.RandomHash(10)
sessions[token] = user
repr.Println("Sessions: ", sessions)
return token, nil
},
UserFromSessionToken: func(session string) (string, error) {
user, present := sessions[session]
if !present {
return "", auth.ErrNoUserForSession
}
return user.ID, nil
},
AuthenticationFailed: func(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusUnauthorized)
})
},
OtherError: func(err error) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
})
},
}, },
SessionTokenFromUser: func(userID string) (string, error) {
user, err := server.Database.GetUser(userID)
if err != nil {
return "", err
}
token := util.RandomHash(10) roomServerEventStreams: make(map[string]serverevents.Handler),
server.sessions[token] = user
return token, nil ApiRoute: chi.NewRouter(),
}, }
UserFromSessionToken: func(session string) (string, error) {
user, present := server.sessions[session]
if !present {
return "", auth.ErrNoUserForSession
}
return user.ID, nil roomIDs, _ := database.GetRooms()
}, for _, roomID := range roomIDs {
AuthenticationFailed: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server.roomServerEventStreams[roomID] = serverevents.New(&serverevents.Config{
http.Error(w, `not authenticated`, http.StatusUnauthorized) Connected: func(client chan string) {
}), server.ConnectedToRoom(roomID, client)
OtherError: func(err error) http.Handler { },
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { })
http.Error(w, err.Error(), http.StatusInternalServerError)
})
},
} }
server.setupRoutes() server.setupRoutes()
@ -81,7 +103,7 @@ func NewServer() *Server {
func (server *Server) setupRoutes() { func (server *Server) setupRoutes() {
api := server.ApiRoute api := server.ApiRoute
db := server.Database database := server.Database
// Authenticated Routes // Authenticated Routes
@ -104,31 +126,116 @@ func (server *Server) setupRoutes() {
server.authService.Logout(w) server.authService.Logout(w)
}) })
api.Get("/user", func(w http.ResponseWriter, r *http.Request) {
userID, err := server.authService.UserFromSession(r)
if err != nil {
httputil.WriteJSON(w, nil)
return
}
user, err := database.GetUser(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
}
httputil.WriteJSON(w, user)
})
api.Get("/room/seats", func(w http.ResponseWriter, r *http.Request) {
roomID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing room id`, http.StatusBadRequest)
}
room, err := database.GetRoom(roomID[0])
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
seats := []*db.Seat{}
for _, seatID := range room.SeatIDs {
seat, err := database.GetSeat(seatID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
seats = append(seats, seat)
}
httputil.WriteJSON(w, seats)
})
api.HandleFunc("/room_events", func(w http.ResponseWriter, r *http.Request) {
roomID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing room id`, http.StatusBadRequest)
return
}
server.roomServerEventStreams[roomID[0]].ServeHTTP(w, r)
})
api.With(server.authService.LoggedMiddleware()). api.With(server.authService.LoggedMiddleware()).
Get("/current-seat", func(w http.ResponseWriter, r *http.Request) { Post("/seat/occupy", func(w http.ResponseWriter, r *http.Request) {
userID, err := server.authService.UserFromSession(r) userID, err := server.authService.UserFromSession(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
seatID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing seat id`, http.StatusBadRequest)
return return
} }
occupiedSeatID, err := db.GetUserSeat(userID) seat, err := database.GetSeat(seatID[0])
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
repr.Println(userID, seatID)
if err := database.OccupySeat(seatID[0], userID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
if len(occupiedSeatID) == 0 { server.roomServerEventStreams[seat.RoomID].Broadcast("refresh")
httputil.WriteJSON(w, nil) httputil.WriteJSON(w, "ok")
})
api.With(server.authService.LoggedMiddleware()).
Post("/seat/leave", func(w http.ResponseWriter, r *http.Request) {
seatID, ok := r.URL.Query()["id"]
if !ok {
http.Error(w, `missing seat id`, http.StatusBadRequest)
return return
} }
seat, err := db.GetSeat(occupiedSeatID[0]) seat, err := database.GetSeat(seatID[0])
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if err := database.FreeSeat(seatID[0]); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
httputil.WriteJSON(w, seat) server.roomServerEventStreams[seat.RoomID].Broadcast("refresh")
httputil.WriteJSON(w, "ok")
}) })
} }
func (server *Server) ConnectedToRoom(roomID string, client chan string) {
// go func() {
// for {
// client <- "hi from server!"
// time.Sleep(1 * time.Second)
// }
// }()
}

Loading…
Cancel
Save