From cdc7d69ad9a2b1e6e0aa28c8ba411ddec38c82c5 Mon Sep 17 00:00:00 2001 From: Tom Wiesing Date: Fri, 14 Oct 2022 16:48:12 +0200 Subject: [PATCH] dis: Rework styling and build procedure --- go.mod | 1 + go.sum | 8 +- internal/component/home/home.html | 64 +-- internal/component/home/public.go | 4 +- internal/component/info/html/index.html | 346 ++++++++++++---- internal/component/info/html/instance.html | 371 ++++++++++++++---- internal/component/info/index.go | 4 +- internal/component/info/instance.go | 4 +- .../static/dist/control/index.a0bd71f2.css | 1 + .../static/dist/control/index.a27ed337.js | 1 + .../component/static/dist/control/index.css | 1 - .../component/static/dist/control/index.html | 1 + .../component/static/dist/control/index.js | 1 - .../static/dist/control/instance.html | 1 + .../static/dist/home/index.38d394c2.js | 0 internal/component/static/dist/home/index.css | 1 - .../component/static/dist/home/index.html | 1 + internal/component/static/dist/home/index.js | 1 - internal/component/static/entry.go | 20 + internal/component/static/package.json | 14 +- internal/component/static/src/base/base.css | 55 --- internal/component/static/src/base/base.ts | 1 - internal/component/static/src/base/index.css | 34 ++ internal/component/static/src/base/index.ts | 4 + .../component/static/src/control/index.ts | 5 - .../component/static/src/control/remote.ts | 62 --- .../static/src/{ => entry}/control/index.css | 6 - .../static/src/entry/control/index.html | 1 + .../static/src/entry/control/index.ts | 5 + .../static/src/entry/control/instance.html | 1 + .../static/src/entry/home/index.html | 1 + .../component/static/src/entry/home/index.ts | 1 + internal/component/static/src/global.css | 2 - internal/component/static/src/global.ts | 2 - internal/component/static/src/home/index.css | 1 - internal/component/static/src/home/index.ts | 2 - .../autolink.css => lib/autolink/index.css} | 0 .../autolink.ts => lib/autolink/index.ts} | 2 + .../highlight.ts => lib/highlight/index.ts} | 4 +- .../component/static/src/lib/remote/index.css | 42 ++ .../component/static/src/lib/remote/index.ts | 112 ++++++ .../src/{socket => lib/remote}/socket.ts | 0 internal/component/static/static.go | 9 + internal/component/static/tsconfig.json | 105 +++++ internal/component/static/yarn.lock | 5 + package.json | 5 + pkg/fsx/filter.go | 94 +++++ pkg/resources/resources.go | 113 ++++++ pkg/resources/resources_test.go | 34 ++ pkg/resources/template.go | 29 ++ yarn.lock | 8 + 51 files changed, 1251 insertions(+), 339 deletions(-) create mode 100644 internal/component/static/dist/control/index.a0bd71f2.css create mode 100644 internal/component/static/dist/control/index.a27ed337.js delete mode 100644 internal/component/static/dist/control/index.css create mode 100644 internal/component/static/dist/control/index.html delete mode 100644 internal/component/static/dist/control/index.js create mode 100644 internal/component/static/dist/control/instance.html create mode 100644 internal/component/static/dist/home/index.38d394c2.js delete mode 100644 internal/component/static/dist/home/index.css create mode 100644 internal/component/static/dist/home/index.html delete mode 100644 internal/component/static/dist/home/index.js create mode 100644 internal/component/static/entry.go delete mode 100644 internal/component/static/src/base/base.css delete mode 100644 internal/component/static/src/base/base.ts create mode 100644 internal/component/static/src/base/index.css create mode 100644 internal/component/static/src/base/index.ts delete mode 100644 internal/component/static/src/control/index.ts delete mode 100644 internal/component/static/src/control/remote.ts rename internal/component/static/src/{ => entry}/control/index.css (65%) create mode 100644 internal/component/static/src/entry/control/index.html create mode 100644 internal/component/static/src/entry/control/index.ts create mode 100644 internal/component/static/src/entry/control/instance.html create mode 100644 internal/component/static/src/entry/home/index.html create mode 100644 internal/component/static/src/entry/home/index.ts delete mode 100644 internal/component/static/src/global.css delete mode 100644 internal/component/static/src/global.ts delete mode 100644 internal/component/static/src/home/index.css delete mode 100644 internal/component/static/src/home/index.ts rename internal/component/static/src/{autolink/autolink.css => lib/autolink/index.css} (100%) rename internal/component/static/src/{autolink/autolink.ts => lib/autolink/index.ts} (96%) rename internal/component/static/src/{control/highlight.ts => lib/highlight/index.ts} (94%) create mode 100644 internal/component/static/src/lib/remote/index.css create mode 100644 internal/component/static/src/lib/remote/index.ts rename internal/component/static/src/{socket => lib/remote}/socket.ts (100%) create mode 100644 internal/component/static/tsconfig.json create mode 100644 package.json create mode 100644 pkg/fsx/filter.go create mode 100644 pkg/resources/resources.go create mode 100644 pkg/resources/resources_test.go create mode 100644 pkg/resources/template.go create mode 100644 yarn.lock diff --git a/go.mod b/go.mod index b89da52..3417efa 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/tkw1536/goprogram v0.1.1 golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 + golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458 golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 gorm.io/driver/mysql v1.3.6 gorm.io/gorm v1.23.10 diff --git a/go.sum b/go.sum index 6578251..031cb79 100644 --- a/go.sum +++ b/go.sum @@ -23,16 +23,12 @@ github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peK github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/tkw1536/goprogram v0.0.17 h1:SAD/rHtxm7CTEdUV1a37LUyE6G0MdcKhLzO8fZ8cFKI= -github.com/tkw1536/goprogram v0.0.17/go.mod h1:Jqs0sTMzhrAGCX3JQrlEwQ0WRWQACCvuQQkaBDp65pE= -github.com/tkw1536/goprogram v0.1.0 h1:cP+Z7VKgRF93JApsau1azKxGrH/nTwcGLnSYfZP9XjE= -github.com/tkw1536/goprogram v0.1.0/go.mod h1:Jqs0sTMzhrAGCX3JQrlEwQ0WRWQACCvuQQkaBDp65pE= github.com/tkw1536/goprogram v0.1.1 h1:gamK9OuRqoX2yQlA/nkgfVHHZWd/u2uUj6vJMYrYa70= github.com/tkw1536/goprogram v0.1.1/go.mod h1:Jqs0sTMzhrAGCX3JQrlEwQ0WRWQACCvuQQkaBDp65pE= -golang.org/x/exp v0.0.0-20220929160808-de9c53c655b9 h1:lNtcVz/3bOstm7Vebox+5m3nLh/BYWnhmc3AhXOW6oI= -golang.org/x/exp v0.0.0-20220929160808-de9c53c655b9/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741 h1:fGZugkZk2UgYBxtpKmvub51Yno1LJDeEsRp2xGD+0gY= golang.org/x/exp v0.0.0-20221004215720-b9f4876ce741/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458 h1:MgJ6t2zo8v0tbmLCueaCbF1RM+TtB0rs3Lv8DGtOIpY= +golang.org/x/net v0.0.0-20221012135044-0b7e1fb9d458/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY= golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/component/home/home.html b/internal/component/home/home.html index e2d2786..8b1d217 100644 --- a/internal/component/home/home.html +++ b/internal/component/home/home.html @@ -1,28 +1,40 @@ + + + WissKI Distillery + {{ CSS }} + + +
+

WissKI Distillery

+
+
+
+
+

+ For more information, see {{ .SelfRedirect }}. +

+
+ +
+

WissKIs on this Distillery

+
+ + {{range .Instances}} + {{ if .Running }} +
+

{{.Slug}}

+

+ {{.URL}}
+

+
+ {{ end }} + {{ end }} +
- - -WissKI Distillery -

WissKI Distillery

- -

- For more information, see {{ .SelfRedirect }}. -

- -

WissKIs on this Distillery

-
- {{range .Instances}} - {{ if .Running }} -

{{.Slug}}

-

- {{.URL}}
-

