~otheb/kb

bffb801d80fe4bd3ebdde3a89414cb4f2544492b — Olie Ayre 2 months ago 0efc828
Wrote most of web UI

The Web UI can now show an index of entries either by ID or time. It can
open an entry to view in detail. It can create new entries and even
perform searches for entries, displaying results in relevance order
based on the heuristic values returned by the API. Remaining features
are deleting and editing entries.

Also wrote a small declarative HTML library to accompany the web UI.
Created example config. Moved styling out to a proper CSS file as well
as adding light and dark theme files - the dark theme of which is the
only properly tested one. The light theme will be tested once the UI
itself is done.
9 files changed, 740 insertions(+), 152 deletions(-)

A kb-web/declhtml.d
M kb-web/dub.json
A kb-web/example-config
M kb-web/main.d
A kb-web/static/dark.css
A kb-web/static/err.html
M kb-web/static/global.css
D kb-web/static/index.html
A kb-web/static/light.css
A kb-web/declhtml.d => kb-web/declhtml.d +92 -0
@@ 0,0 1,92 @@
module declhtml ;

import std.xml ;
import std.conv ;

import core.vararg ;

// create a document
string mkdoc( Element[] elements... ) {
	auto doc = new Document( "<!DOCTYPE html><html></html>" ) ;
	foreach ( Element e ; elements ) { doc ~= e ; }
	return doc.toString() ;
}

// generic element creation methods
Element element( string name , ... ) {
	return element( name , _arguments , _argptr ) ;
}
Element element( string name , TypeInfo[] args , __va_list_tag* argptr ) {
	auto e = new Element( name ) ;
	for ( int i = 0 ; i < args.length ; i ++ ) {
		if ( args[i] == typeid( Element ) ) {
			auto el = va_arg!Element( argptr ) ;
			if ( el.tag.name != "null" ) { e ~= el ; }
		} else if ( args[i] == typeid( string ) ) {
			e ~= new Text( va_arg!string( argptr ) ) ;
		} else if ( args[i] == typeid( Element[] ) ) {
			foreach ( Element c ; va_arg!(Element[])( argptr ) ) //
			if ( c.tag.name != "null" ) { e ~= c ; }
		} else if ( args[i] == typeid( string[] ) ) {
			foreach ( string s ; va_arg!(string[])( argptr ) ) {
				e ~= new Text( s ) ;
			}
		} else if ( args[i] == typeid( Text ) ) {
			e ~= va_arg!Text( argptr ) ;
		} else if ( args[i] == typeid( Text[] ) ) {
			foreach ( Text t ; va_arg!(Text[])( argptr ) ) { e ~= t ; }
		} else if ( args[i] != typeid( null ) ) {
			throw new Exception( text( "Invalid type " , args[i] ) ) ;
		}
	}
	return e ;
}

// set an attribute
Element attr( Element e , string key , string val ) {
	e.tag.attr[key] = val ;
	return e ;
}

// add a class
Element class_( Element e , string name ) {
	if ( ( "class" in e.tag.attr ) is null ) {
		e.tag.attr["class"] = name ;
	} else {
		e.tag.attr["class"] ~= " " ~ name ;
	}
	return e ;
}

// set the id
Element id( Element e , string name ) {
	e.tag.attr["id"] = name ;
	return e ;
}

// pre-made element helper methods
static foreach ( e ; [
	"head" , "body" , "title" , "h1" , "h2" , "h3" , "h4" , "h5" , "h6" , "p" ,
	"div" , "span" , "ul" , "ol" , "li" , "pre" , "label" , "input" , "button" ,
	"textarea"
] ) {
	mixin( "Element " ~ e ~ "( ... ) {" ~
		"return element( \"" ~ e ~ "\" , _arguments , _argptr ) ;" ~
	"}" ) ;
}
Element charset( string cs = "utf-8" ) {
	return element( "meta" ).attr( "charset" , cs ) ;
}
Element style( string path ) {
	return element( "link" ).attr( "rel" , "stylesheet" )
	       .attr( "type" , "text/css" ).attr( "href" , path ) ;
}
Element a( string url , ... ) {
	return element( "a" , _arguments , _argptr ).attr( "href" , url ) ;
}
Element br() { return element( "br" ) ; }
Element hr() { return element( "hr" ) ; }
Element form( string action , ... ) {
	return element( "form" , _arguments , _argptr ).attr( "action" , action ) ;
}
auto nothing() { return new Element( "null" ) ; }

M kb-web/dub.json => kb-web/dub.json +4 -4
@@ 3,15 3,15 @@
		"Oliver Ayre"
	],
	"dependencies": {
		"arsd-official:dom": "~>5.0.0",
		"arsd-official:http": "~>5.0.0",
		"lighttp": "~>0.5.3"
	},
	"description": "Minimal WUI to the kb API server",
	"license": "AGPL-3.0",
	"name": "kbw",
	"name": "kb-web",
	"sourceFiles": [
		"main.d"
		"main.d" ,
		"declhtml.d"
	],
	"targetType": "executable"
}
\ No newline at end of file
}

A kb-web/example-config => kb-web/example-config +6 -0
@@ 0,0 1,6 @@
{
	"port" : 8080 ,
	"friendly-name" : "Big title here" ,
	"light-theme" : false ,
	"server" : "localhost:8000"
}

M kb-web/main.d => kb-web/main.d +431 -85
@@ 1,7 1,20 @@
module kbw ;
module kbweb ;

import std.stdio ;
import std.json  ;
import std.stdio     ;
import std.json      ;
import std.file      ;
import std.algorithm ;
import std.uri       ;
import std.conv      ;
import std.array     ;
import std.string    ;
import std.typecons  ;
import std.socket    ;

import lighttp    ;
import arsd.http2 ;

import declhtml ;

// default config file
const string CONFFILE = "/etc/kbw/config" ;


@@ 11,78 24,6 @@ string FRIENDLYNAME = "KB Web UI"      ;
bool   LIGHTTHEME   = false            ;
string SERVER       = "localhost:8000" ;

const string globalcss = q"EOS
/* font sizes */
@media screen and ( min-width : 600px ) {
	:root {
		--normal : 14px ;
		--big    : 24px ;
		--title  : 50px ;
	}
}
@media screen and ( max-width : 599px ) {
	:root {
		--normal : 12px ;
		--big    : 16px ;
		--title  : 25px ;
	}
}

/* main body elements */
html , body { background-color : var( --bg ) ; }
body {
	max-width  : 800px           ;
	margin     : auto            ;
	padding    : 10px            ;
	color      : var( --fg )     ;
	font-size  : var( --normal ) ;
	text-align : right           ;
}
* {
	scrollbar-width : thin                     ;
	scrollbar-color : var( --dfg ) var( --bg ) ;
	tab-size        : 4                        ;
	-moz-tab-size   : 4                        ;
	-o-tab-size     : 4                        ;
}

/* title at top */
h1 {
	text-align : center         ;
	color      : var( --bfg )   ;
	font-size  : var( --title ) ;
}

/* sort link on home page */
a { transition-duration : 0.1s ; }
a.sort { color : var( --acc ) ; }
a.sort:hover { color : var( --bfg ) ; }

/* entry listing */
div {
	background-color : var( --bbg ) ;
	margin           : 10px 0px     ;
	padding          : 10px         ;
	text-align       : left         ;
	color            : var( --bg )  ;
}
div span.id {
	color     : var( --dfg ) ;
	font-size : var( --big ) ;
}
a.entry {
	color     : var( --bfg ) ;
	font-size : var( --big ) ;
}
a.entry:hover { color : var( --acc ) ; }
span.tag {
	display          : inline-block     ;
	background-color : var( --dfg )     ;
	padding          : 2px 4px          ;
	margin           : 10px 3px 0px 3px ;
}
EOS" ;

int main( string[] args ) {
	// read config
	// if an argument is provided, that is used as the config file


@@ 114,6 55,15 @@ int main( string[] args ) {
	server.host( "0.0.0.0" , PORT ) ;
	server.host( "::" , PORT ) ;
	server.router.add( new router() ) ;
	server.router.add( Get( "global.css" ) ,
	                   new Resource( "text/css" ,
	                                 readText( "static/global.css" ) ) ) ;
	server.router.add( Get( "light.css" ) ,
	                   new Resource( "text/css" ,
	                                 readText( "static/light.css" ) ) ) ;
	server.router.add( Get( "dark.css" ) ,
	                   new Resource( "text/css" ,
	                                 readText( "static/dark.css" ) ) ) ;
	server.run() ;
	return 0 ;
}


@@ 130,20 80,416 @@ void readconf( string content ) {
	if ( ( "server" in j ) !is null ) { SERVER = j["server"].str ; }
}

string fmtdate( long ts ) {
	import std.datetime ;
	auto dt = cast(DateTime)SysTime.fromUnixTime( ts , LocalTime() ) ;
	return text( // D Month YEAR @ HH:MMap
		dt.day , " " , // day
		[ "" , "January" , "February" , "March" , "April" , "May" , "June" ,
		  "July" , "August" , "September" , "October" , "November" , "December"
		][ dt.month ] , " " , // month
		dt.year , " @ " , // year
		dt.hour % 12 == 0 ? 12 : dt.hour % 12 , ":" , // hour
		std.conv.to!string( dt.minute ).rightJustify( 2 , '0' ) , // minute
		dt.hour >= 12 ? "pm" : "am" // am/pm
	) ;
}

class entry {
	long     id      ;
	string   title   ;
	string[] tags    ;
	string   time    ;
	bool     old     ;
	string   content ;
	Tuple!( string , string )[] history ; // [0] is time, [1] is content

	this( long i , string t , string[] tg , long ts , bool o , string c ,
	      Tuple!( long , string )[] h ) {
		id      = i            ;
		title   = t            ;
		tags    = tg           ;
		time    = ts.fmtdate() ;
		old     = o            ;
		content = c            ;
		history = h.map!( e => tuple( e[0].fmtdate() , e[1] ) ).array ;
	}
}

auto getindex() { return get( "http://" ~ SERVER ~ "/" ).waitForCompletion() ; }
auto getentry( long id ) {
	return get( "http://" ~ SERVER ~ "/" ~ id.to!string ).waitForCompletion() ;
}
auto getentry( string id ) {
	return get( "http://" ~ SERVER ~ "/" ~ id ).waitForCompletion() ;
}
auto getsearch( string[] terms ) {
	return new HttpClient().request(
		Uri( "http://" ~ SERVER ~ "/s" ) ,
		HttpVerb.POST ,
		cast(ubyte[])JSONValue( [ "search" : JSONValue( terms.join( " " ) ) ] )
			.toString() ,
		"application/json"
	).waitForCompletion() ;
}
auto postnew( string j ) {
	return new HttpClient().request( Uri( "http://" ~ SERVER ~ "/" ) ,
	                                 HttpVerb.POST , cast(ubyte[])j ,
	                                 "application/json" ).waitForCompletion() ;
}

auto noapi() {
	return mkdoc(
		head(
			charset() , style( "/global.css" ) ,
			title( "No API connection - " , FRIENDLYNAME ) ,
			style( LIGHTTHEME ? "/light.css" : "/dark.css" )
		) ,
		body( div(
			h2( "502 Bad Gateway - No API connection" ) ,
			p(
				"No connection could be made to the API server at http://" ,
				SERVER ,
				". Make sure it's running on that host, configured to listen at
				that port, and that there are no firewall rules preventing
				access to it."
			)
		) )
	) ;
}

auto notfound() {
	return mkdoc(
		head(
			charset() , style( "/global.css" ) ,
			title( "Not found - " , FRIENDLYNAME ) ,
			style( LIGHTTHEME ? "/light.css" : "/dark.css" )
		) ,
		body( div(
			h2( "404 Not Found" ) ,
			p(
				"The requested entry does not exist."
			)
		) )
	) ;
}

class router {
	@Get( "" )
	index( ServerRequest req , ServerResponse res ) {
		writeln( "GET /" ) ;
		bool sortrecent = ( "sort" in req.url.queryParams ) is null ? false :
			              req.url.queryParams["sort"] == "recent" ;
		// build base document
		auto doc = new Document() ;
		doc.title( FRIENDLYNAME ) ;
		doc.mainBody
			// add title at top
			.addChild( "h1" , FRIENDLYNAME )
			// add "sort by" node
			.addChild( "#text" , "Sort by " )

		// check whether we're sorting by recency
		bool sortrecent = req.url.queryParams["sort"].canFind( "recent" ) ;

		// get entries
		HttpResponse apires ;
		try {
			apires = getindex() ;
		} catch ( SocketOSException ) {
			res.status = StatusCodes.badGateway ;
			res.body = noapi() ;
			return ;
		}
		entry[] entries = apires.contentText.parseJSON()["ids"].array
			.map!( i => getentry( i.integer ) ).filter!( r => r.code == 200 )
			.map!( r => r.contentText.parseJSON() ).array.sort!( ( a , b ) =>
				sortrecent ? a["time"].integer > b["time"].integer :
					a["id"].integer < b["id"].integer
			).map!( e => e.fromjson() ).array ;

		res.status = StatusCodes.ok ;
		res.body = makeindex( entries , sortrecent ) ;
	}

	@Get( "entry" , "([0-9]+)" )
	entrypage( ServerRequest req , ServerResponse res , string id ) {
		writeln( "GET /entry/" , id ) ;

		// get the entry
		HttpResponse apires ;
		try {
			apires = getentry( id ) ;
		} catch ( SocketOSException e ) {
			res.status = StatusCodes.badGateway ;
			res.body = noapi() ;
			return ;
		}
		if ( apires.code == 404 ) {
			writeln( "Entry doesn't exist" ) ;
			res.status = StatusCodes.notFound ;
			res.body = notfound() ;
			return ;
		}
		auto e = apires.fromjson() ;

		res.status = StatusCodes.ok ;
		res.body = makeentry( e ) ;
	}

	@Get( "search" )
	searchpage( ServerRequest req , ServerResponse res ) {
		auto terms = req.url.queryParams["s"].join( "+" ).split( "+" )
		             .filter!( t => t.length ).array ;
		writeln( "GET /search : " , terms ) ;

		// make the API request
		HttpResponse apires ;
		try {
			apires = getsearch( terms ) ;
		} catch ( SocketOSException e ) {
			res.status = StatusCodes.badGateway ;
			res.body = noapi() ;
			return ;
		}

		// get entries
		entry[] entries = apires.contentText.parseJSON()["results"].array
			.map!( r => tuple( r["id"].integer , r["relevance"].integer ) )
			.array.sort!( ( a , b ) => a[1] > b[1] )
			.map!( r => getentry( r[0] ) ).filter!( r => r.code == 200 )
			.map!( r => r.contentText.parseJSON().fromjson ).array ;

		res.status = StatusCodes.ok ;
		res.body = makeresults( entries , terms.join( " " ) ) ;
	}

	@Get( "new" )
	createpage( ServerRequest req , ServerResponse res ) {
		res.body = makenewpage() ;
	}
	@Post( "new" )
	handlecreate( ServerRequest req , ServerResponse res ) {
		// get parameters
		string[string] items ;
		foreach ( string param ; req.body.split( "&" ) ) {
			Tuple!( string , string ) kv = param.split( "=" )[0..2] ;
			items[ kv[0] ] = kv[1].tr( "+" , " " ).decodeComponent()
			// we split lines and join again to remove nasty CRLF line endings
			                 .splitLines().join( "\n" ) ;
		}
		// adjust parameters for better formatting
		items["title"] = items["title"].tr( "\n" , " " ) ;
		string[] lines ;
		foreach ( string l ; items["content"].splitLines() ) {
			l = l.stripRight() ;
			while ( l.startsWith( "    " ) && l.length > 4 ) {
				l = "\t" ~ l[4..$] ;
			}
			lines ~= l ;
		}
		items["content"] = lines.join( "\n" ).wrap( 80 ) ;
		writeln( "Creating new entry with data : " , items ) ;

		// build json
		auto j = JSONValue( [
			"title"   : JSONValue( items["title"] ) ,
			"old"     : JSONValue( ( "old" in items ) !is null ) ,
			"tags"    : JSONValue( items.get( "tags" , "" ).split()
			                       .filter!( t => t != "" ).array ) ,
			"content" : JSONValue( items["content"] )
		] ).toString() ;

		// make API request
		HttpResponse apires ;
		try {
			apires = postnew( j ) ;
		} catch ( SocketOSException e ) {
			res.status = StatusCodes.badGateway ;
			res.body = noapi() ;
			return ;
		}
		auto newentry = apires.contentText.parseJSON()["id"].integer.to!string ;

		// redirect to new entry page
		writeln( "Made new entry " , newentry , ", redirecting" ) ;
		res.status = StatusCodes.seeOther ;
		res.headers["Location"] = "/entry/" ~ newentry ;
	}

	@Get( "edit" , "([0-9]+)" )
	editpage( ServerRequest req , ServerResponse res , string id ) {
		// get existing entry
		HttpResponse apires ;
		try {
			apires = getentry( id ) ;
		} catch ( SocketOSException e ) {
			res.status = StatusCodes.badGateway ;
			res.body = noapi() ;
			return ;
		}
		if ( apires.code != 200 ) {
			res.status = StatusCodes.notFound ;
			res.body = notfound() ;
			return ;
		}

		auto j = apires.contentText.parseJSON() ;

		res.body = makeeditpage( id , j["title"].str ,
		                         j["tags"].array.map!( t => t.str ).array ,
		                         j["old"].boolean , j["content"].str ) ;
	}
}

entry fromjson( JSONValue j ) {
	return new entry(
		j["id"].integer ,
		j["title"].str ,
		j["tags"].array.map!( t => t.str ).array ,
		j["time"].integer ,
		j["old"].boolean ,
		j["content"].str ,
		j["history"].array.map!( h =>
			tuple( h["time"].integer , h["content"].str )
		).array.sort!( ( a , b ) => a[0] > b[0] ).array
	) ;
}
entry fromjson( HttpResponse r ) {
	return r.contentText.parseJSON().fromjson() ;
}

string makeindex( entry[] entries , bool recent ) {
	return mkdoc(
		makehead( FRIENDLYNAME ) ,
		body(
			h1( a( "/" , FRIENDLYNAME ) ) ,
			makeactionbar() ,
			"Sort by " ,
			( recent ? a( "/?sort=id" , "ID" ) : a( "/?sort=recent" , "time" ) )
				.class_( "sort" ) ,
			" instead." ,
			entries.length == 0
			? [ div( p( "No entries found" ).class_( "middle" ) ) ]
			: entries.map!( e => makecard( e ) ).array
		)
	) ;
}

string makeresults( entry[] entries , string terms ) {
	return mkdoc(
		makehead( "Search results for \"" ~ terms ~ "\" - " ~ FRIENDLYNAME ) ,
		body(
			h1( a( "/" , FRIENDLYNAME ) ) ,
			makeactionbar() ,
			// non-breaking space to add the gap between the search bar and the
			// results below that is present on the homepage
			"\u00a0" ,
			entries.length == 0
			? [ div( p( "No entries found" ).class_( "middle" ) ) ]
			: entries.map!( e => makecard( e ) ).array
		)
	) ;
}

auto makehead( string t ) {
	return head( charset() , style( "/global.css" ) ,
	             style( LIGHTTHEME ? "/light.css" : "/dark.css" ) ,
	             title( t ) ) ;
}

auto makeactionbar( bool extras = false , long id = 0 ) {
	return div(
		form( "/search" , input().attr( "type" , "text" )
		      .attr( "name" , "s" ).attr( "placeholder" , "Search" ) )
			.id( "search" ) ,
		form( "/new" , input().attr( "type" , "submit" ).attr( "value" , "+" ) )
			.id( "create" ) ,
		extras == false ? [] : [
			form( "/edit/" ~ id.text , input().attr( "type" , "submit" )
			      .attr( "value" , "Edit" ) ).id( "edit" ) ,
			form( "/delete/" ~ id.text , input().attr( "type" , "submit" )
			      .attr( "value" , "Delete" ) ).id( "delete" )
		]
	).id( "actionbar" ) ;
}

auto makecard( entry e ) {
	return div(
		h2(
			span( "#" , e.id.text , " " ).class_( "id" ) ,
			a( "/entry/" ~ e.id.text ,
				e.old ? "[DEPRECATED] " : "" ,
				e.title
			).class_( "entry" ) ,
		) ,
		span( e.time ).class_( "datetime" ) ,
		e.tags.map!( t => a( "/search?s=" ~ t , t ).class_( "tag" ) ).array
	) ;
}

string makeentry( entry e ) {
	return mkdoc(
		makehead( "#" ~ e.id.text ~ " " ~ e.title ~ " - " ~ FRIENDLYNAME ) ,
		body(
			h1( a( "/" , FRIENDLYNAME ) ) ,
			makeactionbar( true , e.id ) ,
			// non-breaking space to add the gap between the search bar and the
			// entry below that is present on the homepage
			"\u00a0" ,
			div(
				h2( span( "#" , e.id.text , " " ).class_( "id" ) ,
					e.old ? "[DEPRECATED] " : "" ,
					e.title ) ,
				span ( e.time ).class_( "datetime" ) ,
				e.tags.map!( t =>
					a( "/search?s=" ~ t , t ).class_( "tag" )
				).array ,
				pre( e.content )
			) ,
			e.history.length == 0
			? div( p( "No history" ).class_( "middle" ) )
			: div(
				label( "Show/hide history" ).attr( "for" , "history-toggle" ) ,
				input().attr( "type" , "checkbox" ).id( "history-toggle" ) ,
				div( e.history.map!( h => [ div( h[0] , pre( h[1] ) ) ] )
					.join( hr ).array ).id( "history-view" )
			)
		)
	) ;
}

string makenewpage() {
	return mkdoc(
		makehead( "Create a new entry - " ~ FRIENDLYNAME ) ,
		body(
			h1( a( "/" , FRIENDLYNAME ) ) ,
			div(
				h2( "Create a new entry" ) ,
				form( "/new" ,
					span( "Title" ).class_( "label" ) ,
					input().attr( "type" , "text" ).attr( "name" , "title" )
						.attr( "placeholder" , "Title" )
						.attr( "required" , "" ) , br ,
					span( "Tags" ).class_( "label" ) ,
					input().attr( "type" , "text" ).attr( "name" , "tags" )
						.attr( "placeholder" , "first second third" ) , br ,
					span( "Deprecated" ).class_( "label" ) ,
					input().attr( "type" , "checkbox" ).attr( "name" , "old" ) ,
					br ,
					textarea( "" ).attr( "name" , "content" )
						.attr( "cols" , "80" )
						.attr( "placeholder" , "Message content" )
						.attr( "required" , "" ) ,
					input().attr( "type" , "submit" ).attr( "value" , "Submit" )
				).id( "form" ).attr( "method" , "post" )
			)
		)
	) ;
}

string makeeditpage( string id , string ) {
	return mkdoc(
		makehead( "Edit entry #" ~ id ~ " - " ~ FRIENDLYNAME ) ,
		body(
			h1( a( "/" , FRIENDLYNAME ) ) ,
			div(
				h2( "Edit entry #" , id ) ,
				form( "/edit/" ~ id ,
					span( "Title" ).class_( "label" ) ,
					input( )
				).id( "form").attr( "method" , "post" )
			)
		)
	) ;
}

A kb-web/static/dark.css => kb-web/static/dark.css +10 -0
@@ 0,0 1,10 @@
:root {
	--backgroundcolour : #1f1f1f ;
	--titlecolour : #fff ;
	--textcolour : #d5d5d5 ;
	--contentcolour : #2e2e2e ;
	--accent : #1099eb ;
	--red : #ec2b2b ;
	--emph : #fff ;
	--deemph : #6e6e6e ;
}

A kb-web/static/err.html => kb-web/static/err.html +11 -0
@@ 0,0 1,11 @@
<!DOCTYPE html>
<head>
	<meta charset="utf-8">
	<link rel="stylesheet" type="text/css" href="/global.css">
	<title>Error</title>
</head>
<body>
	<h1>Oh no</h1>
	The API server didn't send back the right information. Check the API server
	is up and kb-web is pointing at it.
</body>

M kb-web/static/global.css => kb-web/static/global.css +177 -29
@@ 1,14 1,4 @@
/* THEMES WILL BE IN SEPARATE FILES */
:root {
	--bg : #1f1f1f ;
	--fg : #d5d5d5 ;
	--bfg : #fff ;
	--bbg : #4e4e4e ;
	--dfg : #6e6e6e ;
	--acc : #1099eb ;
}

/* RESPONSIVE FONT SIZES */
/* font sizes */
@media screen and ( min-width : 600px ) {
	:root {
		--normal : 14px ;


@@ 24,57 14,215 @@
	}
}

/* GLOBAL */
html , body { background-color : var( --bg ) ; }
/* main body elements */
html , body { background-color : var( --backgroundcolour ) ; }
body {
	max-width : 800px ;
	margin : auto ;
	padding : 10px ;
	color : var( --fg ) ;
	color : var( --textcolour ) ;
	font-size : var( --normal ) ;
	text-align : right ;
}
* {
	scrollbar-width : thin ;
	scrollbar-color : var( --dfg ) var( --bg ) ;
	scrollbar-color : var( --deemph ) var( --backgroundcolour ) ;
	tab-size : 4 ;
	-moz-tab-size : 4 ;
	-o-tab-size : 4 ;
	font-family : sans-serif ;
}

/* HEADER AT TOP */
/* HEADERS AT TOP */
h1 {
	text-align : center ;
	color : var( --bfg ) ;
	color : var( --titlecolour ) ;
	font-size : var( --title ) ;
}
h1 a {
	text-decoration : none ;
	color : var( --titlecolour ) ;
}
h1 a:hover { color : var( --titlecolour ) ; }
h2 {
	color : var( --titlecolour ) ;
	font-size : var( --big ) ;
	margin : 0 ;
	margin-bottom : 20px ;
	font-weight : normal ;
}

/* ACTION BAR */
#actionbar {
	padding : 0 ;
	margin : 0 0 8px 0 ;
	background-color : var( --backgroundcolour ) ;
	text-align : center ;
	overflow : auto ;
}
#actionbar form {
	display : inline-block ;
	margin : 0 10px ;
	padding : 0 ;
}
#actionbar input {
	margin : 0 ;
	transition-duration : 0.1s ;
}
#search input {
	max-width : calc( 100% - 56px ) ;
	width : 300px ;
	background-color : var( --contentcolour ) ;
	color : var( --textcolour ) ;
	height : 32px ;
	padding : 4px 8px 0px 8px ;
	border : 0 ;
	border-bottom : 4px solid var( --contentcolour ) ;
}
#search input:focus { border-bottom : 4px solid var( --deemph ) ; }
#create input , #edit input {
	height : 40px ;
	background-color : var( --contentcolour ) ;
	color : var( --accent ) ;
	font-weight : bold ;
	border : 0 ;
}
#create input { width : 40px ; }
#create input:hover , #edit input:hover {
	background-color : var( --accent ) ;
	color : var( --textcolour ) ;
}
#delete input {
	height : 40px ;
	background-color : var( --contentcolour ) ;
	color : var( --red ) ;
	font-weight : bold ;
	border : 0 ;
}
#delete input:hover {
	background-color : var( --red ) ;
	color : var( --textcolour ) ;
}

