Skip to content

Commit

Permalink
Add theme support
Browse files Browse the repository at this point in the history
  • Loading branch information
saaste committed Mar 16, 2024
1 parent ddb7fd3 commit 94c8ecb
Show file tree
Hide file tree
Showing 18 changed files with 278 additions and 123 deletions.
42 changes: 42 additions & 0 deletions components/bookmark_add_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{{ define "bookmark_add_form" }}
<form method="post" action="{{ .BaseURL}}/admin/bookmarks/add" id="bookmark-form">
<div class="input-group">
<label for="url">URL (required)</label>
<div class="input-group horizontal">
<input type="text" name="url" id="url" value="{{ .Bookmark.URL }}" required aria-required="true" />
<button type="button" id="scrape">Fetch Metadata</button>
</div>
<div id="fetching-metadata-message" role="alert" aria-live="assertive" class="hidden progress-indicator">
</div>
{{ if index .Errors "url" }}
<span class="warning">{{ index .Errors "url" }}</span>
{{ end }}
</div>

<div class="input-group">
<label for="title">Title (required)</label>
<input type="text" name="title" id="title" value="{{ .Bookmark.Title }}" required aria-required="true" />
{{ if index .Errors "title" }}
<span class="warning">{{ index .Errors "title" }}</span>
{{ end }}
</div>

<div class="input-group">
<label for="description">Description</label>
<textarea name="description" id="description" rows="4">{{ .Bookmark.Description }}</textarea>
</div>

<div class="input-group horizontal checkbox">
<input type="checkbox" name="is_private" id="is_private" value="1" {{ if .Bookmark.IsPrivate }}checked{{end}} />
<label for="is_private">Private</label>
</div>

<div class="input-group">
<label for="tags">Tags (separated with spaces)</label>
<input type="text" name="tags" id="tags" value="{{ .Tags }}" autocomplete="off" />
<ul id="tag-suggestions" class="autocomplete"></ul>
</div>

<button type="submit">Add</button>
</form>
{{ end }}
12 changes: 12 additions & 0 deletions components/bookmark_delete_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{ define "bookmark_delete_form" }}
<form method="post" action="{{ .BaseURL}}/admin/bookmarks/{{ .Bookmark.ID }}/delete">
<p>Are you sure you want to delete the following bookmark?</p>

<div class="box">
<h3>{{ .Bookmark.Title }}</h3>
{{ if .Bookmark.Description }}<p>{{ .Bookmark.Description }}</p>{{ end }}
</div>

<button type="submit" class="warning">Delete</button>
</form>
{{ end }}
42 changes: 42 additions & 0 deletions components/bookmark_edit_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{{ define "bookmark_edit_form" }}
<form method="post" action="{{ .BaseURL}}/admin/bookmarks/{{ .Bookmark.ID }}" id="bookmark-form">
<div class="input-group">
<label for="url">URL (required)</label>
<input type="text" name="url" id="url" value="{{ .Bookmark.URL }}" required aria-required="true" />
{{ if index .Errors "url" }}
<span class="warning">{{ index .Errors "url" }}</span>
{{ end }}
</div>

<div class="input-group">
<label for="title">Title (required)</label>
<input type="text" name="title" id="title" value="{{ .Bookmark.Title }}" required aria-required="true" />
{{ if index .Errors "title" }}
<span class="warning">{{ index .Errors "title" }}</span>
{{ end }}
</div>

<div class="input-group">
<label for="description">Description</label>
<textarea name="description" id="description" rows="4">{{ .Bookmark.Description }}</textarea>
</div>

<div class="input-group horizontal">
<input type="checkbox" name="is_private" id="is_private" value="1" {{ if .Bookmark.IsPrivate }}checked{{end}} /><br />
<label for="is_private">Private</label>
</div>

<div class="input-group horizontal">
<input type="checkbox" name="is_working" id="is_working" value="1" {{ if .Bookmark.IsWorking }}checked{{end}} /><br />
<label for="is_working">Is Working</label>
</div>

<div class="input-group">
<label for="tags">Tags (separated with spaces)</label>
<input type="text" name="tags" id="tags" value="{{ .Tags }}" autocomplete="off" /><br />
<ul id="tag-suggestions" class="autocomplete"></ul>
</div>

<button type="submit">Save changes</button>
</form>
{{ end }}
15 changes: 15 additions & 0 deletions components/headers.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{{ define "headers" }}
<base href="{{ .BaseURL }}">

<meta property="og:title" content="{{ .Title }} | {{ .SiteName }}">
<meta property="og:description" content="{{ .Description }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ .CurrentURL }}">

<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{{ feedUrl "rss" }}">
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{ feedUrl "atom" }}">
<link rel="alternate" type="application/json" title="JSON Feed" href="{{ feedUrl "json" }}">