- {{ end }} - {{ end }} -
- - - - \ No newline at end of file + + + {{ JS }} + diff --git a/internal/component/home/public.go b/internal/component/home/public.go index a356ea4..c351149 100644 --- a/internal/component/home/public.go +++ b/internal/component/home/public.go @@ -3,12 +3,12 @@ package home import ( "bytes" "context" - "text/template" "time" _ "embed" "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/component/static" "github.com/FAU-CDI/wisski-distillery/pkg/timex" "github.com/tkw1536/goprogram/stream" "golang.org/x/sync/errgroup" @@ -47,7 +47,7 @@ func (home *Home) updateRender(ctx context.Context, io stream.IOStream) { //go:embed "home.html" var homeHTMLStr string -var homeTemplate = template.Must(template.New("home.html").Parse(homeHTMLStr)) +var homeTemplate = static.EntryHome.MustParse(homeHTMLStr) func (home *Home) homeRender() ([]byte, error) { var context HomeContext diff --git a/internal/component/info/html/index.html b/internal/component/info/html/index.html index bb9fcbb..60c7580 100644 --- a/internal/component/info/html/index.html +++ b/internal/component/info/html/index.html @@ -1,80 +1,286 @@ - -Distillery Status Page -

Distillery Status Page

+ + + Distillery Control Page + {{ CSS }} + -

Overview

+ +
+

Distillery Control Page

+
+
+
+
+

Distillery Configuration

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ Domains +
+ Primary + + {{.Config.DefaultDomain}} +
+ Extra + + {{ range .Config.SelfExtraDomains }} + {{.}}
+ {{ end }} +
+ Email (HTTPS) + + {{.Config.CertbotEmail}} +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Database Settings +
+ MySQL User Prefix + + {{.Config.MysqlUserPrefix}} +
+ MySQL Database Prefix + + {{.Config.MysqlDatabasePrefix}} +
+ GraphDB User Prefix + + {{.Config.GraphDBUserPrefix}} +
+ GraphDB Database Prefix + + {{.Config.GraphDBRepoPrefix}} +
+ Bookkeeping Database + + {{.Config.DistilleryDatabase}} +
+
+
+
+
+
+
+ + + + + + -

- Domain: {{.Config.DefaultDomain}}
- Legacy Domain(s): {{.Config.SelfExtraDomains}}
- HTTPS Email: {{.Config.CertbotEmail}}
- Docker Network Name: {{.Config.DockerNetworkName}}
-


- Homepage Redirect:{{.Config.SelfRedirect}}
-
- Backup Age:{{.Config.MaxBackupAge}} Day(s)
-
- Base Directory:{{.Config.DeployRoot}}
- Configuration File:{{.Config.ConfigPath}}
- Authorized_Keys File:{{.Config.GlobalAuthorizedKeysFile}}
-
- MySQL User Prefix:{{.Config.MysqlUserPrefix}}
- MySQL Database Prefix:{{.Config.MysqlDatabasePrefix}}
- GraphDB User Prefix:{{.Config.GraphDBUserPrefix}}
- GraphDB Database Prefix:{{.Config.GraphDBRepoPrefix}}
-
- Bookkeeping Database:{{.Config.DistilleryDatabase}}
-
- Backups: -
+ Directory Settings +
- - - - - - - - - {{ range .Backups }} - - - - - - {{ end}} - -
PathCreatedPacked
- {{ .Path }} - - {{ .Created.Format "2006-01-02T15:04:05Z07:00" }} - - {{ .Packed }} -
-

+ + + + root + + + {{.Config.DeployRoot}} + + + + + config + + + {{.Config.ConfigPath}} + + + + + authorized_keys + + + {{.Config.GlobalAuthorizedKeysFile}} + + + + +
+
+
+
+
+
+ + + + + + -

Instances

+ + + + + + + + + + + + + + +
+ Misc Settings +
+ Homepage + + {{.Config.SelfRedirect}} +
+ Docker Network Name + + {{.Config.DockerNetworkName}} +
+ Backup Age + + {{.Config.MaxBackupAge}} Day(s) +
+
+
+
-

- {{ .TotalCount }} instance(s) = {{ .RunningCount }} running + {{ .StoppedCount }} stopped
-

+
+

Backups

+
-{{range .Instances}} -
-

{{.Slug}}{{ if not .Running }} not running{{ end }}

-

- {{.URL}}
+

+ + + + + + + + + + {{ range .Backups }} + + + + + + {{ end}} + +
PathCreatedPacked
+ {{ .Path }} + + {{ .Created.Format "2006-01-02T15:04:05Z07:00" }} + + {{ .Packed }} +
+
- - More Details - -

-
-{{end}} +
+

Instances

-
- Generated at {{ .Time }} -
+ + + + + + + + + + + + + + + +
TotalRunningStopped
+ {{ .TotalCount }} + + {{ .RunningCount }} + + {{ .StoppedCount }} +
- \ No newline at end of file + +
+ + {{range .Instances}} +
+
+

{{.Slug}}{{ if not .Running }} not running{{ end }}

+

+ {{.URL}}
+ + + More Details + +

+
+
+ {{end}} + +
+
+ + + + {{ JS }} + \ No newline at end of file diff --git a/internal/component/info/html/instance.html b/internal/component/info/html/instance.html index 111e500..8f09c26 100644 --- a/internal/component/info/html/instance.html +++ b/internal/component/info/html/instance.html @@ -1,85 +1,300 @@ - -Distillery Status Page - {{ .Info.Slug }} -

Distillery Status Page - {{ .Info.Slug }}

-

- Reload - Back to index -

+ + + Distillery Control Page - {{ .Info.Slug }} + {{ CSS }} + -
- Slug: {{ .Info.Slug }}
- URL: {{ .Info.URL }}
-
- URI Prefixes: - - Excluded from Resolver: {{ .Info.NoPrefixes }}
-
- Running: {{ .Info.Running }}
- Locked: {{ .Info.Locked }}
- -
- Created: {{ .Instance.Created.Format "2006-01-02T15:04:05Z07:00" }}
- Last Rebuild: {{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}
-
-

- -


-    

-
- FilesystemBase: {{ .Instance.FilesystemBase }}
- AutoBlindUpdateEnabled: {{ .Instance.AutoBlindUpdateEnabled }}
-
- Pathbuilders: {{ .Info.Pathbuilders }}
- -
- SqlDatabase: {{ .Instance.SqlDatabase }}
- SqlUsername: {{ .Instance.SqlUsername }}
-
- GraphDBRepository: {{ .Instance.GraphDBRepository }}
- GraphDBUsername: {{ .Instance.GraphDBUsername }}
-
- Snapshots: - - - - - - - - - - {{ range .Info.Snapshots }} - - - - - - {{ end }} - -
PathCreatedPacked
- {{ .Path }} - - {{ .Created.Format "2006-01-02T15:04:05Z07:00" }} - - {{ .Packed }} -
-
-

- -


-    

-
+ +
+

Distillery Control Page - {{ .Info.Slug }}

+

+ Reload + Back to index +

+
+
+
+
+

Instance Overview

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Overview +
+ Slug + + {{ .Info.Slug }} +
+ URL + + {{ .Info.URL }} +
+ Running + + {{ .Info.Running }} +
+ Locked + + {{ .Info.Locked }} +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ Build + +
+ Created + + {{ .Instance.Created.Format "2006-01-02T15:04:05Z07:00" }} +
+ Last Rebuild + + {{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }} +
+ Automatic Updates + + {{ .Instance.AutoBlindUpdateEnabled }} +
+
+

+


+                    

+
+
-
- Generated at {{ .Time }} -
+
+
+
+ + + + + + + + + + + + + + + + +
+ Resolver +
+ Excluded + + {{ .Info.NoPrefixes }} +
+ URI Prefixes + + {{ range .Info.Prefixes }} + {{ . }}
+ {{ end}} +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Database Settings +
+ SQL Database + + {{ .Instance.SqlDatabase }} +
+ SQL Username + + {{ .Instance.SqlUsername }} +
+ GraphDB Repository + + {{ .Instance.GraphDBRepository }} +
+ GraphDB Username + + {{ .Instance.GraphDBUsername }} +
+
+
+
+
+
+
+ + + + + + - \ No newline at end of file + + + + + + +
+ Whitebox Data +
+ Pathbuilders + + {{ .Info.Pathbuilders }} +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+ Misc Settings +
+ Filesystem Base + + {{ .Instance.FilesystemBase }} + +
+
+
+
+ +
+

Snapshots

+
+ +
+ + + + + + + + + + {{ range .Info.Snapshots }} + + + + + + {{ end}} + +
PathCreatedPacked
+ {{ .Path }} + + {{ .Created.Format "2006-01-02T15:04:05Z07:00" }} + + {{ .Packed }} +
+
+ +

+ +

+
+ +
+ + + + {{ JS }} + \ No newline at end of file diff --git a/internal/component/info/index.go b/internal/component/info/index.go index 056688e..b8b8d26 100644 --- a/internal/component/info/index.go +++ b/internal/component/info/index.go @@ -1,13 +1,13 @@ package info import ( - "html/template" "net/http" "time" _ "embed" "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/component/static" "github.com/FAU-CDI/wisski-distillery/internal/config" "github.com/FAU-CDI/wisski-distillery/internal/models" "golang.org/x/sync/errgroup" @@ -15,7 +15,7 @@ import ( //go:embed "html/index.html" var indexTemplateStr string -var indexTemplate = template.Must(template.New("index.html").Parse(indexTemplateStr)) +var indexTemplate = static.EntryControlIndex.MustParse(indexTemplateStr) type indexPageContext struct { Time time.Time diff --git a/internal/component/info/instance.go b/internal/component/info/instance.go index cd880c1..f16964d 100644 --- a/internal/component/info/instance.go +++ b/internal/component/info/instance.go @@ -2,19 +2,19 @@ package info import ( _ "embed" - "html/template" "net/http" "strings" "time" "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/component/static" "github.com/FAU-CDI/wisski-distillery/internal/models" "github.com/FAU-CDI/wisski-distillery/pkg/httpx" ) //go:embed "html/instance.html" var instanceTemplateString string -var instanceTemplate = template.Must(template.New("instance.html").Parse(instanceTemplateString)) +var instanceTemplate = static.EntryControlInstance.MustParse(instanceTemplateString) type instancePageContext struct { Time time.Time diff --git a/internal/component/static/dist/control/index.a0bd71f2.css b/internal/component/static/dist/control/index.a0bd71f2.css new file mode 100644 index 0000000..6dc9273 --- /dev/null +++ b/internal/component/static/dist/control/index.a0bd71f2.css @@ -0,0 +1 @@ +body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol}header,main,footer{margin:2em}.padding{padding:1em}.overflow{overflow-x:auto}.overflow table{width:100%}.overflow table td:not(:last-child),.overflow table th:not(:last-child){width:1px;text-align:left;white-space:nowrap}.overflow table td:last-child,.overflow table th:last-child{white-space:nowrap}.hspace{height:2em;display:block}html{-webkit-text-size-adjust:100%;line-height:1.15}body{margin:0}main{display:block}h1{margin:.67em 0;font-size:2em}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace;font-size:1em}a{background-color:#0000}abbr[title]{border-bottom:none;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:100%;line-height:1.15}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;max-width:100%;white-space:normal;padding:0;display:table}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template,[hidden]{display:none}html{font-family:sans-serif}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{letter-spacing:-.31em;text-rendering:optimizespeed;flex-flow:wrap;align-content:flex-start;font-family:FreeSans,Arimo,Droid Sans,Helvetica,Arial,sans-serif;display:flex}@media (-ms-high-contrast:none),(-ms-high-contrast:active){table .pure-g{display:block}}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-g [class*=pure-u]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-2,.pure-u-1-3,.pure-u-2-3,.pure-u-1-4,.pure-u-3-4,.pure-u-1-5,.pure-u-2-5,.pure-u-3-5,.pure-u-4-5,.pure-u-5-5,.pure-u-1-6,.pure-u-5-6,.pure-u-1-8,.pure-u-3-8,.pure-u-5-8,.pure-u-7-8,.pure-u-1-12,.pure-u-5-12,.pure-u-7-12,.pure-u-11-12,.pure-u-1-24,.pure-u-2-24,.pure-u-3-24,.pure-u-4-24,.pure-u-5-24,.pure-u-6-24,.pure-u-7-24,.pure-u-8-24,.pure-u-9-24,.pure-u-10-24,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-5-12,.pure-u-10-24{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-7-12,.pure-u-14-24{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-5-8,.pure-u-15-24{width:62.5%}.pure-u-2-3,.pure-u-16-24{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-3-4,.pure-u-18-24{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-5-6,.pure-u-20-24{width:83.3333%}.pure-u-7-8,.pure-u-21-24{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-5-5,.pure-u-24-24{width:100%}.pure-button{white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;user-select:none;box-sizing:border-box;line-height:normal;display:inline-block}.pure-button::-moz-focus-inner{border:0;padding:0}.pure-button-group{letter-spacing:-.31em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-button-group{word-spacing:-.43em}.pure-button-group .pure-button{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-button{color:#000c;background-color:#e6e6e6;border:#0000;border-radius:2px;padding:.5em 1em;font-family:inherit;font-size:100%;text-decoration:none}.pure-button-hover,.pure-button:hover,.pure-button:focus{background-image:linear-gradient(#0000,#0000000d 40%,#0000001a)}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{border-color:#000;box-shadow:inset 0 0 0 1px #00000026,inset 0 0 6px #0003}.pure-button[disabled],.pure-button-disabled,.pure-button-disabled:hover,.pure-button-disabled:focus,.pure-button-disabled:active{opacity:.4;cursor:not-allowed;-webkit-box-shadow:none;box-shadow:none;pointer-events:none;background-image:none;border:none}.pure-button-hidden{display:none}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{color:#fff;background-color:#0078e7}.pure-button-group .pure-button{border-right:1px solid #0003;border-radius:0;margin:0}.pure-button-group .pure-button:first-child{border-top-left-radius:2px;border-bottom-left-radius:2px}.pure-button-group .pure-button:last-child{border-right:none;border-top-right-radius:2px;border-bottom-right-radius:2px}.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form select,.pure-form textarea{vertical-align:middle;box-sizing:border-box;border:1px solid #ccc;border-radius:4px;padding:.5em .6em;display:inline-block;box-shadow:inset 0 1px 3px #ddd}.pure-form input:not([type]){box-sizing:border-box;border:1px solid #ccc;border-radius:4px;padding:.5em .6em;display:inline-block;box-shadow:inset 0 1px 3px #ddd}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=text]:focus,.pure-form input[type=password]:focus,.pure-form input[type=email]:focus,.pure-form input[type=url]:focus,.pure-form input[type=date]:focus,.pure-form input[type=month]:focus,.pure-form input[type=time]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=week]:focus,.pure-form input[type=number]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=color]:focus,.pure-form select:focus,.pure-form textarea:focus,.pure-form input:not([type]):focus{border-color:#129fea;outline:0}.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus,.pure-form input[type=checkbox]:focus{outline:1px auto #129fea}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=text][disabled],.pure-form input[type=password][disabled],.pure-form input[type=email][disabled],.pure-form input[type=url][disabled],.pure-form input[type=date][disabled],.pure-form input[type=month][disabled],.pure-form input[type=time][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=week][disabled],.pure-form input[type=number][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=color][disabled],.pure-form select[disabled],.pure-form textarea[disabled],.pure-form input:not([type])[disabled]{cursor:not-allowed;color:#cad2d3;background-color:#eaeded}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{color:#777;background-color:#eee;border-color:#ccc}.pure-form input:focus:invalid,.pure-form textarea:focus:invalid,.pure-form select:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus,.pure-form input[type=checkbox]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;background-color:#fff;border:1px solid #ccc}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{border:0;margin:0;padding:.35em 0 .75em}.pure-form legend{width:100%;color:#333;border-bottom:1px solid #e5e5e5;margin-bottom:.3em;padding:.3em 0;display:block}.pure-form-stacked input[type=text],.pure-form-stacked input[type=password],.pure-form-stacked input[type=email],.pure-form-stacked input[type=url],.pure-form-stacked input[type=date],.pure-form-stacked input[type=month],.pure-form-stacked input[type=time],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=week],.pure-form-stacked input[type=number],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=color],.pure-form-stacked input[type=file],.pure-form-stacked select,.pure-form-stacked label,.pure-form-stacked textarea,.pure-form-stacked input:not([type]){margin:.25em 0;display:block}.pure-form-aligned input,.pure-form-aligned textarea,.pure-form-aligned select,.pure-form-message-inline{vertical-align:middle;display:inline-block}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;vertical-align:middle;width:10em;margin:0 1em 0 0;display:inline-block}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form input.pure-input-rounded,.pure-form .pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{border-radius:0;margin:0 0 -1px;padding:10px;display:block;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{border-radius:4px 4px 0 0;margin:0;top:1px}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{border-radius:4px;margin:0;top:1px}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{border-radius:0 0 4px 4px;margin:0;top:-2px}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-3-4{width:75%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form-message-inline{color:#666;vertical-align:middle;padding-left:.3em;font-size:.875em;display:inline-block}.pure-form-message{color:#666;font-size:.875em;display:block}@media only screen and (max-width:480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=color],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=text],.pure-group input[type=password],.pure-group input[type=email],.pure-group input[type=url],.pure-group input[type=date],.pure-group input[type=month],.pure-group input[type=time],.pure-group input[type=datetime],.pure-group input[type=datetime-local],.pure-group input[type=week],.pure-group input[type=number],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=color]{margin-bottom:0}.pure-form-aligned .pure-control-group label{text-align:left;width:100%;margin-bottom:.3em;display:block}.pure-form-aligned .pure-controls{margin:1.5em 0 0}.pure-form-message-inline,.pure-form-message{padding:.2em 0 .8em;font-size:.75em;display:block}}.pure-menu{box-sizing:border-box}.pure-menu-fixed{z-index:3;position:fixed;top:0;left:0}.pure-menu-list,.pure-menu-item{position:relative}.pure-menu-list{margin:0;padding:0;list-style:none}.pure-menu-item{height:100%;margin:0;padding:0}.pure-menu-link,.pure-menu-heading{white-space:nowrap;text-decoration:none;display:block}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-separator{vertical-align:middle;display:inline-block}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{z-index:3;margin:0;padding:0;display:none;position:absolute;top:0;left:100%}.pure-menu-horizontal .pure-menu-children{width:inherit;top:auto;left:0}.pure-menu-allow-hover:hover>.pure-menu-children,.pure-menu-active>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{content:"▸";padding-left:.5em;font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"▾"}.pure-menu-scrollable{overflow-x:hidden;overflow-y:scroll}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;padding:.5em 0;overflow-x:auto;overflow-y:hidden}.pure-menu-separator,.pure-menu-horizontal .pure-menu-children .pure-menu-separator{height:1px;background-color:#ccc;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-horizontal .pure-menu-children .pure-menu-separator{width:auto;display:block}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-link,.pure-menu-heading{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{cursor:default;background-color:#0000}.pure-menu-active>.pure-menu-link,.pure-menu-link:hover,.pure-menu-link:focus{background-color:#eee}.pure-menu-selected>.pure-menu-link,.pure-menu-selected>.pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;text-align:center;padding:1em 0;font:italic 85%/1 arial,sans-serif}.pure-table td,.pure-table th{font-size:inherit;border-width:0 0 0 1px;border-left-style:solid;border-left-color:#cbcbcb;margin:0;padding:.5em 1em;overflow:visible}.pure-table thead{color:#000;text-align:left;vertical-align:bottom;background-color:#e0e0e0}.pure-table td{background-color:#0000}.pure-table-odd td,.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px;border-bottom-style:solid;border-bottom-color:#cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0}@media screen and (min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-2,.pure-u-sm-1-3,.pure-u-sm-2-3,.pure-u-sm-1-4,.pure-u-sm-3-4,.pure-u-sm-1-5,.pure-u-sm-2-5,.pure-u-sm-3-5,.pure-u-sm-4-5,.pure-u-sm-5-5,.pure-u-sm-1-6,.pure-u-sm-5-6,.pure-u-sm-1-8,.pure-u-sm-3-8,.pure-u-sm-5-8,.pure-u-sm-7-8,.pure-u-sm-1-12,.pure-u-sm-5-12,.pure-u-sm-7-12,.pure-u-sm-11-12,.pure-u-sm-1-24,.pure-u-sm-2-24,.pure-u-sm-3-24,.pure-u-sm-4-24,.pure-u-sm-5-24,.pure-u-sm-6-24,.pure-u-sm-7-24,.pure-u-sm-8-24,.pure-u-sm-9-24,.pure-u-sm-10-24,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-sm-1-24{width:4.1667%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%}.pure-u-sm-1-5{width:20%}.pure-u-sm-5-24{width:20.8333%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%}.pure-u-sm-7-24{width:29.1667%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%}.pure-u-sm-2-5{width:40%}.pure-u-sm-5-12,.pure-u-sm-10-24{width:41.6667%}.pure-u-sm-11-24{width:45.8333%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%}.pure-u-sm-13-24{width:54.1667%}.pure-u-sm-7-12,.pure-u-sm-14-24{width:58.3333%}.pure-u-sm-3-5{width:60%}.pure-u-sm-5-8,.pure-u-sm-15-24{width:62.5%}.pure-u-sm-2-3,.pure-u-sm-16-24{width:66.6667%}.pure-u-sm-17-24{width:70.8333%}.pure-u-sm-3-4,.pure-u-sm-18-24{width:75%}.pure-u-sm-19-24{width:79.1667%}.pure-u-sm-4-5{width:80%}.pure-u-sm-5-6,.pure-u-sm-20-24{width:83.3333%}.pure-u-sm-7-8,.pure-u-sm-21-24{width:87.5%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%}.pure-u-sm-23-24{width:95.8333%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-5-5,.pure-u-sm-24-24{width:100%}}@media screen and (min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-2,.pure-u-md-1-3,.pure-u-md-2-3,.pure-u-md-1-4,.pure-u-md-3-4,.pure-u-md-1-5,.pure-u-md-2-5,.pure-u-md-3-5,.pure-u-md-4-5,.pure-u-md-5-5,.pure-u-md-1-6,.pure-u-md-5-6,.pure-u-md-1-8,.pure-u-md-3-8,.pure-u-md-5-8,.pure-u-md-7-8,.pure-u-md-1-12,.pure-u-md-5-12,.pure-u-md-7-12,.pure-u-md-11-12,.pure-u-md-1-24,.pure-u-md-2-24,.pure-u-md-3-24,.pure-u-md-4-24,.pure-u-md-5-24,.pure-u-md-6-24,.pure-u-md-7-24,.pure-u-md-8-24,.pure-u-md-9-24,.pure-u-md-10-24,.pure-u-md-11-24,.pure-u-md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-md-1-24{width:4.1667%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%}.pure-u-md-1-5{width:20%}.pure-u-md-5-24{width:20.8333%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%}.pure-u-md-7-24{width:29.1667%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%}.pure-u-md-2-5{width:40%}.pure-u-md-5-12,.pure-u-md-10-24{width:41.6667%}.pure-u-md-11-24{width:45.8333%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%}.pure-u-md-13-24{width:54.1667%}.pure-u-md-7-12,.pure-u-md-14-24{width:58.3333%}.pure-u-md-3-5{width:60%}.pure-u-md-5-8,.pure-u-md-15-24{width:62.5%}.pure-u-md-2-3,.pure-u-md-16-24{width:66.6667%}.pure-u-md-17-24{width:70.8333%}.pure-u-md-3-4,.pure-u-md-18-24{width:75%}.pure-u-md-19-24{width:79.1667%}.pure-u-md-4-5{width:80%}.pure-u-md-5-6,.pure-u-md-20-24{width:83.3333%}.pure-u-md-7-8,.pure-u-md-21-24{width:87.5%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%}.pure-u-md-23-24{width:95.8333%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-5-5,.pure-u-md-24-24{width:100%}}@media screen and (min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-2,.pure-u-lg-1-3,.pure-u-lg-2-3,.pure-u-lg-1-4,.pure-u-lg-3-4,.pure-u-lg-1-5,.pure-u-lg-2-5,.pure-u-lg-3-5,.pure-u-lg-4-5,.pure-u-lg-5-5,.pure-u-lg-1-6,.pure-u-lg-5-6,.pure-u-lg-1-8,.pure-u-lg-3-8,.pure-u-lg-5-8,.pure-u-lg-7-8,.pure-u-lg-1-12,.pure-u-lg-5-12,.pure-u-lg-7-12,.pure-u-lg-11-12,.pure-u-lg-1-24,.pure-u-lg-2-24,.pure-u-lg-3-24,.pure-u-lg-4-24,.pure-u-lg-5-24,.pure-u-lg-6-24,.pure-u-lg-7-24,.pure-u-lg-8-24,.pure-u-lg-9-24,.pure-u-lg-10-24,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-lg-1-24{width:4.1667%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%}.pure-u-lg-1-5{width:20%}.pure-u-lg-5-24{width:20.8333%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%}.pure-u-lg-7-24{width:29.1667%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%}.pure-u-lg-2-5{width:40%}.pure-u-lg-5-12,.pure-u-lg-10-24{width:41.6667%}.pure-u-lg-11-24{width:45.8333%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%}.pure-u-lg-13-24{width:54.1667%}.pure-u-lg-7-12,.pure-u-lg-14-24{width:58.3333%}.pure-u-lg-3-5{width:60%}.pure-u-lg-5-8,.pure-u-lg-15-24{width:62.5%}.pure-u-lg-2-3,.pure-u-lg-16-24{width:66.6667%}.pure-u-lg-17-24{width:70.8333%}.pure-u-lg-3-4,.pure-u-lg-18-24{width:75%}.pure-u-lg-19-24{width:79.1667%}.pure-u-lg-4-5{width:80%}.pure-u-lg-5-6,.pure-u-lg-20-24{width:83.3333%}.pure-u-lg-7-8,.pure-u-lg-21-24{width:87.5%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%}.pure-u-lg-23-24{width:95.8333%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-5-5,.pure-u-lg-24-24{width:100%}}@media screen and (min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-2,.pure-u-xl-1-3,.pure-u-xl-2-3,.pure-u-xl-1-4,.pure-u-xl-3-4,.pure-u-xl-1-5,.pure-u-xl-2-5,.pure-u-xl-3-5,.pure-u-xl-4-5,.pure-u-xl-5-5,.pure-u-xl-1-6,.pure-u-xl-5-6,.pure-u-xl-1-8,.pure-u-xl-3-8,.pure-u-xl-5-8,.pure-u-xl-7-8,.pure-u-xl-1-12,.pure-u-xl-5-12,.pure-u-xl-7-12,.pure-u-xl-11-12,.pure-u-xl-1-24,.pure-u-xl-2-24,.pure-u-xl-3-24,.pure-u-xl-4-24,.pure-u-xl-5-24,.pure-u-xl-6-24,.pure-u-xl-7-24,.pure-u-xl-8-24,.pure-u-xl-9-24,.pure-u-xl-10-24,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-19-24,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-xl-1-24{width:4.1667%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%}.pure-u-xl-1-5{width:20%}.pure-u-xl-5-24{width:20.8333%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%}.pure-u-xl-7-24{width:29.1667%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%}.pure-u-xl-2-5{width:40%}.pure-u-xl-5-12,.pure-u-xl-10-24{width:41.6667%}.pure-u-xl-11-24{width:45.8333%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%}.pure-u-xl-13-24{width:54.1667%}.pure-u-xl-7-12,.pure-u-xl-14-24{width:58.3333%}.pure-u-xl-3-5{width:60%}.pure-u-xl-5-8,.pure-u-xl-15-24{width:62.5%}.pure-u-xl-2-3,.pure-u-xl-16-24{width:66.6667%}.pure-u-xl-17-24{width:70.8333%}.pure-u-xl-3-4,.pure-u-xl-18-24{width:75%}.pure-u-xl-19-24{width:79.1667%}.pure-u-xl-4-5{width:80%}.pure-u-xl-5-6,.pure-u-xl-20-24{width:83.3333%}.pure-u-xl-7-8,.pure-u-xl-21-24{width:87.5%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%}.pure-u-xl-23-24{width:95.8333%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-5-5,.pure-u-xl-24-24{width:100%}}@media screen and (min-width:120em){.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-1-2,.pure-u-xxl-1-3,.pure-u-xxl-2-3,.pure-u-xxl-1-4,.pure-u-xxl-3-4,.pure-u-xxl-1-5,.pure-u-xxl-2-5,.pure-u-xxl-3-5,.pure-u-xxl-4-5,.pure-u-xxl-5-5,.pure-u-xxl-1-6,.pure-u-xxl-5-6,.pure-u-xxl-1-8,.pure-u-xxl-3-8,.pure-u-xxl-5-8,.pure-u-xxl-7-8,.pure-u-xxl-1-12,.pure-u-xxl-5-12,.pure-u-xxl-7-12,.pure-u-xxl-11-12,.pure-u-xxl-1-24,.pure-u-xxl-2-24,.pure-u-xxl-3-24,.pure-u-xxl-4-24,.pure-u-xxl-5-24,.pure-u-xxl-6-24,.pure-u-xxl-7-24,.pure-u-xxl-8-24,.pure-u-xxl-9-24,.pure-u-xxl-10-24,.pure-u-xxl-11-24,.pure-u-xxl-12-24,.pure-u-xxl-13-24,.pure-u-xxl-14-24,.pure-u-xxl-15-24,.pure-u-xxl-16-24,.pure-u-xxl-17-24,.pure-u-xxl-18-24,.pure-u-xxl-19-24,.pure-u-xxl-20-24,.pure-u-xxl-21-24,.pure-u-xxl-22-24,.pure-u-xxl-23-24,.pure-u-xxl-24-24{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto;display:inline-block}.pure-u-xxl-1-24{width:4.1667%}.pure-u-xxl-1-12,.pure-u-xxl-2-24{width:8.3333%}.pure-u-xxl-1-8,.pure-u-xxl-3-24{width:12.5%}.pure-u-xxl-1-6,.pure-u-xxl-4-24{width:16.6667%}.pure-u-xxl-1-5{width:20%}.pure-u-xxl-5-24{width:20.8333%}.pure-u-xxl-1-4,.pure-u-xxl-6-24{width:25%}.pure-u-xxl-7-24{width:29.1667%}.pure-u-xxl-1-3,.pure-u-xxl-8-24{width:33.3333%}.pure-u-xxl-3-8,.pure-u-xxl-9-24{width:37.5%}.pure-u-xxl-2-5{width:40%}.pure-u-xxl-5-12,.pure-u-xxl-10-24{width:41.6667%}.pure-u-xxl-11-24{width:45.8333%}.pure-u-xxl-1-2,.pure-u-xxl-12-24{width:50%}.pure-u-xxl-13-24{width:54.1667%}.pure-u-xxl-7-12,.pure-u-xxl-14-24{width:58.3333%}.pure-u-xxl-3-5{width:60%}.pure-u-xxl-5-8,.pure-u-xxl-15-24{width:62.5%}.pure-u-xxl-2-3,.pure-u-xxl-16-24{width:66.6667%}.pure-u-xxl-17-24{width:70.8333%}.pure-u-xxl-3-4,.pure-u-xxl-18-24{width:75%}.pure-u-xxl-19-24{width:79.1667%}.pure-u-xxl-4-5{width:80%}.pure-u-xxl-5-6,.pure-u-xxl-20-24{width:83.3333%}.pure-u-xxl-7-8,.pure-u-xxl-21-24{width:87.5%}.pure-u-xxl-11-12,.pure-u-xxl-22-24{width:91.6667%}.pure-u-xxl-23-24{width:95.8333%}.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-5-5,.pure-u-xxl-24-24{width:100%}}.wisski{padding-left:5px}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}.modal-terminal{width:66vw;height:66vh;background-color:#fff;background-clip:padding-box;-webkit-background-clip:padding-box;z-index:1000;border:17vh solid #000c;border-width:17vh 17vw;margin:-17vh -17vw;position:fixed;top:17vh;left:17vw;overflow:auto}.modal-terminal button{z-index:1001;position:fixed;top:17vh;right:17vw}.modal-terminal pre,.modal-terminal button{margin:5px} \ No newline at end of file diff --git a/internal/component/static/dist/control/index.a27ed337.js b/internal/component/static/dist/control/index.a27ed337.js new file mode 100644 index 0000000..9ccf786 --- /dev/null +++ b/internal/component/static/dist/control/index.a27ed337.js @@ -0,0 +1 @@ +function t(t){return t&&t.__esModule?t.default:t}const e=document.getElementsByClassName("remote-action");Array.from(e).forEach((t=>{const e=t.getAttribute("data-action"),n=t.getAttribute("data-param"),r=function(){const e=parseInt(t.getAttribute("data-buffer")??"",10)??0;return isFinite(e)&&e>0?e:0}();t.addEventListener("click",(function(t){t.preventDefault();const i=document.createElement("div");i.className="modal-terminal",document.body.append(i);const s=document.createElement("pre"),a=function(t,e,n){let r=null;const i=[],s=()=>{a.paintedFrames++,t.innerText=i.join("\n"),e.scrollTop=e.scrollHeight,r=null},a=(t,e)=>{if(i.push(t),0!==n&&i.length>n&&i.splice(0,i.length-n),null!==r&&(a.missedFrames++,window.cancelAnimationFrame(r)),e)return s();r=window.requestAnimationFrame(s)};return a.paintedFrames=0,a.missedFrames=0,a}(s,i,r);i.append(s);const o=document.createElement("button");o.className="pure-button",o.append("Close"),o.addEventListener("click",(function(t){t.preventDefault(),i.parentNode?.removeChild(i)}));let u=!1;const c=function(){u||(u=!0,i.append(o))};var d,h;a("Connecting ...",!0),(d=t=>{a("Connected",!0),t.send(e),"string"==typeof n&&t.send(n)},h=t=>{a(t)},new Promise(((t,e)=>{const n=new WebSocket(location.href.replace("http","ws"));n.onclose=t,n.onerror=e,n.onmessage=t=>h(t.data),n.onopen=()=>d(n)}))).then((()=>{a("Connection closed.",!0),c()})).catch((()=>{a("Connection errored.",!0),c()}))}))}));var n={};n=function(){var t=1e3,e=6e4,n=36e5,r="millisecond",i="second",s="minute",a="hour",o="day",u="week",c="month",d="quarter",h="year",f="date",l="Invalid Date",m=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,$=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,p={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_")},g=function(t,e,n){var r=String(t);return!r||r.length>=e?t:""+Array(e+1-r.length).join(n)+t},y={s:g,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+g(r,2,"0")+":"+g(i,2,"0")},m:function t(e,n){if(e.date()1)return t(a[0])}else{var o=e.name;M[o]=e,i=o}return!r&&i&&(v=i),i||!r&&v},S=function(t,e){if(D(t))return t.clone();var n="object"==typeof e?e:{};return n.date=t,n.args=arguments,new O(n)},b=y;b.l=w,b.i=D,b.w=function(t,e){return S(t,{locale:e.$L,utc:e.$u,x:e.$x,$offset:e.$offset})};var O=function(){function p(t){this.$L=w(t.locale,null,!0),this.parse(t)}var g=p.prototype;return g.parse=function(t){this.$d=function(t){var e=t.date,n=t.utc;if(null===e)return new Date(NaN);if(b.u(e))return new Date;if(e instanceof Date)return new Date(e);if("string"==typeof e&&!/Z$/i.test(e)){var r=e.match(m);if(r){var i=r[2]-1||0,s=(r[7]||"0").substring(0,3);return n?new Date(Date.UTC(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)):new Date(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)}}return new Date(e)}(t),this.$x=t.x||{},this.init()},g.init=function(){var t=this.$d;this.$y=t.getFullYear(),this.$M=t.getMonth(),this.$D=t.getDate(),this.$W=t.getDay(),this.$H=t.getHours(),this.$m=t.getMinutes(),this.$s=t.getSeconds(),this.$ms=t.getMilliseconds()},g.$utils=function(){return b},g.isValid=function(){return!(this.$d.toString()===l)},g.isSame=function(t,e){var n=S(t);return this.startOf(e)<=n&&n<=this.endOf(e)},g.isAfter=function(t,e){return S(t)t(n)(e.innerText).format("YYYY-MM-DD HH:mm:ss ([UTC]Z)"),path:t=>{const e=t.innerText.split("/");return e[e.length-1]},pathbuilders:()=>{const t=window.pathbuilders,e=document.createElement("span");let n=!1;if(Object.keys(t).forEach((r=>{n=!0;const s=r+".xml",a=t[r];e.append(i(s,r,a,"application/xml")),e.append(document.createTextNode(" "))})),!n)return"(none)";const r=document.createElement("small");return r.append(document.createTextNode("(click to download)")),e.append(r),e}},i=(t,e,n,r)=>{const i=new Blob([n],{type:r??"text/plain"}),s=document.createElement("a");return s.target="_blank",s.download=t,s.href=URL.createObjectURL(i),s.append(document.createTextNode(e)),s};Object.keys(r).forEach((t=>{const e=r[t];document.querySelectorAll("code."+t).forEach((t=>{const n=e(t);if("string"==typeof n)return t.innerHTML="",void t.appendChild(document.createTextNode(n));t.parentNode.replaceChild(n,t)}))})); \ No newline at end of file diff --git a/internal/component/static/dist/control/index.css b/internal/component/static/dist/control/index.css deleted file mode 100644 index 4e11381..0000000 --- a/internal/component/static/dist/control/index.css +++ /dev/null @@ -1 +0,0 @@ -html{color:#1a1a1a;background-color:#fdfdfd;font-family:Roboto;font-size:20px;line-height:1.5}body{max-width:36em;-webkit-hyphens:auto;hyphens:auto;overflow-wrap:break-word;text-rendering:optimizelegibility;font-kerning:normal;margin:0 auto;padding:50px}@media (max-width:600px){body{padding:1em;font-size:.9em}}h1{margin-top:1.4em}h2,h3{margin-top:1em}code{color:#00f;font-family:Roboto Mono}p{text-align:justify;margin:1em 0}a,a:visited{color:#1a1a1a}footer{text-align:center;border-top:1px solid #1a1a1a;font-size:small}.header-link{opacity:0;font-size:.8em;text-decoration:none;transition:opacity .2s ease-in-out .1s;position:relative;left:.5em}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1}.wisski{padding-left:5px}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}.remote-action-out{font-size:small} \ No newline at end of file diff --git a/internal/component/static/dist/control/index.html b/internal/component/static/dist/control/index.html new file mode 100644 index 0000000..be44d12 --- /dev/null +++ b/internal/component/static/dist/control/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/dist/control/index.js b/internal/component/static/dist/control/index.js deleted file mode 100644 index 7f0a620..0000000 --- a/internal/component/static/dist/control/index.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{const e=e=>{const t=document.getElementsByTagName("h"+e);Array.from(t).forEach((e=>{void 0!==e.id&&""!==e.id&&e.appendChild((e=>{const t=document.createElement("a");return t.className="header-link",t.href="#"+e,t.innerHTML="#",t})(e.id))}))};new Array(6).fill(0).forEach(((t,n)=>e(n+1)));const t={date:e=>new Date(e.innerText).toISOString(),path:e=>{const t=e.innerText.split("/");return t[t.length-1]},pathbuilders:()=>{const e=window.pathbuilders,t=document.createElement("span");let o=!1;if(Object.keys(e).forEach((r=>{o=!0;const a=r+".xml",c=e[r];t.append(n(a,r,c,"application/xml")),t.append(document.createTextNode(" "))})),!o)return"(none)";const r=document.createElement("small");return r.append(document.createTextNode("(click to download)")),t.append(r),t}},n=(e,t,n,o)=>{const r=new Blob([n],{type:o??"text/plain"}),a=document.createElement("a");return a.target="_blank",a.download=e,a.href=URL.createObjectURL(r),a.append(document.createTextNode(t)),a};Object.keys(t).forEach((e=>{const n=t[e];document.querySelectorAll("code."+e).forEach((e=>{const t=n(e);if("string"==typeof t)return e.innerHTML="",void e.appendChild(document.createTextNode(t));e.parentNode.replaceChild(t,e)}))}));const o=document.getElementsByClassName("remote-action");Array.from(o).forEach((e=>{const t=e.getAttribute("data-action"),n=e.getAttribute("data-param"),o=document.querySelector(e.getAttribute("data-target")),r=function(){const t=parseInt(e.getAttribute("data-buffer")??"",10)??0;return isFinite(t)&&t>0?t:0}();let a=!1;e.addEventListener("click",(function(c){if(c.preventDefault(),a)return;a=!0,e.setAttribute("disabled","disabled");const d=function(){e.removeAttribute("disabled"),a=!1};o.innerText="";const i=[],s=function(e){0!==r?(i.push(e),i.length>r&&i.splice(0,i.length-r),o.innerText=i.join("\n")):o.innerText+=e+"\n"};var l,u;s("Connecting ..."),(l=e=>{s("Connected"),e.send(t),"string"==typeof n&&e.send(n)},u=e=>{s(e)},new Promise(((e,t)=>{const n=new WebSocket(location.href.replace("http","ws"));n.onclose=e,n.onerror=t,n.onmessage=e=>u(e.data),n.onopen=()=>l(n)}))).then((()=>{s("Connection closed.\n"),d()})).catch((()=>{s("Connection errored.\n"),d()}))}))}))})(); \ No newline at end of file diff --git a/internal/component/static/dist/control/instance.html b/internal/component/static/dist/control/instance.html new file mode 100644 index 0000000..be44d12 --- /dev/null +++ b/internal/component/static/dist/control/instance.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/dist/home/index.38d394c2.js b/internal/component/static/dist/home/index.38d394c2.js new file mode 100644 index 0000000..e69de29 diff --git a/internal/component/static/dist/home/index.css b/internal/component/static/dist/home/index.css deleted file mode 100644 index 1414376..0000000 --- a/internal/component/static/dist/home/index.css +++ /dev/null @@ -1 +0,0 @@ -html{color:#1a1a1a;background-color:#fdfdfd;font-family:Roboto;font-size:20px;line-height:1.5}body{max-width:36em;-webkit-hyphens:auto;hyphens:auto;overflow-wrap:break-word;text-rendering:optimizelegibility;font-kerning:normal;margin:0 auto;padding:50px}@media (max-width:600px){body{padding:1em;font-size:.9em}}h1{margin-top:1.4em}h2,h3{margin-top:1em}code{color:#00f;font-family:Roboto Mono}p{text-align:justify;margin:1em 0}a,a:visited{color:#1a1a1a}footer{text-align:center;border-top:1px solid #1a1a1a;font-size:small}.header-link{opacity:0;font-size:.8em;text-decoration:none;transition:opacity .2s ease-in-out .1s;position:relative;left:.5em}h2:hover .header-link,h3:hover .header-link,h4:hover .header-link,h5:hover .header-link,h6:hover .header-link{opacity:1} \ No newline at end of file diff --git a/internal/component/static/dist/home/index.html b/internal/component/static/dist/home/index.html new file mode 100644 index 0000000..495b39a --- /dev/null +++ b/internal/component/static/dist/home/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/dist/home/index.js b/internal/component/static/dist/home/index.js deleted file mode 100644 index f4eab71..0000000 --- a/internal/component/static/dist/home/index.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{const e=e=>{const n=document.getElementsByTagName("h"+e);Array.from(n).forEach((e=>{void 0!==e.id&&""!==e.id&&e.appendChild((e=>{const n=document.createElement("a");return n.className="header-link",n.href="#"+e,n.innerHTML="#",n})(e.id))}))};new Array(6).fill(0).forEach(((n,r)=>e(r+1)))})(); \ No newline at end of file diff --git a/internal/component/static/entry.go b/internal/component/static/entry.go new file mode 100644 index 0000000..9645d54 --- /dev/null +++ b/internal/component/static/entry.go @@ -0,0 +1,20 @@ +package static + +import ( + "bytes" + + "github.com/FAU-CDI/wisski-distillery/pkg/resources" +) + +var EntryHome = mustParseResources("dist/home/index.html") +var EntryControlIndex = mustParseResources("dist/control/index.html") +var EntryControlInstance = mustParseResources("dist/control/instance.html") + +// mustParseResources loads the resources from the provided files or panic()s +func mustParseResources(path string) resources.Resources { + data, err := distStaticFS.ReadFile(path) + if err != nil { + panic("mustParseResources: Unable to open " + path) + } + return resources.Parse(bytes.NewReader(data)) +} diff --git a/internal/component/static/package.json b/internal/component/static/package.json index e6a9cd5..abda7fa 100644 --- a/internal/component/static/package.json +++ b/internal/component/static/package.json @@ -1,21 +1,13 @@ { "name": "wisski-distillery-frontend", "version": "1.0.0", - "main": "index.js", "license": "MIT", "private": true, "dependencies": { + "dayjs": "^1.11.5", "parcel": "^2.7.0" }, "scripts": { - "dist": "parcel build --dist-dir dist --target default --public-url /static/ --no-source-maps" - }, - "targets": { - "default": { - "source": [ - "src/control/index.ts", - "src/home/index.ts" - ] - } + "dist": "parcel build --dist-dir dist --public-url /static/ src/entry/*/*.html --no-source-maps" } -} \ No newline at end of file +} diff --git a/internal/component/static/src/base/base.css b/internal/component/static/src/base/base.css deleted file mode 100644 index 0a4e996..0000000 --- a/internal/component/static/src/base/base.css +++ /dev/null @@ -1,55 +0,0 @@ -/* This file is included globally into every frontend page */ -html { - line-height: 1.5; - font-family: Roboto; - font-size: 20px; - color: #1a1a1a; - background-color: #fdfdfd; -} -body { - margin: 0 auto; - max-width: 36em; - padding-left: 50px; - padding-right: 50px; - padding-top: 50px; - padding-bottom: 50px; - hyphens: auto; - overflow-wrap: break-word; - text-rendering: optimizeLegibility; - font-kerning: normal; -} - -@media (max-width: 600px) { - body { - font-size: 0.9em; - padding: 1em; - } -} - -h1 { - margin-top: 1.4em; -} - -h2,h3 { - margin-top: 1em; -} - -code { - font-family: Roboto Mono; - color: blue; -} - -p { - margin: 1em 0; - text-align: justify; -} - -a, a:visited { - color: #1a1a1a; -} - -footer { - border-top: 1px solid #1a1a1a; - font-size: small; - text-align: center; -} diff --git a/internal/component/static/src/base/base.ts b/internal/component/static/src/base/base.ts deleted file mode 100644 index b18e38f..0000000 --- a/internal/component/static/src/base/base.ts +++ /dev/null @@ -1 +0,0 @@ -// This file is included globally into every distillery frontend page diff --git a/internal/component/static/src/base/index.css b/internal/component/static/src/base/index.css new file mode 100644 index 0000000..3a2aeca --- /dev/null +++ b/internal/component/static/src/base/index.css @@ -0,0 +1,34 @@ +body { + font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} +header, main, footer { + margin: 2em; +} + +.padding { + padding: 1em; +} + +.overflow { + overflow-x:auto; +} + +.overflow table { + width: 100%; +} + +.overflow table td:not(:last-child), +.overflow table th:not(:last-child) { + width:1px; + text-align:left; + white-space: nowrap; +} +.overflow table td:last-child, +.overflow table th:last-child { + white-space: nowrap; +} + +.hspace { + display: block; + height: 2em; +} \ No newline at end of file diff --git a/internal/component/static/src/base/index.ts b/internal/component/static/src/base/index.ts new file mode 100644 index 0000000..d5adf19 --- /dev/null +++ b/internal/component/static/src/base/index.ts @@ -0,0 +1,4 @@ +import "purecss/build/pure.css" +import "purecss/build/grids-responsive.css" + +import "./index.css" diff --git a/internal/component/static/src/control/index.ts b/internal/component/static/src/control/index.ts deleted file mode 100644 index 2f1489f..0000000 --- a/internal/component/static/src/control/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import '../global.ts'; -import './index.css'; - -import './highlight.ts'; -import './remote.ts'; diff --git a/internal/component/static/src/control/remote.ts b/internal/component/static/src/control/remote.ts deleted file mode 100644 index 7200eb8..0000000 --- a/internal/component/static/src/control/remote.ts +++ /dev/null @@ -1,62 +0,0 @@ -import connectSocket from '../socket/socket'; - -const elements = document.getElementsByClassName('remote-action') -Array.from(elements).forEach((element) => { - const action = element.getAttribute('data-action') as string; - const param = element.getAttribute('data-param') as string | undefined; - const target = document.querySelector(element.getAttribute('data-target')!) as HTMLElement; - const bufferSize = (function() { - const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0; - return (isFinite(number) && number > 0) ? number : 0; - })() - - let running = false - element.addEventListener('click', function(ev) { - ev.preventDefault(); - - // already running - if (running) return - - running = true - element.setAttribute('disabled', 'disabled'); - const close = function() { - element.removeAttribute('disabled'); - running = false; - } - - target.innerText = ""; - - const buffer: Array = []; - const println = function(line: string) { - if(bufferSize === 0) { - target.innerText += line + "\n"; - return; - } - - buffer.push(line); - if(buffer.length > bufferSize) { - buffer.splice(0, buffer.length - bufferSize) - } - target.innerText = buffer.join("\n"); - } - - println("Connecting ...") - - // connect to the socket and send the action - connectSocket((socket) => { - println("Connected") - socket.send(action); - if (typeof param === 'string') { - socket.send(param); - } - }, (data) => { - println(data); - }).then(() => { - println("Connection closed.\n") - close(); - }).catch(() => { - println("Connection errored.\n") - close(); - }); - }); -}) \ No newline at end of file diff --git a/internal/component/static/src/control/index.css b/internal/component/static/src/entry/control/index.css similarity index 65% rename from internal/component/static/src/control/index.css rename to internal/component/static/src/entry/control/index.css index 0de8706..a9cf000 100644 --- a/internal/component/static/src/control/index.css +++ b/internal/component/static/src/entry/control/index.css @@ -1,5 +1,3 @@ -@import '../global.css'; - .wisski { padding-left: 5px; } @@ -11,7 +9,3 @@ .wisski.stopped { background-color: #ff7a7a; } - -.remote-action-out { - font-size: small; -} diff --git a/internal/component/static/src/entry/control/index.html b/internal/component/static/src/entry/control/index.html new file mode 100644 index 0000000..8dbb4d7 --- /dev/null +++ b/internal/component/static/src/entry/control/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/src/entry/control/index.ts b/internal/component/static/src/entry/control/index.ts new file mode 100644 index 0000000..8ac8f51 --- /dev/null +++ b/internal/component/static/src/entry/control/index.ts @@ -0,0 +1,5 @@ +import "~/src/base/index" + +import "./index.css" +import "~/src/lib/remote" +import "~/src/lib/highlight" diff --git a/internal/component/static/src/entry/control/instance.html b/internal/component/static/src/entry/control/instance.html new file mode 100644 index 0000000..8dbb4d7 --- /dev/null +++ b/internal/component/static/src/entry/control/instance.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/src/entry/home/index.html b/internal/component/static/src/entry/home/index.html new file mode 100644 index 0000000..8dbb4d7 --- /dev/null +++ b/internal/component/static/src/entry/home/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/internal/component/static/src/entry/home/index.ts b/internal/component/static/src/entry/home/index.ts new file mode 100644 index 0000000..0b03dd1 --- /dev/null +++ b/internal/component/static/src/entry/home/index.ts @@ -0,0 +1 @@ +import "~/src/base/index" \ No newline at end of file diff --git a/internal/component/static/src/global.css b/internal/component/static/src/global.css deleted file mode 100644 index d50ae33..0000000 --- a/internal/component/static/src/global.css +++ /dev/null @@ -1,2 +0,0 @@ -@import './base/base.css'; -@import './autolink/autolink.css'; \ No newline at end of file diff --git a/internal/component/static/src/global.ts b/internal/component/static/src/global.ts deleted file mode 100644 index f04e927..0000000 --- a/internal/component/static/src/global.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './base/base.ts'; -import './autolink/autolink.ts'; diff --git a/internal/component/static/src/home/index.css b/internal/component/static/src/home/index.css deleted file mode 100644 index be8cbcd..0000000 --- a/internal/component/static/src/home/index.css +++ /dev/null @@ -1 +0,0 @@ -@import '../global.css'; diff --git a/internal/component/static/src/home/index.ts b/internal/component/static/src/home/index.ts deleted file mode 100644 index c040c1f..0000000 --- a/internal/component/static/src/home/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import '../global.ts'; -import './index.css'; \ No newline at end of file diff --git a/internal/component/static/src/autolink/autolink.css b/internal/component/static/src/lib/autolink/index.css similarity index 100% rename from internal/component/static/src/autolink/autolink.css rename to internal/component/static/src/lib/autolink/index.css diff --git a/internal/component/static/src/autolink/autolink.ts b/internal/component/static/src/lib/autolink/index.ts similarity index 96% rename from internal/component/static/src/autolink/autolink.ts rename to internal/component/static/src/lib/autolink/index.ts index 54885b6..d8f016d 100644 --- a/internal/component/static/src/autolink/autolink.ts +++ b/internal/component/static/src/lib/autolink/index.ts @@ -1,3 +1,5 @@ +import "./index.css" + /** Adapted from http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */ const anchorForId = (id) => { const anchor = document.createElement("a") diff --git a/internal/component/static/src/control/highlight.ts b/internal/component/static/src/lib/highlight/index.ts similarity index 94% rename from internal/component/static/src/control/highlight.ts rename to internal/component/static/src/lib/highlight/index.ts index 5c16d9c..aa7c40d 100644 --- a/internal/component/static/src/control/highlight.ts +++ b/internal/component/static/src/lib/highlight/index.ts @@ -1,7 +1,7 @@ - +import dayjs from "dayjs" const types: Record HTMLElement | string> = { "date": (element) => { - return (new Date(element.innerText)).toISOString() + return dayjs(element.innerText).format('YYYY-MM-DD HH:mm:ss ([UTC]Z)') }, "path": (element) => { const text = element.innerText.split("/"); diff --git a/internal/component/static/src/lib/remote/index.css b/internal/component/static/src/lib/remote/index.css new file mode 100644 index 0000000..9a08e26 --- /dev/null +++ b/internal/component/static/src/lib/remote/index.css @@ -0,0 +1,42 @@ +.modal-terminal { + width: 66vw; + height: 66vh; + + position: fixed; + left: 17vw; + top: 17vh; + + background-color: white; + + background-clip: padding-box; + -webkit-background-clip: padding-box; + + border-left: 17vw solid rgba(0, 0, 0, 0.8); + border-right: 17vw solid rgba(0, 0, 0, 0.8); + margin-left: -17vw; + margin-right: -17vw; + + border-top: 17vh solid rgba(0, 0, 0, 0.8); + border-bottom: 17vh solid rgba(0, 0, 0, 0.8); + margin-top: -17vh; + margin-bottom: -17vh; + + overflow: auto; + + z-index: 1000; +} + +.modal-terminal button { + position: fixed; + top: 17vh; + right: 17vw; + + z-index: 1001; +} + +.modal-terminal pre, +.modal-terminal button +{ + margin: 5px; +} + diff --git a/internal/component/static/src/lib/remote/index.ts b/internal/component/static/src/lib/remote/index.ts new file mode 100644 index 0000000..e2ad23d --- /dev/null +++ b/internal/component/static/src/lib/remote/index.ts @@ -0,0 +1,112 @@ +import "./index.css" +import connectSocket from './socket'; + +type Println = ((line: string, flush?: boolean) => void) & { + paintedFrames: number; + missedFrames: number; +} + +/** + * makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback. + * scrollContainer is used to scroll on every painted update. + */ +function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println { + let lastAnimationFrame: number | null = null; // last scheduled animation frame + + const buffer: Array = []; // the internal buffer of lines + const paint = () => { + println.paintedFrames++ + target.innerText = buffer.join("\n") + scrollContainer.scrollTop = scrollContainer.scrollHeight + lastAnimationFrame = null + } + + const println = (line: string, flush?: boolean) => { + // add the line + buffer.push(line) + if (size !== 0 && buffer.length > size) { + buffer.splice(0, buffer.length - size) + } + + // and update the browser in the next animation frame + if (lastAnimationFrame !== null) { + println.missedFrames++ + window.cancelAnimationFrame(lastAnimationFrame) + } + + // force a repaint! + if(flush) return paint(); + + // schedule an animation frame + lastAnimationFrame = window.requestAnimationFrame(paint); + } + println.paintedFrames = 0; + println.missedFrames = 0; + + return println; +} + +const elements = document.getElementsByClassName('remote-action') +Array.from(elements).forEach((element) => { + const action = element.getAttribute('data-action') as string; + const param = element.getAttribute('data-param') as string | undefined; + const bufferSize = (function () { + const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0; + return (isFinite(number) && number > 0) ? number : 0; + })() + + element.addEventListener('click', function (ev) { + ev.preventDefault(); + + // create a modal dialog and append it to the body + const modal = document.createElement("div") + modal.className = "modal-terminal" + document.body.append(modal) + + // create a
 to write stuff into
+        const target = document.createElement("pre")
+        const println = makeTextBuffer(target, modal, bufferSize)
+        modal.append(target)
+
+        
+        // create a button to eventually close everything
+        const button = document.createElement("button")
+        button.className = "pure-button"
+        button.append("Close")
+        button.addEventListener('click', function (event) {
+            event.preventDefault();
+            modal.parentNode?.removeChild(modal);
+        })
+
+        // when closing, add a button to the modal!
+        let didClose = false
+        const close = function () {
+            if (didClose) return
+            didClose = true
+
+            modal.append(button)
+            // DEBUG: print terminal stats!
+            // const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
+            // println(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
+        }
+
+        println("Connecting ...", true)
+
+        // connect to the socket and send the action
+        connectSocket((socket) => {
+            println("Connected", true)
+            socket.send(action);
+            if (typeof param === 'string') {
+                socket.send(param);
+            }
+        }, (data) => {
+            println(data);
+        }).then(() => {
+            println("Connection closed.", true)
+            close();
+        }).catch(() => {
+            println("Connection errored.", true)
+            close();
+        });
+    });
+})
\ No newline at end of file
diff --git a/internal/component/static/src/socket/socket.ts b/internal/component/static/src/lib/remote/socket.ts
similarity index 100%
rename from internal/component/static/src/socket/socket.ts
rename to internal/component/static/src/lib/remote/socket.ts
diff --git a/internal/component/static/static.go b/internal/component/static/static.go
index 676069b..08637a6 100644
--- a/internal/component/static/static.go
+++ b/internal/component/static/static.go
@@ -6,8 +6,10 @@ import (
 	"embed"
 	"io/fs"
 	"net/http"
+	"strings"
 
 	"github.com/FAU-CDI/wisski-distillery/internal/component"
+	"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
 	"github.com/tkw1536/goprogram/stream"
 )
 
@@ -25,6 +27,13 @@ func (static *Static) Handler(route string, context context.Context, io stream.I
 		return nil, err
 	}
 
+	// censor *.html in the filesystem
+	fs = fsx.Censor(fs, func(path string) bool {
+		suffix := "html"
+		return len(path) >= len(suffix) && strings.EqualFold(path[len(path)-len(suffix):], suffix)
+	})
+
+	// and serve it
 	return http.StripPrefix(route, http.FileServer(http.FS(fs))), nil
 }
 
