improved UI

This commit is contained in:
didier amyot authored and didier amyot committed 2024-11-04 18:50:34 -05:00
1 parent 520fab5c93
commit e9da24b54d
13 files changed
+491 -32

No files matched your search

+10 -32
View File
@@ -2,12 +2,6 @@ import m from "https://esm.sh/mithril@2.2.2"
import colormap from "https://esm.sh/colormap@2.3.2" import colormap from "https://esm.sh/colormap@2.3.2"
import {range} from "./util.js"; import {range} from "./util.js";
// const second = 1000;
// const canvasWidth = Number.parseFloat(document.querySelector("svg").getAttribute("width"));
// const canvasHeight = Number.parseFloat(document.querySelector("svg").getAttribute("height"));
function gaussianRandom(mean=0, stdev=1) { function gaussianRandom(mean=0, stdev=1) {
const u = 1 - Math.random(); // Converting [0,1) to (0,1] const u = 1 - Math.random(); // Converting [0,1) to (0,1]
const v = Math.random(); const v = Math.random();
@@ -45,28 +39,6 @@ function* keyFrames(duration, framePerSeconds){
} }
} }
// const duration = 60;
// const timeline = anime.timeline({
// duration: duration * second,
// autoplay: false,
// easing: 'linear'});
//
// for(let i = 1; i <= 12; i++){
// timeline.add({
// targets: `#rect-${i}`,
// keyframes: Array.from(keyFrames(duration, 30)),
// }, 0)
// }
// document.querySelector('#play').onclick = timeline.play;
// document.querySelector('#pause').onclick = timeline.pause;
// document.querySelector('#seek').onclick = function(){
// const seekVal = Number.parseFloat(second * document.querySelector('#seek-value').value);
// timeline.pause();
// timeline.seek(seekVal);
// }
function generatePoints(points, frames){ function generatePoints(points, frames){
const lst = []; const lst = [];
@@ -105,7 +77,15 @@ function getCourtLines(){
m("line", {x1: 600, x2: 600, y1: 0, y2: 450, class: "return-line"}) m("line", {x1: 600, x2: 600, y1: 0, y2: 450, class: "return-line"})
] ]
} }
class WorkingTabs{ class WorkingTabs{
#datasets = []
async oninit(){
const url = "/dodgeball-tracker/outputs/index.json"
this.#datasets = (await m.request({method: "GET", url: url}))?.index ?? [];
}
tabs(){ tabs(){
return m("div.tabs", return m("div.tabs",
@@ -115,16 +95,14 @@ class WorkingTabs{
} }
info(){ info(){
const options = [ const options = this.#datasets.map(ds => m('option', {value: ds.id}, ds.label));
m("option", "one"),
m("option", "two"),
]
return m("div.field", return m("div.field",
m("label.label", "Dataset"), m("label.label", "Dataset"),
m("div.control", m("div.control",
m("div.select", m("div.select",
m("select", options)))); m("select", options))));
} }
view(){ view(){
return m("div", return m("div",
this.tabs(), this.tabs(),
View File
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
{
"imports": {
"preact": "https://esm.sh/preact@10.19.2",
"preact/": "https://esm.sh/preact@10.19.2/",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.1",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0",
"$std/": "https://deno.land/std@0.208.0/"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630 630">
<rect width="630" height="630" fill="#f7df1e"/>
<path d="m423.2 492.19c12.69 20.72 29.2 35.95 58.4 35.95 24.53 0 40.2-12.26 40.2-29.2 0-20.3-16.1-27.49-43.1-39.3l-14.8-6.35c-42.72-18.2-71.1-41-71.1-89.2 0-44.4 33.83-78.2 86.7-78.2 37.64 0 64.7 13.1 84.2 47.4l-46.1 29.6c-10.15-18.2-21.1-25.37-38.1-25.37-17.34 0-28.33 11-28.33 25.37 0 17.76 11 24.95 36.4 35.95l14.8 6.34c50.3 21.57 78.7 43.56 78.7 93 0 53.3-41.87 82.5-98.1 82.5-54.98 0-90.5-26.2-107.88-60.54zm-209.13 5.13c9.3 16.5 17.76 30.45 38.1 30.45 19.45 0 31.72-7.61 31.72-37.2v-201.3h59.2v202.1c0 61.3-35.94 89.2-88.4 89.2-47.4 0-74.85-24.53-88.81-54.075z"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

+33
View File
@@ -0,0 +1,33 @@
const sessionLocalStorageKey = 'dgt-session';
let cachedPathVariables = null;
function getPathVariables(){
if (cachedPathVariables !== null) {
return cachedPathVariables;
}
const raw = document.getElementById("url-placeholders")?.textContent || "{}"
cachedPathVariables = Object.entries(JSON.parse(raw)?.pathVariables || {})
return cachedPathVariables
}
window.addEventListener('login', function (evt) {
localStorage.setItem(sessionLocalStorageKey, evt.detail.value);
document.location.href = "/";
});
window.addEventListener('logout', function (evt) {
window.localStorage.removeItem(sessionLocalStorageKey);
document.location.href = "/login.html";
});
window.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['dgt-session'] = localStorage.getItem(sessionLocalStorageKey);
function reducer(path, [key, value]){
return path.replace(key, value)
}
evt.detail.path = getPathVariables().reduce(reducer, evt.detail.path)
});
+9
View File
@@ -0,0 +1,9 @@
import {app} from "./src/server.ts";
import Logger from "https://deno.land/x/logger@v1.1.5/logger.ts";
const logger = new Logger()
const port = 8000
logger.info(`Starting web server on port ${port}`)
await app.listen({ port });
+65
View File
@@ -0,0 +1,65 @@
import {Router} from "https://deno.land/x/oak/mod.ts";
import {FileMatchService} from "../services/matches.ts";
import {ViewTabService} from "./tab.tsx";
export const apiRouter = new Router( {prefix: '/api/v1'});
async function getMatchService(): Promise<FileMatchService> {
const f = "/home/didier/alldressed-programming/dodgeball-tracker/outputs/index.json"
return new FileMatchService(f)
}
apiRouter.get('/ping', (ctx) => {
ctx.response.body = <span>pong</span>
})
apiRouter.get("/match/:id/title", async function(ctx){
const service = await getMatchService()
const match = await service.getMatchInfoById(ctx.params.id)
ctx.response.body = (<span>Match: {match.label}</span>)
});
apiRouter.get("/match/:id/pane/info", async function(ctx){
const service = await getMatchService()
const match = await service.getMatchInfoById(ctx.params.id)
const viewService = new ViewTabService();
ctx.response.body = viewService.infoTab(match)
});
apiRouter.get("/match/:id/pane/player", async function(ctx){
const service = await getMatchService()
const match = await service.getMatchInfoById(ctx.params.id)
const viewService = new ViewTabService();
ctx.response.body = viewService.playerTab(match)
});
apiRouter.get("/match/:id/pane/player/data", async function(ctx){
ctx.response.body = <svg:rect width="100" height="100" />
});
apiRouter.get('/list-matches', async (ctx) => {
const matchService = await getMatchService()
const rows = [];
for await (const match of matchService.getAllMatchInfo()){
rows.push(
<tr>
<td><a href={`/match/${match.id}/info`}>{ match.label }</a></td>
<td><a href={match.originalVideoUrl}>{ match.originalVideoUrl }</a></td>
</tr>
)
}
ctx.response.body = (<div>
<table class="table is-bordered is-striped is-fullwidth">
<thead>
<tr>
<th>Label</th>
<th>Video Link</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</div>)
})
+73
View File
@@ -0,0 +1,73 @@
import {MatchInfo} from "../services/matches.ts";
export class ViewTabService {
infoTab(matchInfo: MatchInfo){
return (<table class="table is-bordered is-striped is-fullwidth">
<tbody>
<tr>
<td>Label</td>
<td>{matchInfo.label}</td>
</tr>
<tr>
<td>Id</td>
<td>{matchInfo.id}</td>
</tr>
<tr>
<td>Date</td>
<td>{matchInfo.matchDate}</td>
</tr>
<tr>
<td>Video URL</td>
<td>
<a href={matchInfo.originalVideoUrl}>{matchInfo.originalVideoUrl}</a>
</td>
</tr>
</tbody>
</table>)
}
playerTab(matchInfo: MatchInfo){
return (<div>
<div id="foo"></div>
<form hx-get={"/api/v1/match/:id/pane/player/data"}
hx-target="#court-content">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Min Time</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input is-danger" type="range" min="0" max="100" />
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Max Time</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input class="input is-danger" type="range" min="0" max="100" />
</div>
</div>
</div>
</div>
<div class="field">
<div class="control">
<button class="button"> Show </button>
</div>
</div>
</form>
</div>)
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Router } from "https://deno.land/x/oak/mod.ts";
export const loginRouter = new Router();
export function isLoggedIn(ctx){
return ctx.request.headers.get('dgt-session')
}
loginRouter.post("/api/v1/login", async (ctx)=> {
const username = (await ctx.request.body().value).get("username");
ctx.response.headers.append("HX-Trigger", JSON.stringify({'login': username}));
ctx.response.body = username;
});
loginRouter.post("/api/v1/logout", (ctx)=> {
ctx.response.headers.append("HX-Trigger", "logout");
ctx.response.body = "";
});
+45
View File
@@ -0,0 +1,45 @@
import { Application } from "https://deno.land/x/oak/mod.ts";
import staticFiles from "https://deno.land/x/static_files@1.1.6/mod.ts";
import {loginRouter, isLoggedIn} from "./login.ts";
import {apiRouter} from "./api/mod.tsx";
import {viewRouter} from "./views/mod.tsx";
import {renderToString} from "https://esm.sh/preact-render-to-string@5.1.19?deps=preact@10.5.15";
import Logger from "https://deno.land/x/logger@v1.1.5/logger.ts";
const logger = new Logger();
export const app = new Application();
function isReactComponent(x){
return !!x && Object.hasOwn(x, "type") && Object.hasOwn(x, "props")
}
async function reactRendererMiddleware(ctx, next){
await next();
if(isReactComponent(ctx.response.body)){
logger.info("Converting react component to string.")
ctx.response.body = renderToString(ctx.response.body)
}
}
async function loggingMiddleware(ctx, next){
if(!isLoggedIn(ctx)){
logger.info("user not logged in.")
ctx.response.headers.append("HX-Redirect", "/login.html");
return
}
logger.info('user logged in')
await next();
}
app.use(loginRouter.routes());
app.use(viewRouter.routes());
app.use(staticFiles("public", {brotli: true, fetch: true}));
app.use(loggingMiddleware)
app.use(reactRendererMiddleware)
app.use(apiRouter.routes());
+41
View File
@@ -0,0 +1,41 @@
export type MatchInfo = {
id: string,
originalVideoUrl: string,
matchDate: string,
label: string
}
export interface MatchService {
getAllMatchInfo(): AsyncIterator<MatchInfo>;
getMatchInfoById(id: string): Promise<MatchInfo|undefined>;
}
export class FileMatchService implements MatchService{
readonly #indexFile: string
constructor(indexFile: string) {
this.#indexFile = indexFile;
}
async *getAllMatchInfo(): AsyncIterator<MatchInfo>{
const data = await Deno.readTextFile(this.#indexFile)
const ret = JSON.parse(data) as {matches: MatchInfo[]}
yield* ret.matches
}
async getMatchInfoById(id: string): Promise<MatchInfo|undefined>{
for await(const mi of this.getAllMatchInfo()){
if(mi.id === id){
return mi
}
}
}
}
+48
View File
@@ -0,0 +1,48 @@
export function Head(){
return (<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="ie=edge"/>
<title>JS Reading App</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/default.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-light.min.css"/>
<script src="https://unpkg.com/htmx.org@1.9.9"></script>
</head>)
}
export function NavBar(){
return (<nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-menu is-active">
<div class="navbar-start">
<span class="navbar-item">
<a href="/">
<img src="/images/logo.png" alt="js logo" style="height: 100%"/>
</a>
</span>
</div>
<div class="navbar-end">
<a class="navbar-item" href="#" hx-post="/api/v1/logout">
Way Out
</a>
</div>
</div>
</nav>)
}
export function Body({pathVariables, children}){
const urlPlaceHolders = {
pathVariables: Object.fromEntries(Object.entries(pathVariables ?? {}).map(([k, v]) => [`:${k}`, v]))
}
return (<body>
{ children }
<script src="/scripts/auth.js" type="module"> </script>
<script
type="json"
id="url-placeholders"
dangerouslySetInnerHTML={{__html: JSON.stringify(urlPlaceHolders, null, 4)}}></script>
</body>)
}
+124
View File
@@ -0,0 +1,124 @@
import { Router } from "https://deno.land/x/oak/mod.ts";
import {renderToString} from "https://esm.sh/preact-render-to-string@5.1.19?deps=preact@10.5.15";
import * as base from './base.tsx';
import {Head} from "./base.tsx";
export const viewRouter = new Router();
function renderPageToString(page){
return `<!DOCTYPE html>${renderToString(page)}`
}
function Court(){
const margin = 50;
const returnLine = {stroke: 'blue', strokeWidth: '2px'}
const courtBorder = {stroke: 'red', strokeWidth: '4px', fill: 'gray' }
const lines = [
<line x1={450} x2={450} y1={0} y2={450} style={courtBorder}></line>,
<line x1={300} x2={300} y1={0} y2={450} style={returnLine}></line>,
<line x1={600} x2={600} y1={0} y2={450} style={returnLine}></line>,
]
return (<svg class="court-side"
style={{width: 900 + 2 * margin, height: 450 + 2 * margin, backgroundColor: 'pink'}}>
<g class="court-border" id="court" transform = {`translate(${margin} ${margin})`}>
<rect width={900} height={450} style={courtBorder}/>
{ lines }
<g id="court-content"></g>
</g>
</svg>)
}
viewRouter.get("/match/:id/:tab", (ctx) => {
const page = (<html lang="en">
<base.Head/>
<base.Body pathVariables={ctx.params}>
<base.NavBar></base.NavBar>
<div class="columns is-centered">
<div class="column is-two-thirds">
<div class="columns">
<div class="column">
<h4 class="title is-4"
hx-get="/api/v1/match/:id/title"
hx-trigger="load"> &nbsp; </h4>
</div>
</div>
<div class="columns">
<div class="column" style={{textAlign: "center"}}>
<Court></Court>
</div>
</div>
<div class="columns">
<div class="column" style={{textAlign: "center"}}>
<div class="tabs">
<ul>
<li className={ctx.params.tab === 'info' ? "is-active": ""}>
<a href={`/match/${ctx.params.id}/info`}>Information</a>
</li>
<li className={ctx.params.tab === 'player' ? "is-active": ""}>
<a href={`/match/${ctx.params.id}/player`}>Player</a>
</li>
</ul>
</div>
</div>
</div>
<div class="columns">
<div class="column"
hx-get={`/api/v1/match/${ctx.params.id}/pane/${ctx.params.tab}`}
hx-trigger="load">
foo bar toto
</div>
</div>
</div>
</div>
</base.Body>
</html>)
ctx.response.body = renderPageToString(page)
})
viewRouter.get("/index.html", (ctx) => {
const page = (<html lang="en">
<base.Head/>
<base.Body>
<base.NavBar></base.NavBar>
<div class="columns is-centered">
<div class="column is-two-thirds"
id="main-block"
hx-get="/api/v1/list-matches"
hx-trigger="load">
</div>
</div>
</base.Body>
</html>)
ctx.response.body = renderPageToString(page)
})
viewRouter.redirect("/", "/index.html")
viewRouter.get("/login.html", (ctx) => {
const page = (<html lang="en">
<base.Head/>
<base.Body>
<base.NavBar></base.NavBar>
<div class="columns is-centered">
<div class="column is-half">
<form hx-post="/api/v1/login" hx-swap="none">
<div class="field has-addons">
<div class="control">
<input class="input" id="username" type="text" placeholder="bob.l.eponge@ocean.com" name="username" required />
</div>
<div class="control">
<input type="submit" class="button" value="Login" />
</div>
</div>
</form>
</div>
</div>
</base.Body>
</html>)
ctx.response.body = renderPageToString(page)
})