commit d7a964c186d980f83776a4fffbe61d543d946977 Author: mappu Date: Sat May 2 14:16:49 2020 +1200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2751ada --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Local config +config.toml + +# Binary artefacts +teafolio diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0921f0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# Dockerfile for production Teafolio deployments + +FROM golang:1.14-alpine AS builder + +WORKDIR /app +COPY . . +RUN go build -ldflags "-s -w" && chmod +x teafolio + +FROM alpine:latest + +WORKDIR /app +COPY --from=builder /app/teafolio /app/teafolio +COPY /static /app/static + +ENTRYPOINT [ "/app/teafolio" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..721f414 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# teafolio + +Teafolio is a web-based portfolio frontend for a Gitea server. + +Compared to the earlier [codesite](https://code.ivysaur.me/codesite/) project, the repository list and detailed information is loaded live from a Gitea server. + +Written in Go + +## Usage + +1. Compile the binary: `go build` +2. Modify the sample `config.toml` file to point to your Gitea instance + - `teafolio` will look for `config.toml` in the current directory, or, you can supply a custom path with `-ConfigFile` +3. Deploy binary + `static/` directory to webserver + +### Production (Docker) + +1. `docker build -t teafolio:latest .` +2. `docker run --restart=always -d -p 5656:5656 -v $(pwd)/config.toml:/app/config.toml teafolio:latest` diff --git a/api.go b/api.go new file mode 100644 index 0000000..e6e35cd --- /dev/null +++ b/api.go @@ -0,0 +1,204 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" +) + +type Repo struct { + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ContentsResponse struct { + Content []byte `json:"content"` // Assume base64 "encoding" parameter in Gitea response, and use Go's auto decode +} + +type TopicsResponse struct { + Topics []string `json:"topics"` +} + +type ReaddirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int `json:"size"` + RawURL string `json:"download_url"` +} + +func (rde ReaddirEntry) isImage() bool { + return strings.HasSuffix(rde.Name, `.png`) || strings.HasSuffix(rde.Name, `.jpg`) || strings.HasSuffix(rde.Name, `.jpeg`) +} + +type MarkdownRequest struct { + Context string + Mode string + Text string + Wiki bool +} + +// repos gets a list of Git repositories in this organisation. +func (this *Application) repos() ([]Repo, error) { + resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/orgs/` + url.PathEscape(this.cfg.Gitea.Org) + `/repos`) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var repos []Repo + err = json.NewDecoder(resp.Body).Decode(&repos) + if err != nil { + return nil, err + } + + return repos, nil +} + +// repoFile gets a single file from the default branch of the git repository +// Usually the default branch is `master`. +func (this *Application) repoFile(repo, filename string) ([]byte, error) { + resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + url.PathEscape(filename)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var cr ContentsResponse + err = json.NewDecoder(resp.Body).Decode(&cr) + if err != nil { + return nil, err + } + + return cr.Content, nil +} + +func (this *Application) filesInDirectory(repo, dir string) ([]ReaddirEntry, error) { + resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/contents/` + dir) // n.b. $dir param not escaped + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + + // "No files found" happens with a HTTP 500/404 error depending on Gitea version. Catch this special case + if resp.StatusCode == 500 || resp.StatusCode == 404 { + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if strings.Contains(string(b), `does not exist`) { + return []ReaddirEntry{}, nil // no files found + } + } + + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var ret []ReaddirEntry + err = json.NewDecoder(resp.Body).Decode(&ret) + if err != nil { + return nil, err + } + + return ret, nil +} + +// imageFilesForRepo finds documentation images for the repository. +// It searches the dist/ and doc/ subdirectories. +func (this *Application) imageFilesForRepo(repo string) ([]ReaddirEntry, error) { + + ret := []ReaddirEntry{} + + for _, dirName := range []string{`dist`, `doc`} { + + files, err := this.filesInDirectory(repo, dirName) + if err != nil { + return nil, fmt.Errorf("readdir(%s): %w", dirName, err) + } + + for _, f := range files { + if f.isImage() { + ret = append(ret, f) + } + } + } + + return ret, nil +} + +func (this *Application) topicsForRepo(repo string) ([]string, error) { + resp, err := http.Get(this.cfg.Gitea.URL + `api/v1/repos/` + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repo) + `/topics`) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + var tr TopicsResponse + err = json.NewDecoder(resp.Body).Decode(&tr) + if err != nil { + return nil, err + } + + return tr.Topics, nil +} + +// renderMarkdown calls the remote Gitea server's own markdown renderer. +func (this *Application) renderMarkdown(repoName string, body string) ([]byte, error) { + req := MarkdownRequest{ + Context: this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + `/src/branch/master`, + Mode: "gfm", // magic constant - Github Flavoured Markdown + Text: body, + } + + jb, err := json.Marshal(req) + if err != nil { + return nil, err + } + + resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/`, `application/json`, bytes.NewReader(jb)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return ioutil.ReadAll(resp.Body) +} + +// renderMarkdownRaw calls the remote Gitea server's own markdown renderer. +func (this *Application) renderMarkdownRaw(body []byte) ([]byte, error) { + resp, err := http.Post(this.cfg.Gitea.URL+`api/v1/markdown/raw`, `text/plain`, bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return ioutil.ReadAll(resp.Body) +} diff --git a/config.toml.sample b/config.toml.sample new file mode 100644 index 0000000..d67150a --- /dev/null +++ b/config.toml.sample @@ -0,0 +1,16 @@ +# teafolio config file + +BindTo="0.0.0.0:5656" + +[Gitea] +URL="https://gitea.com/" +Org="gitea" + +[Template] +AppName = "Teafolio" + +HomepageHeaderHTML=""" +