<script type="module" src="/scripts/api.js"></script>
<script type="module" src="/scripts/admin.js"></script>
{{ end }}
12 changes: 12 additions & 0 deletions components/login_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{{ define "login_form" }}
<form method="post" action="{{ .BaseURL }}/login">
<div class="input-group">
<label for="password">Password</label>
<div class="input-group horizontal">
<input type="password" name="password" id="password" autofocus />
<button type="submit">Log In</button>
</div>
{{ if .Error }}<p class="warning">{{ .Error }}</p>{{ end }}
</div>
</form>
{{ end }}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ let tagSuggestions;

window.onload = () => {
baseUrl = document.querySelector("base").attributes.getNamedItem("href").value;

scrape = document.getElementById("scrape");

url = document.getElementById("url");
title = document.getElementById("title");
description = document.getElementById("description");
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type AppConfig struct {
Secret string `yaml:"secret"`
Port int `yaml:"port"`
PageSize int `yaml:"page_size"`
Template string `yaml:"template"`
Theme string `yaml:"theme"`
CheckInterval int `yaml:"check_interval,omitempty"`
CheckRunOnStartup bool `yaml:"check_on_app_start,omitempty"`
GotifyEnabled bool `yaml:"gotify_enabled,omitempty"`
Expand Down
93 changes: 93 additions & 0 deletions docs/THEMES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Themes
You can customize the interface by creating a new theme. Themes are located in the `templates` directory. Each theme must have its own subdirectory. By default, the app uses the [default](/templates/default/) theme. The theme can be changed in the `config.yml` file by changing the `theme` configuration value.

If you are creating a new theme, it is probably easiest to use the `default` template as a base. Any static assets required by the theme must be in the `assets` subdirectory.


## Available template variables

| Variable | Type | Description
|------------------ | ------------- | -------------------------------------------------------
| .SiteName | string | Site name, defined in the config
| .Description | string | Site description, defined in the config
| .BaseURL | string | Site base URL, defined in the config
| .Title | string | Title of the current page
| .CurrentURL | string | URL requested to access the current view
| .IsAuthenticated | bool | Boolean indicating if user is authenticated
| .Bookmarks | [[]Bookmark](#bookmark) | List of bookmarks visible in the view
| .Tags | []string | List of all tags
| .TextFilter | string | Current search term
| .Pages | []Page | List of available pages for paginated content
| .BrokenBookmarks | []Bookmark | List of broken bookmarks (only available for authenticated users)

## Types

### Bookmark
| Field | Type | Description
|------------------ | ------------- | -------------------------------------------------------
| .ID | int64 | Bookmark ID
| .URL | string | Bookmark URL
| .Title | string | Bookmark title
| .Description | string | Bookmark description
| .IsPrivate | bool | Is bookmark private
| .Created | time.Time | Bookmark creation datetime
| .Tags | []string | Bookmark tags
| .IsWorking | bool | Is bookmark working

## Components
Components render elements, that are necessary for the app to work. You should use these instead of building your own. Otherwise some features may not work.


| Components | Usage
|------------| --------
| bookmark_add_form | Form for adding new bookmarks
| bookmark_edit_form | Form for editing bookmarks
| bookmark_delete_form | Form for deleting bookmarks
| login_form | Form for logging in
| headers | Required headers used in `<head>` section

How to use:
```
{{ template "feed-links" . }}
```

## Functions

### feedUrl (feedType string)
Returns a feed URL. Supported `feedType` values:
- rss
- atom
- json

How to use:
```html
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href='{{ feedUrl "rss" }}'>
```

### anchorUrl (id string)
Returns an URL pointing to a specific element on the page

How to use:
```html
<a href='{{ anchorUrl "q" }}' id="top">Go to Search</a>
...
<input type="text" name="q" id="q" aria-label="Search by keyword">
```

### paginationUrl (pageNumber int)
return an URL pointing to a specific page of the paginated content

How to use:
```html
<nav id="pagination" aria-label="Pagination Navigation">
{{ range .Pages }}
{{ if .IsActive }}
{{ .Number }}
{{ else }}
<a href="{{ paginationUrl .Number }}" aria-label="Go to page {{ .Number }}">
{{ .Number }}
</a>
{{ end }}
{{ end }}
</nav>
```
30 changes: 30 additions & 0 deletions handlers/admin_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ func (h *Handler) HandlePrivateBookmarks(w http.ResponseWriter, r *http.Request)
return
}

brokenBookmarks, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
return
}

data := templateData{
SiteName: h.appConf.SiteName,
Description: h.appConf.Description,
Expand All @@ -44,6 +50,7 @@ func (h *Handler) HandlePrivateBookmarks(w http.ResponseWriter, r *http.Request)
Bookmarks: bookmarkResult.Bookmarks,
Tags: allTags,
Pages: h.getPages(page, bookmarkResult.PageCount),
BrokenBookmarks: brokenBookmarks,
}

h.parseTemplateWithFunc("index.html", r, w, data)
Expand Down Expand Up @@ -91,6 +98,12 @@ func (h *Handler) HandleBookmarkAdd(w http.ResponseWriter, r *http.Request) {
return
}

brokenBookmarks, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
return
}