/* SORT LINKS FOR HOME PAGE */
a { transition-duration : 0.1s ; }
a.sort { color : var( --acc ) ; }
a.sort:hover { color : var( --bfg ) ; }
a.sort { color : var( --accent ) ; }
a.sort:hover { color : var( --emph ) ; }

/* ENTRY LISTINGS */
div {
	background-color : var( --bbg ) ;
	background-color : var( --contentcolour ) ;
	margin : 10px 0px ;
	padding : 10px ;
	text-align : left ;
	color : var( --bg ) ;
	color : var( --textcolour ) ;
}
div
span.id {
	color : var( --dfg ) ;
	color : var( --deemph ) ;
	font-size : var( --big ) ;
	font-weight : bold ;
}
span.datetime {
	display : inline-block ;
	padding : 4px 8px ;
}
a.entry {
	color : var( --bfg ) ;
	color : var( --emph ) ;
	font-size : var( --big ) ;
	text-decoration : none ;
}
a.entry:hover { color : var( --accent ) ; }
a.tag {
	display : inline-block ;
	background-color : var( --backgroundcolour ) ;
	padding : 4px 8px ;
	margin : 0px 3px 0px 3px ;
	text-decoration : none ;
	color : var( --textcolour ) ;
}
a.entry:hover { color : var( --acc ) ; }
span.tag {
a.tag:first-of-type { margin-left : 15px ; }
a.tag:hover { color : var( --accent ) ; }
p.middle {
	text-align : center ;
	padding : 0 ;
	margin : 0 ;
}

/* ENTRIES */
pre {
	white-space : pre-wrap ;
	margin-bottom : 0 ;
	font-size : var( --normal ) ;
	font-family : monospace ;
}
#history-view {
	margin : 0 ;
	padding : 0 ;
}

