Help us shape the future of Go! Take the 2026 Go Developer Survey

admin

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 49 Imported by: 0

Documentation

Overview

Package admin is the shared back office: one shell (layout, navigation, notices), sign-in with optional two-step sign-in, password reset and first-run setup, translations and a permission check on every route. Form posts from other websites are refused (http.CrossOriginProtection), so forms need no tokens. Core packages and modules contribute Sections.

The admin is served at a path of the site's choosing (Options.Path, webthings.Config.AdminPath; /admin by default), so nothing links into it by path. Every page is a named route, and links are built from the names with go-router's reverse routing:

  • The admin's own pages are "admin.dashboard", "admin.login", "admin.login-code" (the second step of two-step sign-in), "admin.logout", "admin.account", "admin.trash", "admin.setup", "admin.forgot-password", "admin.reset-password", "admin.presence", and its files "admin.asset" (with "file", such as "icons.svg") and "admin.stylesheet". Templates link to the shared files with go-importmap's importmap_url, which adds the file's version so browsers may cache it: {{importmap_url "icons.svg"}}; a script or stylesheet tag comes from importmap_script and importmap_style, which add its Subresource Integrity too: {{importmap_script "tree.js" "type" "module"}}.

  • A section's routes are named "admin.<section ID>.<name>" (RouteName): Routes.Get and Routes.Post return a Route, and Route.As gives it its short name. By convention the section's own page is "index", the form for a new item "new", an item's page "edit" or "show", and actions are named after what they do, such as "delete" or "settings"; a POST to the address of a named GET needs no name of its own. Sections made with package editor name theirs (index, new, edit, delete, publish, …), so screens a module adds to them choose other names.

  • In Go, Routes.URL builds the address of one of the section's own routes by its short name, Admin.URL (or Admin.TryURL) that of any route by its full name, RouteURL that of the admin of a request's context, for hooks such as Section.Recent, and webthings.Env.AdminURL links into the admin from outside it, such as an edit link on the site or a link in an e-mail:

    routes.Get("/{id}", admin.View, show).As("show") routes.URL("show", "id", id, url.Values{"locale": {locale}}) env.AdminURL(admin.RouteName(env.Key, "show"), "id", id)

  • In templates, go-router's functions build them: route (name, then key value pairs, maps and url.Values; an unknown route or parameter fails the template), routePath, routePathWithParams and routeURL:

    <a href="{{route "admin.news.edit" "id" .ID}}">…</a> <use href="{{importmap_url "icons.svg"}}#close"/>

Section.Path is data, the section's address below the admin ("/news"; empty is "/" followed by its ID), never a link. External modules use the same public API and the same convention.

Styling section templates

The admin ships one compiled stylesheet. Section templates may use its component classes and a fixed set of Tailwind utilities; any other Tailwind class used only by a module outside core is not in the stylesheet and has no effect.