diff --git a/internal/component/static/tsconfig.json b/internal/component/static/tsconfig.json
new file mode 100644
index 0000000..b19b767
--- /dev/null
+++ b/internal/component/static/tsconfig.json
@@ -0,0 +1,105 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig to read more about this file */
+
+    /* Projects */
+    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
+    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
+    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
+    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
+    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
+
+    /* Language and Environment */
+    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
+    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
+    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
+    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
+    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
+    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
+
+    /* Modules */
+    "module": "commonjs",                                /* Specify what module code is generated. */
+    // "rootDir": "./",                                  /* Specify the root folder within your source files. */
+    // "moduleResolution": "node",                       /* Specify how TypeScript looks up a file from a given module specifier. */
+    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
+    "paths": {
+      "~/*": ["./*"],
+    },                                      /* Specify a set of entries that re-map imports to additional lookup locations. */
+    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
+    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
+    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
+    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
+    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
+    // "resolveJsonModule": true,                        /* Enable importing .json files. */
+    // "noResolve": true,                                /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+    /* JavaScript Support */
+    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
+    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+    /* Emit */
+    // "declaration": true,                              /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+    // "declarationMap": true,                           /* Create sourcemaps for d.ts files. */
+    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
+    // "sourceMap": true,                                /* Create source map files for emitted JavaScript files. */
+    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
+    // "removeComments": true,                           /* Disable emitting comments. */
+    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
+    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */
+    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
+    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
+    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
+    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
+    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
+    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
+    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
+    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
+    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
+
+    /* Interop Constraints */
+    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
+    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
+    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
+
+    /* Type Checking */
+    "strict": true,                                      /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
+    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
+    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
+    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
+    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
+    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
+    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
+    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
+    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
+    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
+    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
+    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
+    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
+    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
+    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
+
+    /* Completeness */
+    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
+    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
+  }
+}
diff --git a/internal/component/static/yarn.lock b/internal/component/static/yarn.lock
index 8def55b..a7554b5 100644
--- a/internal/component/static/yarn.lock
+++ b/internal/component/static/yarn.lock
@@ -891,6 +891,11 @@ csso@^4.2.0:
   dependencies:
     css-tree "^1.1.2"
 