/* SHOW/HIDE HISTORY */
label[for=history-toggle] {
	cursor : pointer ;
	display : block ;
	text-align : center ;
	transition-duration : 0.1s ;
}
label[for=history-toggle]:hover {
	color : var( --accent ) ;
}
#history-toggle { display : none ; }
#history-toggle:not(:checked) ~ #history-view { display : none ; }

/* FORMS */
span.label {
	display : inline-block ;
	width : 100px ;
	margin : 10px 0px ;
}
#form textarea {
	font-family : monospace ;
	font-size : var( --normal ) ;
	width : 80ch ;
	resize : vertical ;
	height : 40ch ;
	min-height : 8ch ;
	margin : 10px auto ;
	display : block ;
}
#form input { width : 400px ; }
#form input[type=checkbox] {
	appearance : none ;
	display : inline-block ;
	background-color : var( --dfg ) ;
	padding : 2px 4px ;
	margin : 10px 3px 0px 3px ;
	vertical-align : middle ;
	-webkit-appearance : none ;
	-moz-appearance : none ;
	-o-appearance : none ;
	background-color : var( --backgroundcolour ) ;
	border : 4px solid var( --backgroundcolour ) ;
	margin : 0 ;
	width : 16px ;
	height : 16px ;
}
#form input[type=checkbox]:checked {
	background-color : var( --accent ) ;
}
#form textarea , #form input {
	background-color : var( --backgroundcolour ) ;
	color : var( --textcolour ) ;
	border : 0 ;
	border-radius : 0 ;
	padding : 8px ;
	max-width : 90% ;
}
#form input[type=submit] {
	width : 80px ;
	margin : 10px auto 0 auto ;
	font-weight : bold ;
	color : var( --accent ) ;
	display : block ;
	transition-duration : 0.1s ;
}
#form input[type=submit]:hover {
	background-color : var( --accent ) ;
	color : var( --textcolour ) ;
}