Components:

  • Page frame: every page is <main id="content" class="admin-page"> around one <div class="admin-page-body">, which gives all pages the same padding, centred width and spacing. The body starts with <header class="admin-page-header"> holding <h1 class="admin-page-title"> and a <div class="admin-page-actions"> of buttons; .admin-page-intro is a muted line below it. Then come notices, tabs and content boxes: a <section class="card"> per part of the page, titled with <h2 class="card-title">. Tables inside the content area are styled automatically and scroll inside their box on small screens. Give numeric cells .numeric (right-aligned, tabular figures). .card-footer separates the actions at the end of a box.
  • Lists: declare the search box, {{/* @model Search github.com/donseba/go-webthings/admin.SearchBox */}}, put {{template "admin-search" Search}} first in the header's actions and read the rows with ListPage, which searches and pages them the same way on every screen; show {{template "admin-empty" Search}} when nothing is left and {{template "admin-pager" .Pager}} below the rows. Filter tabs come from FilterTabs and show with {{template "admin-tabs" .Tabs}}; language tabs from LocaleTabs, with {{template "locale-tabs.gohtml" .Tabs}}. Ordered lists show their arrows with {{template "admin-moves" .Moves}} (see Moves). ReadCSV, CSVCell and WriteCSV import and export lists as CSV.
  • Text: .muted for secondary text, .text-danger for problems, .notice for a message box.
  • Buttons: .button (primary), .button-secondary and .button-danger, also on links and summaries; all share one size, .button-sm is the compact one. Buttons in table rows and in a .row-actions group are compact; forms of only a button (go-form's) add no margin there or in an .action-bar.
  • Badges: .badge for a neutral label ("Archived"), .badge-accent for a count that asks for attention.
  • Filters and tabs: a <nav class="filter-tabs"> of links, as the "admin-tabs" template writes it; the current one has aria-current="page". Put a .badge in a link for a count.
  • Figures: a .stat-tiles grid of .stat-tile boxes, each with a .stat-label and a .stat-value.
  • Bars: <progress class="meter" max="100" value="40"> is a horizontal share bar. A .bar-chart holds vertical .bar elements whose height is one of the classes bar-0 to bar-100, in percent: <span class="bar bar-40"></span>.

No admin page has style attributes or <style> elements, which a strict Content-Security-Policy blocks: a section styles its markup with classes and its own stylesheet, and its scripts may set element.style.

Utilities (with sm:, md: and lg: variants where noted):

  • flex, inline-flex, grid, block, inline, inline-block, hidden, flex-wrap, flex-col, flex-1, grow, shrink-0, min-w-0, w-full, max-w-full
  • items-start|center|end|baseline, justify-start|center|end|between
  • grid-cols-1…4 and col-span-1…3|full (sm:, md:, lg:)
  • gap, gap-x, gap-y, m, mt, mb, ml, mr, mx, my, p, pt, pb, px, py with 0…6 and 8; space-y-1…6
  • text-xs|sm|base|lg|xl|2xl|3xl, text-left|center|right, font-medium| semibold|bold, whitespace-pre-wrap, break-all, break-words, truncate, tabular-nums, sr-only
  • border, border-t, border-b, rounded, rounded-lg, rounded-xl
  • max-w-xl…6xl, max-h-40|64|96, h-40, w-16|24|32, min-h-24, overflow-auto, overflow-x-auto

For anything else a section brings its own stylesheet: Section.Stylesheet names a CSS file in Section.Files, which every admin page links from <admin>/assets/sections/<section>.css. All sections share the page, so scope the rules under a class of the module, and use the theme's custom properties (--surface, --ink, --muted, --border, --accent, --danger, …) so the light, dark and retro themes keep working.

Navigation and dashboard

The navigation lists the sections a user may open. The Dashboard and the sections without Group (Pages and Media) come first, without heading; then the groups, sorted by translated name, each with its sections sorted by translated title; GroupSystem (Accounts, Groups, Your account, the site's settings and Backup) comes last. Editors can collapse a group; the browser remembers it, and the group of the current page is always open.

The dashboard is built from the same sections: "Needs attention" lists the revisions waiting for the user's review (Section.Review) and the sections with a Badge count, "Recently edited" the newest items of every Section.Recent, "Scheduled" the planned changes of every Section.Scheduled, and quick actions open the section's new item where the user may create: the route Section.NewRoute names, preferably, or else Section.NewPath below the admin. Sections made with package editor provide all of these. The page shows at once; its panels load after it, each on its own, asking all sections at the same time for a few items and waiting a few seconds at most, so a slow section delays only its panel and is named there. The navigation's badges are asked for at the same time too, and a badge that takes longer than a second shows no count.

Presence

Every admin page of a section shows, in the header, who else is looking at it: the other users' initials, with their names as tooltip (never their e-mail addresses), to users who may view the section. The language tabs of an editor are one page. The browser reports the page it shows when it opens it and every 15 seconds, and follows the other viewers over a stream of server-sent events (go-partial's exp/sse) while the tab is visible; tabs that close or stop reporting are gone within a minute. Presence locks nothing. It is kept in memory (package presence), so it covers one server.

Confirmations and notices

Forms built with Admin.Form whose action ends in /delete ask "Move this item to the trash?" before they are sent. A screen with one form, such as a module's settings, is a FormScreen. A refused post shows its problems at the fields (FieldError and Admin.Validate), or as an error toast (WithError) when they concern the whole request. Admin.Confirm (or the confirm template function) sets another question or removes it, as for deletes that cannot be undone; a hand-written form can set hx-confirm itself. Every question is asked in the layout's dialog, with the buttons Cancel and Confirm in the admin's language, rather than with the browser's window.confirm, which some embedded browsers never show.

Admin.Redirect shows a notice after the redirect chosen by the last part of the name of the post's route: publish shows "Published.", unpublish "Unpublished.", restore "Revision restored as a new draft.", delete "Moved to the trash.", schedule "Publication scheduled." and unschedule "Schedule cancelled.", other posts "Changes saved."; Admin.RedirectNotice names the notice explicitly, as a translation key, and Admin.RedirectFlash carries a toast of any level whose text is translated already. Notices show as toasts in the corner of the screen; a handler can add one to the page it renders with go-partial's flash.Add (success and info hide themselves, warnings and errors stay until dismissed). Refused posts answer with Admin.BadRequest or Admin.Forbidden: an HTMX request gets their translated text as an error toast added out of band, any other request the text itself.

Trash

Deleting moves items to the trash (see Trash and Section.Trash): the admin's trash ("admin.trash") lists them per section, restores them or deletes them for good. Sections made with package editor have a trash; modules with tables of their own implement Trash. Admin.PurgeTrash deletes for good, in every section, the items deleted before a time, as webthings.App does every hour with the days Site settings › Trash set.

Index

Constants

View Source
const (
	// View opens a section and its items.
	View = auth.Read
	// Create adds new items.
	Create = auth.Create
	// Edit changes items.
	Edit = auth.Edit
	// Publish makes changes visible on the public site, or hides them.
	Publish = auth.Publish
	// Delete moves items to the trash, and brings them back or deletes them
	// for good there.
	Delete = auth.Delete
	// Review approves or rejects revisions submitted for review.
	Review = auth.Review
	// Export downloads personal data, such as subscribers as CSV. A section
	// that registers a route with it offers the right in the permission
	// matrix; other sections do not.
	Export = auth.Export
)

The actions of the permission matrix, by which routes say what they need (see auth.Read and the others for what each allows).

View Source
const (
	// GroupSystem holds the admin's own sections (Accounts, Groups, Your
	// account), the site's settings and theme and the backup; it is listed
	// last.
	GroupSystem = "System"
	// GroupModules holds the sections of modules that have no group of their
	// own (see webthings.Module.Group).
	GroupModules = "Modules"
)

Navigation groups the admin names itself. Their names are translated, as are some common names sites may choose for their modules: Agenda, Content, Visitors, Website, Communication and Shop.

View Source
const DefaultPageSize = 50

DefaultPageSize is how many rows a list screen shows per page when its ListPage sets no Size.

View Source
const DefaultPath = "/admin"

DefaultPath is the admin's path when Options.Path is empty.

View Source
const NotInTrash = "This item is no longer in the trash."

NotInTrash explains an item that another editor restored or deleted for good meanwhile, for the Reason of a Trash; it is translated.

Variables

This section is empty.

Functions

func AdministratorsOnly

func AdministratorsOnly(next http.Handler) http.Handler

AdministratorsOnly is route middleware that answers 403 to every user who is not an administrator, after the route's own permission check, for changes only administrators may make, such as the site's security settings or the permanent mark of an item:

routes.Post("/security", admin.Edit, s.saveSecurity).As("security").Use(admin.AdministratorsOnly)

Pages that show such settings to other users show them read-only themselves.

func CSVCell

func CSVCell(value string) string

CSVCell keeps spreadsheet programs from running a value of an export as a formula: a value that starts with =, +, -, @, a tab or a carriage return gets a leading apostrophe, which spreadsheets show as text.

func ContentLocale

func ContentLocale(r *http.Request, config locales.Config) string

ContentLocale returns the content language the request asks for with its locale parameter, when config enables it, and config's default language otherwise. Screens that edit content in several languages use it to pick the language they show.

func Date

func Date(ctx context.Context, t time.Time) string

Date shows the day of an instant in the admin language and the site's timezone, for example "5 Oct 2026" or "5 okt 2026". A zero time gives "".

func DateTime

func DateTime(ctx context.Context, t time.Time) string

DateTime shows an instant with its date and time of day, in the admin language and the site's timezone: "5 Oct 2026 14:05" in English, "5 okt 2026 14:05" in Dutch. A zero time gives "".

func EachTrashItem

func EachTrashItem(ids []string, fn func(id string) error, reason func(err error) string) error

EachTrashItem runs fn for every ID, for implementations of Restore and Purge. Errors for which reason returns a message become TrashErrors and do not stop the others; any other error stops at once and is returned.

func FieldError

func FieldError(field, text string) form.FieldErrors

FieldError is the refusal of one form field, shown at the field by form_render_localized: field is the field's name in the form's input struct, such as "Email", and text the message, translated already, such as Admin.T(r, "Enter a valid email address.").

func Filter

func Filter[T any](s Search, items []T, fields func(T) []string) []T

Filter keeps the items whose fields match the search.

func FilterURL

func FilterURL(r *http.Request, path, name, value string) string

FilterURL returns the address of the list at path with its parameter name set to value, or without it when value is empty. It keeps the request's other parameters, such as the search, and starts again at the first page.

func Format

func Format(ctx context.Context) frontend.Format

Format formats dates, times and amounts of money in the language of the admin request in ctx and in the site's timezone, so admin screens show "5 okt 2026 14:05" and "€ 120,00" to a Dutch editor rather than "2026-10-05 14:05" and "120.00". Like T, it is for module code that only has a context. Outside an admin request it formats in English and UTC.

func KeepSearch

func KeepSearch(r *http.Request, target string) string

KeepSearch adds the search of r to target, a link within the same list such as a filter tab or the next page, so the search stays.

func LanguageLabel

func LanguageLabel(code string) string

LanguageLabel is a language's own name with its code, such as "Nederlands (nl)" or "Português (pt-BR)"; an unknown code is shown alone.

func LanguageName

func LanguageName(code string) string

LanguageName names a language code in its own language: "Nederlands" for "nl", "Deutsch" for "de". A regional variant such as "pt-BR" gets the name of its language ("Português"); a code without a known name is returned as it is.

func LocaleURL

func LocaleURL(target, locale string) string

LocaleURL returns target with its locale parameter set to locale, keeping target's other parameters.

func Matches

func Matches(query string, fields ...string) bool

Matches reports whether every word of query occurs in one of fields; an empty query matches everything. A list checking many rows parses the query once with SearchOf instead.

func PageNumber

func PageNumber(r *http.Request) int

PageNumber returns the page the request asks for with its page parameter; 1 when it asks for none or for an invalid one.

func PageURL

func PageURL(r *http.Request, path string, number int) string

PageURL returns the address of page number of the list at path, keeping the request's other parameters. The first page has no page parameter.

func Paginate

func Paginate[T any](items []T, number, size int) ([]T, bool)

Paginate returns page number (from 1) of items with size items per page, and whether more pages follow; for lists that are searched in memory.

func Query

func Query(r *http.Request) string

Query returns the search text of the request's q parameter.

func ReservedSections

func ReservedSections() []string

ReservedSections are the section IDs the admin uses itself: its own sections (Accounts and Groups) and the first segments of its addresses outside them, such as the sign-in and password reset pages, which its routes give. Modules cannot use them as keys.

func ReviewLabel

func ReviewLabel(state content.ReviewState) string

ReviewLabel names a review state for editors, as a translation key.

func RouteName

func RouteName(section, name string) string

RouteName is the full name of the route named name with Route.As in the section with the given ID: "admin.<section>.<name>", for example RouteName("news", "edit") is "admin.news.edit". Every section names its routes this way, the admin's own (Accounts, Groups), those of admin/editor and those of modules, so code anywhere can link to them by name with Admin.URL, Routes.URL, webthings.Env.AdminURL or the route template function, without knowing their paths. By convention the section's own page is "index", the form for a new item "new", an item's page "edit" or "show", and other routes are named after what they do, such as "delete" or "settings"; a POST to the address of a named GET route needs no name of its own.

func RouteURL

func RouteURL(ctx context.Context, name string, args ...any) string

RouteURL is Admin.URL for the admin request in ctx, for code that gets a context rather than the Admin, such as Section.Recent, Section.Review or a Trash: the address of the named route with its parameters filled in. Outside an admin request it returns "". Like Admin.URL, it panics on an unknown route or parameter.

func Schema

func Schema() fs.FS

Schema returns the admin's tables for db.ApplySchema: the permanent marks of NewPermanence.

func SearchLimit

func SearchLimit() int

SearchLimit is how many rows a paged list reads when it searches: the list filters them with Search.Matches and pages the matches with Paginate.

func T

func T(ctx context.Context, key string, args ...any) string

T translates an admin message in the language of the admin request in ctx, for module code that has a context but no request, such as an editor's Notes. Like Admin.T, a message without translation is shown as written, with its arguments. Outside an admin request the message is never translated.

func TN

func TN(ctx context.Context, singular, plural string, n int, args ...any) string

TN translates an admin message with a singular and a plural form, choosing the form for n by the plural rules of the admin language. args format the chosen form, so n is usually passed again:

admin.TN(ctx, "%d download", "%d downloads", count, count)

A catalogue writes such a message with msgid, msgid_plural and one msgstr[i] per form. Without translation, or outside an admin request, the English rule picks singular for 1 and plural otherwise.

func TimezoneChoices

func TimezoneChoices(selected string) form.SortedSelect[string]

TimezoneChoices is a dropdown of the IANA time zones editors choose from, one per country region and "UTC", each with its current offset from UTC, such as "Europe/Amsterdam (UTC+02:00)". Zones the server cannot load (it has no time zone database) are left out. They are sorted by name so the zones of a region are together, with selected chosen. A selected zone that is not in the list, such as an old name like "Europe/Kiev", is added so a stored value stays as it is.

func WithError

func WithError(r *http.Request, text string) *http.Request

WithError returns r with text, translated already, as an error toast on the page rendered for it (see Admin.Render); an empty text returns r unchanged. Error toasts stay until the editor dismisses them and are announced by screen readers at once.

func WriteCSV

func WriteCSV(w http.ResponseWriter, name string, header []string, rows [][]string) error

WriteCSV answers with a CSV file to download, named name plus ".csv": the header row and then rows. Every value of rows goes through CSVCell, since exports hold what visitors typed. The file is never cached, as exports usually hold personal data.

Types

type AccountRow

type AccountRow struct {
	ID, Email, Name, Role, Status, URL string
	Created                            string
	Admin                              bool
	Disabled                           bool
}

AccountRow is one account. Role and Status are translated labels, such as "Beheerder" and "Actief"; Admin and Disabled hold what they say.

type AccountView

type AccountView struct {
	Email    string
	Name     string
	Role     string
	Password form.RenderModel
	Errors   form.FieldErrors
	// TwoStep is the card of two-step sign-in; nil when the accounts
	// service cannot keep its secrets (auth.Service.TwoStepAvailable).
	TwoStep *TwoStepView
}

AccountView is the signed-in user's own account page.

type AccountsView

type AccountsView struct {
	Users []AccountRow
	// Pager pages through many accounts.
	Pager       Pager
	Selected    *AccountRow
	Sessions    []SessionRow
	ProfileForm *form.RenderModel
	StatusForm  *form.RenderModel
	RoleForm    *form.RenderModel
	// DeleteForm is nil for the signed-in user's own account.
	DeleteForm *form.RenderModel
	// TwoStep says whether the selected account has two-step sign-in on,
	// as shown; empty when the accounts service cannot keep its secrets.
	TwoStep string
	// TwoStepForm turns two-step sign-in off for the selected account, for
	// someone who lost their phone; nil while it is off and for the
	// signed-in user's own account.
	TwoStepForm *form.RenderModel
	Membership  *MembershipForm
	Create      form.RenderModel
	Errors      form.FieldErrors
	// ProfileErrors and RoleErrors are the refusals of the selected
	// account's profile and role forms, shown at their fields.
	ProfileErrors form.FieldErrors
	RoleErrors    form.FieldErrors
}

AccountsView is the data of the Accounts screen: the list of accounts and, with one selected, its forms and sessions.

type ActionGroup

type ActionGroup struct {
	Name    string
	Actions []QuickAction
}

ActionGroup are the quick actions of one navigation group.

type Admin

type Admin struct {
	// contains filtered or unexported fields
}

Admin is the back office: the sign-in, the shell with its navigation, and the sections of core and the modules, each behind its permissions. It is an http.Handler for everything below its Path. Sections use it to render their screens, build links and check permissions.

func New

func New(accounts *auth.Service, options Options, sections ...Section) (*Admin, error)

New builds the admin with its sections. Serve the result at its Path and below it as it is (see Path): it serves the full paths, such as <Path>/login.

func (*Admin) BadRequest

func (a *Admin) BadRequest(w http.ResponseWriter, r *http.Request)

BadRequest answers 400 with "This request could not be read." in the admin language, for a post that no form of the admin sends, such as one whose fields do not fit the form: an error toast for an HTMX request, the text for any other.

func (*Admin) Bind

func (a *Admin) Bind(r *http.Request, input any) (form.FieldErrors, error)

Bind maps a submitted form onto input like MapForm and validates it like Validate (go-form's Bind): the field errors of both, one field's unreadable value taking the place of what validation says about the value it left. err is MapForm's:

errs, err := a.Bind(r, &input)
if err != nil {
	a.BadRequest(w, r)

	return
}

if len(errs) > 0 {
	// Show the form again with errs.
}

func (*Admin) Can

func (a *Admin) Can(r *http.Request, permission Permission) bool

Can reports whether the current user may perform an action.

func (*Admin) Close

func (a *Admin) Close(context.Context) error

Close ends the open presence streams, so a server can shut down without waiting for them.

func (*Admin) Confirm

func (a *Admin) Confirm(r *http.Request, model form.RenderModel, message string) form.RenderModel

Confirm returns the form with a question the editor must confirm before it is sent, such as "Reset the votes? This cannot be undone." The message is a translation key. An empty message removes the question, also the default one of delete forms. Templates can do the same with the confirm function:

{{form_render_localized localizer (confirm localizer .Reset "Reset the votes?") nil}}

func (*Admin) Connector

func (a *Admin) Connector() connector.Connector

Connector is the go-partial connector of the admin's pages (HTMX 4), for handlers that inspect a request, such as connector.IsRequest(a.Connector(), r) for one HTMX made.

func (*Admin) Context

func (a *Admin) Context(ctx context.Context) context.Context

Context returns ctx as the context of a request to this admin, for code that runs section hooks outside a request, such as Section.Recent in a test or background work: RouteURL, T and TN work with it.

func (*Admin) Editor

func (a *Admin) Editor(r *http.Request) (user auth.User, can func(adminURL, action string) bool, ok bool)

Editor recognises the user signed in to the admin on a request outside it, such as a page of the public site, from the session cookie. It records no activity. can reports whether the user may perform an action, such as Edit, in the section of an admin URL; it loads the user's permissions on its first use.

func (*Admin) Fail

func (a *Admin) Fail(w http.ResponseWriter, r *http.Request, err error)

Fail logs an unexpected error and answers 500 with "The request failed. Please try again.": an error toast for an HTMX request, the text for any other.

func (*Admin) Forbidden

func (a *Admin) Forbidden(w http.ResponseWriter, r *http.Request)

Forbidden answers 403 with "You are not allowed to do this." in the admin language, as routes do for users without the right: an error toast for an HTMX request, the text for any other.

func (*Admin) Form

func (a *Admin) Form(r *http.Request, input any, action, submit string) form.RenderModel

Form builds a go-form model posting to action. Forms posting to a route named delete, or ending in -delete such as mailing-delete (see RouteName), ask "Move this item to the trash?" before they are sent, and those posting to one named unpublish ask "Take this off the website?"; Confirm sets another question, for example for a delete that cannot be undone.

func (*Admin) ImportMapHash

func (a *Admin) ImportMapHash() string

ImportMapHash is the Content-Security-Policy hash of the import map the admin's layout writes ({{importmap}}), "'sha256-…'", for the script-src of a policy without 'unsafe-inline'. The assets are embedded, so it never changes while the program runs.

func (*Admin) Language

func (a *Admin) Language() string

Language is the admin UI locale, as Options.Language names it, for example "nl"; "en" when it names none.

func (*Admin) Location

func (a *Admin) Location(r *http.Request) *time.Location

Location is the site's timezone, for times editors enter and read.

func (*Admin) MapForm

func (a *Admin) MapForm(r *http.Request, input any) (form.FieldErrors, error)

MapForm maps a submitted form onto input, a pointer to the form's struct, strictly: a value that cannot be read as its field's type, such as "abc" for a number, a date in the wrong format or a choice the form does not offer, is not mapped and comes back as a field error, translated, for the form to show at its field. Date-times of datetime-local inputs are read in the site's timezone (Location), in which the admin shows them; time.Time fields so hold the instant the editor meant. A time the clocks skip moves forward by the change and a time they show twice is the first of the two (see form.ParseDateTimeLocal). err is only set when the submission cannot be mapped at all, such as a multiple choice with an option the form does not offer: answer it with BadRequest.

func (*Admin) NotifyReviewers

func (a *Admin) NotifyReviewers(r *http.Request, section, title, adminPath string)

NotifyReviewers tells the users who may review in section, other than the signed-in user, that a revision of title waits for their review at adminPath, such as the editor of a revision. It sends a plain e-mail in the admin's language, like the password reset link, and only when the admin can send mail (see Options.Mail). It returns at once; the mail goes out in the background and failures are logged.

func (*Admin) ParseSchedule

func (a *Admin) ParseSchedule(r *http.Request) (publishAt, unpublishAt time.Time, err error)

ParseSchedule reads a submitted ScheduleInput as instants in UTC, entered in the site's timezone. Empty fields give zero times; a date-time that cannot be read is content.ErrSchedule, rather than no time at all.

func (*Admin) Path

func (a *Admin) Path() string

Path is where the admin is served, Options.Path, such as "/admin": the admin answers its own addresses below it. Links into the admin are built by route name (see URL), not from Path.

func (*Admin) Permanence

func (a *Admin) Permanence() Permanence

Permanence returns the store of permanent items; nil when the admin has none (see Options.Permanence), so no item can be marked.

func (*Admin) PurgeTrash

func (a *Admin) PurgeTrash(ctx context.Context, cutoff time.Time) (int, error)

PurgeTrash deletes for good the items that went to the trash before cutoff, in every section with a trash, the way Delete selected for good does (Trash.Purge), and returns how many it deleted. It logs, at Info, how many items each section deleted. webthings.App calls it in the background with the cutoff of the Site settings (see webthings.App.PurgeTrash).

Items a section keeps, such as a file that a deleted item still uses (see TrashError), are tried again after the other sections, as long as a round deletes something, and otherwise stay in the trash. A section that fails does not stop the others: their errors are returned together.

func (*Admin) QuickActions

func (a *Admin) QuickActions(r *http.Request) []QuickAction

QuickActions are the "new item" actions of the sections the user may create in, such as "New post", in navigation order, as the dashboard offers them; for screens that suggest what to write first, such as the setup guide.

func (*Admin) Redirect

func (a *Admin) Redirect(w http.ResponseWriter, r *http.Request, target string)

Redirect sends the browser to target after a successful change and shows a one-time notice there, chosen by the name of the route of the post (see RouteName), the part after its last dot and dash: publish shows "Published.", unpublish "Unpublished.", restore (such as revision-restore) "Revision restored as a new draft.", delete (such as mailing-delete) "Moved to the trash.", schedule "Publication scheduled." and unschedule "Schedule cancelled."; other posts show "Changes saved.", and signing in or out none. HTMX requests swap the content area.

func (*Admin) RedirectFlash

func (a *Admin) RedirectFlash(w http.ResponseWriter, r *http.Request, target string, message flash.Message)

RedirectFlash is Redirect with a toast of any level, such as flash.Warn(a.T(r, "Saved as a draft.")). The text is shown exactly as given, so translate it first (Admin.T, admin.TN); a very long text is shortened to fit the cookie that carries it. Success and info toasts hide after a while, warnings and errors stay until they are dismissed.

func (*Admin) RedirectNotice

func (a *Admin) RedirectNotice(w http.ResponseWriter, r *http.Request, target, message string)

RedirectNotice is Redirect with the notice to show, as a success toast: message is a translation key, such as "Mailing sent.", translated for the toast. An empty message shows none. A message that is already translated or formatted, or a warning or an error, goes through RedirectFlash.

func (*Admin) Render

func (a *Admin) Render(w http.ResponseWriter, r *http.Request, view Page)

Render shows a page in the admin shell. Shared components (revision panel, locale tabs, move arrows) can be included by any section template.

The notice of the Redirect that led here, and messages a handler added to the request with go-partial's flash.Add, show as toasts; an HTMX response appends them to the toasts already shown.

func (*Admin) ReviewSections

func (a *Admin) ReviewSections(r *http.Request) []ReviewSection

ReviewSections lists the sections whose revisions can be reviewed (see Section.Review), in the order they were added: Pages first, then the modules'.

func (*Admin) RevisionPanel

func (a *Admin) RevisionPanel(r *http.Request, revisions Revisions) RevisionPanel

RevisionPanel builds the publication box: publish, unpublish, preview and history.

func (*Admin) Router

func (a *Admin) Router() *router.Router

Router is the admin's go-router (Options.Router), whose routes have their full paths below Path. A site mounts it on its own router with them, so its route names, such as admin.dashboard, are the site's too; the admin's own middleware runs around the router (UseAll), so the router is all a site mounts:

site.Mount(backoffice.Path(), backoffice.Router(), router.AbsolutePaths())

func (*Admin) ServeHTTP

func (a *Admin) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP serves the admin's pages at their full paths below Path.

func (*Admin) T

func (a *Admin) T(r *http.Request, key string, args ...any) string

T translates a UI message.

func (*Admin) TrashURL

func (a *Admin) TrashURL(section string) string

TrashURL is the admin's trash showing one section, for a link on the section's overview.

func (*Admin) TryURL

func (a *Admin) TryURL(name string, args ...any) (string, error)

TryURL is URL returning an error, rather than panicking, for an unknown route or a parameter that is missing or that the route does not have; for links whose route may not exist, such as the edit link of a module without an admin section of that name.

func (*Admin) URL

func (a *Admin) URL(name string, args ...any) string

URL is the address of a named admin route, such as "admin.accounts.show" or a section's Section.RouteName("edit"), with its parameters filled in and an optional query string. The arguments are those of the route template function: name/value pairs, maps of parameters and url.Values for the query:

a.URL("admin.accounts.show", "user", user.ID)
a.URL(section.RouteName("edit"), "id", id, url.Values{"locale": {locale}})

An unknown route, or a parameter that is missing or that the route does not have, is a mistake in the code rather than in the request: URL panics, and the admin answers 500 and logs it.

func (*Admin) UserName

func (a *Admin) UserName(r *http.Request, id string) string

UserName names the user with id as the revision history, the trash and the dashboard do: by the account's name, or its e-mail address without one. It is empty for an empty id, such as that of a change the system made, and for an account that no longer exists.

func (*Admin) Validate

func (a *Admin) Validate(r *http.Request, input any) form.FieldErrors

Validate checks a submitted form; input must be a pointer.

func (*Admin) ValidateSchema

func (a *Admin) ValidateSchema(r *http.Request, schema form.Schema, values url.Values) form.FieldErrors

ValidateSchema checks the values of a form defined at runtime, such as the settings a theme declares, as Validate checks a struct.

type AttentionPanel

type AttentionPanel struct {
	PanelState

	Reviews  []ReviewEntry
	Sections []NavigationLink
}

AttentionPanel is "Needs attention": revisions waiting for the user's review, and the sections with a badge count, such as new comments.

type CSVRow

type CSVRow struct {
	Line   int
	Values []string
}

CSVRow is one row of a CSV file, with the line it starts on.

func ReadCSV

func ReadCSV(source io.Reader) ([]CSVRow, error)

ReadCSV reads every row of a CSV file made with a spreadsheet program, for an import. A byte order mark is skipped. The separator is whichever of comma, semicolon or tab the first line uses most, because spreadsheets in many countries save CSV with semicolons. Rows may have different numbers of values, spaces before a value are dropped and a stray quote is kept as written. It fails when source cannot be read, or with a *csv.ParseError, which names the line, when a row is not CSV at all.

type Callout

type Callout struct {
	// Title and Text are shown as written: translate them first (T).
	Title string
	Text  string
	// URL is where the card's button leads; Action is the button's text,
	// a translation key, such as "Continue setup".
	URL    string
	Action string
	// Done of Total show as a progress bar; a zero Total shows none.
	Done  int
	Total int
}

Callout is a card on the dashboard that asks the user to finish something, with how far they got (see Section.Callout).

type DashboardView

type DashboardView struct {
	// Actions are the quick actions shown as buttons; More holds the rest,
	// by navigation group, when there are many.
	Actions []QuickAction
	More    []ActionGroup
	// WebsiteURL is the root of the website, which leads to the home page in
	// its default language.
	WebsiteURL string
	// Callouts are the sections' cards above the panels (see
	// Section.Callout).
	Callouts []Callout
}

DashboardView is the dashboard's data, limited to what the user may see.

type FormPageView

type FormPageView struct {
	Title string
	// Intro explains the form; Message reports the outcome of a post, and
	// Error why the page cannot be used, such as an expired link. Refusals
	// of single fields show at the field (see FieldError).
	Intro   string
	Message string
	Error   string
	// Form is nil when there is nothing to fill in, for example after a
	// reset link was requested.
	Form   *form.RenderModel
	Errors form.FieldErrors
	Links  []PageLink
}

FormPageView is a page with one form outside the admin's sections: the first-run setup and the password reset screens.

type FormScreen

type FormScreen[In any] struct {
	Admin *Admin
	// Title is the page heading: a translation key such as "Settings".
	Title string
	// BackURL links back to where the screen was opened from, such as the
	// module's list, with BackLabel as text (a translation key, or a name
	// chosen by the site owner, which shows as written).
	BackURL   string
	BackLabel string
	// Heading is the title of the form's box and Intro a muted paragraph
	// above the form, both translation keys; either may be empty.
	Heading string
	Intro   string
	// Action is where the form posts to, and where a saved form returns.
	// Empty is the address the screen is shown at, so a screen whose Show
	// and Post share an address, as above, needs none.
	Action string
	// Submit is the label of the button, a translation key; "Save" when
	// empty.
	Submit string
	// Load returns the stored values the form shows.
	Load func(ctx context.Context) (In, error)
	// Save stores a valid post. Refusals of single fields come back as
	// field errors (see FieldError); other errors fail the request.
	Save func(r *http.Request, in In) (form.FieldErrors, error)
}

FormScreen is a page with one form, such as the settings of a module instance. Show fills the form with Load; Post reads and validates it, stores it with Save and returns to the page with "Changes saved.". Field errors, of the validation or of Save, show at their fields. Register both on the section's routes, with the rights the module asks:

settings := admin.FormScreen[settingsInput]{Admin: routes.Admin(), …}
routes.Get("/settings", admin.View, settings.Show).As("settings")
routes.Post("/settings", admin.Edit, settings.Post)

func (FormScreen[In]) Post

func (s FormScreen[In]) Post(w http.ResponseWriter, r *http.Request)

Post saves the posted form, or shows it again with its errors.

func (FormScreen[In]) Show

func (s FormScreen[In]) Show(w http.ResponseWriter, r *http.Request)

Show renders the form with the stored values.

type FormScreenView

type FormScreenView struct {
	Title     string
	BackURL   string
	BackLabel string
	Heading   string
	Intro     string
	Form      form.RenderModel
	Errors    form.FieldErrors
}

FormScreenView is the data of templates/form-screen.gohtml.

type GroupRow

type GroupRow struct {
	Name    string
	URL     string
	Members int
}

GroupRow is one group in the list of groups, with its number of members.

type GroupsView

type GroupsView struct {
	Groups []GroupRow
	Create form.RenderModel
	Errors form.FieldErrors
}

GroupsView is the list of groups with a form to add one.

type HistoryEntry

type HistoryEntry struct {
	Number int
	// When is the revision's time as shown; Time is the same in RFC 3339,
	// for the datetime attribute.
	When      string
	Time      string
	Author    string
	Note      string
	URL       string
	Published bool
	Selected  bool
	Restore   *form.RenderModel
	// Review names the revision's review state while the workflow is on,
	// such as "Approved"; empty otherwise.
	Review string
	// PreviewURL shows the revision on the website; empty without preview.
	PreviewURL string
}

HistoryEntry is one revision in the history of an item, newest first. Restore copies it into a new revision; nil for the revision being shown and without the right to edit.

type List

type List[T any] struct {
	// Rows are the rows of the page.
	Rows []T
	// Pager links to the pages before and after this one.
	Pager Pager
}

List is one page of a list screen.

type ListPage

type ListPage[T any] struct {
	// Path is the address of the list, such as Routes.URL("index"). The pager's
	// links point there and keep the request's other parameters, such as a
	// filter tab and the search.
	Path string
	// Size is the number of rows per page; DefaultPageSize when zero.
	Size int
	// Load returns at most limit rows starting at offset, and whether more
	// rows follow. The arguments are in the order of TrashRecords.Load and
	// SQL's LIMIT and OFFSET.
	Load func(ctx context.Context, limit, offset int) (rows []T, more bool, err error)
	// All returns every row, for lists that are read whole; it is used when
	// Load is nil.
	All func(ctx context.Context) ([]T, error)
	// Fields returns the texts of a row that a search looks in, such as a
	// name, an e-mail address and a message. Without Fields a search
	// matches nothing.
	Fields func(T) []string
}

ListPage reads one page of a list screen, such as an inbox of messages or a list of subscribers, from the request's page and q parameters.

Without a search the rows come from the database a page at a time. A search reads at most SearchLimit rows, keeps those whose Fields match (ignoring case and accents, like the site search) and pages the matches, so every list searches the same way and within the same bounds.

Lists that hold all their rows in memory already set All instead of Load: they are filtered and paged in memory.

func (ListPage[T]) Read

func (p ListPage[T]) Read(r *http.Request) (List[T], error)

Read returns the page of the list that r asks for.

type Locale

type Locale string

Locale is the admin UI language as a locale, for example "nl" or "pt_BR". Catalogues are named by locale, such as nl_NL.po; a lookup in a locale without a catalogue of its own uses one of the same language (see loadTranslations).

func (Locale) GetLocale

func (l Locale) GetLocale() string

GetLocale returns the locale as text, as go-translator asks its localizers.

func (Locale) Language

func (l Locale) Language() string

Language is the locale as a BCP 47 language tag, such as "nl" or "pt-BR" for pt_BR, for the html lang attribute; a locale that is not a valid tag gives its language part.

type LocaleTab

type LocaleTab struct {
	Code string
	// Name is the language's own name, such as "Nederlands" (LanguageName),
	// with its code when another tab has the same name, such as
	// "Português (pt-BR)" next to "Português (pt)" (LanguageLabel).
	Name   string
	URL    string
	Active bool
	// Complete means the translation can be published; Started means some
	// text exists but it is not complete yet.
	Complete bool
	Started  bool
	// Overview tabs show only the language, without a translation status,
	// as on a list screen; see LocaleTabs.
	Overview bool
}

LocaleTab links to a page in one content language; LocaleTabs builds them.

func LocaleTabs

func LocaleTabs(codes []string, current, target string, status TranslationStatus) []LocaleTab

LocaleTabs returns the language tabs of the page at target, one for each of codes, named by the language's own name and linking to target in that language (see LocaleURL); the tab of current is active. Show them with {{template "locale-tabs.gohtml" .Tabs}}.

status tells each tab how far its translation is, for a screen that edits one item. A nil status gives overview tabs, which only name the language: a list as a whole has no single translation state.

type LoginView

type LoginView struct {
	Form   form.RenderModel
	Errors form.FieldErrors
	// Message says why the sign-in was refused.
	Message string
	// Setup links to the first-run setup: no account exists yet.
	Setup bool
	// ForgotPassword links to the password reset: the admin can send mail.
	ForgotPassword bool
}

LoginView is the data of the sign-in screen.

type MatrixCell

type MatrixCell struct {
	Value   string
	Allowed bool
	// Unavailable cells have no checkbox: the action does not apply to the
	// section, such as Review in a section without revisions, or Export in
	// a section without an export.
	Unavailable bool
}

MatrixCell is one checkbox of the permission matrix: Value is posted as "<section>:<action>" when ticked, and Allowed ticks it.

type MatrixGroup

type MatrixGroup struct {
	Name string
	Rows []MatrixRow
}

MatrixGroup is the sections under one navigation heading; Name is empty for the sections listed without heading (Pages, Media).

type MatrixRow

type MatrixRow struct {
	Title   string
	Section string
	Cells   []MatrixCell
	// contains filtered or unexported fields
}

MatrixRow is one section with a checkbox per action.

type MatrixView

type MatrixView struct {
	Name    string
	Form    form.RenderModel
	Errors  form.FieldErrors
	Actions []string
	// Rows are all sections; Groups are the same rows under the headings of
	// the navigation, in its order.
	Rows   []MatrixRow
	Groups []MatrixGroup
	// Columns is the number of columns: the section and one per action.
	Columns int
	Delete  form.RenderModel
}

MatrixView is one group's permission matrix. Form is the form around it, with the group's name; the matrix's checkboxes post "allow".

type MembershipForm

type MembershipForm struct {
	// Form ticks the account's groups; nil while no group exists.
	Form *form.RenderModel
}

MembershipForm lists the groups with the account's memberships ticked.

type Move

type Move struct {
	// URL is the form action that moves the item; empty shows the arrow
	// disabled, so the arrows of all rows stay aligned at the ends.
	URL string
	// Arrow is the button's symbol, such as "↑".
	Arrow string
	// Label is the translation key of the button's accessible name and
	// tooltip, such as "Move up".
	Label string
}

Move is one arrow button of an ordered list, such as the pages tree or an editor's ordered items. Templates show a pair with the shared "admin-moves" template: {{template "admin-moves" .Moves}}.

func Moves

func Moves(up, down string) []Move

Moves returns the up and down arrows of an item; an empty URL disables that arrow, at the first or the last place.

type NavigationGroup struct {
	// Name is the translated heading; empty for the links at the top.
	Name string
	// Key is the untranslated name, by which the browser remembers that the
	// editor collapsed the group.
	Key   string
	Links []NavigationLink
	// Active groups hold the current page; they are never collapsed.
	Active bool
	// Badge is the total of the links' badges, shown while collapsed.
	Badge int
}

NavigationGroup is a heading of the navigation with its links.

type NavigationLink struct {
	Title  string
	Path   string
	Icon   string
	Active bool
	// Badge is the section's count, such as new submissions; zero hides it.
	Badge int
	// contains filtered or unexported fields
}

NavigationLink is one entry of the admin's navigation: a section or one of the admin's own screens, such as the dashboard.

type Options

type Options struct {
	// Path is where the admin is served, such as "/admin" (DefaultPath,
	// when empty) or "/toolbox": every address of the admin is below it.
	// Nothing in the admin, its sections or modules assumes it: they link
	// to named routes (see URL and RouteName).
	Path string
	// Title is shown in the header and browser tab.
	Title string
	// Language is the admin UI language, for example "en" or "nl".
	Language string
	// CatalogueDir is an optional folder of catalogues, <locale>.po such as
	// nl_NL.po, whose translations win over the built-in ones of the admin
	// and its sections. Nothing is written to it.
	CatalogueDir string
	// Secure marks cookies HTTPS-only. Disable only for local plain HTTP.
	Secure bool
	// Authorize replaces the built-in rules, by which administrators may do
	// everything and editors what their groups' matrix allows (see
	// auth.Group). It decides every permission check, for administrators
	// too. Sections that are AdminOnly, such as Accounts and Groups, always
	// need the administrator role as well, and Authorize is asked about them
	// too, so it can keep them from administrators but never open them to
	// editors. Nil applies the built-in rules.
	//
	// Authorize must be safe for concurrent use: requests ask it at the same
	// time, and so do background work such as finding the reviewers to
	// notify of a submitted revision.
	Authorize func(ctx context.Context, user auth.User, permission Permission) bool
	// Mail sends password reset links. Nil offers no password reset.
	Mail mail.Sender
	// SetupToken guards the first-run setup ("admin.setup"), which creates
	// the first administrator while no account exists: the screen opens
	// only with ?token=<SetupToken>, and answers 404 otherwise. Empty
	// disables it. webthings.App makes a new one at every start of a site
	// without accounts and logs the link.
	SetupToken string
	// RemindSetup is called when the sign-in page is shown while no
	// account exists, so whoever runs the site can find the setup link
	// again; webthings.App logs it once more, at most once a minute. The
	// link itself is never shown to visitors: the first to open it would
	// get the site. Nil reminds nobody.
	RemindSetup func()
	// AbsoluteURL makes an admin path absolute, for links in e-mail; nil uses
	// the address of the request.
	AbsoluteURL func(r *http.Request, path string) string
	// Timezone is the site's timezone, in which editors enter and read
	// scheduled times; nil uses UTC.
	Timezone func(ctx context.Context) *time.Location
	// Diagnostics is what the admin's renderer reports, such as render
	// metrics, and whether it shows template errors in detail
	// (development); see render.Diagnostics.
	Diagnostics render.Diagnostics
	// Review reports whether a section's review workflow is on, by the
	// section's permission ID; signed-in requests carry it (see
	// content.WithReview). Nil turns review off everywhere.
	Review content.ReviewPolicy
	// TrashDays reports after how many days the site deletes items in the
	// trash for good (see PurgeTrash), which the trash page says; nil, or
	// 0, is never.
	TrashDays func(ctx context.Context) int
	// Permanence keeps the items administrators marked permanent (see
	// Permanence and NewPermanence). Nil offers no permanent mark; items
	// that are permanent by their nature, such as the home page, stay
	// protected.
	Permanence Permanence
	// Router is the go-router the admin registers its routes on, at their
	// full paths below Path, such as one a site mounted at Path with
	// router.AbsolutePaths before it built the admin; nil makes one (see
	// Router).
	Router *router.Router
}

Options are the settings of the admin that the application decides, such as where it is served and how it sends mail. webthings.App fills them from its Config.

type Page

type Page struct {
	// Title is the page title, shown in the header and browser tab: a
	// translation key, such as "Pages", or a name chosen by the site owner,
	// such as a module title, which shows as written without translation.
	Title string
	// Label is a page title that is shown exactly as written and never
	// translated, such as the title of the item being edited, a file name or
	// a group name: what editors type must not be looked up in the
	// catalogue. It replaces Title when set; Title stays the fallback for
	// an empty Label.
	Label string
	// Template is a template path in Files, or in the shared admin templates.
	Template string
	// Files are the section's templates; shared templates are found as well.
	Files fs.FS
	// Includes are extra templates the page calls, as paths or fs.Glob
	// patterns (see render.View.Includes).
	Includes []string
	Data     any
	// Dot, when set, replaces Data, such as a dot computed only when the
	// page's content renders, which a request for a fragment alone leaves
	// out (partial.DotFuncOf; see render.View.Dot).
	Dot    partial.Dot
	Status int
	// Fragments are parts of the page that can be asked for on their own,
	// by the ID of their element (see render.Fragment), such as the panels
	// of the dashboard that load after the page.
	Fragments []render.Fragment
}

Page is a page rendered inside the admin shell.

type PageLink struct {
	URL   string
	Title string
}

PageLink is a link below the form, such as "Back to sign in".

type Pager

type Pager struct {
	// Page is the current page number, from 1.
	Page        int
	PreviousURL string
	NextURL     string
}

Pager is the data of the shared "admin-pager" template: links to the previous and the next page of a list, empty at either end. Show it with {{template "admin-pager" .Pager}}; it shows nothing when the list fits on one page.

func NewPager

func NewPager(r *http.Request, path string, number int, more bool) Pager

NewPager returns the pager of page number of the list at path; more reports whether a next page exists. Its links keep the request's other parameters, such as a filter and the search, and change only the page. Lists that read their rows with ListPage get their pager from it; NewPager is for lists that page in another way.

type PanelHeading

type PanelHeading struct {
	// ID is the panel's element ID; the heading's is ID + "-title".
	ID string
	// Icon names the heading's icon in icons.svg.
	Icon string
	// Title is a translation key.
	Title string
}

PanelHeading is the heading of a dashboard panel, which its placeholder shows too while the panel loads.

type PanelState

type PanelState struct {
	Heading PanelHeading
	// Failed names the sections that failed or did not answer in time.
	Failed []string
}

PanelState is a dashboard panel's heading and what went wrong for it.

func (PanelState) FailedList

func (p PanelState) FailedList() string

FailedList names the sections that could not be loaded, for the panel.

type Permanence

type Permanence interface {
	// Permanent reports whether the item is marked permanent.
	Permanent(ctx context.Context, section, id string) (bool, error)
	// SetPermanent sets or removes the mark; the acting user is the one in
	// ctx (see db.WithActor).
	SetPermanent(ctx context.Context, section, id string, permanent bool) error
}

Permanence keeps which items an administrator marked permanent. A permanent item stays on the website: its editor offers no unpublish, no planned unpublication and no delete, and refuses them when they are posted anyway. Administrators set and remove the mark in the item's editor (see editor.Config.Permanent for items that are permanent by their nature, such as the home page, whose mark cannot be removed).

Items are named by the ID of their admin section, such as "pages" or "news", and their own ID. Implementations must be safe for concurrent use.

func NewPermanence

func NewPermanence(database *db.DB) Permanence

NewPermanence keeps the permanent marks in database, in the table of Schema. A removed mark stays as a row with permanent false, which records who removed it and when.

type Permission

type Permission struct {
	Section string
	Action  string
}

Permission names an action in a section, for example {"news", "publish"}.

type PresenceView

type PresenceView struct {
	// Stream is the address of the page's stream, to which the browser adds
	// the tab's id; empty on pages without presence, such as the dashboard.
	Stream  string
	Viewers []PresenceViewer
	// More counts the viewers beyond those shown.
	More int
	// Label says who is on the page, for screen readers; MoreLabel names
	// the viewers counted in More.
	Label     string
	MoreLabel string
}

PresenceView is who else is on the current page, for the header.

type PresenceViewer

type PresenceViewer struct {
	Name     string
	Initials string
	// Title is "<name> is also on this page", the chip's tooltip.
	Title string
	// Hue colours the chip, the same for a user everywhere: one of
	// presenceHues, 0 to 11, the class presence-hue-<Hue> of the admin's
	// stylesheet, so the chip needs no style attribute, which a
	// Content-Security-Policy would block.
	Hue int
}

PresenceViewer is another user on the page. Their e-mail address is never shown.

type QuickAction

type QuickAction struct {
	Label   string
	Section string
	URL     string
}

QuickAction creates an item in a section.

type RecentEntry

type RecentEntry struct {
	Title   string
	URL     string
	Section string
	Icon    string
	Status  string
	// Author is the name of who made the change; empty when unknown.
	Author string
	// When is the time as shown, such as "Today 14:05"; Time is the same as
	// RFC 3339, for the datetime attribute.
	When string
	Time string
	// contains filtered or unexported fields
}

RecentEntry is a recently changed item, with its section and the times in the site's timezone.

type RecentItem

type RecentItem struct {
	Title string
	// URL is the item's editor, built from its route name.
	URL       string
	UpdatedAt time.Time
	// Status is a translation key such as "Draft" or "Published"; empty
	// shows none.
	Status string
	// Author is the ID of the user who made the change; empty when unknown.
	Author string
}

RecentItem is an item a section changed lately, for the dashboard's "Recently edited" (see Section.Recent).

type RecentPanel

type RecentPanel struct {
	PanelState

	Items []RecentEntry
}

RecentPanel is "Recently edited": the latest changes of all sections.

type ReviewEntry

type ReviewEntry struct {
	Title   string
	URL     string
	Section string
	Icon    string
	// Author is the name of who asked for the review; empty when unknown.
	Author string
	// When is since when it waits, as shown; Time is the same as RFC 3339.
	When string
	Time string
	// contains filtered or unexported fields
}

ReviewEntry is a revision waiting for review (see Section.Review).

type ReviewInput

type ReviewInput struct {
	Note    string `form:"textarea" label:"Note"                      maxLength:"500"`
	Publish bool   `form:"checkbox" label:"Publish this revision now"`
}

ReviewInput is a reviewer's decision: a note, required when rejecting, and whether to publish an approved revision at once.

type ReviewItem

type ReviewItem struct {
	// Title names the item in the site's default language.
	Title string
	// URL opens the revision in its editor.
	URL string
	// SubmittedBy is the ID of the user who asked for the review, like
	// RecentItem.Author; SubmittedAt is when.
	SubmittedBy string
	SubmittedAt time.Time
}

ReviewItem is a revision waiting for review, for the dashboard (see Section.Review).

type ReviewPanel

type ReviewPanel struct {
	// State names the selected revision's state, such as "In review";
	// SubmittedBy/At and ReviewedBy/At say who did what and when, with the
	// reviewer's Note.
	State       string
	SubmittedBy string
	SubmittedAt string
	ReviewedBy  string
	ReviewedAt  string
	Note        string
	// SubmittedTime and ReviewedTime are SubmittedAt and ReviewedAt in RFC
	// 3339, for the datetime attribute.
	SubmittedTime string
	ReviewedTime  string
	// NeedsApproval is set when the selected revision cannot be published
	// or scheduled until a reviewer approves it.
	NeedsApproval bool
	// Submit asks for a review (editors); Approve and Reject decide on it
	// (reviewers). Nil when not possible.
	Submit  *form.RenderModel
	Approve *form.RenderModel
	Reject  *form.RenderModel
}

ReviewPanel is the review part of the publication box. Labels are translation keys.

type ReviewSection

type ReviewSection struct {
	// ID is the section's permission ID, which the setting stores.
	ID string
	// Title is the section's translated title.
	Title string
}

ReviewSection is a section whose revisions can be reviewed, for the site setting that turns the review workflow on per section.

type RevisionPanel

type RevisionPanel struct {
	Selected     int
	Latest       int
	Published    int
	PreviewURL   string
	CanPublish   bool
	CanUnpublish bool
	Publish      form.RenderModel
	Unpublish    form.RenderModel
	History      []HistoryEntry
	// Schedule plans the publication of the selected revision and/or the
	// unpublication of the item; Unschedule cancels the plan. Both are nil
	// without the right to publish.
	Schedule   *form.RenderModel
	Unschedule *form.RenderModel
	// ScheduledRevision is published at PublishAt; UnpublishAt takes the item
	// offline. They are empty when not planned. All times, also those of
	// the history, are in Timezone, the site's timezone.
	ScheduledRevision int
	PublishAt         string
	UnpublishAt       string
	Timezone          string
	// PublishTime and UnpublishTime are PublishAt and UnpublishAt in RFC
	// 3339, for the datetime attribute.
	PublishTime   string
	UnpublishTime string
	// Permanent items cannot be unpublished; see Revisions.Permanent.
	Permanent bool
	// Review is on while the section's review workflow is (see
	// Revisions.Review); the rest describes the selected revision's review.
	Review bool
	ReviewPanel
}

RevisionPanel is the publication box beside revisioned editors.

type Revisions

type Revisions struct {
	// Section is the section ID, for permission checks.
	Section string
	// URL builds the address of one of the item's editor routes by its
	// short name, such as "edit" or "revision-publish" (see admin/editor),
	// with the item's ID filled in; args add the other route parameters and
	// url.Values for the query, as Admin.URL takes them. The panel only asks
	// for the review and schedule routes while Review or Schedule is set.
	URL    func(name string, args ...any) string
	Locale string
	// Selected is the revision in the editor; Latest is the newest number.
	Selected    content.Revision
	Latest      int
	PublishedID string
	History     []content.Revision
	// Names maps author IDs to display names.
	Names map[string]string
	// Preview adds a preview link when the website can render previews.
	Preview bool
	// Schedule is the item's planned publication change; nil when the content
	// cannot be scheduled.
	Schedule *content.Schedule
	// Permanent items must stay on the website, such as the home page: the
	// panel offers no unpublish and plans publications only.
	Permanent bool
	// Review is on while the section's review workflow is (see
	// content.WithReview): only approved revisions can be published or
	// scheduled, and the panel offers submitting, approving and rejecting.
	Review bool
}

Revisions describes the revision state an editor shows.

type Route

type Route struct {
	// contains filtered or unexported fields
}

Route is a route a section registered.

func (*Route) As

func (r *Route) As(name string) *Route

As names the route, so Admin.URL and the route template function build its address: the full name is Section.RouteName(name), for example "admin.news.edit" for As("edit") in the section news. A name used twice makes New fail.

func (*Route) Use

func (r *Route) Use(middleware ...func(http.Handler) http.Handler) *Route

Use adds middleware to the route, inside its permission check, such as AdministratorsOnly.

type Routes

type Routes struct {
	// contains filtered or unexported fields
}

Routes registers section handlers. Every route requires a signed-in user who is allowed the given action in this section, and reads the body within the section's MaxBodyBytes.

func (*Routes) Admin

func (r *Routes) Admin() *Admin

Admin returns the admin the routes belong to, for handlers that render or check permissions.

func (*Routes) Get

func (r *Routes) Get(pattern, action string, handler http.HandlerFunc) *Route

Get registers a GET handler for a pattern below the section path. An empty pattern is the section's own page.

func (*Routes) Post

func (r *Routes) Post(pattern, action string, handler http.HandlerFunc) *Route

Post registers a POST handler for a pattern below the section path.

func (*Routes) Section

func (r *Routes) Section() Section

Section returns the section the routes were registered for.

func (*Routes) URL

func (r *Routes) URL(name string, args ...any) string

URL is the address of one of this section's routes by the name it got with Route.As, as Admin.URL builds it:

routes.URL("edit", "id", id, url.Values{"locale": {locale}})

is Admin.URL(RouteName(section ID, "edit"), ...). Handlers may keep the Routes to build their links while they serve; like Admin.URL, it panics on an unknown route or parameter.

type ScheduleInput

type ScheduleInput struct {
	PublishAt   time.Time `form:"input,datetime-local" label:"Publish at"`
	UnpublishAt time.Time `form:"input,datetime-local" label:"Unpublish at"`
}

ScheduleInput is the form that plans publication changes. Editors enter the times in the site's timezone (see Admin.MapForm); zero means not planned.

type ScheduledEntry

type ScheduledEntry struct {
	Title   string
	URL     string
	Section string
	Icon    string
	// Change is "Goes online" or "Goes offline", translated.
	Change string
	// Offline is set for an unpublication.
	Offline bool
	When    string
	Time    string
	// contains filtered or unexported fields
}

ScheduledEntry is one planned publication change.

type ScheduledItem

type ScheduledItem struct {
	Title string
	URL   string
	// PublishAt puts the item online, UnpublishAt takes it offline; zero
	// when not planned.
	PublishAt   time.Time
	UnpublishAt time.Time
}

ScheduledItem is an item whose publication is planned, for the dashboard's "Scheduled" (see Section.Scheduled).

type ScheduledPanel

type ScheduledPanel struct {
	PanelState

	Items []ScheduledEntry
}

ScheduledPanel is "Scheduled": the planned publication changes.

type Search struct {
	// Text is the search as typed.
	Text string
	// contains filtered or unexported fields
}

Search is the search of a list screen: the q parameter of the request, matched the way the site search matches, ignoring case and accents.

func NewSearch

func NewSearch(text string) Search

NewSearch parses a search text.

func SearchOf

func SearchOf(r *http.Request) Search

SearchOf returns the search of the request's q parameter.

func (Search) Active

func (s Search) Active() bool

Active reports whether there is something to search for.

func (Search) Matches

func (s Search) Matches(fields ...string) bool

Matches reports whether every word of the search occurs in one of fields, such as a title, a name and an e-mail address. Without search everything matches.

type SearchBox struct {
	Text   string
	Action string
	// Keep are the other parameters of the page.
	Keep []SearchParam
	// ClearURL is the page without the search.
	ClearURL string
	// NewLabel, translated, and NewURL are the section's "new item" action,
	// such as "New post", on the overview of a section that has one
	// (Section.NewLabel) when the user may create; the empty list offers
	// it. Both are empty otherwise.
	NewLabel string
	NewURL   string
}

SearchBox is the data of the shared "admin-search" template: a search field for the header of a list screen. It searches the current page, keeping its other parameters (such as the language or a filter tab) but starting again at the first page. Every admin page has it as a go-partial @model, computed only when a template calls it; a list template declares it and passes it on:

{{/* @model Search github.com/donseba/go-webthings/admin.SearchBox */}}
{{template "admin-search" Search}}

type SearchParam

type SearchParam struct {
	Name  string
	Value string
}

SearchParam is one parameter kept by a search.

type Section

type Section struct {
	// ID names the section, for example "pages" or "news".
	ID string
	// Permission is the section whose rights apply, when they are shared with
	// another section (News categories use "news"). Empty means ID.
	Permission string
	// AdminOnly sections are for administrators only and not in the matrix.
	AdminOnly bool
	// Title is the navigation label; it is translated.
	Title string
	// Path is the section's address below the admin, for example "/news"
	// for <admin path>/news; empty is "/" followed by ID. Links to
	// the section use its route names (see RouteName), so nothing depends
	// on where the admin is (see Options.Path).
	Path string
	// Icon is an icon name from assets/icons.svg; "box" when empty.
	Icon string
	// Group is the heading the section is listed under in the navigation,
	// such as "Website"; it is translated. Groups are sorted by name, and
	// GroupSystem comes last. Empty lists the section at the top, without
	// heading, as Pages and Media are. Modules set it with
	// webthings.Module.Group.
	Group string
	// Hidden keeps the section out of navigation.
	Hidden bool
	// Badge returns a count shown next to the section in the navigation, such
	// as new submissions; zero shows nothing. It runs on every admin page for
	// the sections the user may see, so it must be cheap. The dashboard lists
	// the sections with a count under "Needs attention".
	Badge func(ctx context.Context) int
	// Callout returns a card the dashboard shows above its panels, such as
	// the Site settings' "Finish setting up your site", and false to show
	// none. The dashboard asks it for users who may view the section, with
	// the request's context, which carries the signed-in user
	// (auth.CurrentUser); it runs on every dashboard view, so it must be
	// cheap.
	Callout func(ctx context.Context) (Callout, bool)
	// NewLabel with NewRoute or NewPath offers a quick action on the
	// dashboard, such as "New post", to users who may create in the
	// section. NewLabel is translated. NewRoute is the name of the
	// section's route for a new item, such as "new" (see RouteName);
	// NewPath is its path below the admin instead, such as "/news/new",
	// and is used only without NewRoute. Editor sections set NewLabel and
	// NewRoute.
	NewLabel string
	NewRoute string
	NewPath  string
	// Recent lists the section's most recently changed items, newest first
	// and at most limit, for the dashboard's "Recently edited". It runs on
	// every dashboard view for the sections the user may see, so it must
	// read no more than limit items (content stores have Recent) and leave
	// deleted items out. Editor sections set it.
	//
	// The dashboard's panels load after the page, each on its own, and ask
	// every section at the same time, so a slow section delays only its
	// panel. Recent, Scheduled, Review and Badge get a context that ends
	// after a few seconds; a section that has not answered by then is
	// named in the panel as one that could not be loaded.
	Recent func(ctx context.Context, limit int) ([]RecentItem, error)
	// Scheduled lists the section's planned publication changes, the
	// earliest first and at most limit, for the dashboard's "Scheduled".
	// Editor sections whose store can plan publication set it.
	Scheduled func(ctx context.Context, limit int) ([]ScheduledItem, error)
	// Review lists the section's revisions waiting for review, oldest
	// request first and at most limit, for the dashboard's "Needs
	// attention". The dashboard calls it for users who may review in the
	// section; it returns nothing while the section's review workflow is
	// off. Sections that set it are revisioned content that can be
	// reviewed: the permission matrix offers the Review right for them
	// only. Editor sections of revisioned stores set it.
	Review func(ctx context.Context, limit int) ([]ReviewItem, error)
	// Trash holds the section's deleted items, for the admin's trash (see
	// Trash). Editor sections set it; modules with records of their own
	// implement it.
	Trash Trash
	// Files holds the section's templates and translations/<locale>.po.
	Files fs.FS
	// Stylesheet names a CSS file in Files, for example "assets/stats.css",
	// that every admin page links. Scope its rules under classes of your own:
	// all sections share the page.
	Stylesheet string
	// MaxBodyBytes raises the 512 KiB request limit, for uploads.
	MaxBodyBytes int64
	// Routes registers the section's handlers.
	Routes func(*Routes)
}

Section is one area of the admin, such as Pages, Media or a module instance.

func (Section) PermissionID

func (s Section) PermissionID() string

PermissionID is the section whose rights apply to this section.

func (Section) RouteName

func (s Section) RouteName(name string) string

RouteName is the full name of a route the section named name with Route.As: "admin.<section ID>.<name>", for example "admin.news.edit". Admin.URL and the route template function build addresses from it.

type SessionRow

type SessionRow struct {
	Created, Expires, LastSeen string
	Active                     bool
	Revoke                     *form.RenderModel
}

SessionRow is one session of the selected account, with its times as shown. Revoke ends it; nil for sessions that ended already.

type ShellView

type ShellView struct {
	Title        string
	PageTitle    string
	SectionTitle string
	SectionURL   string
	// Dashboard is set on the dashboard, where the breadcrumbs, which start
	// at the dashboard, show only the page.
	Dashboard bool
	Language  string
	// Stylesheets are the link tags of the sections' own stylesheets, with
	// their versions and integrity, on every page.
	Stylesheets []template.HTML
	// Navigation lists the links of Groups in their order.
	Navigation []NavigationLink
	// Groups are the navigation as shown: first the links without heading
	// (Dashboard, Pages, Media), then the named groups by name, and
	// GroupSystem last.
	Groups []NavigationGroup
	User   *auth.User
	Logout form.RenderModel
	// Presence is who else is on this page (see presence.go).
	Presence PresenceView
}

ShellView is the layout's data.

type Tab

type Tab struct {
	// Label is the text of the link: a translation key such as "Pending",
	// or a name chosen by the site owner, which shows as written.
	Label string
	// Value is the value of the filter parameter the tab selects; empty for
	// the tab without the parameter, such as "All". FilterTabs uses it.
	Value string
	URL   string
	// Active marks the tab of the list being shown.
	Active bool
	// Count is shown as a badge after the label when Counted is set, such
	// as the number of messages with the tab's status.
	Count   int
	Counted bool
}

Tab is one link of Tabs.

type Tabs

type Tabs struct {
	// Label names the row for screen readers: a translation key such as
	// "Status".
	Label string
	Items []Tab
}

Tabs is the data of the shared "admin-tabs" template: a row of links that filter a list screen, such as its statuses. Show it with {{template "admin-tabs" .Tabs}}.

func FilterTabs

func FilterTabs(r *http.Request, label, path, name, current string, tabs ...Tab) Tabs

FilterTabs returns the tabs of the list at path that set its filter parameter name to each tab's Value; the tab whose Value is current is active. The links keep the request's other parameters, such as the search, and start again at the first page. Tabs that have a URL already keep it.

type TranslationStatus

type TranslationStatus func(locale string) (complete, started bool)

TranslationStatus reports how far the text in one content language is: complete when it can be published, started when some text exists.

type Trash

type Trash interface {
	// List returns the deleted items whose title or kind matches query (see
	// Matches; empty matches all), most recently deleted first: at most limit
	// of them from offset, and how many match in all.
	List(ctx context.Context, query string, limit, offset int) ([]TrashItem, int, error)
	// Restore brings items back. Items that cannot come back, such as an item
	// whose slug another item took meanwhile, are reported as TrashErrors
	// (see EachTrashItem) while the others are restored.
	Restore(ctx context.Context, ids []string) error
	// Purge removes items for good, with what belongs to them.
	Purge(ctx context.Context, ids []string) error
}

Trash holds a section's deleted items (see Section.Trash). Sections built with admin/editor have one; modules with tables of their own implement it for their records, and JoinTrash combines several.

func JoinTrash

func JoinTrash(trashes ...Trash) Trash

JoinTrash lists the items of several trashes as one, for a section that keeps several kinds of records, such as forms and their submissions.

func RecordTrash

func RecordTrash[T any](records TrashRecords[T]) Trash

RecordTrash is the Trash of records. Without a search it lists one page, which Load reads with LIMIT and OFFSET. A search reads at most SearchLimit records, matches them in memory the way list screens do, and pages the matches.

type TrashEntry

type TrashEntry struct {
	// Value identifies the item in the posted selection; empty when the user
	// may not restore or purge it.
	Value     string
	Title     string
	Kind      string
	URL       string
	DeletedBy string
	When      string
	Time      string
	// contains filtered or unexported fields
}

TrashEntry is a deleted item as listed.

type TrashError

type TrashError struct {
	ID string
	// Reason explains it to the editor; it is translated, for example "Its
	// slug is used by another item now.".
	Reason string
	Err    error
}

TrashError is an item that Restore or Purge could not handle.

func (*TrashError) Error

func (e *TrashError) Error() string

Error names the item and the underlying error, for the log; the editor sees Reason.

func (*TrashError) Unwrap

func (e *TrashError) Unwrap() error

Unwrap returns the underlying error, so errors.Is sees through it.

type TrashGroup

type TrashGroup struct {
	Section string
	Title   string
	Icon    string
	Items   []TrashEntry
	Total   int
	// MoreURL shows all items of the section when the overview shows only
	// the first ones.
	MoreURL string
	// Error is set when the section could not list its trash.
	Error bool
}

TrashGroup are the deleted items of one section.

type TrashItem

type TrashItem struct {
	ID    string
	Title string
	// Kind names the type of item when a section keeps several, such as
	// "Submission"; it is translated. Empty shows none.
	Kind      string
	DeletedAt time.Time
	// DeletedBy is the ID of the user who deleted it; empty when unknown.
	DeletedBy string
	// URL shows the item somewhere, if it can be seen while deleted; empty
	// shows the title without link.
	URL string
}

TrashItem is a deleted item.

type TrashRecords

type TrashRecords[T any] struct {
	// Load returns records in the trash, most recently deleted first: at
	// most limit of them from offset (all of them for a limit of 0), and how
	// many the trash holds in all. db.SoftTable.Trashed reads both.
	Load func(ctx context.Context, limit, offset int) ([]T, int, error)
	// Fields are the texts of a record that a search of the trash looks in,
	// such as a name and an e-mail address. Nil searches the title of Item.
	Fields func(record T) []string
	// Item describes a record in the trash's list.
	Item func(record T) TrashItem
	// Undelete brings the record with id back from the trash.
	Undelete func(ctx context.Context, id string) error
	// Purge removes the record with id for good.
	Purge func(ctx context.Context, id string) error
	// Reason explains to the editor why a record stays where it is, for the
	// module's own errors, such as "This redirect cannot be restored:
	// another redirect uses its old path now."; it is translated. A record
	// that is no longer in the trash (db.ErrNotFound) is explained already.
	// An error without a reason stops the whole action and is shown as a
	// failure.
	Reason func(err error) string
}

TrashRecords describes the records of a table of their own, such as a module's messages or registrations, for the admin's trash. RecordTrash turns it into a Trash, so a module writes only what is particular to its records:

section.Trash = admin.RecordTrash(admin.TrashRecords[Note]{
	Load: service.Trashed,
	Fields: func(note Note) []string {
		return []string{note.Title, note.Body}
	},
	Item: func(note Note) admin.TrashItem {
		return admin.TrashItem{
			ID:        note.ID,
			Title:     note.Title,
			DeletedAt: *note.DeletedAt,
			DeletedBy: note.DeletedBy.String(),
		}
	},
	Undelete: service.Undelete,
	Purge:    service.Purge,
})

db.SoftTable has the store's side: Trashed, Undelete and Purge.

type TrashView

type TrashView struct {
	// Filters are the sections with a trash, for the filter tabs; the first
	// is all sections.
	Filters Tabs
	// Section is the section shown alone; empty shows all.
	Section string
	Groups  []TrashGroup
	// Action is where the selection is posted; it keeps the filter and the
	// search.
	Action string
	// Restore and Purge are offered when some listed item may be changed.
	CanChange bool
	// PurgeQuestion asks before deleting for good.
	PurgeQuestion string
	// Days is after how many days the site deletes items in the trash for
	// good (Options.TrashDays); 0 is never.
	Days int
	// Pager pages through one section.
	Pager Pager
}

TrashView is the trash page.

type TwoStepSetup

type TwoStepSetup struct {
	// QR is the address of the QR code, a PNG.
	QR string
	// Key is the secret in groups of four characters, to type by hand.
	Key     string
	Confirm form.RenderModel
	Errors  form.FieldErrors
	Cancel  form.RenderModel
}

TwoStepSetup is a setup of two-step sign-in that waits for its first code: the QR code and the key for the authenticator app, and the form for the code the app then shows.

type TwoStepView

type TwoStepView struct {
	// On means that signing in asks for a code after the password.
	On bool
	// Start turns two-step sign-in on; nil while it is on or a setup waits
	// for its first code.
	Start *form.RenderModel
	// Setup is the setup that waits for its first code.
	Setup *TwoStepSetup
	// RecoveryCodes is the number of recovery codes not used yet.
	RecoveryCodes int
	// Codes are new recovery codes, shown on this page only: only their
	// digests are stored.
	Codes []string
	// Off turns two-step sign-in off and Recovery makes new recovery
	// codes; both are nil while it is off.
	Off            *form.RenderModel
	OffErrors      form.FieldErrors
	Recovery       *form.RenderModel
	RecoveryErrors form.FieldErrors
}

TwoStepView is the card "Two-step sign-in" of the own account page.

Directories

Path Synopsis
Package editor is the ready-made admin for content stores (see the content package): an overview with language tabs, a go-form editor per language, publication and, for revisioned content, history, restore and preview.
Package editor is the ready-made admin for content stores (see the content package): an overview with language tabs, a go-form editor per language, publication and, for revisioned content, history, restore and preview.
Package presence keeps track of which admin page every signed-in user is looking at, so the admin can show "Ann is also on this page".
Package presence keeps track of which admin page every signed-in user is looking at, so the admin can show "Ann is also on this page".

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL