This commit is contained in:
2025-08-30 10:30:43 +02:00
commit 912c7ed374
35 changed files with 2327 additions and 0 deletions

158
web/QRgen.html Normal file
View File

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html>
<head>
<title>FTracker</title>
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form {
padding: 16px;
max-width: 512px;
margin: auto;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
}
.print {
display: none;
text-align: center;
}
.print * {
display: inline-block;
margin: 42px auto;
}
.print .link {
font-size: 24px;
}
@media print {
.print h1 {
margin-top: 64px;
color: #000;
}
}
</style>
</head>
<body>
<main id="formView">
<h1>FTracker<br>Door Sign Genrator</h1>
<form id="roomform">
<label>
Room Nr/Name:<br>
<input type="text" name="room" id="room" placeholder="123" required>
</label>
<input type="submit" value="Print">
</form>
</main>
<main id="printA" class="print">
<h1 class="title"></h1><br>
<div class="qr"></div><br>
<span class="link"></span><br>
<span>
Made with FTracker<br>
https://git.fasttube.de/FaSTTUBe/ftracker<br>
&copy; 2020 Oskar / FaSTTUBe
</span>
</main>
<main id="printD" class="print">
<h1 class="title"></h1><br>
<div class="qr"></div><br>
<span class="link"></span><br>
<span>
Made with FTracker<br>
https://git.fasttube.de/FaSTTUBe/ftracker<br>
&copy; 2020 Oskar / FaSTTUBe
</span>
</main>
<script src="/qrcodejs/qrcode.min.js"></script>
<script>
var fv = document.getElementById('formView')
var pa = document.getElementById('printA')
var pd = document.getElementById('printD')
var rform = document.getElementById('roomform')
rform.onsubmit = function(e) {
e.preventDefault()
var room = e.srcElement[0].value
writePage(pa, room, 'arrival')
writePage(pd, room, 'departure')
printPage(pa, 'ftracker-arrival-'+room)
printPage(pd, 'ftracker-departure-'+room)
}
function writePage(el, room, type) {
var base = location.href.split('/').slice(0,3).join('/')
var url = base + '/?' + type + '=' + room
var title = el.querySelector('.title')
var qr = el.querySelector('.qr')
var link = el.querySelector('.link')
title.innerHTML =
'Scan here to log ' + type + '<br> in room ' + room
link.innerHTML = url
qr.innerHTML = ''
new QRCode(qr, {
text: url,
width: 320,
height: 320
})
}
function printPage(el, name) {
var t = document.title
document.title = name
fv.style.display = 'none'
el.style.display = 'block'
window.print()
el.style.display = 'none'
fv.style.display = 'block'
document.title = t
}
</script>
</body>
</html>

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