D kb-web/static/index.html => kb-web/static/index.html +0 -34
@@ 1,34 0,0 @@
<!DOCTYPE html>
<head>
	<meta charset="utf-8">
	<link rel="stylesheet" type="text/css" href="/global.css">
	<title>FRIENDLYNAME</title>
</head>
<body>
	<h1>FRIENDLYNAME</h1>
	Sort by <a class="sort" href="/index.html?sort=recent">time</a> instead.
	<div>
		<span class="id">#ID</span>
		<a class="entry" href="/entry/0">[DEPRECATED] The title of this entry</a>
		<br>
		3 January 2020 @ 3:50pm
		<span class="tag">firsttag</span>
		<span class="tag">second</span>
	</div>
	<div>
		<span class="id">#ID</span>
		<a class="entry" href="/entry/0">[DEPRECATED] The title of this entry</a>
		<br>
		3 January 2020 @ 3:50pm
		<span class="tag">firsttag</span>
		<span class="tag">second</span>
	</div>
	<div>
		<span class="id">#ID</span>
		<a class="entry" href="/entry/0">[DEPRECATED] The title of this entry</a>
		<br>
		3 January 2020 @ 3:50pm
		<span class="tag">firsttag</span>
		<span class="tag">second</span>
	</div>
</body>

A kb-web/static/light.css => kb-web/static/light.css +9 -0
@@ 0,0 1,9 @@
:root {
	--backgroundcolour : #9e9e9e ;
	--titlecolour : #1f1f1f ;
	--textcolour : #1f1f1f ;
	--contentcolour : #d5d5d5 ;
	--accent : #fff ;
	--emph : #000 ;
	--deemph : #9e9e9e ;
}