improved UI
This commit is contained in:
1 parent
520fab5c93
commit
e9da24b54d
13 files changed
+491
-32
No files matched your search
+10
-32
@@ -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 {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) {
|
||||
const u = 1 - Math.random(); // Converting [0,1) to (0,1]
|
||||
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){
|
||||
const lst = [];
|
||||
@@ -105,7 +77,15 @@ function getCourtLines(){
|
||||
m("line", {x1: 600, x2: 600, y1: 0, y2: 450, class: "return-line"})
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class WorkingTabs{
|
||||
#datasets = []
|
||||
|
||||
async oninit(){
|
||||
const url = "/dodgeball-tracker/outputs/index.json"
|
||||
this.#datasets = (await m.request({method: "GET", url: url}))?.index ?? [];
|
||||
}
|
||||
|
||||
tabs(){
|
||||
return m("div.tabs",
|
||||
@@ -115,16 +95,14 @@ class WorkingTabs{
|
||||
}
|
||||
|
||||
info(){
|
||||
const options = [
|
||||
m("option", "one"),
|
||||
m("option", "two"),
|
||||
]
|
||||
const options = this.#datasets.map(ds => m('option', {value: ds.id}, ds.label));
|
||||
return m("div.field",
|
||||
m("label.label", "Dataset"),
|
||||
m("div.control",
|
||||
m("div.select",
|
||||
m("select", options))));
|
||||
}
|
||||
|
||||
view(){
|
||||
return m("div",
|
||||
this.tabs(),
|
||||
|
||||
Whitespace-only changes.
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 |
@@ -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)
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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>)
|
||||
})
|
||||
@@ -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>)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = "";
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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"> </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)
|
||||
})
|
||||
Reference in new issue
Block a user