initial commit
This commit is contained in:
commit
d7a964c186
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Local config
|
||||
config.toml
|
||||
|
||||
# Binary artefacts
|
||||
teafolio
|
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
19
README.md
Normal 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
204
api.go
Normal 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
16
config.toml.sample
Normal 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
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module teafolio
|
||||
|
||||
go 1.13
|
||||
|
||||
require github.com/BurntSushi/toml v0.3.1
|
2
go.sum
Normal file
2
go.sum
Normal 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
327
main.go
Normal 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
BIN
static/greyzz.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 243 B |
BIN
static/no_image.png
Normal file
BIN
static/no_image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 436 B |
83
static/site.js
Normal file
83
static/site.js
Normal 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
221
static/style.css
Normal 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:"⇩ ";
|
||||
}
|
Loading…
Reference in New Issue
Block a user