initial commit

This commit is contained in:
mappu 2020-05-02 14:16:49 +12:00
commit d7a964c186
13 changed files with 897 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Local config
config.toml
# Binary artefacts
teafolio

15
Dockerfile Normal file
View File

@ -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" ]

19
README.md Normal file
View File

@ -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`

204
api.go Normal file
View File

@ -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)
}

16
config.toml.sample Normal file
View File

@ -0,0 +1,16 @@
# teafolio config file
BindTo="0.0.0.0:5656"
[Gitea]
URL="https://gitea.com/"
Org="gitea"
[Template]
AppName = "Teafolio"
HomepageHeaderHTML="""
<p>
Teafolio is a web-based portfolio frontend for a Gitea server.
</p>
"""

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module teafolio
go 1.13
require github.com/BurntSushi/toml v0.3.1

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

327
main.go Normal file
View File

@ -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, `<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=960">
<title>`+html.EscapeString(pageTitle)+`</title>
`+extraHead+`
<link rel="shortcut icon" href="/static/logo.png" type="image/png">
<link rel="apple-touch-icon" href="/static/logo.png" type="image/png">
<link type="text/css" rel="stylesheet" href="/static/style.css">
</head>
<body>
<div id="container">
<div id="content">
<h1><a href="/"><div id="logo"></div>`+html.EscapeString(this.cfg.Template.AppName)+`</a></h1>
`)
cb()
fmt.Fprint(w, `
</body>
<script type="text/javascript" src="/static/site.js"></script>
</html>
`)
}
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+`
<h2>Projects</h2>
<table id="projtable-main" class="projtable">
`)
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 `<COMMA><SPACE>` to separate from the repo title
normalisedDesc = `, ` + normalisedDesc
}
rowClass := ""
for _, topic := range topics[repo.Name] {
rowClass += `taggedWith-` + topic + ` `
}
fmt.Fprint(w, `
<tr class="`+html.EscapeString(rowClass)+`">
<td>
<a href="`+pageHref+`"><img class="homeimage" src="`+html.EscapeString(`/:banner/`+url.PathEscape(repo.Name))+`"></div></a>
</td>
<td>
<strong>`+html.EscapeString(repo.Name)+`</strong>`+html.EscapeString(normalisedDesc)+`
<a href="`+pageHref+`" class="article-read-more">more...</a>
<br>
<small>
`)
for _, topic := range topics[repo.Name] {
fmt.Fprint(w, `<a class="tag tag-link" data-tag="`+html.EscapeString(topic)+`">`+html.EscapeString(topic)+`</a> `)
}
fmt.Fprint(w, `
</small>
</td>
</tr>
`)
}
fmt.Fprint(w, `
</table>
`)
})
}
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 = `<meta name="go-import" content="` + html.EscapeString(string(moduleName)) + ` git ` + repoURL + `.git">`
}
}
// De-escalate all headers in rendered markdown to match our style
repl := strings.NewReplacer(`<h1`, `<h2`, `<h2`, `<h3`, `<h3`, `<h4`,
`</h1>`, `</h2>`, `</h2>`, `</h3>`, `</h3>`, `</h4>`)
// Ready for template
this.Templatepage(w, r, repoName, extraHead, func() {
projBodyclass := `projbody`
if len(images) > 0 {
projBodyclass += ` projbody_halfw`
}
fmt.Fprint(w, `<div class="projinfo"><div class="`+projBodyclass+`">`)
repl.WriteString(w, string(readmeHtml))
fmt.Fprint(w, `</div>`)
if len(images) > 0 {
fmt.Fprint(w, `<div class="projimg">`)
for _, img := range images {
fmt.Fprint(w, `<a href="`+html.EscapeString(img.RawURL)+`"><img alt="" class="thumbimage" src="`+html.EscapeString(img.RawURL)+`" /></a>`)
}
fmt.Fprint(w, `</div>`)
}
fmt.Fprint(w, `<div style="clear:both;"></div>`)
fmt.Fprint(w, `</div>`) // 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))
}

BIN
static/greyzz.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

BIN
static/no_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

83
static/site.js Normal file
View File

@ -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. <a>reset</a>";
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;
})();

221
static/style.css Normal file
View File

@ -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 <p> 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:"⇩ ";
}