data := adminTemplateData{
templateData: templateData{
SiteName: h.appConf.SiteName,
Expand All @@ -99,6 +112,7 @@ func (h *Handler) HandleBookmarkAdd(w http.ResponseWriter, r *http.Request) {
BaseURL: h.appConf.BaseURL,
CurrentURL: h.getCurrentURL(r, h.appConf),
IsAuthenticated: isAuthenticated,
BrokenBookmarks: brokenBookmarks,
},
Errors: make(map[string]string),
Bookmark: &bookmarks.Bookmark{},
Expand Down Expand Up @@ -170,6 +184,12 @@ func (h *Handler) HandleBookmarkEdit(w http.ResponseWriter, r *http.Request) {
return
}

brokenBookmarks, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
return
}

data := adminTemplateData{
templateData: templateData{
SiteName: h.appConf.SiteName,
Expand All @@ -178,6 +198,7 @@ func (h *Handler) HandleBookmarkEdit(w http.ResponseWriter, r *http.Request) {
BaseURL: h.appConf.BaseURL,
CurrentURL: h.getCurrentURL(r, h.appConf),
IsAuthenticated: isAuthenticated,
BrokenBookmarks: brokenBookmarks,
},
Errors: make(map[string]string),
Bookmark: bookmark,
Expand Down Expand Up @@ -250,6 +271,14 @@ func (h *Handler) HandleBookmarkDelete(w http.ResponseWriter, r *http.Request) {
return
}

// TODO: There is no need to fetch broken bookmarks every time.
// Just fetch a boolean indicating if broken bookmarks exists
brokenBookmarks, err := h.bookmarkRepo.GetBrokenBookmarks()
if err != nil {
h.internalServerError(w, "Failed to fetch broken bookmarks", err)
return
}

data := adminTemplateData{
templateData: templateData{
SiteName: h.appConf.SiteName,
Expand All @@ -258,6 +287,7 @@ func (h *Handler) HandleBookmarkDelete(w http.ResponseWriter, r *http.Request) {
BaseURL: h.appConf.BaseURL,
CurrentURL: h.getCurrentURL(r, h.appConf),
IsAuthenticated: isAuthenticated,
BrokenBookmarks: brokenBookmarks,
},
Bookmark: bookmark,
}
Expand Down
21 changes: 19 additions & 2 deletions handlers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"

Expand All @@ -27,6 +28,22 @@ func (h *Handler) getBookmarksWithPagination(isAuthenticated bool, q, tags strin
}

func (h *Handler) parseTemplateWithFunc(templateFile string, r *http.Request, w http.ResponseWriter, data any) {
templateFiles := []string{
h.getTemplateFile("base.html"),
h.getTemplateFile(templateFile),
}

entries, err := os.ReadDir("components")
if err != nil {
h.internalServerError(w, "Reading UI components failed", err)
return
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".html") {
templateFiles = append(templateFiles, fmt.Sprintf("components/%s", entry.Name()))
}
}

t, err := template.New("").
Funcs(template.FuncMap{
"paginationUrl": func(pageNumber int) string {
Expand All @@ -48,7 +65,7 @@ func (h *Handler) parseTemplateWithFunc(templateFile string, r *http.Request, w
"anchorUrl": func(id string) string {
return h.getAnchorURL(r, id)
},
}).ParseFiles(h.getTemplateFile("base.html"), h.getTemplateFile(templateFile))
}).ParseFiles(templateFiles...)
if err != nil {
h.internalServerError(w, fmt.Sprintf("Failed to parse template %s", templateFile), err)
return
Expand All @@ -62,7 +79,7 @@ func (h *Handler) parseTemplateWithFunc(templateFile string, r *http.Request, w
}

func (h *Handler) getTemplateFile(filename string) string {
return fmt.Sprintf("templates/%s/%s", h.appConf.Template, filename)
return fmt.Sprintf("templates/%s/%s", h.appConf.Theme, filename)
}

func (h *Handler) isAuthenticated(r *http.Request) bool {
Expand Down
3 changes: 2 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ func main() {
r.Get("/api/metadata", handler.HandleAPIMetadata)
r.Get("/api/tags", handler.HandleAPITags)

handler.ServeFiles(r, "/assets", http.Dir(fmt.Sprintf("templates/%s/assets", appConf.Template)))
handler.ServeFiles(r, "/assets", http.Dir(fmt.Sprintf("templates/%s/assets", appConf.Theme)))
handler.ServeFiles(r, "/scripts", http.Dir("components/scripts"))

log.Printf("Server address: http://localhost:%d", appConf.Port)
err = http.ListenAndServe(fmt.Sprintf(":%d", appConf.Port), r)
Expand Down
Loading

0 comments on commit 94c8ecb

Please sign in to comment.