+ Teafolio is a web-based portfolio frontend for a Gitea server. +

+""" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21f55e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module teafolio + +go 1.13 + +require github.com/BurntSushi/toml v0.3.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9cb2df8 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3c5437d --- /dev/null +++ b/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "encoding/base64" + "flag" + "fmt" + "html" + "log" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/BurntSushi/toml" +) + +type Config struct { + BindTo string + Gitea struct { + URL, Org string + } + Template struct { + AppName string + HomepageHeaderHTML string + CustomLogoPngBase64 string + } +} + +type Application struct { + cfg Config + + rxRepoPage, rxRepoImage *regexp.Regexp +} + +func (this *Application) Templatepage(w http.ResponseWriter, r *http.Request, pageDesc, extraHead string, cb func()) { + + pageTitle := this.cfg.Template.AppName + if pageDesc != "" { + pageTitle = pageDesc + ` | ` + pageTitle + } + + w.Header().Set(`Content-Type`, `text/html; charset=UTF-8`) + w.WriteHeader(200) + fmt.Fprint(w, ` + + + + + + + `+html.EscapeString(pageTitle)+` + `+extraHead+` + + + + + +
+
+

`+html.EscapeString(this.cfg.Template.AppName)+`

+ `) + cb() + fmt.Fprint(w, ` + + + +`) + +} + +func (this *Application) internalError(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("%s %s: %s", r.Method, r.URL.Path, err) + http.Error(w, "An internal error occurred.", 500) +} + +func (this *Application) Homepage(w http.ResponseWriter, r *http.Request) { + repos, err := this.repos() + if err != nil { + this.internalError(w, r, fmt.Errorf("listing repos: %w", err)) + return + } + + topics := make(map[string][]string) + for _, repo := range repos { + if t, err := this.topicsForRepo(repo.Name); err == nil { + topics[repo.Name] = t + } + } + + // Ready for template + + this.Templatepage(w, r, "", "", func() { + fmt.Fprint(w, ` + `+this.cfg.Template.HomepageHeaderHTML+` +

Projects

+ +`) + for _, repo := range repos { + pageHref := html.EscapeString(`/` + url.PathEscape(repo.Name)) + + normalisedDesc := repo.Description + normalisedDesc = strings.TrimRight(repo.Description, `.`) + if len(normalisedDesc) > 0 { + // Lowercase the first letter of the description, unless it starts with an acronym (all letters uppercase first word) or CamelCase word + firstWord := strings.SplitN(normalisedDesc, " ", 2)[0] + isAcronymOrCamelCase := len(firstWord) > 1 && (firstWord[1:] != strings.ToLower(firstWord[1:])) + + if !(isAcronymOrCamelCase || firstWord == `Go`) { + normalisedDesc = strings.ToLower(normalisedDesc[0:1]) + normalisedDesc[1:] + } + + // Add leading `` to separate from the repo title + normalisedDesc = `, ` + normalisedDesc + } + + rowClass := "" + for _, topic := range topics[repo.Name] { + rowClass += `taggedWith-` + topic + ` ` + } + + fmt.Fprint(w, ` + + + + + `) + } + fmt.Fprint(w, ` +
+ + + `+html.EscapeString(repo.Name)+``+html.EscapeString(normalisedDesc)+` + more... +
+ + `) + for _, topic := range topics[repo.Name] { + fmt.Fprint(w, ``+html.EscapeString(topic)+` `) + } + fmt.Fprint(w, ` + +
+ `) + }) +} + +func (this *Application) Bannerpage(w http.ResponseWriter, r *http.Request, repoName string) { + + images, err := this.imageFilesForRepo(repoName) + if err != nil { + this.internalError(w, r, fmt.Errorf("listing images: %w", err)) + return + } + + if len(images) == 0 { + w.Header().Set(`Location`, `/static/no_image.png`) + w.WriteHeader(301) + return + } + + w.Header().Set(`Location`, images[0].RawURL) + w.WriteHeader(301) +} + +func (this *Application) Repopage(w http.ResponseWriter, r *http.Request, repoName string) { + + repoURL := this.cfg.Gitea.URL + url.PathEscape(this.cfg.Gitea.Org) + `/` + url.PathEscape(repoName) + extraHead := "" + + readme, err := this.repoFile(repoName, `README.md`) + if err != nil { + this.internalError(w, r, fmt.Errorf("loading README.md: %w", err)) + return + } + + lines := strings.Split(string(readme), "\n") + + // We add some extra badges based on special text entries + extraBadgesMd := ` ![](https://img.shields.io/badge/build-success-brightgreen)` + extraBadgesMd += ` [![](https://img.shields.io/badge/vcs-git-green?logo=git)](` + repoURL + `)` + + // Convert [`Written in LANG` "\n"] to badge + // This was special syntax used by codesite + writtenInPrefix := `Written in ` + for i, line := range lines { + if strings.HasPrefix(line, writtenInPrefix) { + extraBadgesMd += ` ![](https://img.shields.io/badge/written%20in-` + url.QueryEscape(line[len(writtenInPrefix):]) + `-blue)` + lines = append(lines[0:i], lines[i+1:]...) + break + } + } + + // Inject more badges to 3rd line; or, create badges on 3rd line if there are none already + if len(lines) >= 3 && strings.Contains(lines[2], `shields.io`) { + lines[2] += ` ` + extraBadgesMd + } else { + // Push other lines down + lines = append([]string{lines[0], lines[1], extraBadgesMd, ""}, lines[2:]...) + } + + readmeHtml, err := this.renderMarkdown(repoName, strings.Join(lines, "\n")) + if err != nil { + this.internalError(w, r, fmt.Errorf("rendering markdown: %w", err)) + return + } + + images, err := this.imageFilesForRepo(repoName) + if err != nil { + this.internalError(w, r, fmt.Errorf("listing images: %w", err)) + return + } + + // If the Git repository contains a top-level go.mod file, allow vanity imports + if goMod, err := this.repoFile(repoName, `go.mod`); err == nil { + + // Check the first line should be `module MODULENAME\n` + firstLine := bytes.SplitN(goMod, []byte("\n"), 2)[0] + if bytes.HasPrefix(firstLine, []byte("module ")) { + moduleName := firstLine[7:] + extraHead = `` + } + } + + // De-escalate all headers in rendered markdown to match our style + repl := strings.NewReplacer(``, ``, ``, ``, ``, ``) + + // Ready for template + + this.Templatepage(w, r, repoName, extraHead, func() { + + projBodyclass := `projbody` + if len(images) > 0 { + projBodyclass += ` projbody_halfw` + } + + fmt.Fprint(w, `
`) + repl.WriteString(w, string(readmeHtml)) + fmt.Fprint(w, `
`) + + if len(images) > 0 { + fmt.Fprint(w, `
`) + for _, img := range images { + fmt.Fprint(w, ``) + } + fmt.Fprint(w, `
`) + } + + fmt.Fprint(w, `
`) + fmt.Fprint(w, `
`) // projbody + }) +} + +func (this *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == `GET` { + if r.URL.Path == `/` { + this.Homepage(w, r) + + } else if r.URL.Path == `/favicon.ico` { + w.Header().Set(`Location`, `/static/logo.png`) + w.WriteHeader(301) + + } else if r.URL.Path == `/robots.txt` { + http.Error(w, "not found", 404) + + } else if parts := this.rxRepoImage.FindStringSubmatch(r.URL.Path); parts != nil { + this.Bannerpage(w, r, parts[1]) + + } else if parts := this.rxRepoPage.FindStringSubmatch(r.URL.Path); parts != nil { + this.Repopage(w, r, parts[1]) + + } else if r.URL.Path == `/static/logo.png` { + if this.cfg.Template.CustomLogoPngBase64 != "" { + + logoPng, err := base64.StdEncoding.DecodeString(this.cfg.Template.CustomLogoPngBase64) + if err != nil { + this.internalError(w, r, fmt.Errorf("parsing base64 logo: %w", err)) + return + } + + w.Header().Set(`Content-Length`, fmt.Sprintf("%d", len(logoPng))) + w.Header().Set(`Content-Type`, `image/png`) + w.WriteHeader(200) + w.Write(logoPng) + + } else { + r.URL.Path = r.URL.Path[8:] + http.FileServer(http.Dir(`static`)).ServeHTTP(w, r) + + } + + } else if strings.HasPrefix(r.URL.Path, `/static/`) { + r.URL.Path = r.URL.Path[8:] + http.FileServer(http.Dir(`static`)).ServeHTTP(w, r) + + } else { + http.Error(w, "not found", 404) + } + + } else { + http.Error(w, "invalid method", 400) + } + +} + +func main() { + app := Application{ + rxRepoPage: regexp.MustCompile(`^/([^/]+)/?$`), + rxRepoImage: regexp.MustCompile(`^/:banner/([^/]+)/?$`), + } + + configFile := flag.String(`ConfigFile`, `config.toml`, `Configuration file in TOML format`) + flag.Parse() + + _, err := toml.DecodeFile(*configFile, &app.cfg) + if err != nil { + panic(err) + } + + // Assert Gitea URL always has trailing slash + if !strings.HasSuffix(app.cfg.Gitea.URL, `/`) { + app.cfg.Gitea.URL += `/` + } + + log.Fatal(http.ListenAndServe(app.cfg.BindTo, &app)) +} diff --git a/static/greyzz.png b/static/greyzz.png new file mode 100644 index 0000000..9c91922 Binary files /dev/null and b/static/greyzz.png differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..3cddad4 Binary files /dev/null and b/static/logo.png differ diff --git a/static/no_image.png b/static/no_image.png new file mode 100644 index 0000000..504b7e0 Binary files /dev/null and b/static/no_image.png differ diff --git a/static/site.js b/static/site.js new file mode 100644 index 0000000..8864376 --- /dev/null +++ b/static/site.js @@ -0,0 +1,83 @@ +(function() { + "use strict"; + + // + // Tag support + // + + var show_all = function() { + var tr = document.querySelectorAll(".projtable tr"); + for (var i = 0, e = tr.length; i !== e; ++i) { + tr[i].style.display = "table-row"; + } + + var warn = document.querySelector(".tag-filter-warn"); + warn.parentNode.removeChild(warn); + }; + + var show_tag = function(tag) { + if (document.querySelector(".tag-filter-warn") !== null) { + show_all(); + } + + var tr = document.querySelectorAll(".projtable tr"); + for (var i = 0, e = tr.length; i !== e; ++i) { + tr[i].style.display = (tr[i].className.split(" ").indexOf("taggedWith-"+tag) === -1) ? "none" : "table-row"; + } + + var div = document.createElement("div"); + div.className = "tag-filter-warn"; + div.innerHTML = "Filtering by tag. reset"; + document.body.appendChild(div); + + document.querySelector(".tag-filter-warn a").addEventListener('click', function() { + show_all(); + return false; + }); + }; + + var get_show_tag = function(tag) { + return function() { + show_tag(tag); + return false; + }; + }; + + window.addEventListener('load', function() { + var taglinks = document.querySelectorAll(".tag-link"); + for (var i = 0, e = taglinks.length; i !== e; ++i) { + var tag = taglinks[i].getAttribute("data-tag"); + taglinks[i].addEventListener('click', get_show_tag(tag)); + } + }); + + // + // Sort support (theme opt-in) + // + + var sort_rows = function(cb) { + var tr = document.querySelectorAll(".projtable tr"); + var items = []; + for (var i = 0, e = tr.length; i !== e; ++i) { + items.push([i, cb(tr[i])]); + } + items.sort(function(a, b) { + return (a[1] - b[1]); + }); + for (var i = 0, e = items.length; i !== e; ++i) { + var el = tr[items[i][0]]; + var parent = el.parentElement; + parent.removeChild(el); + parent.appendChild(el); + } + }; + + var sort_update = function(sort_by) { + sort_rows(function(el) { + return el.getAttribute(sort_by); + }); + }; + + window.sortUpdate = sort_update; + +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..8415b5e --- /dev/null +++ b/static/style.css @@ -0,0 +1,221 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ +html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}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{display:none}[hidden]{display:none} + + +/* style.css */ + +html { + overflow-y:scroll; /* always display scrollbar to prevent horizontal lurch */ +} +img { + border:0; +} +a { + color:#4078c0; + text-decoration:none; +} +a:hover { + cursor:pointer; + text-decoration:underline; +} +h1 a { + color:black; + text-decoration:none; +} +h1 a:hover { + color:black; +} +h1,h2,h3 { + margin-top:0; +} +.code { + background: #F8F8F8; + font-family:Consolas,monospace; + white-space:pre; +} +.code-multiline { + display:inline-block; + padding:8px; + border-radius:8px; +} +.content-paragraph { + /* mimic default

margins */ + margin-top: 1em; + margin-bottom: 1em; +} + +/* */ + +html, body { + /* structural */ + height:100%; + min-height:100%; + margin:0; + border:0; + padding:0; + + /* cosmetic */ + font-family:"Helvetica Neue","Segoe UI",Arial,sans-serif; + font-size:13px; + line-height:1.4; + background:#DDD url('greyzz.png'); /* thanks subtlepatterns.com ! */ + color:#333; +} + +#container { + margin:0 auto; + width:960px; + position:relative; + + height:auto !important; + height:100%; /* oldIE */ + min-height:100%; + + /* cosmetic */ + background:white; +} + +#content { + padding:14px; + background:white; +} + +/* */ + +.tag::before { + content:""; + + display:inline-block; + width:7px; + height:7px; + + margin-right:2px; + + background:transparent url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAZUlEQVQI12P4//8/Awyrqqp6KSkp/QdhFRWV7cgS+kDBN0B8RVlZuRikACahB+T8ger6BMTXwJJAiTwg4xvMOCj+ART3ZwCa3YYm8QcopgsyEWasDVT3W5AVMHcgO6gViNORXQ8A84NToxbSsJcAAAAASUVORK5CYII=') no-repeat 0 0; +} + +.tag-filter-warn { + position:fixed; + top:0; + right:0; + + padding:4px; + + background:lightyellow; + border-bottom: 1px solid #888; + border-left:1px solid #888; +} + +/* */ + +.projtable { + border-collapse: collapse; + width:100%; +} +.projtable tr { + transition:0.2s linear; +} +.projtable tr:hover { + background:#F8F8F8; +} +.projtable td { + padding: 2px 4px; +} +.projtable small { + color:grey; + font-style:italic; +} +.projtable tr td:first-child { + width:95px; +} + +.projinfo { +} +.projbody { +} +.projbody_halfw { + float:left; + width: 860px; /* 740px full - 60px rhs column - 2px border */ +} +.projbody_fullw { + +} +.projimg { + float:right; + width:62px; /* 60px + 2px border */ +} + +/* */ + +@media screen and (max-width:960px) { + + #container { + width:100%; + } + .projimg { + float:clear; + width:100%; + } + .projbody_halfw { + float:clear; + width:100%; + } + +} + +/* */ + +#logo { + background:transparent url('logo.png') no-repeat 0 0; + width:24px; + height:24px; + display:inline-block; + margin-right:4px; + position:relative; + top:4px; +} + +/* */ + +.homeimage { + width:90px; + height:32px; + + object-fit: cover; +} + +.thumbimage { + width:60px; + height:60px; + opacity: 0.8; + transition:0.2s opacity; + border:1px solid lightgrey; + + object-fit: cover; +} +.thumbimage:hover { + opacity:1.0; +} + +.no-image { + width:90px; + height:32px; + display:block; + background: white url('no_image.png') no-repeat 0 0; +} + +/* */ + +.downloads-small { + margin:0; + padding:0; + list-style-type:none; +} + +.downloads-small li:before { + content:"•"; +} +.downloads-small li a:before { + font-weight:bold; + content:"⇩ "; +}