53
web/index.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<title>Schnitzeljagt</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="style.css" rel="stylesheet" type="text/css">
<script>
// 1st script, prepares values needed for writing document
function getParams() {
var qparams = document.location.search.substr(1)
if (qparams == "") return {}
qparams = qparams.split('&')
var qps = {}
for (var qparam of qparams) {
var vals = qparam.split('=')
qps[vals[0]] = vals[1] || null
}
return qps
}
var qp = getParams()
</script>
</head>
<body>
<h1><script>
document.write(qp.checkpoint ? ("Checkpoint<br>" + qp.checkpoint) : 'Schnitzeljagt<br>V 0.1')
</script></h1>
<div id="startpage">
This is a web app to track games at the 20J FaSTTUBe festival.<br><br>
If you've reached this page that either means your're testing
things or something has gone quite wrong with the URL.<br>
In the former case: Yay it works! In the latter you should
probably contact an admin or a dev nearby :(<br><br>
Here are a few links for testing:<br>
<a href="/view">View Data</a>,
<a href="/QRgen">Door Sign Generator</a>,
<a href="/?game=schnitzeljagd&checkpoint=42">Test Checkpoint</a>,
<br><br>
&copy; 2025 made by <a target="_blank" href="mailto:&#111;&#46;&#119;&#105;&#110;&#107;&#101;&#108;&#115;&#64;&#102;&#97;&#115;&#116;&#116;&#117;&#98;&#101;&#46;&#100;&#101;">Oskar</a>
for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br>
For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/schnitzeljagt">git repo</a>
</div>
<form id="mainform" action="javascript:void(0);" style="display: none">
<label>
Vollständiger Name:<br>
<input type="text" name="name" id="name" placeholder="Max Mustermensch" required disabled>
</label>
<input type="submit" value="Einchecken">
</form>
<script src="main.js"></script>
</body>
</html>

93
web/main.js Normal file
View File

@ -0,0 +1,93 @@
var spage = document.getElementById('startpage')
var mform = document.getElementById('mainform')
if (qp.checkpoint) {
spage.style.display = 'none'
mform.style.display = 'block'
}
// Get the name field if it was successfully entered before
var savedName = localStorage.getItem('name')
if (qp && qp.name) {
// Forced Admin checkout - prefill qp name and auto-agree
document.getElementById('name').value = qp.name.replace(/-/g, ' ').toUpperCase();
document.getElementById('agree').checked = true
document.getElementById('agree').parentElement.style.display = 'none'
} else if (savedName && qp) {
// Prefill the client's locally saved name
document.getElementById('name').value = savedName
}
if (qp.askname)
document.getElementById('name').disabled = false
// 2nd script, server API communication
var name, datetime, agreed, tested
mform.onsubmit = function(e) {
e.preventDefault()
var i = 0;
name = e.srcElement[i++].value
if (qp.edittime && qp.edittime == 1) {
var value = e.srcElement[i++].value
datetime = new Date(value).toISOString()
}
agreed = e.srcElement[i++].checked
if (qp.action && qp.action == 'arrival')
tested = e.srcElement[i++].checked
sendMainData()
}
function sendMainData() {
// POST JSON. See docs/API.md
var payload = {
'game': qp.game,
'checkpoint': qp.checkpoint,
'name': name
}
post("/checkin", payload)
}
function post(url, payload) {
console.log("Sending payload:", payload)
return fetch(url, {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(res => {
handleResponse(res)
})
}
function handleResponse(res) {
console.log("Request complete! response:", res)
if (Math.floor(res.status / 100) == 2) {
// Success
mform = document.getElementById('mainform')
smsg = qp.success_message ? decodeURI(qp.success_message) : "Eingecheckt. Weiter so!"
mform.innerHTML = "<h2>"+smsg+"</h2>"
localStorage.setItem('name', name)
} else {
// Any other generic error
res.text().then(function (text) {
alert(text)
})
}
}

4
web/qrcodejs/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.DS_Store
.idea
.project

14
web/qrcodejs/LICENSE Normal file
View File

@ -0,0 +1,14 @@
The MIT License (MIT)
---------------------
Copyright (c) 2012 davidshimjs
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1
web/qrcodejs/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
web/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

79
web/style.css Normal file
View File

@ -0,0 +1,79 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form, #startpage {
padding: 16px;
max-width: 512px;
margin: auto;
}
label {
display: block;
font-size: 16px;
margin-bottom: 16px;
color: #444;
}
label.checkbox {
height: 32px;
line-height: 32px;
}
label span {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
line-height: normal;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit], input[type=button] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input[type=checkbox] {
transform: translateY(-3px);
float: left;
height: 32px;
width: 32px;
margin-right: 8px;
}
.request {
display: block;
position: fixed;
background: #ddd;
top: 16px;
left: 16px;
width: calc(100% - 32px);
box-shadow: 0 1px 4px 0;
}
input[type=datetime-local] {
width: calc(100% - 24px);
padding: 12px;
font-size: 12px;
background: #fff;
}

124
web/view.css Normal file
View File

@ -0,0 +1,124 @@
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
}
header {
height: calc(38px - 16px);
padding: 8px;
white-space: nowrap;
overflow: hidden;
}
main {
height: calc(100% - 38px);
vertical-align: top;
}
main > section {
height: calc(100% - 32px);
display: inline-block;
vertical-align: top;
}
main > .viewheader {
display: inline-block;
vertical-align: top;
}
main > section.names, #nameheader {
width: 199px;
overflow: hidden;
border-right: 1px solid gray;
font-weight: bold;
text-transform: capitalize;
white-space: nowrap;
}
main > section.times, #timeheader {
width: calc(100% - 200px);
overflow: hidden;
}
.scroll {
height: 100%;
width: 100%;
overflow: scroll;
}
.row, .row #timelabels {
position: relative;
height: 32px;
background-size: calc(15 * 5px) 100%; /* segment size in minutes * PX_PER_MINUTE */
}
.names .row:nth-child(even) { background: #ddd; }
.names .row:nth-child(odd) { background: #eee; }
.times .row:nth-child(even) {
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
}
.times .row:nth-child(odd) {
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
}
.row span {
height: 18px;
padding: 7px;
display: inline-block;
}
.times span, #timeheader span {
position: absolute;
margin-right: 16px;
}
.times span {
background: #c50e1f;
color: #ddd;
font-weight: bold;
-webkit-text-stroke: .4px #c50e1f;
}
.times span.blue {
background: #1205a6;
color: #ddd;
font-weight: bold;
-webkit-text-stroke: .4px #1205a6;
}
.viewheader.row {
height: 30px;
background: #ddd !important;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
}
.viewheader span {
background: none !important;
color: #000 !important;
padding-left: 4px;
}
#credprompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 95%;
max-width: 320px;
margin: auto;
background: #ddd;
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
}
#credprompt h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
#credprompt input {
display: block;
border: none;
margin: 16px auto;
padding: 16px;
font-size: 16px;
}
#credprompt input[type=text],
#credprompt input[type=password] {
color: #000;
width: calc(100% - 64px);
}
#credprompt input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: calc(100% - 32px);
}

36
web/view.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>FTracker Data</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="view.css" rel="stylesheet" type="text/css">
</head>
<body>
<header>
Start: <input type="datetime-local" id="start" value="2025-08-30T08:00" onchange="renderData()">
Ende: <input type="datetime-local" id="end" onchange="renderData()">
Spiel: <input type="text" id="game" placeholder=".* (regex)" onchange="renderData()">
<input type="button" value="Log Out" style="float:right" onclick="localStorage.removeItem('dataauth'); location.reload()">
<input type="button" value="Export CSV" style="float:right; margin-right: 8px;" onclick="exportCSV()">
</header>
<main>
<div class="row viewheader" id="nameheader">
<span><b>Names</b></span>
</div><!--
--><div class="row viewheader" id="timeheader">
<div id="timelabels" style="padding-right: 256px;"></div>
</div>
<section class="names">
<div id="names" style="padding-bottom: 256px;"></div>
</section><!--
--><section class="times">
<div class="scroll">
<div id="times"></div>
</div>
</section>
</main>
<script src="view.js"></script>
</body>
</html>

278
web/view.js Normal file
View File

@ -0,0 +1,278 @@
var PX_PER_MINUTE = 5
var data = null;
var names = document.querySelector('main #names')
var times = document.querySelector('main #times')
function exportCSV() {
if (data == null) {
alert('No data found.')
return
}
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var gameInput = document.querySelector('input#game')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(gameInput.value || '.*')
csv = '"ftracker-export",'
days = []
var tc = new Date(startDate.getTime())
tc.setHours(1,0,0,0)
while (tc < endDate) {
var isodate = tc.toISOString().split('T')[0]
csv += ('"' + isodate + '",')
days.push(isodate)
tc.setDate(tc.getDate() + 1);
}
csv = csv.replace(/,$/, '')
csv += '\n'
for (var [name, list] of Object.entries(data)) {
csv += '"' + name + '"'
for (day of days) {
csv += ',"'
daytexts = []
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
var [arrDay, arrT] = localISOTimeMinutes(arrD).split('T')
var [depDay, depT] = localISOTimeMinutes(depD).split('T')
if ((arrDay == day) && (depDay == day)) {
daytexts.push(arrT + '-' + depT + ' (' + entry.room + ')')
} else if (arrDay == day) {
daytexts.push(arrT + '-... (' + entry.room + ')')
} else if (depDay == day) {
daytexts.push('...-' + depT + ' (' + entry.room + ')')
}
}
csv += daytexts.join('\n')
csv += '"'
}
csv += '\n'
}
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
element.setAttribute('download', 'ftracker-export.csv');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function renderData() {
if (data == null) {
alert('No data found.')
return
}
names = document.querySelector('main #names')
times = document.querySelector('main #times')
names.innerHTML = ''
times.innerHTML = ''
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var gameInput = document.querySelector('input#game')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var gameRE = new RegExp(gameInput.value || '.*')
// Clear everything below hours because that would lead to
// misalignments with the day grid
startDate.setMinutes(0,0,0)
var tc = new Date(startDate.getTime())
var content = ''
while (tc < endDate) {
var h = tc.getHours()
var m = tc.getMinutes()
var t = (h == 0) ?
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
h+':'+m.toString().padStart(2, "0")
var left = ((tc - startDate) / (1000 * 60))
content += '<span style="left:'+(PX_PER_MINUTE*left)+'px;">'+t+'</span>'
tc.setTime(tc.getTime() + (15*60*1000));
}
var timeheader = document.getElementById('timelabels')
timeheader.innerHTML = content
var viewwidth = ((endDate - startDate) / (1000 * 60))
timeheader.style.width = (PX_PER_MINUTE * viewwidth) + 'px'
times.style.width = (PX_PER_MINUTE * viewwidth) + 'px'
var rowCount = 0
for (var [name, list] of Object.entries(data)) {
var row = document.createElement('div')
row.classList.add('row')
var rowHasBlock = false
for (entry of list) {
if (entry.game.match(game) == null)
continue
var arrD = new Date(entry.arrival)
var depD = new Date(arrD) + 1000 * 60 * 5
if (depD < startDate || arrD > endDate)
continue
rowHasBlock = true
// Minutes since start date / beginning
var arr = (arrD - startDate) / (1000 * 60)
var dep = (depD - startDate) / (1000 * 60)
var dur = dep - arr
var block = document.createElement('span')
block.innerHTML = entry.checkpoint
block.style.left = (PX_PER_MINUTE * arr) + 'px' // 1px/min
block.style.width = (PX_PER_MINUTE * Math.max(0,(dur-14))) + 'px' // 1px/min
if (entry.game == "zeitfahren")
block.classList.add('blue')
row.appendChild(block)
}
if (rowHasBlock) {
var vname = name.replace(/-/g, ' ')
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
times.appendChild(row)
rowCount += 12
}
}
//var viewheight = rowCount * 32;
//times.style.height = viewheight + 'px'
var tw = document.querySelector('main .scroll')
tw.scrollLeft = tw.scrollWidth
}
function saveData(rdata) {
data = rdata.reduce((acc, entry) => {
var name = entry.name
delete entry.name
acc[name] = [...acc[name] || [], entry];
return acc;
}, {});
console.log(data)
renderData()
}
function submitCredentials() {
var cp = document.querySelector('#credprompt')
var user = cp.querySelector('#user').value
var pass = cp.querySelector('#pass').value
cp.remove()
var auth = btoa(user + ":" + pass)
localStorage.setItem('dataauth', auth)
loadData()
}
function loadData() {
var auth = localStorage.getItem('dataauth')
if (auth == null) {
var prompt = document.createElement('div')
prompt.id = 'credprompt'
prompt.innerHTML = '<h1>Credentials Required</h1>\
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="submit" onclick="submitCredentials()">'
document.body.appendChild(prompt)
document.querySelector('#credprompt #user').focus()
return // Abort load, wait for submit
}
var headers = new Headers()
headers.append('Authorization', 'Basic ' + auth)
var fetchopts = {
method: 'GET',
headers: headers
}
fetch('/data', fetchopts)
.then(res => {
if (Math.floor(res.status / 100) == 2)
return res.json()
else
localStorage.removeItem('dataauth')
res.text().then(function (text) {
alert(text)
location.reload()
})
})
.then(rdata => saveData(rdata))
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
var now = new Date()
var startDate = new Date()
startDate.setDate(now.getDate())
startDate.setHours(8,0,0,0)
document.querySelector('input#start').value = localISOTimeMinutes(startDate)
document.querySelector('input#end').value = localISOTimeMinutes(now)
var scrollbox = document.querySelector('.scroll')
var timehead = document.querySelector('#timeheader')
var namebox = document.querySelector('section.names')
scrollbox.onscroll = function() {
timehead.scrollLeft = scrollbox.scrollLeft
namebox.scrollTop = scrollbox.scrollTop
}
loadData()