improved UI
This commit is contained in:
1 parent
520fab5c93
commit
e9da24b54d
13 files changed
+491
-32
No files matched your search
@@ -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