+dayjs@^1.11.5:
+  version "1.11.5"
+  resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93"
+  integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==
+
 detect-libc@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6fb5314
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "purecss": "^2.1.0"
+  }
+}
diff --git a/pkg/fsx/filter.go b/pkg/fsx/filter.go
new file mode 100644
index 0000000..3e7494f
--- /dev/null
+++ b/pkg/fsx/filter.go
@@ -0,0 +1,94 @@
+package fsx
+
+import (
+	"io/fs"
+	"path/filepath"
+
+	"github.com/tkw1536/goprogram/lib/collection"
+)
+
+// Censor returns a new filesystem censors files for which the censor function returns true.
+//
+// A censored file cannot be opened by the filesystem and return [fs.ErrNotExist].
+// Hard and Soft Links pointing to the file might still read it.
+func Censor(fsys fs.FS, censor func(name string) bool) fs.FS {
+	return &censorFS{
+		fsys:   fsys,
+		censor: censor,
+	}
+}
+
+type censorFS struct {
+	censor func(path string) bool
+	fsys   fs.FS
+}
+
+func (cf *censorFS) Sub(path string) (fs.FS, error) {
+	sub, err := fs.Sub(cf.fsys, path)
+	if err != nil {
+		return nil, err
+	}
+	return &censorFS{
+		censor: func(name string) bool {
+			return cf.censor(filepath.Join(path, name))
+		},
+		fsys: sub,
+	}, nil
+}
+
+func (ef *censorFS) Open(name string) (fs.File, error) {
+	if ef.censor(name) {
+		return nil, fs.ErrNotExist
+	}
+
+	file, err := ef.fsys.Open(name)
+
+	// we need to also censor the ReadDir function of the returned file
+	// this is to prevent the file from appearing in directory listings.
+	if rdf, ok := file.(fs.ReadDirFile); ok {
+		return &censorFSFile{
+			ReadDirFile: rdf,
+
+			name:   name,
+			censor: ef,
+		}, err
+	}
+	return file, err
+}
+
+type censorFSFile struct {
+	fs.ReadDirFile
+
+	name   string
+	censor *censorFS
+}
+
+func (f *censorFSFile) ReadDir(n int) ([]fs.DirEntry, error) {
+	entries, err := f.ReadDirFile.ReadDir(n)
+	return f.censor.handleReadDir(f.name, entries, err)
+}
+
+func (ef *censorFS) ReadDir(name string) ([]fs.DirEntry, error) {
+	if ef.censor(name) {
+		return nil, fs.ErrNotExist
+	}
+
+	// censor ReadDir() entries too
+	entries, err := fs.ReadDir(ef.fsys, name)
+	return ef.handleReadDir(name, entries, err)
+}
+
+// handleReadDir censors a ReadDir call
+func (ef *censorFS) handleReadDir(base string, entries []fs.DirEntry, err error) ([]fs.DirEntry, error) {
+	entries = collection.Filter(entries, func(entry fs.DirEntry) bool {
+		return !ef.censor(filepath.Join(base, entry.Name()))
+	})
+	return entries, err
+}
+
+func (ef *censorFS) ReadFile(name string) ([]byte, error) {
+	if ef.censor(name) {
+		return nil, fs.ErrNotExist
+	}
+	return fs.ReadFile(ef.fsys, name)
+}
diff --git a/pkg/resources/resources.go b/pkg/resources/resources.go
new file mode 100644
index 0000000..fa58c1a
--- /dev/null
+++ b/pkg/resources/resources.go
@@ -0,0 +1,113 @@
+// Package resources provides Resources
+package resources
+
+import (
+	"html/template"
+	"io"
+	"strings"
+
+	"github.com/tkw1536/goprogram/lib/collection"
+	"golang.org/x/net/html"
+)
+
+// Resources represents resources found inside a "html" file
+type Resources struct {
+	JSModules []string // ")
+
+// WriteCSS writes all link tags to writer
+func (resources *Resources) WriteCSS(writer io.Writer) {
+	for _, href := range resources.CSS {
+		writer.Write(openLinkBytes)
+		writer.Write([]byte(attributeValue(href)))
+		writer.Write(closeLinkBytes)
+	}
+}
+
+func (resources *Resources) CSSTemplate() template.HTML {
+	var buffer strings.Builder
+	resources.WriteCSS(&buffer)
+	return template.HTML(buffer.String())
+}
+
+// WriteJS writes all JavaScript tags to writer
+func (resources *Resources) WriteJS(writer io.Writer) {
+	for _, href := range resources.JSModules {
+		writer.Write(openModuleBytes)
+		writer.Write([]byte(attributeValue(href)))
+		writer.Write(closeScriptBytes)
+	}
+	for _, href := range resources.JSRegular {
+		writer.Write(openRegularBytes)
+		writer.Write([]byte(attributeValue(href)))
+		writer.Write(closeScriptBytes)
+	}
+}
+
+func (resources *Resources) JSTemplate() template.HTML {
+	var buffer strings.Builder
+	resources.WriteJS(&buffer)
+	return template.HTML(buffer.String())
+}
+
+// Parse parses resources from reader
+func Parse(r io.Reader) (src Resources) {
+	z := html.NewTokenizer(r)
+	for {
+		// read the next token
+		z.Next()
+		token := z.Token()
+
+		switch {
+		case token.Type == html.ErrorToken:
+			return
+
+		case token.Type == html.StartTagToken && token.Data == "script":
+			// 
+			
+			
+			
+		
+	
+	`))
+
+	var builder strings.Builder
+	builder.WriteString("css: ")
+	resources.WriteCSS(&builder)
+
+	builder.WriteString("\njs: ")
+	resources.WriteJS(&builder)
+	fmt.Println(builder.String())
+
+	// Output: css: 
+	// js: